diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 38b85b099..6c8322715 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,16 +19,17 @@ jobs: - run: dart pub get - run: dart format --output=none . - run: dart analyze --no-fatal-warnings - - run: dart test --coverage="coverage" - - run: dart test ./example + - run: dart test --coverage="coverage" -j 2 + - run: dart test ./example -j 2 - run: dart pub global activate coverage - run: $HOME/.pub-cache/bin/format_coverage --ignore-files **/*.g.dart --lcov --check-ignore --in=coverage --out=coverage.lcov --report-on=lib - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: files: coverage.lcov + fail_ci_if_error: true # fails only on upload errors, not coverage drops verbose: true # optional (default = false) nip07-tests: @@ -48,4 +49,4 @@ jobs: architecture: x64 # optional, x64 or arm - run: dart pub get - run: dart analyze --no-fatal-warnings - - run: flutter test --platform chrome + - run: flutter test --platform chrome --concurrency=2 diff --git a/doc/concepts/signers.md b/doc/concepts/signers.md index 5a37f2d2a..17699b581 100644 --- a/doc/concepts/signers.md +++ b/doc/concepts/signers.md @@ -11,11 +11,11 @@ Signers are responsible for cryptographic operations: signing events, encrypting | `NdkEventSigner` | Automatic platform selection (web/native) | No (instant) | | `Nip46EventSigner` | Remote signer via NIP-46 bunker | Yes | | `Nip07EventSigner` | Browser extension (NIP-07) | Yes | -| `AmberEventSigner` | Android Amber app | Yes | +| `Nip55EventSigner` | Android NIP-55 external signer app | Yes | ## Pending Requests -External signers (NIP-46, NIP-07, Amber) require user approval for operations. The unified pending requests API allows your UI to: +External signers (NIP-46, NIP-07, NIP-55) require user approval for operations. The unified pending requests API allows your UI to: - Display pending operations to the user - Cancel pending requests @@ -82,7 +82,7 @@ try { ### Handling Remote Rejections -When the user rejects a request on the remote signer (bunker, browser extension, Amber), the caller receives a `SignerRequestRejectedException`: +When the user rejects a request on the remote signer (bunker, browser extension, NIP-55 signer app), the caller receives a `SignerRequestRejectedException`: ```dart try { diff --git a/doc/guides/cache-behavior.md b/doc/guides/cache-behavior.md new file mode 100644 index 000000000..2a8c14e35 --- /dev/null +++ b/doc/guides/cache-behavior.md @@ -0,0 +1,106 @@ +--- +icon: database +order: 97 +--- + +# Cache Behavior + +NDK's cache persists event data, delivery state, and decrypted payload sidecars used by app-facing reads and writes. + +This guide focuses on the behavior you can rely on when building an app with NDK. It does **not** cover backend implementation details. + +## What gets cached + +At a high level, NDK may persist: + +- Nostr events +- event source relays +- pending delivery state for locally created events +- decrypted plaintext sidecars for encrypted events +- convenience projections like metadata, contact lists, and user relay lists + +## What `load`-style reads mean + +When NDK reads events from cache, it applies these visibility rules: + +- expired events are hidden +- author-deleted events are hidden +- for replaceable and addressable events, only the latest visible winner is returned + +App-facing reads return the current logical state, not raw historical storage. + +## Event sources vs delivery targets + +These are different concepts: + +- **Event sources** answer: "where did this event come from?" +- **Delivery targets** answer: "where does this locally created event still need to be sent?" + +As an app developer: + +- use normal usecases and requests for reading and publishing +- only inspect low-level source relay APIs if your app explicitly needs provenance +- do not assume a source relay is automatically a pending broadcast target + +## Local-first publishing + +When your app broadcasts an event through normal NDK APIs: + +- the event can become visible locally before every relay acknowledges it +- relay delivery progress can survive app restarts when the cache backend is persistent +- retries can continue in the background while the app is running +- replaceable events retry only the newest visible version + +This lets the UI update from locally persisted state without waiting for every relay to accept the event. + +## Encrypted payload cache + +For encrypted content, NDK can cache decrypted plaintext separately from the original event. + +This is useful when: + +- the app needs to render the same DM or private list repeatedly +- decryption depends on a slow or remote signer + +Important behavior: + +- the original event stays encrypted in the canonical event store +- plaintext is cached in a separate sidecar keyed by event and viewer +- different viewers may have different cached plaintext results + +## Convenience accessors + +`metadata`, `follows`, and `userRelayLists` are high-level app APIs backed by the generic event cache. + +In practice this means: + +- you should keep using the high-level usecases +- you should not maintain a separate storage assumption for metadata/contact lists +- replaceable and deletion semantics are applied consistently across generic event reads and convenience accessors + +## Cache eviction + +Eviction behavior is: + +- removing expired events +- removing author-deleted events +- removing superseded replaceable/addressable events +- optionally applying caps to visible events per kind + +If background eviction scheduling is enabled, NDK uses the configured values from `NdkConfig`: + +- `cacheEvictionEnabled` +- `cacheEvictionPolicy` +- `cacheEvictionStartupDelay` +- `cacheEvictionInterval` +- `runCacheEvictionOnStartup` + +If you enable background eviction scheduling, NDK will periodically run the configured eviction policy while the app is alive. + +## Recommended app usage + +- Use a persistent `CacheManager` in real apps. +- Read through high-level usecases unless you specifically need low-level control. +- Treat local-first publish as locally durable and eventually relay-delivered. +- Configure eviction for long-lived apps with large caches. +- Let NDK manage decrypted payload caching instead of rewriting encrypted event content yourself. diff --git a/doc/guides/cli/accounts.md b/doc/guides/cli/accounts.md new file mode 100644 index 000000000..c2e3a5566 --- /dev/null +++ b/doc/guides/cli/accounts.md @@ -0,0 +1,55 @@ +--- +label: accounts +title: accounts +icon: person +order: 3 +--- + +# `accounts` — manage local identities + +Login, persist and switch between Nostr identities. Identities are stored in +plaintext at `~/.ndk/accounts.json` (file mode `0600`) so that subsequent +commands (`broadcast`, etc.) can reuse the active signer. Override the file +location with the `NDK_ACCOUNTS_FILE` environment variable. + +> **Security warning**: the accounts file contains signing material (private +> keys or bunker connection secrets) in plaintext. Treat it like an SSH key. +> Do not commit or share it. + +``` +ndk accounts [args] +``` + +| Sub-command | Description | +|-------------|-------------| +| `login nsec ` | Login with a private key | +| `login npub ` | Read-only login (public key only, cannot sign) | +| `login bunker ` | Connect to a NIP-46 bunker (async; prints an auth URL if the bunker requires one) | +| `login generate [name]` | Generate a fresh keypair, login and persist | +| `logout [pubkey]` | Logout current (or specific) account and remove from store | +| `list` | List persisted accounts | +| `switch ` | Set the active account | +| `whoami` | Show the active account | + +```bash +# login with an existing nsec / hex private key +ndk accounts login nsec nsec1... +ndk accounts login nsec 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +# read-only login (no signing) +ndk accounts login npub npub1... + +# connect to a NIP-46 bunker +ndk accounts login bunker "bunker://...?relay=wss://...&secret=..." + +# generate a fresh keypair (nsec is printed once + saved to disk) +ndk accounts login generate + +ndk accounts list +ndk accounts whoami +ndk accounts switch npub1... +ndk accounts logout +``` + +The first account logged in becomes the active one. Use `switch` to change +the active account; the choice persists across CLI invocations. diff --git a/doc/guides/cli/blossom.md b/doc/guides/cli/blossom.md new file mode 100644 index 000000000..f254e8856 --- /dev/null +++ b/doc/guides/cli/blossom.md @@ -0,0 +1,61 @@ +--- +label: blossom +title: blossom +icon: server +order: 7 +--- + +# `blossom` — low-level Blossom operations + +Direct access to the Blossom protocol (BUD-*) and the user server list +(kind `10063`). Use this when you need control that +[`files`](files.md) doesn't expose. See the +[blossom usecase](/usecases/blossom.md). + +``` +ndk blossom [args] +``` + +| Sub-command | Description | +|-------------|-------------| +| `upload ` | Upload a local file (hash → upload → mirror, streamed) | +| `download ` | Download a blob by sha256 to disk | +| `delete ` | Delete a blob | +| `list [pubkey]` | List blobs for a pubkey (defaults to active account) | +| `mirror --server ...` | Mirror an existing blob to new server(s) | +| `check ` | Check blob existence, return an alive URL | +| `servers list [pubkey]` | Show a user's kind `10063` server list | +| `servers publish [url ...]` | Publish your own server list (login required) | + +## Options + +| Option | Description | +|--------|-------------| +| `--server ` (repeatable) | Blossom server(s); defaults to the user server list | +| `--pubkey ` | Server-list owner | +| `--content-type ` | Override mime type (`upload`) | +| `--media` | Server-side media optimisation (`upload`) | +| `--auth` | Use signed GET (`download`, `check`, `list`) | +| `--since ` | `list`: only blobs after this date | +| `--until ` | `list`: only blobs before this date | + +## Examples + +```bash +# list someone's blossom servers +ndk blossom servers list npub1... + +# publish your own server list (order matters — first = most trusted) +ndk accounts login nsec nsec1... +ndk blossom servers publish https://cdn.example.com https://cdn.backup.io + +# list all blobs owned by the active account on a specific server +ndk blossom list --server https://cdn.example.com + +# mirror an existing blob to a second server +ndk blossom mirror "https://cdn.example.com/.pdf" \ + --server https://cdn.backup.io + +# download a blob straight to disk +ndk blossom download 0123...abcdef ~/blob.bin +``` diff --git a/doc/guides/cli/broadcast.md b/doc/guides/cli/broadcast.md new file mode 100644 index 000000000..115894d05 --- /dev/null +++ b/doc/guides/cli/broadcast.md @@ -0,0 +1,67 @@ +--- +label: broadcast +title: broadcast +icon: arrow-up-right +order: 2 +--- + +# `broadcast` — publish events to relays + +Publishes one event to the given relays and prints a per-relay result +(`OK`, `REJECTED`, or `NO-RESPONSE`). Accepts three kinds of input: + +1. **Pre-signed event JSON** — sent as-is (no signer needed). +2. **Unsigned event JSON** or **inline build** (`--kind` + `--content`) — + signed with `--privkey` or the active `accounts login` identity. +3. **Build a text note inline** — `--kind 1 --content "..."` plus a signer. + +``` +ndk broadcast [options] [relay ...] +``` + +Event source (exactly one required): + +| Option | Description | +|--------|-------------| +| `--event ` | Inline event JSON | +| `--file ` | Read event JSON from file | +| `--stdin` | Read event JSON from stdin (pipe or interactive) | +| `--kind ` + `--content ` | Build an unsigned text note inline | +| `--pubkey ` | Pubkey for inline build (defaults to active account) | + +Signing: + +| Option | Description | +|--------|-------------| +| `--privkey ` | Sign with this key (overrides the active account) | +| *(none)* | Use the active account from `ndk accounts login` | + +Output control: + +| Option | Description | +|--------|-------------| +| `--relay ` | Target relay (repeatable; also accepted positionally) | +| `--timeout ` | Per-event broadcast timeout (default `10`) | +| `--consider-done <0..1>` | Fraction of OKs to wait for (default `0.5`) | +| `--no-cache` | Don't save event to local cache | + +```bash +# publish a text note using the active account +ndk accounts login nsec nsec1... # one-time +ndk broadcast --kind 1 --content "hello from ndk cli" relay.damus.io nos.lol + +# publish a text note with an explicit private key +ndk broadcast --kind 1 --content "hello" --privkey nsec1... relay.damus.io + +# broadcast a pre-signed event from a file +ndk broadcast --file signed.json relay.damus.io + +# broadcast a pre-signed event piped on stdin +cat signed.json | ndk broadcast --stdin relay.damus.io + +# custom OK threshold and timeout +ndk broadcast --event '{...}' --consider-done 1.0 --timeout 20 \ + relay.damus.io nos.lol relay.nostr.band +``` + +Exit code is `0` if at least one relay accepted the event, `1` otherwise. diff --git a/doc/guides/cli/files.md b/doc/guides/cli/files.md new file mode 100644 index 000000000..3a8374c0a --- /dev/null +++ b/doc/guides/cli/files.md @@ -0,0 +1,50 @@ +--- +label: files +title: files +icon: upload +order: 6 +--- + +# `files` — high-level file management + +A convenience wrapper over Blossom that auto-resolves user server lists and +handles sha256-in-URL detection transparently. See the +[files usecase](/usecases/files.md). For lower-level control, use the +[`blossom`](blossom.md) command. + +``` +ndk files [args] +``` + +| Sub-command | Description | +|-------------|-------------| +| `upload ` | Upload a local file, streaming hashing/upload/mirror progress | +| `download ` | Download to disk (blossom sha256 URL or plain HTTPS) | +| `delete ` | Delete a blob from its server(s) | +| `check ` | Resolve a URL to a known-alive mirror | + +## Options + +| Option | Description | +|--------|-------------| +| `--server ` (repeatable) | Blossom server(s); defaults to the user server list | +| `--pubkey ` | Server-list owner (for download/check of someone else's blobs) | +| `--content-type ` | Override mime type (`upload`) | +| `--media` | Server-side media optimisation (`upload`, [BUD-05]) | + +## Examples + +```bash +# upload to your default blossom servers (requires login for signing) +ndk accounts login nsec nsec1... +ndk files upload ~/photos/cat.jpg + +# download a blossom URL to disk +ndk files download "https://cdn.example.com/.jpg" ~/out.jpg + +# delete by sha256 +ndk files delete 0123...abcdef + +# check that a URL resolves to an alive server +ndk files check "https://cdn.example.com/.jpg" --pubkey npub1... +``` diff --git a/doc/guides/cli/index.md b/doc/guides/cli/index.md new file mode 100644 index 000000000..d07712628 --- /dev/null +++ b/doc/guides/cli/index.md @@ -0,0 +1,106 @@ +--- +label: NDK CLI +title: NDK CLI +icon: terminal +order: 99.5 +--- + +# NDK CLI + +NDK ships a standalone command line interface for querying relays, managing +identities, publishing events, and operating Lightning/Cashu wallets — without +writing any Dart code. It is a prebuilt binary — no Dart or Flutter toolchain +required. + +## Commands + +| Command | Description | +|---------|-------------| +| [`req`](req.md) | Query relays for events | +| [`broadcast`](broadcast.md) | Publish events to relays | +| [`accounts`](accounts.md) | Manage local identities (login, logout, list, switch, whoami) | +| [`wallets`](wallets.md) | Lightning / Cashu wallet operations | +| [`zaps`](zaps.md) | NIP-57 zap operations (invoice, zap, receipts) | +| [`files`](files.md) | High-level file management (upload, download, delete, check) | +| [`blossom`](blossom.md) | Low-level Blossom operations (upload, download, delete, list, mirror, check, servers) | + +## Install + +### Single command (Linux / macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash +``` + +By default this installs the `ndk` binary to `~/.local/bin/ndk` and its shared libraries to `~/.local/lib` (user mode). + +### Install options + +```bash +# system-wide install (/usr/bin/ndk) — may require sudo +curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash -s -- --system + +# pin a specific version +NDK_VERSION=v0.8.3 curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash + +# latest pre-release +NDK_VERSION=latest-pre curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash +``` + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NDK_VERSION` | `latest` | Version tag, `latest`, or `latest-pre` | +| `NDK_PRERELEASE` | `0` | When `1` and `NDK_VERSION=latest`, picks the latest pre-release | +| `NDK_INSTALL_MODE` | `user` | `user` or `system` | +| `NDK_INSTALL_DIR` | `~/.local/bin` | Binary destination (user mode) | +| `NDK_USER_LIB_DIR` | `~/.local/lib` | Shared libraries destination (user mode) | +| `NDK_SYSTEM_BIN_DIR` | `/usr/bin` | Binary destination (system mode) | +| `NDK_SYSTEM_LIB_DIR` | `/usr/lib` | Shared libraries destination (system mode) | + +### PATH + +If `~/.local/bin` is not in your `PATH`, add it to your shell profile: + +```bash +export PATH="$PATH:$HOME/.local/bin" +``` + +Verify the installation: + +```bash +ndk --version +``` + +See [releases](https://github.com/relaystr/ndk/releases) for available versions. + +## Usage + +``` +ndk [global options] [args] +``` + +Global options (must come before the command name): + +| Option | Description | +|--------|-------------| +| `--version` | Show the package version | +| `-v` | Warning-level logging | +| `-vv` | Info-level logging | +| `-vvv` | Debug-level logging | +| `-h`, `--help` | Show help | + +Run `ndk` with no arguments, or `ndk --help`, to list all commands. + +## Persistence & local state + +The CLI stores the following files in your working directory (or the +locations noted): + +| File | Purpose | +|------|---------| +| `~/.ndk/accounts.json` | Persisted identities (private keys / bunker connections). Override with `NDK_ACCOUNTS_FILE`. | +| `wallets_db.db` | Wallet definitions and transaction history. | +| `ndk_cache.db` | Event cache, Cashu proofs/keysets/mint infos. | +| `NDK_CASHU_SEED` | Environment variable supplying the Cashu seed phrase. | diff --git a/doc/guides/cli/req.md b/doc/guides/cli/req.md new file mode 100644 index 000000000..3b0543027 --- /dev/null +++ b/doc/guides/cli/req.md @@ -0,0 +1,58 @@ +--- +label: req +title: req +icon: arrow-down-left +order: 1 +--- + +# `req` — query relays for events + +Fetches events from one or more relays. Defaults to one JSON object per line; +use `--output summary` for a compact one-line-per-event view. + +``` +ndk req [options] [relay2 ...] +``` + +| Option | Description | +|--------|-------------| +| `-k`, `--kind ` | Filter by event kind (repeatable) | +| `-a`, `--author ` | Author pubkey (repeatable) | +| `-i`, `--id ` | Event id (repeatable) | +| `-e`, `--e ` | `#e` tag value (repeatable) | +| `-p`, `--p ` | `#p` tag value (repeatable) | +| `-d`, `--d ` | `#d` tag value (repeatable) | +| `-T`, `--hashtag ` | `#t` tag value (repeatable) | +| `--tag ` | Arbitrary single-char tag, e.g. `--tag r=wss://x` (repeatable) | +| `--search ` | NIP-50 search | +| `--since ` | `created_at >= ` value (`1h`, `2d`, `2024-01-01`, …) | +| `--until ` | `created_at <=` value | +| `-l`, `--limit ` | Max events to emit (default `10`) | +| `--timeout ` | Query timeout in seconds (default `12`) | +| `-t ` | Alias of `--timeout` (backwards compatible) | +| `-o`, `--output ` | Output mode (default `json`) | +| `--stream` | Live subscription: keep receiving events until Ctrl+C | + +Relay URLs may be passed with or without the `wss://` scheme. All filter +flags accept either hex or NIP-19 (`npub`, `nevent`, `note`) forms where it +makes sense. + +```bash +# last 5 text notes (kind 1) from a relay +ndk req -k 1 -l 5 wss://relay.damus.net + +# kind-0 metadata from two relays, 5s timeout +ndk req -k 0 --timeout 5 relay.damus.net nos.lol + +# events by an author (npub accepted), last hour, compact output +ndk req -a npub1... -k 1 --since 1h -l 20 -o summary relay.damus.io + +# NIP-50 search +ndk req --search "lightning" -k 1 -l 5 nos.lol + +# arbitrary single-char tag, e.g. #r=relay.damus.io +ndk req --tag r=relay.damus.io -k 3 -l 10 relay.damus.io + +# live stream of new text notes (Ctrl+C to stop) +ndk req --stream -k 1 relay.damus.io +``` diff --git a/doc/guides/cli/wallets.md b/doc/guides/cli/wallets.md new file mode 100644 index 000000000..3e5133087 --- /dev/null +++ b/doc/guides/cli/wallets.md @@ -0,0 +1,69 @@ +--- +label: wallets +title: wallets +icon: credit-card +order: 4 +--- + +# `wallets` — manage Lightning / Cashu wallets + +Wallet operations for NIP-47 (NWC) and Cashu wallets. + +``` +ndk wallets [args] +``` + +| Sub-command | Description | +|-------------|-------------| +| `list` | List configured wallets | +| `add nwc [name]` | Add a Nostr Wallet Connect wallet | +| `add cashu [name]` | Add a Cashu wallet | +| `remove ` | Remove a wallet | +| `receive [walletId]` | Generate a receive invoice | +| `send [walletId]` | Pay a BOLT11 invoice | +| `balance [walletId]` | Show wallet balances | +| `budget [walletId]` | Show NWC budget info (NWC only) | +| `set-default [receive\|send\|both]` | Set default wallet (default scope: `both`) | +| `melt [walletId] [--seed ]` | Pay a BOLT11 invoice with Cashu ecash | +| `mint [walletId] [--seed ] [--wait]` | Mint Cashu tokens from a quote (add `--wait` to poll until paid) | +| `swap-receive [--seed ]` | Receive an incoming Cashu token | +| `swap-spend [walletId] [--seed ]` | Create a sendable Cashu token from your balance | +| `pay-stats [walletId] [--limit N]` | Show recent transactions | + +```bash +ndk wallets list +ndk wallets add nwc "nostr+walletconnect://..." +ndk wallets add cashu "https://mint.example.com" +ndk wallets receive 1000 +ndk wallets send "lnbc1..." +ndk wallets balance +ndk wallets budget +ndk wallets set-default wallet_123 send +ndk wallets pay-stats --limit 50 +``` + +## Cashu operations and the seed phrase + +All Cashu state-modifying operations (`melt`, `mint`, `swap-receive`, +`swap-spend`) require the wallet's seed phrase. Provide it via `--seed`: + +```bash +ndk wallets mint 100 --wait --seed "word1 word2 ... word12" +ndk wallets melt "lnbc1..." --seed "word1 word2 ... word12" +ndk wallets swap-receive "cashuA..." +``` + +…or export `NDK_CASHU_SEED` once per shell session: + +```bash +export NDK_CASHU_SEED="word1 word2 ... word12" +ndk wallets mint 100 --wait +``` + +The mint sub-command prints a BOLT11 invoice; pay it externally and +re-run with `--wait` (or pass `--wait` up front) to poll until the mint +confirms payment and issues tokens. Cashu proofs, keysets and counters +persist in `ndk_cache.db` next to `wallets_db.db`, so state survives +across invocations. + +Run `ndk wallets help` for the full list of examples. diff --git a/doc/guides/cli/zaps.md b/doc/guides/cli/zaps.md new file mode 100644 index 000000000..348edd0e0 --- /dev/null +++ b/doc/guides/cli/zaps.md @@ -0,0 +1,56 @@ +--- +label: zaps +title: zaps +icon: zap +order: 5 +--- + +# `zaps` — NIP-57 zap operations + +Send and inspect Lightning zaps. The `zap` sub-command pays from a stored NWC +wallet (see [`wallets add nwc`](wallets.md)); `invoice` only fetches a BOLT11 +invoice and needs no wallet; `receipts` reads kind `9735` zap receipts. + +``` +ndk zaps [args] +``` + +| Sub-command | Description | +|-------------|-------------| +| `invoice ` | Fetch a zap/lightning invoice (no payment). Zap-encoded when a signer and recipient `--pubkey` are available; `--no-zap` forces a plain LN invoice. | +| `zap ` | Pay a zap using the default NWC sending wallet (or `--wallet `). Waits for the zap receipt unless `--no-receipt`. | +| `receipts ` | List kind `9735` zap receipts for a recipient (filter with `--event` / `--addressable`). | + +## Options + +| Option | Description | +|--------|-------------| +| `--wallet ` | Override the NWC wallet used by `zap` | +| `--comment ` | Zap comment / invoice memo | +| `--pubkey ` | Recipient pubkey (enables true zap encoding) | +| `--event ` | Zap a specific event | +| `--addressable ` | Zap an addressable event (`naddr`) | +| `--relays ` (repeatable) | Relays attached to the zap request | +| `--no-zap` | `invoice`: force a plain LN invoice (skip zap encoding) | +| `--no-receipt` | `zap`: don't wait for the zap receipt | +| `--limit ` | `receipts`: max events (default `50`) | +| `--timeout ` | Per-operation timeout (default `15`) | + +## Examples + +```bash +# fetch a 21-sat zap invoice for a lud16 address +ndk zaps invoice me@example.com 21 + +# zap 1000 sats from the default NWC sending wallet, +# encoding a true zap with the active account as sender +ndk accounts login nsec nsec1... # one-time +ndk wallets add nwc "nostr+walletconnect://..." +ndk zaps zap me@example.com 1000 --pubkey npub1... + +# list zap receipts received by a pubkey (last hour) +ndk zaps receipts npub1... --limit 50 + +# zap a specific note +ndk zaps zap me@example.com 21 --pubkey npub1... --event nevent1... +``` diff --git a/doc/guides/decrypted-payloads.md b/doc/guides/decrypted-payloads.md new file mode 100644 index 000000000..310c2e654 --- /dev/null +++ b/doc/guides/decrypted-payloads.md @@ -0,0 +1,50 @@ +--- +icon: lock +order: 95 +--- + +# Decrypted Payload Caching + +NDK can cache decrypted plaintext separately from the original encrypted event. + +## Behavior + +When a supported usecase needs encrypted content: + +- NDK first checks for a cached plaintext sidecar +- if plaintext is already cached, the signer does not need to decrypt again +- if plaintext is missing, NDK decrypts and stores it in the cache backend + +The original event remains unchanged and stays encrypted in the canonical event store. + +## Cache key + +Decrypted plaintext is cached per: + +- event id +- viewer pubkey + +This means the same event can have different cached plaintext entries for different viewers. + +## What this helps with + +This is especially useful when: + +- the app re-renders the same encrypted content repeatedly +- decryption depends on a remote signer +- the signer is slow compared to local cache reads + +## Current use + +Current NDK usecases that can reuse cached decrypted payloads include: + +- `GiftWrap` +- private `Nip51List` content through `Lists` +- NIP-17 direct message flows that unwrap gift-wrapped messages + +## What apps should assume + +- cached plaintext is an optimization and a persisted sidecar +- encrypted event content is still the authoritative wire format +- deleting the original event from cache should also remove the associated decrypted sidecar +- using a persistent cache backend allows decrypted sidecars to survive restart diff --git a/doc/guides/getting-started.md b/doc/guides/getting-started.md index 99ec54735..6328e6561 100644 --- a/doc/guides/getting-started.md +++ b/doc/guides/getting-started.md @@ -6,41 +6,63 @@ order: 100 # Getting started with NDK -## Install +NDK ships as two packages that work together: -Ndk has a core package `ndk` and optional packages like `amber` and `objectbox` +- **`ndk`** — the core library: relay gossip (inbox/outbox), requests, caching, signers, and high-level usecases. Pure Dart, no Flutter dependency. +- **`ndk_flutter`** — Flutter integration on top of `ndk`: a platform-aware event verifier, Flutter signers, login persistence, and ready-to-use widgets. + +If you are building a **Flutter app** (the common case), start with `ndk_flutter` — it depends on `ndk` and gives you the best defaults for each platform automatically. Use the core `ndk` package alone only for pure-Dart projects (CLIs, servers). + +!!! +Looking for a ready-to-run tool instead of a library? NDK also ships a prebuilt **CLI** for querying relays and managing wallets. See [the CLI guide](./cli.md). +!!! + +## Flutter apps (recommended) + +### Install ```bash -flutter pub add ndk +flutter pub add ndk ndk_flutter ``` -## Import +### Import ```dart import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; ``` -## Usage +### Initialize -!!! -If you code with AI then your AI must read https://github.com/relaystr/ndk/blob/master/AI_GUIDE.md -!!! +`NdkEventVerifier` (from `ndk_flutter`) is the recommended verifier for Flutter apps. It automatically selects the best backend for the current platform: -!!! -We strongly recommend using `RustEventVerifier()` for client applications. It uses a separate thread for signature verification and is therefore more performant. +- **Web** → `WebEventVerifier` (native Web Crypto APIs) +- **Android / iOS / desktop** → `RustEventVerifier` (offloaded to a separate thread) + +This means you write one line and get the optimal verifier everywhere, with no conditional imports or platform checks. + +```dart +final ndk = Ndk( + NdkConfig( + eventVerifier: NdkEventVerifier(), + cache: MemCacheManager(), + ), +); +``` -For **Flutter** apps, use `NdkEventVerifier` from the `ndk_flutter` package. It automatically picks `WebEventVerifier` on web (native Web Crypto APIs) and `RustEventVerifier` on native platforms. +!!! +If you code with AI then your AI must read https://github.com/relaystr/ndk/blob/master/AI_GUIDE.md !!! -### Prerequisites for using the rust verifier +#### Prerequisites for native platforms -- rust ( + toolchain for target) +On native platforms `NdkEventVerifier` uses the Rust verifier, which requires the Rust toolchain. **On web, no Rust toolchain is needed** — it falls back to Web Crypto. Install Rust: ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -```` +``` Rust toolchain android: @@ -63,17 +85,87 @@ rustup target add aarch64-apple-ios-sim rustup target add armv7-apple-ios i386-apple-ios ``` -## Install +### Query events + +```dart +final response = ndk.requests.query( + filters: [ + Filter( + authors: ['hexPubkey'], + kinds: [Nip01Event.kTextNodeKind], + limit: 10, + ), + ], +); + +await for (final event in response.stream) { + print(event); +} +``` + +[!ref create user accounts/login](/usecases/accounts.md) + +### Widgets & login persistence + +`ndk_flutter` gives you ready-to-use Nostr widgets and helpers so you don't have to wire accounts/signers yourself: + +- `NdkFlutter.restoreAccountsState()` / `saveAccountsState()` — persist and restore logged accounts to `flutter_secure_storage` +- Widgets: `NLogin`, `NUserProfile`, `NName`, `NPicture`, `NBanner`, `NSwitchAccount`, ... + +The widgets rely on Flutter's internationalization. Add the delegate to your `MaterialApp`: + +```dart +import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_flutter; + +MaterialApp( + localizationsDelegates: [ + ndk_flutter.AppLocalizations.delegate, + ], +); +``` + +Then wrap `ndk` and restore saved accounts (typically before `runApp`), and save again whenever the auth state changes: + +```dart +final ndkFlutter = NdkFlutter(ndk: ndk); + +await ndkFlutter.restoreAccountsState(); + +// ...later, whenever login/logout/switch happens: +await ndkFlutter.saveAccountsState(); +``` + +```dart +// available widgets +NLogin(ndkFlutter: ndkFlutter); +NUserProfile(ndkFlutter: ndkFlutter); +NName(ndkFlutter: ndkFlutter); +NPicture(ndkFlutter: ndkFlutter); +NBanner(ndkFlutter: ndkFlutter); +NSwitchAccount(ndkFlutter: ndkFlutter); +``` + +By default these widgets target the logged-in user; pass a `pubkey` to render any other user. + +[!ref widgets](https://pub.dev/packages/ndk_flutter) + +--- + +## Pure Dart (non-Flutter) + +For Dart CLIs, servers, or any project without a Flutter dependency, use only the core `ndk` package. + +### Install ```bash -flutter pub add ndk_rust_verifier -flutter pub add ndk_amber +dart pub add ndk ``` +### Usage + ```dart import 'package:ndk/ndk.dart'; -// init final ndk = Ndk( NdkConfig( eventVerifier: RustEventVerifier(), @@ -81,35 +173,21 @@ final ndk = Ndk( ), ); -// usecase - query final response = ndk.requests.query( filters: [ Filter( - authors: ['hexPubkey'] - kinds: [Nip01Event.TEXT_NODE_KIND], + authors: ['hexPubkey'], + kinds: [Nip01Event.kTextNodeKind], limit: 10, ), ], ); -// result await for (final event in response.stream) { print(event); } ``` -[!ref create user accounts/login](/usecases/accounts.md) - -$~~~~~~~~~~~$ - -## Install - -```bash -flutter pub add ndk_amber -``` - -## Import - -```dart -import 'package:ndk_amber/ndk_amber.dart'; -``` +!!! +`RustEventVerifier()` requires the Rust toolchain (see the prerequisites above). For a pure-Dart setup with no native dependencies, use `Bip340EventVerifier()` instead — it is slower but needs no Rust. +!!! diff --git a/doc/guides/local-first.md b/doc/guides/local-first.md new file mode 100644 index 000000000..5a81bed12 --- /dev/null +++ b/doc/guides/local-first.md @@ -0,0 +1,69 @@ +--- +icon: sync +order: 96 +--- + +# Local-First Behavior + +NDK treats app writes and reads as local-first when the relevant data is stored in the configured cache backend. + +## Publish behavior + +When an event is created through NDK broadcast flows: + +- the event can be written to the cache before every relay acknowledges it +- the event can become visible to app reads from that cached state +- relay delivery can continue after the initial publish call finishes + +This applies to normal events as well as targeted relay delivery used for replies, reactions, and other gossip-aware publish paths. + +## Delivery state + +NDK keeps delivery state in the configured cache backend. + +That state includes: + +- aggregate event delivery status +- per-relay delivery targets +- retry timing and retry outcome + +Behavior depends on the backend: + +- with `MemCacheManager`, delivery state exists while the process is alive +- with a persistent backend, delivery state also survives restart + +## Retry behavior + +NDK retries pending delivery in the background while the app is running. + +Retry behavior includes: + +- retry on reconnect when a relay becomes reachable +- periodic retry for targets that are due even if connectivity state does not change +- per-event-kind retry policy differences +- permanent failure detection for relay responses that should not be retried forever + +## Replaceable events + +For replaceable and addressable events: + +- app reads return the latest visible winner +- background delivery follows that same visible winner +- older superseded offline versions are not kept in active retry once a newer visible version exists + +## Reads after write + +After a local publish, app-facing reads may show the event before every relay confirms it. + +This means: + +- your UI can update from NDK state immediately +- relay acknowledgement is a separate concern from local visibility +- delivery progress should be treated as asynchronous + +## What this means for apps + +- use a persistent cache backend if you want local-first behavior to survive restart +- treat locally visible publish as durable in the cache, not necessarily fully delivered to every relay yet +- do not assume one failed relay means the publish is globally failed +- for replaceable data, assume the newest visible version is the authoritative one diff --git a/doc/guides/persistence.md b/doc/guides/persistence.md index 331cdb047..dbc8f7fe4 100644 --- a/doc/guides/persistence.md +++ b/doc/guides/persistence.md @@ -5,6 +5,10 @@ order: 98 Ndk comes with several database offerings. The simplest is the `MemCacheManager` which is an in-memory cache. This is useful for testing and small applications. +If you are building a real app, prefer a persistent cache backend. NDK stores events, delivery state, and decrypted payload sidecars in the configured cache backend. + +For a behavioral overview of how the cache works from an app perspective, see [Cache Behavior](/guides/cache-behavior.md). + Available databases: - `MemCacheManager` @@ -12,6 +16,118 @@ Available databases: - [`SembastCacheManager`](https://pub.dev/packages/sembast_cache_manager) - [`DriftCacheManager`](https://pub.dev/packages/ndk_drift) +## Which cache backend to use + +### `MemCacheManager` + +Best when: + +- writing tests +- prototyping quickly +- building command-line tools or short-lived processes +- you do not need cache state to survive app restart + +Why: + +- simplest setup +- fastest to get running +- keeps all cache behavior while the process is alive + +Limitations: + +- all events, delivery state, and decrypted payload sidecars are lost on restart + +### `SembastCacheManager` + +Best when: + +- you want a simple persistent backend with minimal setup +- you want one option that works well across Dart and Flutter targets +- you want persistent local-first behavior without adding a heavier database stack + +Why: + +- easy to integrate +- good default choice for many apps +- supports persistent cache behavior, including delivery recovery after restart + +Good fit: + +- small to medium apps +- apps where simplicity matters more than squeezing maximum database performance + +### `DriftCacheManager` + +Best when: + +- your app already uses Drift or SQLite +- web cache performance is especially important for your app +- you want stronger SQL-style querying and schema control +- you want one persistent backend that fits naturally into a larger app data layer + +Why: + +- highest-performing web cache backend in NDK based on project performance testing +- good fit for apps that already think in terms of SQLite tables and migrations +- easier choice if your app team is already comfortable with Drift tooling + +Good fit: + +- medium to larger apps +- web apps with heavier cache usage +- apps with an existing SQLite-centric architecture + +### `DbObjectBox` + +Best when: + +- you want a persistent backend optimized around a dedicated embedded database +- you expect large local datasets and care about read/write performance +- you are comfortable bringing in a more specialized storage dependency + +Why: + +- good option for high-volume local persistence +- good fit for apps where the NDK cache is a substantial part of the local data layer + +Good fit: + +- larger apps +- apps with heavy local event storage needs + +## Practical recommendation + +If you just want a good default: + +- start with `SembastCacheManager` + +If your app already uses SQLite/Drift heavily: + +- use `DriftCacheManager` + +If web performance is your main concern: + +- use `DriftCacheManager` + +If you need maximum simplicity and do not care about restart persistence: + +- use `MemCacheManager` + +If the cache is large and central to your app and you want a dedicated embedded database: + +- evaluate `DbObjectBox` + +> **Tip:** You can use a hybrid approach: +> +> - use `DriftCacheManager` when the detected platform is web +> - use `DbObjectBox` on non-web platforms +> +> This is a good fit when: +> +> - you want the strongest web cache performance +> - you want ObjectBox for native and desktop persistence +> - you are comfortable selecting the backend at runtime based on platform + If you want your own database, you need to implement the `CacheManager` interface. Contributions for more database implementations are welcome! Its recommended to use the database only for ndk and spin up a secondary db for your own app data. diff --git a/doc/index.md b/doc/index.md index 6a32659e4..f144d561e 100644 --- a/doc/index.md +++ b/doc/index.md @@ -12,18 +12,19 @@ NDK (Nostr Development Kit) is a Dart library that enhances the Nostr developmen NDK supplies you with high-level usecases like lists or metadata while still allowing you to use low-level queries enhanced with inbox/outbox (gossip) by default.\ Our Target is to make it easy to build constrained Nostr clients, particularly for mobile devices. +!!! +Want to see NDK in action? + +The repository includes a **sample app** that demonstrates many implemented +features, usecases, and Flutter widgets end to end. + +- [sample app live demo](https://dart-nostr.com/app/) +- source: `packages/sample-app` +!!! + ## Apps using NDK -- [sample app](https://dart-nostr.com/app/) -- [yana](https://github.com/frnandu/yana) -- [camelus](https://github.com/leo-lox/camelus) -- [zap.stream](https://github.com/nostrlabs-io/zap-stream-flutter) -- [zapstore](https://github.com/zapstore/zapstore) -- [freeflow](https://github.com/nostrlabs-io/freeflow) -- [hostr](https://github.com/sudonym-btc/hostr) -- [bitblik](https://github.com/bit-blik) -- [donow](https://github.com/nogringo/donow) -- [submarine](https://github.com/nogringo/submarine) +- [sample app](https://dart-nostr.com/app/), [yana](https://github.com/frnandu/yana), [camelus](https://github.com/leo-lox/camelus), [zap.stream](https://github.com/nostrlabs-io/zap-stream-flutter), [zapstore](https://github.com/zapstore/zapstore), [freeflow](https://github.com/nostrlabs-io/freeflow), [hostr](https://github.com/sudonym-btc/hostr), [bitblik](https://github.com/bit-blik/bitblik), [donow](https://github.com/nogringo/donow), [submarine](https://github.com/nogringo/submarine), [nostr-mail-client](https://github.com/nogringo/nostr-mail-client) # ➡️ [Getting Started 🔗](https://dart-nostr.com/guides/getting-started/) @@ -40,18 +41,23 @@ Our Target is to make it easy to build constrained Nostr clients, particularly f - **Smart caching** to reduce network bandwidth and improve performance - **Concurrent event streaming** from both cache and network - **Request coverage control** to specify desired relay coverage per request +- **Local-first delivery** so events can become visible from cache before every relay confirms them +- **Background retry and recovery** for relay delivery across reconnects, periodic due-retry, and restart persistence when using a persistent cache +- **Visibility-aware reads** that apply replaceable winner selection, expiration filtering, and deletion suppression consistently ### Account & Authentication -- **Multiple signer support**: Built-in (BIP-340), WebEventSigner (fast web crypto), Amber, NIP-07 (web), and NIP-46 (remote signing/bunkers) +- **Multiple signer support**: Built-in (BIP-340), WebEventSigner (fast web crypto), NIP-55 external signers, NIP-07 (web), and NIP-46 (remote signing/bunkers) - **Account management** with state tracking and multiple account support - **Relay authentication** (NIP-42) for private relay access +- **Delayed external signing support** so local-first publish can survive signer approval delays or temporary signer unavailability ### High-Level Use Cases - **Metadata management**: Query and update user profiles with automatic caching - **Contact lists**: Follow/unfollow users and manage contact lists - **NIP-51 lists**: Public and private relay sets, mute lists, and custom lists +- **DMs**: Cached direct-message loading with decrypted payload reuse and live updates - **Gift wrap** (NIP-59): Encrypted, metadata-obscured messaging - **Zaps** (NIP-57): Lightning payments on Nostr - **Nostr Wallet Connect** (NIP-47): Integrate Lightning wallets @@ -67,6 +73,7 @@ Our Target is to make it easy to build constrained Nostr clients, particularly f - **Event verification**: BIP-340, Rust-based (recommended for mobile/desktop), or `NdkEventVerifier` from `ndk_flutter` (recommended for Flutter apps) - **Comprehensive logging** with configurable log levels and outputs - **Clean architecture** for maintainability and extensibility +- **Decrypted payload sidecar cache** to avoid repeatedly asking slow or remote signers to decrypt the same encrypted events ## not Included diff --git a/doc/library-development/index.md b/doc/library-development/index.md index 54be03db4..51a479442 100644 --- a/doc/library-development/index.md +++ b/doc/library-development/index.md @@ -20,7 +20,7 @@ Run build runner: (e.g for generating mocks)\ The repo is setup as a monorepo and packages are split to enable user choice of what to include.\ The main package is `ndk` which is the main entry point for the lib user. \ -Other packages like `objectbox` or `amber` are optional and can be included if needed. +Other packages like `objectbox` or `ndk_flutter` are optional and can be included if needed. NDK uses Clean Architecture. Reasons for it being clear separation of concerns and therefore making it more accessible for future contributors.\ You can read more about it [here](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). diff --git a/doc/usecases/blossom.md b/doc/usecases/blossom.md index c8b582306..edd1f42a9 100644 --- a/doc/usecases/blossom.md +++ b/doc/usecases/blossom.md @@ -4,6 +4,8 @@ icon: file-zip [!badge variant="primary" text="low level"] +[!badge text="CLI"](/guides/cli/blossom.md) — `ndk blossom upload|download|delete|list|mirror|check|servers` + ## Example :::code source="../../packages/ndk/example/files/blossom_example_test.dart" language="dart" range="10-19" title="" ::: diff --git a/doc/usecases/broadcast.md b/doc/usecases/broadcast.md index da27a9f84..b18504d35 100644 --- a/doc/usecases/broadcast.md +++ b/doc/usecases/broadcast.md @@ -34,3 +34,84 @@ If you want to use the outbox model, check out the [enabling gossip](/guides/ena Broadcast should be used when your use case has no broadcasting method. \ Signing is done automatically, and you can specify a custom signer just for one broadcast. \ By default, the inbox/outbox model is used for broadcasting looking at the event data (e.g. if it's a reply) you can also specify specific relays to broadcast to. + +## Local-first behavior + +NDK keeps delivery state for locally created events in the configured cache backend. + +That means: + +- an event may be visible locally before every relay accepts it +- pending delivery can survive app restarts when the cache backend is persistent +- if signing is delayed by an external signer, the unsigned event can still stay queued locally +- retries can continue later when relays are reachable again +- replaceable events only keep retrying the newest visible version + +With `MemCacheManager`, this state exists while the process is alive. With a persistent `CacheManager`, it also survives restarts. + +## Retry behavior + +Background delivery retries are not identical for every event. + +Current behavior includes: + +- replaceable events retry as latest-state-only delivery +- ephemeral events are not kept for retry +- some control-style events can use faster retry behavior +- relay responses classified as permanent failure stop retry for that relay target +- relay responses classified as transient failure remain retryable +- external signer flows can also be retried in the background before relay delivery continues + +## External signer retry behavior + +When you broadcast through an external signer such as `Nip46EventSigner`, +`Nip07EventSigner`, or `Nip55EventSigner`, signing and relay delivery are treated +as separate steps. + +Current behavior: + +- if the signer does not complete immediately, the event can remain queued locally +- once signing succeeds later, NDK can continue relay delivery for that same event +- if the signer depends on network transport, retries are biased toward moments when the signer transport looks reachable again +- if the signer succeeds while some target relays are still offline, delivery to those relays can still continue later + +In practice this means apps can treat delayed external signing as part of the same +local-first publish flow instead of having to rebuild the publish manually. + +## Delivery policy + +NDK uses an internal `DeliveryPolicy` for local-first broadcast retry handling. + +Current policy selection is automatic: + +- ephemeral events use `doNotRetry` +- deletion events use `highPriorityControl` +- replaceable and addressable events use `latestStateOnly` +- other events use `persistentEventual` + +This policy is currently **not configurable** through `ndk.broadcast.broadcast()`, +`NdkConfig`, or a per-event override. + +That means: + +- you cannot choose a custom retry/backoff profile for one broadcast +- you cannot inject your own delivery policy classifier +- the practical way to influence policy today is through the event kind you publish + +Examples: + +- `kind:5` deletions get faster control-style retries +- replaceable kinds only keep retrying the newest visible version +- ephemeral kinds are not persisted for background retry + +## Relay targeting + +Broadcast targeting can include more than author outbox relays. + +Depending on the event, NDK may target: + +- explicit relays +- author write relays +- inbox/read relays needed for replies and reactions + +Delivery targets are tracked separately from source provenance. diff --git a/doc/usecases/dms.md b/doc/usecases/dms.md new file mode 100644 index 000000000..33651534d --- /dev/null +++ b/doc/usecases/dms.md @@ -0,0 +1,68 @@ +--- +icon: comment-discussion +label: DM +--- + +[!badge variant="primary" text="high level"] + +## When to use + +Use `ndk.dms` for direct-message style conversations based on NIP-17 gift-wrapped messages. + +## Current behavior + +`ndk.dms` provides: + +- sending a message to a peer +- loading all conversations for the logged-in account +- loading a single conversation with one peer +- loading a cache-only snapshot of conversations +- parsing a wrapped message into a `Nip17Message` + +## Relay behavior + +Sending uses DM relay lists for: + +- the recipient +- the sender + +That means: + +- the recipient receives a wrapped copy on their DM relays +- the sender receives a wrapped copy on their own DM relays +- the sender can load their own conversation history from the same model + +If either side does not have the required DM relay list, sending fails. + +## Loading conversations + +`loadConversations()`: + +- loads wrapped messages addressed to the logged-in user +- unwraps them into message objects +- groups them by peer pubkey +- sorts messages inside a conversation by creation time +- sorts conversations by latest message time + +`loadConversationsSnapshot()`: + +- reads only from cache +- does not require a network round trip +- is useful for immediate UI rendering + +## Existing conversations from other clients + +NDK can load existing conversations created by other clients as long as: + +- the messages are stored on relays queried by the logged-in user's DM relay list +- the logged-in account can decrypt the wrapped payloads + +## Decryption behavior + +Conversation loading can reuse cached decrypted payload sidecars. + +That means: + +- previously unwrapped messages can load from cache +- repeated UI reads do not need to decrypt every message again +- persistent cache backends keep that benefit across restarts diff --git a/doc/usecases/files.md b/doc/usecases/files.md index bc68e8d0e..4001026e3 100644 --- a/doc/usecases/files.md +++ b/doc/usecases/files.md @@ -4,6 +4,8 @@ icon: file-zip [!badge variant="primary" text="high level"] +[!badge text="CLI"](/guides/cli/files.md) — `ndk files upload|download|delete|check` + ## Example :::code source="../../packages/ndk/example/files/files_example_test.dart" language="dart" range="10-15" title="blossom" ::: diff --git a/doc/usecases/follows.md b/doc/usecases/follows.md index b0e79dbbc..42d8bbc06 100644 --- a/doc/usecases/follows.md +++ b/doc/usecases/follows.md @@ -15,3 +15,20 @@ Gives you the list of contacts for a given pubKey. Use `broadcastSetContactList()` to set the list initially (e.g. on signup) \ `broadcastAddContact()` to add a contact to the list \ `broadcastRemoveContact()` to remove a contact from the list + +## Current behavior + +Contact lists behave as replaceable event state. + +That means: + +- reads return the latest visible contact list for the pubkey +- expired or deleted older versions are not returned +- repeated reads can return cached results +- `forceRefresh` refreshes from relays + +When mutating the logged-in user's contact list: + +- NDK refreshes first when needed to avoid overwriting newer remote state +- NDK publishes a newer replaceable version of the full contact list +- the resulting event is cached for later reads diff --git a/doc/usecases/gift-wrap.md b/doc/usecases/gift-wrap.md index e2e5dbab1..04ddc76f2 100644 --- a/doc/usecases/gift-wrap.md +++ b/doc/usecases/gift-wrap.md @@ -20,3 +20,19 @@ Gift Wrap depends on the logged in user (accounts usecase) make sure you are log You can use Gift Wrap to obscure metadata. It also encrypts the content using `nip44`. \ More information here: [!ref target="blank" text="Nostr nip 59"](https://github.com/nostr-protocol/nips/blob/master/59.md) + +## Current behavior + +Gift wrap APIs: + +- create rumors +- seal rumors +- wrap sealed rumors +- unwrap gift wraps +- read cached unwrap results when plaintext sidecars already exist + +Decryption behavior: + +- wrapped and sealed payload plaintext can be cached separately from the original events +- later unwraps can reuse cached plaintext +- cache-only unwrap is available when the required sidecars already exist diff --git a/doc/usecases/lists.md b/doc/usecases/lists.md index f5ded0b9a..dd8aa69ec 100644 --- a/doc/usecases/lists.md +++ b/doc/usecases/lists.md @@ -28,6 +28,19 @@ We distinguish between **lists** and **sets**: Both can have public and private (encrypted) elements. +## Current behavior + +For public list/set content: + +- reads use the latest visible event for the relevant list or set +- cached results are reused on later reads + +For private list/set content: + +- the original event content stays encrypted +- decrypted private tags can be loaded through NDK's decrypted payload cache +- repeated reads can reuse cached plaintext instead of decrypting again + ### Lists Methods #### getSingleNip51List diff --git a/doc/usecases/metadata.md b/doc/usecases/metadata.md index e9558f106..a1dbf392e 100644 --- a/doc/usecases/metadata.md +++ b/doc/usecases/metadata.md @@ -14,3 +14,19 @@ Gives you the metadata for a given pubkey. \ Uses caching, so repeated calls are ok. Use `broadcastMetadata()` to update or set metadata. + +## Current behavior + +Metadata behaves as replaceable event state. + +That means: + +- reads return the latest visible metadata event for the pubkey +- expired or deleted metadata events are not returned +- repeated reads can return cached results +- `forceRefresh` bypasses cached reads and refreshes from relays + +When you broadcast metadata: + +- the new metadata event is cached +- app reads can observe that cached state before every relay acknowledges it diff --git a/doc/usecases/requests.md b/doc/usecases/requests.md index d4869c2ef..c2939b858 100644 --- a/doc/usecases/requests.md +++ b/doc/usecases/requests.md @@ -52,3 +52,15 @@ final response = ndk.requests.query( ``` If `authenticateAs` is not specified, NDK falls back to the currently logged-in account. + +## Event sources + +When events are cached, NDK may also persist source relay information. + +Current behavior: + +- source relays are optional provenance data +- they answer where an event was observed +- they are separate from relay delivery targets + +For most apps, normal request and usecase APIs are enough and you do not need to access source relay data directly. diff --git a/doc/usecases/user-relay-lists.md b/doc/usecases/user-relay-lists.md index f25837b80..d2f26603e 100644 --- a/doc/usecases/user-relay-lists.md +++ b/doc/usecases/user-relay-lists.md @@ -13,3 +13,18 @@ icon: list-ordered User relay lists provides you with the relays for a given user. \ It orients itself on nip65 but also uses data from nip02 in case nip65 is not available. \ It's used by inbox/outbox; only use this if you are doing something custom that is not directly handled by inbox/outbox. + +## Current behavior + +User relay list resolution combines relay information from: + +- NIP-65 relay list events +- contact-list relay content when needed + +Behavior: + +- the cache stores a computed user relay list projection +- that projection is refreshed from authoritative events when needed +- repeated reads can return cached projections + +For DM relay lists, `getDmRelays()` reads the latest visible relay list event for the user and can refresh from relays when requested. diff --git a/doc/usecases/zaps.md b/doc/usecases/zaps.md index b620f1583..8df29cff9 100644 --- a/doc/usecases/zaps.md +++ b/doc/usecases/zaps.md @@ -4,6 +4,8 @@ icon: zap [!badge variant="primary" text="high level"] +[!badge text="CLI"](/guides/cli/zaps.md) — `ndk zaps invoice|zap|receipts` + ## Example :::code source="../../packages/ndk/example/zaps/zap.dart" language="dart" range="18-51" title="zap, receipt" ::: diff --git a/packages/bc_ur/lib/bytewords.dart b/packages/bc_ur/lib/bytewords.dart index 6ae35ca65..2a4af3887 100644 --- a/packages/bc_ur/lib/bytewords.dart +++ b/packages/bc_ur/lib/bytewords.dart @@ -7,11 +7,7 @@ const String BYTEWORDS = List? _wordArray; -enum Style { - standard, - uri, - minimal, -} +enum Style { standard, uri, minimal } int decodeWord(String word, int wordLen) { if (word.length != wordLen) { @@ -38,7 +34,8 @@ int decodeWord(String word, int wordLen) { // 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) - + 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.'); @@ -130,8 +127,6 @@ String encodeStyle(Style style, Uint8List bytes) { return encodeWithSeparator(bytes, '-'); case Style.minimal: return encodeMinimal(bytes); - default: - throw ArgumentError('Invalid Bytewords style.'); } } @@ -143,7 +138,5 @@ Uint8List decodeStyle(Style style, String str) { 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 index 1e2912aa7..fc44bfe1c 100644 --- a/packages/bc_ur/lib/cashu_animated_qr_example.dart +++ b/packages/bc_ur/lib/cashu_animated_qr_example.dart @@ -113,21 +113,25 @@ void multiPartExample() { if (i % 2 == 0 || decoder.isComplete()) { // Show progress every 2 parts print( - ' Scanned part ${i + 1}/${parts.length} - ${(progress * 100).toStringAsFixed(1)}% complete'); + ' 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); + 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); + final totalAmount = decodedToken.proofs.fold( + 0, + (sum, p) => sum + p.amount, + ); print(' Total amount: $totalAmount ${decodedToken.unit}'); print(' Memo: ${decodedToken.memo}'); } diff --git a/packages/bc_ur/lib/cashu_token_ur_encoder.dart b/packages/bc_ur/lib/cashu_token_ur_encoder.dart index fce43eca1..10c99e213 100644 --- a/packages/bc_ur/lib/cashu_token_ur_encoder.dart +++ b/packages/bc_ur/lib/cashu_token_ur_encoder.dart @@ -19,9 +19,7 @@ class CashuTokenUrEncoder { /// 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, - }) { + static String encodeSinglePart({required CashuToken token}) { try { final json = token.toV4Json(); final myCbor = CborValue(json); diff --git a/packages/bc_ur/lib/cbor_lite.dart b/packages/bc_ur/lib/cbor_lite.dart index c378d17c6..07e6cefe7 100644 --- a/packages/bc_ur/lib/cbor_lite.dart +++ b/packages/bc_ur/lib/cbor_lite.dart @@ -1,9 +1,6 @@ import 'dart:typed_data'; -enum Flag { - none, - requireMinimalEncoding, -} +enum Flag { none, requireMinimalEncoding } class CBORTag { static const int majorUnsignedInteger = 0; @@ -75,15 +72,18 @@ class CBOREncoder { if (length >= 5 && length <= 8) { encodeTagAndAdditional(tag, CBORTag.minorLength8); _buffer.add( - Uint8List(8)..buffer.asByteData().setUint64(0, value, Endian.big)); + 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)); + 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)); + Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.big), + ); } else if (length == 1) { encodeTagAndAdditional(tag, CBORTag.minorLength1); _buffer.addByte(value); @@ -91,7 +91,8 @@ class CBOREncoder { encodeTagAndAdditional(tag, value); } else { throw Exception( - "Unsupported byte length of $length for value in encodeTagAndValue()"); + "Unsupported byte length of $length for value in encodeTagAndValue()", + ); } return 1 + length; @@ -111,7 +112,9 @@ class CBOREncoder { int encodeBool(bool value) { return encodeTagAndValue( - CBORTag.majorSimple, value ? CBORTag.minorTrue : CBORTag.minorFalse); + CBORTag.majorSimple, + value ? CBORTag.minorTrue : CBORTag.minorFalse, + ); } int encodeBytes(Uint8List value) { @@ -122,12 +125,16 @@ class CBOREncoder { int encodeEncodedBytesPrefix(int value) { return encodeTagAndValue( - CBORTag.majorSemantic, CBORTag.minorCborEncodedData); + CBORTag.majorSemantic, + CBORTag.minorCborEncodedData, + ); } int encodeEncodedBytes(Uint8List value) { - int length = - encodeTagAndValue(CBORTag.majorSemantic, CBORTag.minorCborEncodedData); + int length = encodeTagAndValue( + CBORTag.majorSemantic, + CBORTag.minorCborEncodedData, + ); return length + encodeBytes(value); } @@ -197,8 +204,11 @@ class CBORDecoder { throw Exception("Not enough input"); } - ByteData byteData = - ByteData.sublistView(_buffer, _position, _position + bytesToRead); + ByteData byteData = ByteData.sublistView( + _buffer, + _position, + _position + bytesToRead, + ); _position += bytesToRead; switch (bytesToRead) { @@ -271,8 +281,11 @@ class CBORDecoder { throw Exception("Not enough input"); } - Uint8List value = - Uint8List.sublistView(_buffer, _position, _position + byteLength); + Uint8List value = Uint8List.sublistView( + _buffer, + _position, + _position + byteLength, + ); _position += byteLength; return (value, sizeLength + byteLength); } @@ -312,8 +325,11 @@ class CBORDecoder { throw Exception("Not enough input"); } - Uint8List utf8Bytes = - Uint8List.sublistView(_buffer, _position, _position + byteLength); + Uint8List utf8Bytes = Uint8List.sublistView( + _buffer, + _position, + _position + byteLength, + ); _position += byteLength; String value = String.fromCharCodes(utf8Bytes); return (value, sizeLength + byteLength); diff --git a/packages/bc_ur/lib/constants.dart b/packages/bc_ur/lib/constants.dart index 5ee6aa827..743f908f0 100644 --- a/packages/bc_ur/lib/constants.dart +++ b/packages/bc_ur/lib/constants.dart @@ -1,3 +1,4 @@ const int MAX_UINT32 = 0xFFFFFFFF; -final BigInt MAX_UINT64 = - BigInt.parse('18446744073709551615'); // 0xFFFFFFFFFFFFFFFF +final BigInt MAX_UINT64 = BigInt.parse( + '18446744073709551615', +); // 0xFFFFFFFFFFFFFFFF diff --git a/packages/bc_ur/lib/fountain_decoder.dart b/packages/bc_ur/lib/fountain_decoder.dart index 4ffaed118..b9c3076b2 100644 --- a/packages/bc_ur/lib/fountain_decoder.dart +++ b/packages/bc_ur/lib/fountain_decoder.dart @@ -121,8 +121,9 @@ class FountainDecoder { } void reduceBy(FountainDecoderPart p) { - var reducedParts = - mixedParts.values.map((value) => reducePartByPart(value, p)).toList(); + var reducedParts = mixedParts.values + .map((value) => reducePartByPart(value, p)) + .toList(); var newMixed = , FountainDecoderPart>{}; for (var reducedPart in reducedParts) { @@ -137,7 +138,9 @@ class FountainDecoder { } FountainDecoderPart reducePartByPart( - FountainDecoderPart a, FountainDecoderPart b) { + 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); @@ -199,8 +202,9 @@ class FountainDecoder { bool validatePart(FountainEncoderPart p) { if (expectedPartIndexes == null) { - expectedPartIndexes = - Set.from(List.generate(p.seqLen, (i) => i)); + expectedPartIndexes = Set.from( + List.generate(p.seqLen, (i) => i), + ); expectedMessageLen = p.messageLen; expectedChecksum = p.checksum; expectedFragmentLen = p.data.length; diff --git a/packages/bc_ur/lib/fountain_encoder.dart b/packages/bc_ur/lib/fountain_encoder.dart index 731a2a023..38568953c 100644 --- a/packages/bc_ur/lib/fountain_encoder.dart +++ b/packages/bc_ur/lib/fountain_encoder.dart @@ -18,7 +18,12 @@ class FountainEncoderPart { final Uint8List data; FountainEncoderPart( - this.seqNum, this.seqLen, this.messageLen, this.checksum, this.data); + this.seqNum, + this.seqLen, + this.messageLen, + this.checksum, + this.data, + ); static FountainEncoderPart fromCbor(Uint8List cborBuf) { var decoder = CBORDecoder(cborBuf); @@ -59,31 +64,46 @@ class FountainEncoder { 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 { + 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) { + 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++) { + for ( + int fragmentCount = 1; + fragmentCount <= maxFragmentCount; + fragmentCount++ + ) { fragmentLen = (messageLen / fragmentCount).ceil(); if (fragmentLen <= maxFragmentLen) { break; diff --git a/packages/bc_ur/lib/fountain_utils.dart b/packages/bc_ur/lib/fountain_utils.dart index 43e475f9e..bee2bfa8c 100644 --- a/packages/bc_ur/lib/fountain_utils.dart +++ b/packages/bc_ur/lib/fountain_utils.dart @@ -32,8 +32,9 @@ Set chooseFragments(int seqNum, int seqLen, int checksum) { if (seqNum <= seqLen) { return {seqNum - 1}; } else { - Uint8List seed = - Uint8List.fromList(intToBytes(seqNum) + intToBytes(checksum)); + 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); diff --git a/packages/bc_ur/lib/random_sampler.dart b/packages/bc_ur/lib/random_sampler.dart index 0ffe92ef0..2401385f7 100644 --- a/packages/bc_ur/lib/random_sampler.dart +++ b/packages/bc_ur/lib/random_sampler.dart @@ -5,8 +5,8 @@ class RandomSampler { final List _aliases; RandomSampler._(List probs, List _aliases) - : _probs = probs, - _aliases = _aliases {} + : _probs = probs, + _aliases = _aliases {} factory RandomSampler(List probs) { assert(probs.every((p) => p > 0), "All probabilities must be positive"); diff --git a/packages/bc_ur/lib/ur.dart b/packages/bc_ur/lib/ur.dart index deba9bff2..5ac487676 100644 --- a/packages/bc_ur/lib/ur.dart +++ b/packages/bc_ur/lib/ur.dart @@ -19,9 +19,7 @@ class UR { @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is UR && - other.type == type && - _listEquals(other.cbor, cbor); + return other is UR && other.type == type && _listEquals(other.cbor, cbor); } @override @@ -36,4 +34,4 @@ class UR { } return true; } -} \ No newline at end of file +} diff --git a/packages/bc_ur/lib/ur_encoder.dart b/packages/bc_ur/lib/ur_encoder.dart index d9bbadef9..f4284311e 100644 --- a/packages/bc_ur/lib/ur_encoder.dart +++ b/packages/bc_ur/lib/ur_encoder.dart @@ -6,10 +6,17 @@ 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); + 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); diff --git a/packages/bc_ur/lib/utils.dart b/packages/bc_ur/lib/utils.dart index ecc1a090e..fa22a7a5e 100644 --- a/packages/bc_ur/lib/utils.dart +++ b/packages/bc_ur/lib/utils.dart @@ -37,9 +37,9 @@ bool isUrType(String 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)); + (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) { diff --git a/packages/bc_ur/lib/xoshiro256.dart b/packages/bc_ur/lib/xoshiro256.dart index 65df40bbb..78344e4b4 100644 --- a/packages/bc_ur/lib/xoshiro256.dart +++ b/packages/bc_ur/lib/xoshiro256.dart @@ -14,14 +14,14 @@ final List JUMP = [ BigInt.parse('1733541517147835066'), // 0x180ec6d33cfd0aba BigInt.parse('15369461998538869804'), // 0xd5a61266f0c9392c BigInt.parse('12197330014494892970'), // 0xa9582618e03fc9aa - BigInt.parse('4138621300654548508') // 0x39abdc4529b1661c + BigInt.parse('4138621300654548508'), // 0x39abdc4529b1661c ]; final List LONG_JUMP = [ BigInt.parse('8555335991981124543'), // 0x76e15d3efefdcbbf BigInt.parse('14194738350262587827'), // 0xc5004e441c522fb3 BigInt.parse('8593769755450971713'), // 0x77710069854ee241 - BigInt.parse('4111657796531716661') // 0x39109bb02acbe635 + BigInt.parse('4111657796531716661'), // 0x39109bb02acbe635 ]; class Xoshiro256 { diff --git a/packages/bc_ur/test/cashu_token_ur_encoder_test.dart b/packages/bc_ur/test/cashu_token_ur_encoder_test.dart index 59147e74e..798764b5b 100644 --- a/packages/bc_ur/test/cashu_token_ur_encoder_test.dart +++ b/packages/bc_ur/test/cashu_token_ur_encoder_test.dart @@ -106,15 +106,17 @@ void main() { }); test('decode invalid UR string returns null', () { - final decodedToken = - CashuTokenUrEncoder.decodeSinglePart('invalid-ur-string'); + 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'); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart( + 'ur:crypto-seed/oeadgdaxbt', + ); expect(decodedToken, isNull); }); }); @@ -196,8 +198,9 @@ void main() { expect(parts.length, greaterThan(1)); // Decode the complete message - final decodedToken = - CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + final decodedToken = CashuTokenUrEncoder.decodeFromMultiPartDecoder( + decoder, + ); // Verify decoded token matches original expect(decodedToken, isNotNull); @@ -285,8 +288,9 @@ void main() { decoder.receivePart(firstPart); // Try to decode incomplete data - final decodedToken = - CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + final decodedToken = CashuTokenUrEncoder.decodeFromMultiPartDecoder( + decoder, + ); expect(decodedToken, isNull); }); @@ -335,8 +339,9 @@ void main() { expect(decoder.isComplete(), isTrue); - final decodedToken = - CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + final decodedToken = CashuTokenUrEncoder.decodeFromMultiPartDecoder( + decoder, + ); expect(decodedToken, isNotNull); expect(decodedToken!.proofs.length, equals(4)); }); diff --git a/packages/bc_ur/test/ur_test.dart b/packages/bc_ur/test/ur_test.dart index c507bae17..35932ce68 100644 --- a/packages/bc_ur/test/ur_test.dart +++ b/packages/bc_ur/test/ur_test.dart @@ -25,42 +25,68 @@ void main() { 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.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)); + 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)); + 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)); + 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())); + () => 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())); + () => 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())); + () => 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())); + expect( + () => Bytewords.decodeStyle(Bytewords.Style.standard, "wolf"), + throwsA(isA()), + ); + expect( + () => Bytewords.decodeStyle(Bytewords.Style.standard, ""), + throwsA(isA()), + ); }); test('Bytewords 2', () { @@ -164,7 +190,7 @@ void main() { 138, 41, 85, - 24 + 24, ]); var encoded = @@ -173,20 +199,30 @@ void main() { 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)); + 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()); + 100, + (_) => (rng.next() % BigInt.from(100)).toInt(), + ); var expectedNumbers = [ 42, @@ -288,17 +324,20 @@ void main() { 10, 43, 43, - 52 + 52, ]; expect(numbers, equals(expectedNumbers)); }); test('RNG 2', () { - var checksum = - bytesToInt(crc32Bytes(Uint8List.fromList("Wolf".codeUnits))); + var checksum = bytesToInt( + crc32Bytes(Uint8List.fromList("Wolf".codeUnits)), + ); var rng = Xoshiro256.fromCrc32(checksum); var numbers = List.generate( - 100, (_) => (rng.next() % BigInt.from(100)).toInt()); + 100, + (_) => (rng.next() % BigInt.from(100)).toInt(), + ); var expectedNumbers = [ 88, @@ -400,7 +439,7 @@ void main() { 81, 3, 1, - 30 + 30, ]; expect(numbers, equals(expectedNumbers)); }); @@ -509,24 +548,30 @@ void main() { 7, 4, 2, - 5 + 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)); + 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 samples = List.generate( + 500, + (_) => sampler.next(rng.nextDouble), + ); var expectedSamples = [ 3, 3, @@ -1027,7 +1072,7 @@ void main() { 0, 3, 3, - 2 + 2, ]; expect(samples, equals(expectedSamples)); }); @@ -1050,15 +1095,18 @@ void main() { [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] + [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 fragmentLen = FountainEncoder.findNominalFragmentLength( + message.length, + 10, + 100, + ); var fragments = FountainEncoder.partitionMessage(message, fragmentLen); var fragmentsHex = fragments.map((f) => dataToHex(f)).toList(); @@ -1073,20 +1121,25 @@ void main() { "c7daaa14ae5152a067277b1b3902677d979f8e39cc2aafb3bc06fcf69160a853e6869dcc09a11b5009f91e6b89e5b927ab1527a735660faa6012b420dd926d940d742be6a64fb01cdc0cff9faa323f02ba41436871a0eab851e7f5782d10", "fbefde2a7e9ae9dc1e5c2c48f74f6c824ce9ef3c89f68800d44587bedc4ab417cfb3e7447d90e1e417e6e05d30e87239d3a5d1d45993d4461e60a0192831640aa32dedde185a371ded2ae15f8a93dba8809482ce49225daadfbb0fec629e", "23880789bdf9ed73be57fa84d555134630e8d0f7df48349f29869a477c13ccca9cd555ac42ad7f568416c3d61959d0ed568b2b81c7771e9088ad7fd55fd4386bafbf5a528c30f107139249357368ffa980de2c76ddd9ce4191376be0e6b5", - "170010067e2e75ebe2d2904aeb1f89d5dc98cd4a6f2faaa8be6d03354c990fd895a97feb54668473e9d942bb99e196d897e8f1b01625cf48a7b78d249bb4985c065aa8cd1402ed2ba1b6f908f63dcd84b66425df00000000000000000000" + "170010067e2e75ebe2d2904aeb1f89d5dc98cd4a6f2faaa8be6d03354c990fd895a97feb54668473e9d942bb99e196d897e8f1b01625cf48a7b78d249bb4985c065aa8cd1402ed2ba1b6f908f63dcd84b66425df00000000000000000000", ]; expect(fragmentsHex, equals(expectedFragments)); - var rejoinedMessage = - FountainDecoder.joinFragments(fragments, message.length); + 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 fragmentLen = FountainEncoder.findNominalFragmentLength( + message.length, + 10, + 100, + ); var fragments = FountainEncoder.partitionMessage(message, fragmentLen); var degrees = []; @@ -1295,7 +1348,7 @@ void main() { 1, 3, 4, - 10 + 10, ]; expect(degrees, equals(expectedDegrees)); }); @@ -1303,8 +1356,11 @@ void main() { test('Choose Fragments', () { var message = makeMessage(1024); var checksum = crc32Int(message); - var fragmentLen = - FountainEncoder.findNominalFragmentLength(message.length, 10, 100); + var fragmentLen = FountainEncoder.findNominalFragmentLength( + message.length, + 10, + 100, + ); var fragments = FountainEncoder.partitionMessage(message, fragmentLen); var fragmentIndexes = >[]; for (var seqNum = 1; seqNum <= 30; seqNum++) { @@ -1343,7 +1399,7 @@ void main() { [0, 1, 3, 4, 5, 6, 7, 9, 10], [6], [5, 6], - [7] + [7], ]; expect(fragmentIndexes, equals(expectedFragmentIndexes)); }); @@ -1389,7 +1445,7 @@ void main() { "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" + "seqNum:20, seqLen:9, messageLen:256, checksum:23570951, data:e055c2433562184fa71b4be94f262e200f01c6f74c284b0dc6fae6673f", ]; expect(parts, equals(expectedParts)); }); @@ -1421,7 +1477,7 @@ void main() { "8511091901001a0167aa07581d23dedeea74e3a0fb052befabefa13e2f80e4315c9dceed4c8630612e64", "8512091901001a0167aa07581dd01a8daee769ce34b6b35d3ca0005302724abddae405bdb419c0a6b208", "8513091901001a0167aa07581d3171c5dc365766eff25ae47c6f10e7de48cfb8474e050e5fe997a6dc24", - "8514091901001a0167aa07581de055c2433562184fa71b4be94f262e200f01c6f74c284b0dc6fae6673f" + "8514091901001a0167aa07581de055c2433562184fa71b4be94f262e200f01c6f74c284b0dc6fae6673f", ]; expect(parts, equals(expectedParts)); }); @@ -1464,7 +1520,12 @@ void main() { test('Fountain CBOR', () { var part = FountainEncoderPart( - 12, 8, 100, 0x12345678, Uint8List.fromList([1, 5, 3, 3, 5])); + 12, + 8, + 100, + 0x12345678, + Uint8List.fromList([1, 5, 3, 3, 5]), + ); var cbor = part.cbor(); var part2 = FountainEncoderPart.fromCbor(cbor); var cbor2 = part2.cbor(); @@ -1518,7 +1579,7 @@ void main() { "ur:bytes/17-9/lpbyascfadaxcywenbpljkhdcamklgftaxykpewyrtqzhydntpnytyisincxmhtbceaykolduortotiaiaiafhiaoyce", "ur:bytes/18-9/lpbgascfadaxcywenbpljkhdcahkadaemejtswhhylkepmykhhtsytsnoyoyaxaedsuttydmmhhpktpmsrjtntwkbkwy", "ur:bytes/19-9/lpbwascfadaxcywenbpljkhdcadekicpaajootjzpsdrbalpeywllbdsnbinaerkurspbncxgslgftvtsrjtksplcpeo", - "ur:bytes/20-9/lpbbascfadaxcywenbpljkhdcayapmrleeleaxpasfrtrdkncffwjyjzgyetdmlewtkpktgllepfrltataztksmhkbot" + "ur:bytes/20-9/lpbbascfadaxcywenbpljkhdcayapmrleeleaxpasfrtrdkncffwjyjzgyetdmlewtkpktgllepfrltataztksmhkbot", ]; expect(parts, equals(expectedParts)); }); @@ -1551,7 +1612,7 @@ void main() { "str": "hello", "list": [1, 2, 3], "map": {"a": 1, "b": 2}, - "null": null + "null": null, }; var sourceBytes = utf8.encode(json.encode(sourceJson)); var cborEncoder = CBOREncoder(); @@ -1559,9 +1620,11 @@ void main() { var ur = UR("bytes", cborEncoder.getBytes()); var encoded = UREncoder.encode(ur); expect( - encoded, - equals( - 'ur:bytes/hdghkgcpinjtjycpfteheyeodwcpidjljljzcpftjyjpkpihdwcpjkjyjpcpftcpisihjzjzjlcpdwcpjzinjkjycpfthpehdweydweohldwcpjnhsjocpftkgcphscpftehdwcpidcpfteykidwcpjtkpjzjzcpftjtkpjzjzkidndrpmhe')); + encoded, + equals( + 'ur:bytes/hdghkgcpinjtjycpfteheyeodwcpidjljljzcpftjyjpkpihdwcpjkjyjpcpftcpisihjzjzjlcpdwcpjzinjkjycpfthpehdweydweohldwcpjnhsjocpftkgcphscpftehdwcpidcpfteykidwcpjtkpjzjzcpftjtkpjzjzkidndrpmhe', + ), + ); }); test('UR Decode json', () { @@ -1572,15 +1635,16 @@ void main() { 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 - })); + json.decode(decoded), + equals({ + "int": 123, + "bool": true, + "str": "hello", + "list": [1, 2, 3], + "map": {"a": 1, "b": 2}, + "null": null, + }), + ); }); }); } diff --git a/packages/bip32_keys/lib/src/bip32_ecurve.dart b/packages/bip32_keys/lib/src/bip32_ecurve.dart index 04c943fe1..40d71e1e6 100644 --- a/packages/bip32_keys/lib/src/bip32_ecurve.dart +++ b/packages/bip32_keys/lib/src/bip32_ecurve.dart @@ -15,10 +15,12 @@ import 'package:pointycastle/signers/ecdsa_signer.dart'; import 'package:pointycastle/src/utils.dart' show negativeFlag; final zero32 = Uint8List.fromList(List.generate(32, (index) => 0)); -final ecGroupOrder = hex - .decode('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); -final ecP = hex - .decode('fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f'); +final ecGroupOrder = hex.decode( + 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +final ecP = hex.decode( + 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f', +); final secp256k1 = ECCurve_secp256k1(); final n = secp256k1.n; final g = secp256k1.G; diff --git a/packages/bip32_keys/lib/src/bip32_keys_base.dart b/packages/bip32_keys/lib/src/bip32_keys_base.dart index 10b1bd10d..14d5d48f5 100644 --- a/packages/bip32_keys/lib/src/bip32_keys_base.dart +++ b/packages/bip32_keys/lib/src/bip32_keys_base.dart @@ -36,8 +36,11 @@ class Bip32Keys { bool get isNeutered => _d == null; Bip32Keys get neutered { - final neutered = - Bip32Keys.fromPublicKey(public, chainCode, network: network); + final neutered = Bip32Keys.fromPublicKey( + public, + chainCode, + network: network, + ); neutered.depth = depth; neutered.index = index; neutered.parentFingerprint = parentFingerprint; @@ -65,8 +68,9 @@ class Bip32Keys { } final depth = buffer[Constants.depthOffset]; - final parentFingerprint = - bytes.getUint32(Constants.parentFingerprintOffset); + final parentFingerprint = bytes.getUint32( + Constants.parentFingerprintOffset, + ); if (depth == Constants.minDepth) { if (parentFingerprint != Constants.defaultParentFingerprint) { throw ArgumentError(Constants.errorInvalidParentFingerprint); @@ -86,11 +90,15 @@ class Bip32Keys { throw ArgumentError(Constants.errorInvalidPrivateKey); } final k = buffer.sublist( - Constants.privateKeyOffset, Constants.extendedKeyLength); + Constants.privateKeyOffset, + Constants.extendedKeyLength, + ); hd = Bip32Keys.fromPrivateKey(k, chainCode, network: network); } else { final x = buffer.sublist( - Constants.publicKeyOffset, Constants.extendedKeyLength); + Constants.publicKeyOffset, + Constants.extendedKeyLength, + ); hd = Bip32Keys.fromPublicKey(x, chainCode, network: network); } @@ -100,8 +108,11 @@ class Bip32Keys { return hd; } - factory Bip32Keys.fromPublicKey(Uint8List publicKey, Uint8List chainCode, - {Bip32Network? network}) { + factory Bip32Keys.fromPublicKey( + Uint8List publicKey, + Uint8List chainCode, { + Bip32Network? network, + }) { network ??= Constants.bitcoin; if (!ecc.isPoint(publicKey)) { @@ -229,8 +240,9 @@ class Bip32Keys { String toBase58({Bip32Network? overrideNetwork}) { final network = overrideNetwork ?? this.network; - final version = - isNeutered ? network.version.public : network.version.private; + final version = isNeutered + ? network.version.public + : network.version.private; final buffer = Uint8List(78); final bytes = buffer.buffer.asByteData(); @@ -254,11 +266,7 @@ class Bip32Keys { if (private == null) throw ArgumentError(Constants.errorMissingPrivateKey); return wif.encode( - wif.WIF( - version: network.wif, - privateKey: private!, - compressed: true, - ), + wif.WIF(version: network.wif, privateKey: private!, compressed: true), ); } } diff --git a/packages/bip32_keys/lib/src/bip32_wif.dart b/packages/bip32_keys/lib/src/bip32_wif.dart index aaccfd173..5c6428945 100644 --- a/packages/bip32_keys/lib/src/bip32_wif.dart +++ b/packages/bip32_keys/lib/src/bip32_wif.dart @@ -59,6 +59,7 @@ WIF decode(String string, [int? version]) { } String encode(WIF wif) { - return bs58check - .encode(encodeRaw(wif.version, wif.privateKey, wif.compressed)); + return bs58check.encode( + encodeRaw(wif.version, wif.privateKey, wif.compressed), + ); } diff --git a/packages/drift/example/drift_cache_manager_example.dart b/packages/drift/example/drift_cache_manager_example.dart index 825623f7f..24cf41d4e 100644 --- a/packages/drift/example/drift_cache_manager_example.dart +++ b/packages/drift/example/drift_cache_manager_example.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:ndk_drift/ndk_drift.dart'; import 'package:ndk/ndk.dart'; diff --git a/packages/drift/lib/src/database/database.dart b/packages/drift/lib/src/database/database.dart index 4778c72e6..afe82f93f 100644 --- a/packages/drift/lib/src/database/database.dart +++ b/packages/drift/lib/src/database/database.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; +import 'package:ndk/ndk.dart' show ContactList, Metadata, Nip01Event; import 'package:path_provider/path_provider.dart'; import 'tables.dart'; @@ -10,12 +13,13 @@ part 'database.g.dart'; @DriftDatabase( tables: [ Events, - Metadatas, - ContactLists, UserRelayLists, RelaySets, Nip05s, FilterFetchedRangeRecords, + EventSourcesTable, + EventDeliveryRecordsTable, + RelayDeliveryTargetsTable, // Cashu tables CashuProofs, CashuKeysets, @@ -37,7 +41,7 @@ class NdkCacheDatabase extends _$NdkCacheDatabase { NdkCacheDatabase.forTesting(super.e); @override - int get schemaVersion => 4; + int get schemaVersion => 6; @override MigrationStrategy get migration { @@ -46,11 +50,6 @@ class NdkCacheDatabase extends _$NdkCacheDatabase { await m.createAll(); }, onUpgrade: (Migrator m, int from, int to) async { - if (from < 2) { - // Add new columns for metadata tags and rawContent - await m.addColumn(metadatas, metadatas.tagsJson); - await m.addColumn(metadatas, metadatas.rawContentJson); - } if (from < 3) { // Add Cashu tables await m.createTable(cashuProofs); @@ -64,10 +63,154 @@ class NdkCacheDatabase extends _$NdkCacheDatabase { // Add key-value table for settings await m.createTable(keyValues); } + if (from < 5) { + // Add event source provenance and delivery tracking tables + await m.createTable(eventSourcesTable); + await m.createTable(eventDeliveryRecordsTable); + await m.createTable(relayDeliveryTargetsTable); + } + if (from < 6) { + // Metadata and contact-list projections are derived from generic + // events now. Copy legacy rows into the events table so cached + // profiles and follow lists survive the upgrade, then drop the + // legacy tables. + await _migrateLegacyMetadatas(); + await _migrateLegacyContactLists(); + } }, ); } + /// Copies rows of the pre-v6 `metadatas` table into the events table as + /// projected kind-0 events (unsigned, `sig` stays null). + Future _migrateLegacyMetadatas() async { + if (!await _legacyTableExists('metadatas')) return; + final rows = await customSelect('SELECT * FROM metadatas').get(); + for (final row in rows) { + final updatedAt = row.readNullable('updated_at'); + // Without the original created_at the event cannot be ordered against + // relay data, so such rows are dropped. + if (updatedAt == null || updatedAt <= 0) continue; + var content = row.readNullable('raw_content_json'); + if (content == null || content.isEmpty) { + final json = {}; + for (final field in const [ + 'name', + 'display_name', + 'picture', + 'banner', + 'website', + 'about', + 'nip05', + 'lud16', + 'lud06', + ]) { + final value = row.readNullable(field); + if (value != null) json[field] = value; + } + content = jsonEncode(json); + } + await _insertProjectedEvent( + Nip01Event( + pubKey: row.read('pub_key'), + kind: Metadata.kKind, + tags: _decodeTagList(row.readNullable('tags_json')), + content: content, + createdAt: updatedAt, + ), + sources: _decodeStringList(row.readNullable('sources_json')), + ); + } + await customStatement('DROP TABLE metadatas'); + } + + /// Copies rows of the pre-v6 `contact_lists` table into the events table as + /// projected kind-3 events (unsigned, `sig` stays null). + Future _migrateLegacyContactLists() async { + if (!await _legacyTableExists('contact_lists')) return; + final rows = await customSelect('SELECT * FROM contact_lists').get(); + for (final row in rows) { + final createdAt = row.read('created_at'); + if (createdAt <= 0) continue; + final contactList = + ContactList( + pubKey: row.read('pub_key'), + contacts: _decodeStringList( + row.readNullable('contacts_json'), + ), + ) + ..contactRelays = _decodeStringList( + row.readNullable('contact_relays_json'), + ) + ..petnames = _decodeStringList( + row.readNullable('petnames_json'), + ) + ..followedTags = _decodeStringList( + row.readNullable('followed_tags_json'), + ) + ..followedCommunities = _decodeStringList( + row.readNullable('followed_communities_json'), + ) + ..followedEvents = _decodeStringList( + row.readNullable('followed_events_json'), + ) + ..createdAt = createdAt; + await _insertProjectedEvent( + contactList.toEvent(), + sources: _decodeStringList(row.readNullable('sources_json')), + ); + } + await customStatement('DROP TABLE contact_lists'); + } + + Future _legacyTableExists(String name) async { + final rows = await customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable.withString(name)], + ).get(); + return rows.isNotEmpty; + } + + Future _insertProjectedEvent( + Nip01Event event, { + required List sources, + }) async { + await into(events).insert( + EventsCompanion.insert( + id: event.id, + pubKey: event.pubKey, + kind: event.kind, + createdAt: event.createdAt, + content: event.content, + sig: Value(event.sig), + validSig: Value(event.validSig), + tagsJson: jsonEncode(event.tags), + sourcesJson: jsonEncode(sources), + ), + mode: InsertMode.insertOrIgnore, + ); + } + + List _decodeStringList(String? json) { + if (json == null || json.isEmpty) return []; + try { + return List.from(jsonDecode(json) as List); + } catch (_) { + return []; + } + } + + List> _decodeTagList(String? json) { + if (json == null || json.isEmpty) return []; + try { + return (jsonDecode(json) as List) + .map((tag) => List.from(tag as List)) + .toList(); + } catch (_) { + return []; + } + } + static QueryExecutor _openConnection(String dbName) { return driftDatabase( name: dbName, diff --git a/packages/drift/lib/src/database/database.g.dart b/packages/drift/lib/src/database/database.g.dart index de5546709..db597c9b1 100644 --- a/packages/drift/lib/src/database/database.g.dart +++ b/packages/drift/lib/src/database/database.g.dart @@ -564,12 +564,12 @@ class EventsCompanion extends UpdateCompanion { } } -class $MetadatasTable extends Metadatas - with TableInfo<$MetadatasTable, DbMetadata> { +class $UserRelayListsTable extends UserRelayLists + with TableInfo<$UserRelayListsTable, DbUserRelayList> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $MetadatasTable(this.attachedDatabase, [this._alias]); + $UserRelayListsTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); @override late final GeneratedColumn pubKey = GeneratedColumn( @@ -579,103 +579,16 @@ class $MetadatasTable extends Metadatas type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _displayNameMeta = const VerificationMeta( - 'displayName', - ); - @override - late final GeneratedColumn displayName = GeneratedColumn( - 'display_name', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _pictureMeta = const VerificationMeta( - 'picture', - ); - @override - late final GeneratedColumn picture = GeneratedColumn( - 'picture', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _bannerMeta = const VerificationMeta('banner'); - @override - late final GeneratedColumn banner = GeneratedColumn( - 'banner', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _websiteMeta = const VerificationMeta( - 'website', - ); - @override - late final GeneratedColumn website = GeneratedColumn( - 'website', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _aboutMeta = const VerificationMeta('about'); - @override - late final GeneratedColumn about = GeneratedColumn( - 'about', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _nip05Meta = const VerificationMeta('nip05'); - @override - late final GeneratedColumn nip05 = GeneratedColumn( - 'nip05', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _lud16Meta = const VerificationMeta('lud16'); - @override - late final GeneratedColumn lud16 = GeneratedColumn( - 'lud16', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _lud06Meta = const VerificationMeta('lud06'); - @override - late final GeneratedColumn lud06 = GeneratedColumn( - 'lud06', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _updatedAtMeta = const VerificationMeta( - 'updatedAt', + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', ); @override - late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, - true, + false, type: DriftSqlType.int, - requiredDuringInsert: false, + requiredDuringInsert: true, ); static const VerificationMeta _refreshedTimestampMeta = const VerificationMeta('refreshedTimestamp'); @@ -683,70 +596,36 @@ class $MetadatasTable extends Metadatas late final GeneratedColumn refreshedTimestamp = GeneratedColumn( 'refreshed_timestamp', aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _sourcesJsonMeta = const VerificationMeta( - 'sourcesJson', - ); - @override - late final GeneratedColumn sourcesJson = GeneratedColumn( - 'sources_json', - aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _tagsJsonMeta = const VerificationMeta( - 'tagsJson', + static const VerificationMeta _relaysJsonMeta = const VerificationMeta( + 'relaysJson', ); @override - late final GeneratedColumn tagsJson = GeneratedColumn( - 'tags_json', + late final GeneratedColumn relaysJson = GeneratedColumn( + 'relays_json', aliasedName, false, type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('[]'), - ); - static const VerificationMeta _rawContentJsonMeta = const VerificationMeta( - 'rawContentJson', - ); - @override - late final GeneratedColumn rawContentJson = GeneratedColumn( - 'raw_content_json', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, + requiredDuringInsert: true, ); @override List get $columns => [ pubKey, - name, - displayName, - picture, - banner, - website, - about, - nip05, - lud16, - lud06, - updatedAt, + createdAt, refreshedTimestamp, - sourcesJson, - tagsJson, - rawContentJson, + relaysJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'metadatas'; + static const String $name = 'user_relay_lists'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -759,68 +638,13 @@ class $MetadatasTable extends Metadatas } else if (isInserting) { context.missing(_pubKeyMeta); } - if (data.containsKey('name')) { - context.handle( - _nameMeta, - name.isAcceptableOrUnknown(data['name']!, _nameMeta), - ); - } - if (data.containsKey('display_name')) { - context.handle( - _displayNameMeta, - displayName.isAcceptableOrUnknown( - data['display_name']!, - _displayNameMeta, - ), - ); - } - if (data.containsKey('picture')) { - context.handle( - _pictureMeta, - picture.isAcceptableOrUnknown(data['picture']!, _pictureMeta), - ); - } - if (data.containsKey('banner')) { - context.handle( - _bannerMeta, - banner.isAcceptableOrUnknown(data['banner']!, _bannerMeta), - ); - } - if (data.containsKey('website')) { - context.handle( - _websiteMeta, - website.isAcceptableOrUnknown(data['website']!, _websiteMeta), - ); - } - if (data.containsKey('about')) { - context.handle( - _aboutMeta, - about.isAcceptableOrUnknown(data['about']!, _aboutMeta), - ); - } - if (data.containsKey('nip05')) { - context.handle( - _nip05Meta, - nip05.isAcceptableOrUnknown(data['nip05']!, _nip05Meta), - ); - } - if (data.containsKey('lud16')) { - context.handle( - _lud16Meta, - lud16.isAcceptableOrUnknown(data['lud16']!, _lud16Meta), - ); - } - if (data.containsKey('lud06')) { - context.handle( - _lud06Meta, - lud06.isAcceptableOrUnknown(data['lud06']!, _lud06Meta), - ); - } - if (data.containsKey('updated_at')) { + if (data.containsKey('created_at')) { context.handle( - _updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), ); + } else if (isInserting) { + context.missing(_createdAtMeta); } if (data.containsKey('refreshed_timestamp')) { context.handle( @@ -830,32 +654,16 @@ class $MetadatasTable extends Metadatas _refreshedTimestampMeta, ), ); - } - if (data.containsKey('sources_json')) { - context.handle( - _sourcesJsonMeta, - sourcesJson.isAcceptableOrUnknown( - data['sources_json']!, - _sourcesJsonMeta, - ), - ); } else if (isInserting) { - context.missing(_sourcesJsonMeta); - } - if (data.containsKey('tags_json')) { - context.handle( - _tagsJsonMeta, - tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), - ); + context.missing(_refreshedTimestampMeta); } - if (data.containsKey('raw_content_json')) { + if (data.containsKey('relays_json')) { context.handle( - _rawContentJsonMeta, - rawContentJson.isAcceptableOrUnknown( - data['raw_content_json']!, - _rawContentJsonMeta, - ), + _relaysJsonMeta, + relaysJson.isAcceptableOrUnknown(data['relays_json']!, _relaysJsonMeta), ); + } else if (isInserting) { + context.missing(_relaysJsonMeta); } return context; } @@ -863,219 +671,74 @@ class $MetadatasTable extends Metadatas @override Set get $primaryKey => {pubKey}; @override - DbMetadata map(Map data, {String? tablePrefix}) { + DbUserRelayList map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbMetadata( + return DbUserRelayList( pubKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}pub_key'], )!, - name: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - ), - displayName: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}display_name'], - ), - picture: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}picture'], - ), - banner: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}banner'], - ), - website: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}website'], - ), - about: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}about'], - ), - nip05: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}nip05'], - ), - lud16: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}lud16'], - ), - lud06: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}lud06'], - ), - updatedAt: attachedDatabase.typeMapping.read( + createdAt: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}updated_at'], - ), + data['${effectivePrefix}created_at'], + )!, refreshedTimestamp: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}refreshed_timestamp'], - ), - sourcesJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}sources_json'], )!, - tagsJson: attachedDatabase.typeMapping.read( + relaysJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}tags_json'], + data['${effectivePrefix}relays_json'], )!, - rawContentJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}raw_content_json'], - ), ); } @override - $MetadatasTable createAlias(String alias) { - return $MetadatasTable(attachedDatabase, alias); + $UserRelayListsTable createAlias(String alias) { + return $UserRelayListsTable(attachedDatabase, alias); } } -class DbMetadata extends DataClass implements Insertable { +class DbUserRelayList extends DataClass implements Insertable { final String pubKey; - final String? name; - final String? displayName; - final String? picture; - final String? banner; - final String? website; - final String? about; - final String? nip05; - final String? lud16; - final String? lud06; - final int? updatedAt; - final int? refreshedTimestamp; - final String sourcesJson; - final String tagsJson; - final String? rawContentJson; - const DbMetadata({ + final int createdAt; + final int refreshedTimestamp; + final String relaysJson; + const DbUserRelayList({ required this.pubKey, - this.name, - this.displayName, - this.picture, - this.banner, - this.website, - this.about, - this.nip05, - this.lud16, - this.lud06, - this.updatedAt, - this.refreshedTimestamp, - required this.sourcesJson, - required this.tagsJson, - this.rawContentJson, + required this.createdAt, + required this.refreshedTimestamp, + required this.relaysJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['pub_key'] = Variable(pubKey); - if (!nullToAbsent || name != null) { - map['name'] = Variable(name); - } - if (!nullToAbsent || displayName != null) { - map['display_name'] = Variable(displayName); - } - if (!nullToAbsent || picture != null) { - map['picture'] = Variable(picture); - } - if (!nullToAbsent || banner != null) { - map['banner'] = Variable(banner); - } - if (!nullToAbsent || website != null) { - map['website'] = Variable(website); - } - if (!nullToAbsent || about != null) { - map['about'] = Variable(about); - } - if (!nullToAbsent || nip05 != null) { - map['nip05'] = Variable(nip05); - } - if (!nullToAbsent || lud16 != null) { - map['lud16'] = Variable(lud16); - } - if (!nullToAbsent || lud06 != null) { - map['lud06'] = Variable(lud06); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); - } - if (!nullToAbsent || refreshedTimestamp != null) { - map['refreshed_timestamp'] = Variable(refreshedTimestamp); - } - map['sources_json'] = Variable(sourcesJson); - map['tags_json'] = Variable(tagsJson); - if (!nullToAbsent || rawContentJson != null) { - map['raw_content_json'] = Variable(rawContentJson); - } - return map; - } + map['created_at'] = Variable(createdAt); + map['refreshed_timestamp'] = Variable(refreshedTimestamp); + map['relays_json'] = Variable(relaysJson); + return map; + } - MetadatasCompanion toCompanion(bool nullToAbsent) { - return MetadatasCompanion( + UserRelayListsCompanion toCompanion(bool nullToAbsent) { + return UserRelayListsCompanion( pubKey: Value(pubKey), - name: name == null && nullToAbsent ? const Value.absent() : Value(name), - displayName: displayName == null && nullToAbsent - ? const Value.absent() - : Value(displayName), - picture: picture == null && nullToAbsent - ? const Value.absent() - : Value(picture), - banner: banner == null && nullToAbsent - ? const Value.absent() - : Value(banner), - website: website == null && nullToAbsent - ? const Value.absent() - : Value(website), - about: about == null && nullToAbsent - ? const Value.absent() - : Value(about), - nip05: nip05 == null && nullToAbsent - ? const Value.absent() - : Value(nip05), - lud16: lud16 == null && nullToAbsent - ? const Value.absent() - : Value(lud16), - lud06: lud06 == null && nullToAbsent - ? const Value.absent() - : Value(lud06), - updatedAt: updatedAt == null && nullToAbsent - ? const Value.absent() - : Value(updatedAt), - refreshedTimestamp: refreshedTimestamp == null && nullToAbsent - ? const Value.absent() - : Value(refreshedTimestamp), - sourcesJson: Value(sourcesJson), - tagsJson: Value(tagsJson), - rawContentJson: rawContentJson == null && nullToAbsent - ? const Value.absent() - : Value(rawContentJson), + createdAt: Value(createdAt), + refreshedTimestamp: Value(refreshedTimestamp), + relaysJson: Value(relaysJson), ); } - factory DbMetadata.fromJson( + factory DbUserRelayList.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbMetadata( + return DbUserRelayList( pubKey: serializer.fromJson(json['pubKey']), - name: serializer.fromJson(json['name']), - displayName: serializer.fromJson(json['displayName']), - picture: serializer.fromJson(json['picture']), - banner: serializer.fromJson(json['banner']), - website: serializer.fromJson(json['website']), - about: serializer.fromJson(json['about']), - nip05: serializer.fromJson(json['nip05']), - lud16: serializer.fromJson(json['lud16']), - lud06: serializer.fromJson(json['lud06']), - updatedAt: serializer.fromJson(json['updatedAt']), - refreshedTimestamp: serializer.fromJson(json['refreshedTimestamp']), - sourcesJson: serializer.fromJson(json['sourcesJson']), - tagsJson: serializer.fromJson(json['tagsJson']), - rawContentJson: serializer.fromJson(json['rawContentJson']), + createdAt: serializer.fromJson(json['createdAt']), + refreshedTimestamp: serializer.fromJson(json['refreshedTimestamp']), + relaysJson: serializer.fromJson(json['relaysJson']), ); } @override @@ -1083,275 +746,111 @@ class DbMetadata extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'pubKey': serializer.toJson(pubKey), - 'name': serializer.toJson(name), - 'displayName': serializer.toJson(displayName), - 'picture': serializer.toJson(picture), - 'banner': serializer.toJson(banner), - 'website': serializer.toJson(website), - 'about': serializer.toJson(about), - 'nip05': serializer.toJson(nip05), - 'lud16': serializer.toJson(lud16), - 'lud06': serializer.toJson(lud06), - 'updatedAt': serializer.toJson(updatedAt), - 'refreshedTimestamp': serializer.toJson(refreshedTimestamp), - 'sourcesJson': serializer.toJson(sourcesJson), - 'tagsJson': serializer.toJson(tagsJson), - 'rawContentJson': serializer.toJson(rawContentJson), + 'createdAt': serializer.toJson(createdAt), + 'refreshedTimestamp': serializer.toJson(refreshedTimestamp), + 'relaysJson': serializer.toJson(relaysJson), }; } - DbMetadata copyWith({ + DbUserRelayList copyWith({ String? pubKey, - Value name = const Value.absent(), - Value displayName = const Value.absent(), - Value picture = const Value.absent(), - Value banner = const Value.absent(), - Value website = const Value.absent(), - Value about = const Value.absent(), - Value nip05 = const Value.absent(), - Value lud16 = const Value.absent(), - Value lud06 = const Value.absent(), - Value updatedAt = const Value.absent(), - Value refreshedTimestamp = const Value.absent(), - String? sourcesJson, - String? tagsJson, - Value rawContentJson = const Value.absent(), - }) => DbMetadata( + int? createdAt, + int? refreshedTimestamp, + String? relaysJson, + }) => DbUserRelayList( pubKey: pubKey ?? this.pubKey, - name: name.present ? name.value : this.name, - displayName: displayName.present ? displayName.value : this.displayName, - picture: picture.present ? picture.value : this.picture, - banner: banner.present ? banner.value : this.banner, - website: website.present ? website.value : this.website, - about: about.present ? about.value : this.about, - nip05: nip05.present ? nip05.value : this.nip05, - lud16: lud16.present ? lud16.value : this.lud16, - lud06: lud06.present ? lud06.value : this.lud06, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, - refreshedTimestamp: refreshedTimestamp.present - ? refreshedTimestamp.value - : this.refreshedTimestamp, - sourcesJson: sourcesJson ?? this.sourcesJson, - tagsJson: tagsJson ?? this.tagsJson, - rawContentJson: rawContentJson.present - ? rawContentJson.value - : this.rawContentJson, + createdAt: createdAt ?? this.createdAt, + refreshedTimestamp: refreshedTimestamp ?? this.refreshedTimestamp, + relaysJson: relaysJson ?? this.relaysJson, ); - DbMetadata copyWithCompanion(MetadatasCompanion data) { - return DbMetadata( + DbUserRelayList copyWithCompanion(UserRelayListsCompanion data) { + return DbUserRelayList( pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, - name: data.name.present ? data.name.value : this.name, - displayName: data.displayName.present - ? data.displayName.value - : this.displayName, - picture: data.picture.present ? data.picture.value : this.picture, - banner: data.banner.present ? data.banner.value : this.banner, - website: data.website.present ? data.website.value : this.website, - about: data.about.present ? data.about.value : this.about, - nip05: data.nip05.present ? data.nip05.value : this.nip05, - lud16: data.lud16.present ? data.lud16.value : this.lud16, - lud06: data.lud06.present ? data.lud06.value : this.lud06, - updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, refreshedTimestamp: data.refreshedTimestamp.present ? data.refreshedTimestamp.value : this.refreshedTimestamp, - sourcesJson: data.sourcesJson.present - ? data.sourcesJson.value - : this.sourcesJson, - tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, - rawContentJson: data.rawContentJson.present - ? data.rawContentJson.value - : this.rawContentJson, + relaysJson: data.relaysJson.present + ? data.relaysJson.value + : this.relaysJson, ); } @override String toString() { - return (StringBuffer('DbMetadata(') + return (StringBuffer('DbUserRelayList(') ..write('pubKey: $pubKey, ') - ..write('name: $name, ') - ..write('displayName: $displayName, ') - ..write('picture: $picture, ') - ..write('banner: $banner, ') - ..write('website: $website, ') - ..write('about: $about, ') - ..write('nip05: $nip05, ') - ..write('lud16: $lud16, ') - ..write('lud06: $lud06, ') - ..write('updatedAt: $updatedAt, ') + ..write('createdAt: $createdAt, ') ..write('refreshedTimestamp: $refreshedTimestamp, ') - ..write('sourcesJson: $sourcesJson, ') - ..write('tagsJson: $tagsJson, ') - ..write('rawContentJson: $rawContentJson') + ..write('relaysJson: $relaysJson') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( - pubKey, - name, - displayName, - picture, - banner, - website, - about, - nip05, - lud16, - lud06, - updatedAt, - refreshedTimestamp, - sourcesJson, - tagsJson, - rawContentJson, - ); + int get hashCode => + Object.hash(pubKey, createdAt, refreshedTimestamp, relaysJson); @override bool operator ==(Object other) => identical(this, other) || - (other is DbMetadata && + (other is DbUserRelayList && other.pubKey == this.pubKey && - other.name == this.name && - other.displayName == this.displayName && - other.picture == this.picture && - other.banner == this.banner && - other.website == this.website && - other.about == this.about && - other.nip05 == this.nip05 && - other.lud16 == this.lud16 && - other.lud06 == this.lud06 && - other.updatedAt == this.updatedAt && + other.createdAt == this.createdAt && other.refreshedTimestamp == this.refreshedTimestamp && - other.sourcesJson == this.sourcesJson && - other.tagsJson == this.tagsJson && - other.rawContentJson == this.rawContentJson); + other.relaysJson == this.relaysJson); } -class MetadatasCompanion extends UpdateCompanion { +class UserRelayListsCompanion extends UpdateCompanion { final Value pubKey; - final Value name; - final Value displayName; - final Value picture; - final Value banner; - final Value website; - final Value about; - final Value nip05; - final Value lud16; - final Value lud06; - final Value updatedAt; - final Value refreshedTimestamp; - final Value sourcesJson; - final Value tagsJson; - final Value rawContentJson; + final Value createdAt; + final Value refreshedTimestamp; + final Value relaysJson; final Value rowid; - const MetadatasCompanion({ + const UserRelayListsCompanion({ this.pubKey = const Value.absent(), - this.name = const Value.absent(), - this.displayName = const Value.absent(), - this.picture = const Value.absent(), - this.banner = const Value.absent(), - this.website = const Value.absent(), - this.about = const Value.absent(), - this.nip05 = const Value.absent(), - this.lud16 = const Value.absent(), - this.lud06 = const Value.absent(), - this.updatedAt = const Value.absent(), + this.createdAt = const Value.absent(), this.refreshedTimestamp = const Value.absent(), - this.sourcesJson = const Value.absent(), - this.tagsJson = const Value.absent(), - this.rawContentJson = const Value.absent(), + this.relaysJson = const Value.absent(), this.rowid = const Value.absent(), }); - MetadatasCompanion.insert({ + UserRelayListsCompanion.insert({ required String pubKey, - this.name = const Value.absent(), - this.displayName = const Value.absent(), - this.picture = const Value.absent(), - this.banner = const Value.absent(), - this.website = const Value.absent(), - this.about = const Value.absent(), - this.nip05 = const Value.absent(), - this.lud16 = const Value.absent(), - this.lud06 = const Value.absent(), - this.updatedAt = const Value.absent(), - this.refreshedTimestamp = const Value.absent(), - required String sourcesJson, - this.tagsJson = const Value.absent(), - this.rawContentJson = const Value.absent(), + required int createdAt, + required int refreshedTimestamp, + required String relaysJson, this.rowid = const Value.absent(), }) : pubKey = Value(pubKey), - sourcesJson = Value(sourcesJson); - static Insertable custom({ + createdAt = Value(createdAt), + refreshedTimestamp = Value(refreshedTimestamp), + relaysJson = Value(relaysJson); + static Insertable custom({ Expression? pubKey, - Expression? name, - Expression? displayName, - Expression? picture, - Expression? banner, - Expression? website, - Expression? about, - Expression? nip05, - Expression? lud16, - Expression? lud06, - Expression? updatedAt, + Expression? createdAt, Expression? refreshedTimestamp, - Expression? sourcesJson, - Expression? tagsJson, - Expression? rawContentJson, + Expression? relaysJson, Expression? rowid, }) { return RawValuesInsertable({ if (pubKey != null) 'pub_key': pubKey, - if (name != null) 'name': name, - if (displayName != null) 'display_name': displayName, - if (picture != null) 'picture': picture, - if (banner != null) 'banner': banner, - if (website != null) 'website': website, - if (about != null) 'about': about, - if (nip05 != null) 'nip05': nip05, - if (lud16 != null) 'lud16': lud16, - if (lud06 != null) 'lud06': lud06, - if (updatedAt != null) 'updated_at': updatedAt, + if (createdAt != null) 'created_at': createdAt, if (refreshedTimestamp != null) 'refreshed_timestamp': refreshedTimestamp, - if (sourcesJson != null) 'sources_json': sourcesJson, - if (tagsJson != null) 'tags_json': tagsJson, - if (rawContentJson != null) 'raw_content_json': rawContentJson, + if (relaysJson != null) 'relays_json': relaysJson, if (rowid != null) 'rowid': rowid, }); } - MetadatasCompanion copyWith({ + UserRelayListsCompanion copyWith({ Value? pubKey, - Value? name, - Value? displayName, - Value? picture, - Value? banner, - Value? website, - Value? about, - Value? nip05, - Value? lud16, - Value? lud06, - Value? updatedAt, - Value? refreshedTimestamp, - Value? sourcesJson, - Value? tagsJson, - Value? rawContentJson, + Value? createdAt, + Value? refreshedTimestamp, + Value? relaysJson, Value? rowid, }) { - return MetadatasCompanion( + return UserRelayListsCompanion( pubKey: pubKey ?? this.pubKey, - name: name ?? this.name, - displayName: displayName ?? this.displayName, - picture: picture ?? this.picture, - banner: banner ?? this.banner, - website: website ?? this.website, - about: about ?? this.about, - nip05: nip05 ?? this.nip05, - lud16: lud16 ?? this.lud16, - lud06: lud06 ?? this.lud06, - updatedAt: updatedAt ?? this.updatedAt, + createdAt: createdAt ?? this.createdAt, refreshedTimestamp: refreshedTimestamp ?? this.refreshedTimestamp, - sourcesJson: sourcesJson ?? this.sourcesJson, - tagsJson: tagsJson ?? this.tagsJson, - rawContentJson: rawContentJson ?? this.rawContentJson, + relaysJson: relaysJson ?? this.relaysJson, rowid: rowid ?? this.rowid, ); } @@ -1362,47 +861,14 @@ class MetadatasCompanion extends UpdateCompanion { if (pubKey.present) { map['pub_key'] = Variable(pubKey.value); } - if (name.present) { - map['name'] = Variable(name.value); - } - if (displayName.present) { - map['display_name'] = Variable(displayName.value); - } - if (picture.present) { - map['picture'] = Variable(picture.value); - } - if (banner.present) { - map['banner'] = Variable(banner.value); - } - if (website.present) { - map['website'] = Variable(website.value); - } - if (about.present) { - map['about'] = Variable(about.value); - } - if (nip05.present) { - map['nip05'] = Variable(nip05.value); - } - if (lud16.present) { - map['lud16'] = Variable(lud16.value); - } - if (lud06.present) { - map['lud06'] = Variable(lud06.value); - } - if (updatedAt.present) { - map['updated_at'] = Variable(updatedAt.value); + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); } if (refreshedTimestamp.present) { map['refreshed_timestamp'] = Variable(refreshedTimestamp.value); } - if (sourcesJson.present) { - map['sources_json'] = Variable(sourcesJson.value); - } - if (tagsJson.present) { - map['tags_json'] = Variable(tagsJson.value); - } - if (rawContentJson.present) { - map['raw_content_json'] = Variable(rawContentJson.value); + if (relaysJson.present) { + map['relays_json'] = Variable(relaysJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -1412,633 +878,511 @@ class MetadatasCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('MetadatasCompanion(') + return (StringBuffer('UserRelayListsCompanion(') ..write('pubKey: $pubKey, ') - ..write('name: $name, ') - ..write('displayName: $displayName, ') - ..write('picture: $picture, ') - ..write('banner: $banner, ') - ..write('website: $website, ') - ..write('about: $about, ') - ..write('nip05: $nip05, ') - ..write('lud16: $lud16, ') - ..write('lud06: $lud06, ') - ..write('updatedAt: $updatedAt, ') + ..write('createdAt: $createdAt, ') ..write('refreshedTimestamp: $refreshedTimestamp, ') - ..write('sourcesJson: $sourcesJson, ') - ..write('tagsJson: $tagsJson, ') - ..write('rawContentJson: $rawContentJson, ') + ..write('relaysJson: $relaysJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $ContactListsTable extends ContactLists - with TableInfo<$ContactListsTable, DbContactList> { +class $RelaySetsTable extends RelaySets + with TableInfo<$RelaySetsTable, DbRelaySet> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $ContactListsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); + $RelaySetsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); @override - late final GeneratedColumn pubKey = GeneratedColumn( - 'pub_key', + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _contactsJsonMeta = const VerificationMeta( - 'contactsJson', - ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); @override - late final GeneratedColumn contactsJson = GeneratedColumn( - 'contacts_json', + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _contactRelaysJsonMeta = const VerificationMeta( - 'contactRelaysJson', + static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); + @override + late final GeneratedColumn pubKey = GeneratedColumn( + 'pub_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, ); + static const VerificationMeta _relayMinCountPerPubkeyMeta = + const VerificationMeta('relayMinCountPerPubkey'); @override - late final GeneratedColumn contactRelaysJson = - GeneratedColumn( - 'contact_relays_json', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _petnamesJsonMeta = const VerificationMeta( - 'petnamesJson', + late final GeneratedColumn relayMinCountPerPubkey = GeneratedColumn( + 'relay_min_count_per_pubkey', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _directionMeta = const VerificationMeta( + 'direction', ); @override - late final GeneratedColumn petnamesJson = GeneratedColumn( - 'petnames_json', + late final GeneratedColumn direction = GeneratedColumn( + 'direction', aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _followedTagsJsonMeta = const VerificationMeta( - 'followedTagsJson', + static const VerificationMeta _relaysMapJsonMeta = const VerificationMeta( + 'relaysMapJson', ); @override - late final GeneratedColumn followedTagsJson = GeneratedColumn( - 'followed_tags_json', + late final GeneratedColumn relaysMapJson = GeneratedColumn( + 'relays_map_json', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _followedCommunitiesJsonMeta = - const VerificationMeta('followedCommunitiesJson'); + static const VerificationMeta _fallbackToBootstrapRelaysMeta = + const VerificationMeta('fallbackToBootstrapRelays'); @override - late final GeneratedColumn followedCommunitiesJson = - GeneratedColumn( - 'followed_communities_json', + late final GeneratedColumn fallbackToBootstrapRelays = + GeneratedColumn( + 'fallback_to_bootstrap_relays', aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.bool, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("fallback_to_bootstrap_relays" IN (0, 1))', + ), ); - static const VerificationMeta _followedEventsJsonMeta = - const VerificationMeta('followedEventsJson'); + static const VerificationMeta _notCoveredPubkeysJsonMeta = + const VerificationMeta('notCoveredPubkeysJson'); @override - late final GeneratedColumn followedEventsJson = + late final GeneratedColumn notCoveredPubkeysJson = GeneratedColumn( - 'followed_events_json', + 'not_covered_pubkeys_json', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _loadedTimestampMeta = const VerificationMeta( - 'loadedTimestamp', - ); - @override - late final GeneratedColumn loadedTimestamp = GeneratedColumn( - 'loaded_timestamp', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _sourcesJsonMeta = const VerificationMeta( - 'sourcesJson', - ); - @override - late final GeneratedColumn sourcesJson = GeneratedColumn( - 'sources_json', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); @override List get $columns => [ + id, + name, pubKey, - contactsJson, - contactRelaysJson, - petnamesJson, - followedTagsJson, - followedCommunitiesJson, - followedEventsJson, - createdAt, - loadedTimestamp, - sourcesJson, + relayMinCountPerPubkey, + direction, + relaysMapJson, + fallbackToBootstrapRelays, + notCoveredPubkeysJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'contact_lists'; + static const String $name = 'relay_sets'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('pub_key')) { - context.handle( - _pubKeyMeta, - pubKey.isAcceptableOrUnknown(data['pub_key']!, _pubKeyMeta), - ); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { - context.missing(_pubKeyMeta); + context.missing(_idMeta); } - if (data.containsKey('contacts_json')) { + if (data.containsKey('name')) { context.handle( - _contactsJsonMeta, - contactsJson.isAcceptableOrUnknown( - data['contacts_json']!, - _contactsJsonMeta, - ), + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), ); } else if (isInserting) { - context.missing(_contactsJsonMeta); + context.missing(_nameMeta); } - if (data.containsKey('contact_relays_json')) { + if (data.containsKey('pub_key')) { context.handle( - _contactRelaysJsonMeta, - contactRelaysJson.isAcceptableOrUnknown( - data['contact_relays_json']!, - _contactRelaysJsonMeta, - ), + _pubKeyMeta, + pubKey.isAcceptableOrUnknown(data['pub_key']!, _pubKeyMeta), ); } else if (isInserting) { - context.missing(_contactRelaysJsonMeta); + context.missing(_pubKeyMeta); } - if (data.containsKey('petnames_json')) { + if (data.containsKey('relay_min_count_per_pubkey')) { context.handle( - _petnamesJsonMeta, - petnamesJson.isAcceptableOrUnknown( - data['petnames_json']!, - _petnamesJsonMeta, + _relayMinCountPerPubkeyMeta, + relayMinCountPerPubkey.isAcceptableOrUnknown( + data['relay_min_count_per_pubkey']!, + _relayMinCountPerPubkeyMeta, ), ); } else if (isInserting) { - context.missing(_petnamesJsonMeta); + context.missing(_relayMinCountPerPubkeyMeta); } - if (data.containsKey('followed_tags_json')) { + if (data.containsKey('direction')) { context.handle( - _followedTagsJsonMeta, - followedTagsJson.isAcceptableOrUnknown( - data['followed_tags_json']!, - _followedTagsJsonMeta, - ), + _directionMeta, + direction.isAcceptableOrUnknown(data['direction']!, _directionMeta), ); } else if (isInserting) { - context.missing(_followedTagsJsonMeta); + context.missing(_directionMeta); } - if (data.containsKey('followed_communities_json')) { + if (data.containsKey('relays_map_json')) { context.handle( - _followedCommunitiesJsonMeta, - followedCommunitiesJson.isAcceptableOrUnknown( - data['followed_communities_json']!, - _followedCommunitiesJsonMeta, + _relaysMapJsonMeta, + relaysMapJson.isAcceptableOrUnknown( + data['relays_map_json']!, + _relaysMapJsonMeta, ), ); } else if (isInserting) { - context.missing(_followedCommunitiesJsonMeta); + context.missing(_relaysMapJsonMeta); } - if (data.containsKey('followed_events_json')) { + if (data.containsKey('fallback_to_bootstrap_relays')) { context.handle( - _followedEventsJsonMeta, - followedEventsJson.isAcceptableOrUnknown( - data['followed_events_json']!, - _followedEventsJsonMeta, + _fallbackToBootstrapRelaysMeta, + fallbackToBootstrapRelays.isAcceptableOrUnknown( + data['fallback_to_bootstrap_relays']!, + _fallbackToBootstrapRelaysMeta, ), ); } else if (isInserting) { - context.missing(_followedEventsJsonMeta); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); - } - if (data.containsKey('loaded_timestamp')) { - context.handle( - _loadedTimestampMeta, - loadedTimestamp.isAcceptableOrUnknown( - data['loaded_timestamp']!, - _loadedTimestampMeta, - ), - ); + context.missing(_fallbackToBootstrapRelaysMeta); } - if (data.containsKey('sources_json')) { + if (data.containsKey('not_covered_pubkeys_json')) { context.handle( - _sourcesJsonMeta, - sourcesJson.isAcceptableOrUnknown( - data['sources_json']!, - _sourcesJsonMeta, + _notCoveredPubkeysJsonMeta, + notCoveredPubkeysJson.isAcceptableOrUnknown( + data['not_covered_pubkeys_json']!, + _notCoveredPubkeysJsonMeta, ), ); } else if (isInserting) { - context.missing(_sourcesJsonMeta); + context.missing(_notCoveredPubkeysJsonMeta); } return context; } @override - Set get $primaryKey => {pubKey}; + Set get $primaryKey => {id}; @override - DbContactList map(Map data, {String? tablePrefix}) { + DbRelaySet map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbContactList( - pubKey: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}pub_key'], - )!, - contactsJson: attachedDatabase.typeMapping.read( + return DbRelaySet( + id: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}contacts_json'], + data['${effectivePrefix}id'], )!, - contactRelaysJson: attachedDatabase.typeMapping.read( + name: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}contact_relays_json'], + data['${effectivePrefix}name'], )!, - petnamesJson: attachedDatabase.typeMapping.read( + pubKey: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}petnames_json'], + data['${effectivePrefix}pub_key'], )!, - followedTagsJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}followed_tags_json'], + relayMinCountPerPubkey: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}relay_min_count_per_pubkey'], )!, - followedCommunitiesJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}followed_communities_json'], + direction: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}direction'], )!, - followedEventsJson: attachedDatabase.typeMapping.read( + relaysMapJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}followed_events_json'], + data['${effectivePrefix}relays_map_json'], )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}created_at'], + fallbackToBootstrapRelays: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}fallback_to_bootstrap_relays'], )!, - loadedTimestamp: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}loaded_timestamp'], - ), - sourcesJson: attachedDatabase.typeMapping.read( + notCoveredPubkeysJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}sources_json'], + data['${effectivePrefix}not_covered_pubkeys_json'], )!, ); } @override - $ContactListsTable createAlias(String alias) { - return $ContactListsTable(attachedDatabase, alias); + $RelaySetsTable createAlias(String alias) { + return $RelaySetsTable(attachedDatabase, alias); } } -class DbContactList extends DataClass implements Insertable { +class DbRelaySet extends DataClass implements Insertable { + final String id; + final String name; final String pubKey; - final String contactsJson; - final String contactRelaysJson; - final String petnamesJson; - final String followedTagsJson; - final String followedCommunitiesJson; - final String followedEventsJson; - final int createdAt; - final int? loadedTimestamp; - final String sourcesJson; - const DbContactList({ + final int relayMinCountPerPubkey; + final int direction; + final String relaysMapJson; + final bool fallbackToBootstrapRelays; + final String notCoveredPubkeysJson; + const DbRelaySet({ + required this.id, + required this.name, required this.pubKey, - required this.contactsJson, - required this.contactRelaysJson, - required this.petnamesJson, - required this.followedTagsJson, - required this.followedCommunitiesJson, - required this.followedEventsJson, - required this.createdAt, - this.loadedTimestamp, - required this.sourcesJson, + required this.relayMinCountPerPubkey, + required this.direction, + required this.relaysMapJson, + required this.fallbackToBootstrapRelays, + required this.notCoveredPubkeysJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); map['pub_key'] = Variable(pubKey); - map['contacts_json'] = Variable(contactsJson); - map['contact_relays_json'] = Variable(contactRelaysJson); - map['petnames_json'] = Variable(petnamesJson); - map['followed_tags_json'] = Variable(followedTagsJson); - map['followed_communities_json'] = Variable( - followedCommunitiesJson, + map['relay_min_count_per_pubkey'] = Variable(relayMinCountPerPubkey); + map['direction'] = Variable(direction); + map['relays_map_json'] = Variable(relaysMapJson); + map['fallback_to_bootstrap_relays'] = Variable( + fallbackToBootstrapRelays, ); - map['followed_events_json'] = Variable(followedEventsJson); - map['created_at'] = Variable(createdAt); - if (!nullToAbsent || loadedTimestamp != null) { - map['loaded_timestamp'] = Variable(loadedTimestamp); - } - map['sources_json'] = Variable(sourcesJson); + map['not_covered_pubkeys_json'] = Variable(notCoveredPubkeysJson); return map; } - ContactListsCompanion toCompanion(bool nullToAbsent) { - return ContactListsCompanion( + RelaySetsCompanion toCompanion(bool nullToAbsent) { + return RelaySetsCompanion( + id: Value(id), + name: Value(name), pubKey: Value(pubKey), - contactsJson: Value(contactsJson), - contactRelaysJson: Value(contactRelaysJson), - petnamesJson: Value(petnamesJson), - followedTagsJson: Value(followedTagsJson), - followedCommunitiesJson: Value(followedCommunitiesJson), - followedEventsJson: Value(followedEventsJson), - createdAt: Value(createdAt), - loadedTimestamp: loadedTimestamp == null && nullToAbsent - ? const Value.absent() - : Value(loadedTimestamp), - sourcesJson: Value(sourcesJson), + relayMinCountPerPubkey: Value(relayMinCountPerPubkey), + direction: Value(direction), + relaysMapJson: Value(relaysMapJson), + fallbackToBootstrapRelays: Value(fallbackToBootstrapRelays), + notCoveredPubkeysJson: Value(notCoveredPubkeysJson), ); } - factory DbContactList.fromJson( + factory DbRelaySet.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbContactList( + return DbRelaySet( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), pubKey: serializer.fromJson(json['pubKey']), - contactsJson: serializer.fromJson(json['contactsJson']), - contactRelaysJson: serializer.fromJson(json['contactRelaysJson']), - petnamesJson: serializer.fromJson(json['petnamesJson']), - followedTagsJson: serializer.fromJson(json['followedTagsJson']), - followedCommunitiesJson: serializer.fromJson( - json['followedCommunitiesJson'], + relayMinCountPerPubkey: serializer.fromJson( + json['relayMinCountPerPubkey'], + ), + direction: serializer.fromJson(json['direction']), + relaysMapJson: serializer.fromJson(json['relaysMapJson']), + fallbackToBootstrapRelays: serializer.fromJson( + json['fallbackToBootstrapRelays'], ), - followedEventsJson: serializer.fromJson( - json['followedEventsJson'], + notCoveredPubkeysJson: serializer.fromJson( + json['notCoveredPubkeysJson'], ), - createdAt: serializer.fromJson(json['createdAt']), - loadedTimestamp: serializer.fromJson(json['loadedTimestamp']), - sourcesJson: serializer.fromJson(json['sourcesJson']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), 'pubKey': serializer.toJson(pubKey), - 'contactsJson': serializer.toJson(contactsJson), - 'contactRelaysJson': serializer.toJson(contactRelaysJson), - 'petnamesJson': serializer.toJson(petnamesJson), - 'followedTagsJson': serializer.toJson(followedTagsJson), - 'followedCommunitiesJson': serializer.toJson( - followedCommunitiesJson, + 'relayMinCountPerPubkey': serializer.toJson(relayMinCountPerPubkey), + 'direction': serializer.toJson(direction), + 'relaysMapJson': serializer.toJson(relaysMapJson), + 'fallbackToBootstrapRelays': serializer.toJson( + fallbackToBootstrapRelays, ), - 'followedEventsJson': serializer.toJson(followedEventsJson), - 'createdAt': serializer.toJson(createdAt), - 'loadedTimestamp': serializer.toJson(loadedTimestamp), - 'sourcesJson': serializer.toJson(sourcesJson), + 'notCoveredPubkeysJson': serializer.toJson(notCoveredPubkeysJson), }; } - DbContactList copyWith({ + DbRelaySet copyWith({ + String? id, + String? name, String? pubKey, - String? contactsJson, - String? contactRelaysJson, - String? petnamesJson, - String? followedTagsJson, - String? followedCommunitiesJson, - String? followedEventsJson, - int? createdAt, - Value loadedTimestamp = const Value.absent(), - String? sourcesJson, - }) => DbContactList( + int? relayMinCountPerPubkey, + int? direction, + String? relaysMapJson, + bool? fallbackToBootstrapRelays, + String? notCoveredPubkeysJson, + }) => DbRelaySet( + id: id ?? this.id, + name: name ?? this.name, pubKey: pubKey ?? this.pubKey, - contactsJson: contactsJson ?? this.contactsJson, - contactRelaysJson: contactRelaysJson ?? this.contactRelaysJson, - petnamesJson: petnamesJson ?? this.petnamesJson, - followedTagsJson: followedTagsJson ?? this.followedTagsJson, - followedCommunitiesJson: - followedCommunitiesJson ?? this.followedCommunitiesJson, - followedEventsJson: followedEventsJson ?? this.followedEventsJson, - createdAt: createdAt ?? this.createdAt, - loadedTimestamp: loadedTimestamp.present - ? loadedTimestamp.value - : this.loadedTimestamp, - sourcesJson: sourcesJson ?? this.sourcesJson, + relayMinCountPerPubkey: + relayMinCountPerPubkey ?? this.relayMinCountPerPubkey, + direction: direction ?? this.direction, + relaysMapJson: relaysMapJson ?? this.relaysMapJson, + fallbackToBootstrapRelays: + fallbackToBootstrapRelays ?? this.fallbackToBootstrapRelays, + notCoveredPubkeysJson: notCoveredPubkeysJson ?? this.notCoveredPubkeysJson, ); - DbContactList copyWithCompanion(ContactListsCompanion data) { - return DbContactList( + DbRelaySet copyWithCompanion(RelaySetsCompanion data) { + return DbRelaySet( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, - contactsJson: data.contactsJson.present - ? data.contactsJson.value - : this.contactsJson, - contactRelaysJson: data.contactRelaysJson.present - ? data.contactRelaysJson.value - : this.contactRelaysJson, - petnamesJson: data.petnamesJson.present - ? data.petnamesJson.value - : this.petnamesJson, - followedTagsJson: data.followedTagsJson.present - ? data.followedTagsJson.value - : this.followedTagsJson, - followedCommunitiesJson: data.followedCommunitiesJson.present - ? data.followedCommunitiesJson.value - : this.followedCommunitiesJson, - followedEventsJson: data.followedEventsJson.present - ? data.followedEventsJson.value - : this.followedEventsJson, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - loadedTimestamp: data.loadedTimestamp.present - ? data.loadedTimestamp.value - : this.loadedTimestamp, - sourcesJson: data.sourcesJson.present - ? data.sourcesJson.value - : this.sourcesJson, + relayMinCountPerPubkey: data.relayMinCountPerPubkey.present + ? data.relayMinCountPerPubkey.value + : this.relayMinCountPerPubkey, + direction: data.direction.present ? data.direction.value : this.direction, + relaysMapJson: data.relaysMapJson.present + ? data.relaysMapJson.value + : this.relaysMapJson, + fallbackToBootstrapRelays: data.fallbackToBootstrapRelays.present + ? data.fallbackToBootstrapRelays.value + : this.fallbackToBootstrapRelays, + notCoveredPubkeysJson: data.notCoveredPubkeysJson.present + ? data.notCoveredPubkeysJson.value + : this.notCoveredPubkeysJson, ); } @override String toString() { - return (StringBuffer('DbContactList(') + return (StringBuffer('DbRelaySet(') + ..write('id: $id, ') + ..write('name: $name, ') ..write('pubKey: $pubKey, ') - ..write('contactsJson: $contactsJson, ') - ..write('contactRelaysJson: $contactRelaysJson, ') - ..write('petnamesJson: $petnamesJson, ') - ..write('followedTagsJson: $followedTagsJson, ') - ..write('followedCommunitiesJson: $followedCommunitiesJson, ') - ..write('followedEventsJson: $followedEventsJson, ') - ..write('createdAt: $createdAt, ') - ..write('loadedTimestamp: $loadedTimestamp, ') - ..write('sourcesJson: $sourcesJson') + ..write('relayMinCountPerPubkey: $relayMinCountPerPubkey, ') + ..write('direction: $direction, ') + ..write('relaysMapJson: $relaysMapJson, ') + ..write('fallbackToBootstrapRelays: $fallbackToBootstrapRelays, ') + ..write('notCoveredPubkeysJson: $notCoveredPubkeysJson') ..write(')')) .toString(); } @override int get hashCode => Object.hash( + id, + name, pubKey, - contactsJson, - contactRelaysJson, - petnamesJson, - followedTagsJson, - followedCommunitiesJson, - followedEventsJson, - createdAt, - loadedTimestamp, - sourcesJson, + relayMinCountPerPubkey, + direction, + relaysMapJson, + fallbackToBootstrapRelays, + notCoveredPubkeysJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is DbContactList && + (other is DbRelaySet && + other.id == this.id && + other.name == this.name && other.pubKey == this.pubKey && - other.contactsJson == this.contactsJson && - other.contactRelaysJson == this.contactRelaysJson && - other.petnamesJson == this.petnamesJson && - other.followedTagsJson == this.followedTagsJson && - other.followedCommunitiesJson == this.followedCommunitiesJson && - other.followedEventsJson == this.followedEventsJson && - other.createdAt == this.createdAt && - other.loadedTimestamp == this.loadedTimestamp && - other.sourcesJson == this.sourcesJson); + other.relayMinCountPerPubkey == this.relayMinCountPerPubkey && + other.direction == this.direction && + other.relaysMapJson == this.relaysMapJson && + other.fallbackToBootstrapRelays == this.fallbackToBootstrapRelays && + other.notCoveredPubkeysJson == this.notCoveredPubkeysJson); } -class ContactListsCompanion extends UpdateCompanion { +class RelaySetsCompanion extends UpdateCompanion { + final Value id; + final Value name; final Value pubKey; - final Value contactsJson; - final Value contactRelaysJson; - final Value petnamesJson; - final Value followedTagsJson; - final Value followedCommunitiesJson; - final Value followedEventsJson; - final Value createdAt; - final Value loadedTimestamp; - final Value sourcesJson; + final Value relayMinCountPerPubkey; + final Value direction; + final Value relaysMapJson; + final Value fallbackToBootstrapRelays; + final Value notCoveredPubkeysJson; final Value rowid; - const ContactListsCompanion({ + const RelaySetsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), this.pubKey = const Value.absent(), - this.contactsJson = const Value.absent(), - this.contactRelaysJson = const Value.absent(), - this.petnamesJson = const Value.absent(), - this.followedTagsJson = const Value.absent(), - this.followedCommunitiesJson = const Value.absent(), - this.followedEventsJson = const Value.absent(), - this.createdAt = const Value.absent(), - this.loadedTimestamp = const Value.absent(), - this.sourcesJson = const Value.absent(), + this.relayMinCountPerPubkey = const Value.absent(), + this.direction = const Value.absent(), + this.relaysMapJson = const Value.absent(), + this.fallbackToBootstrapRelays = const Value.absent(), + this.notCoveredPubkeysJson = const Value.absent(), this.rowid = const Value.absent(), }); - ContactListsCompanion.insert({ + RelaySetsCompanion.insert({ + required String id, + required String name, required String pubKey, - required String contactsJson, - required String contactRelaysJson, - required String petnamesJson, - required String followedTagsJson, - required String followedCommunitiesJson, - required String followedEventsJson, - required int createdAt, - this.loadedTimestamp = const Value.absent(), - required String sourcesJson, + required int relayMinCountPerPubkey, + required int direction, + required String relaysMapJson, + required bool fallbackToBootstrapRelays, + required String notCoveredPubkeysJson, this.rowid = const Value.absent(), - }) : pubKey = Value(pubKey), - contactsJson = Value(contactsJson), - contactRelaysJson = Value(contactRelaysJson), - petnamesJson = Value(petnamesJson), - followedTagsJson = Value(followedTagsJson), - followedCommunitiesJson = Value(followedCommunitiesJson), - followedEventsJson = Value(followedEventsJson), - createdAt = Value(createdAt), - sourcesJson = Value(sourcesJson); - static Insertable custom({ + }) : id = Value(id), + name = Value(name), + pubKey = Value(pubKey), + relayMinCountPerPubkey = Value(relayMinCountPerPubkey), + direction = Value(direction), + relaysMapJson = Value(relaysMapJson), + fallbackToBootstrapRelays = Value(fallbackToBootstrapRelays), + notCoveredPubkeysJson = Value(notCoveredPubkeysJson); + static Insertable custom({ + Expression? id, + Expression? name, Expression? pubKey, - Expression? contactsJson, - Expression? contactRelaysJson, - Expression? petnamesJson, - Expression? followedTagsJson, - Expression? followedCommunitiesJson, - Expression? followedEventsJson, - Expression? createdAt, - Expression? loadedTimestamp, - Expression? sourcesJson, + Expression? relayMinCountPerPubkey, + Expression? direction, + Expression? relaysMapJson, + Expression? fallbackToBootstrapRelays, + Expression? notCoveredPubkeysJson, Expression? rowid, }) { return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, if (pubKey != null) 'pub_key': pubKey, - if (contactsJson != null) 'contacts_json': contactsJson, - if (contactRelaysJson != null) 'contact_relays_json': contactRelaysJson, - if (petnamesJson != null) 'petnames_json': petnamesJson, - if (followedTagsJson != null) 'followed_tags_json': followedTagsJson, - if (followedCommunitiesJson != null) - 'followed_communities_json': followedCommunitiesJson, - if (followedEventsJson != null) - 'followed_events_json': followedEventsJson, - if (createdAt != null) 'created_at': createdAt, - if (loadedTimestamp != null) 'loaded_timestamp': loadedTimestamp, - if (sourcesJson != null) 'sources_json': sourcesJson, + if (relayMinCountPerPubkey != null) + 'relay_min_count_per_pubkey': relayMinCountPerPubkey, + if (direction != null) 'direction': direction, + if (relaysMapJson != null) 'relays_map_json': relaysMapJson, + if (fallbackToBootstrapRelays != null) + 'fallback_to_bootstrap_relays': fallbackToBootstrapRelays, + if (notCoveredPubkeysJson != null) + 'not_covered_pubkeys_json': notCoveredPubkeysJson, if (rowid != null) 'rowid': rowid, }); } - ContactListsCompanion copyWith({ + RelaySetsCompanion copyWith({ + Value? id, + Value? name, Value? pubKey, - Value? contactsJson, - Value? contactRelaysJson, - Value? petnamesJson, - Value? followedTagsJson, - Value? followedCommunitiesJson, - Value? followedEventsJson, - Value? createdAt, - Value? loadedTimestamp, - Value? sourcesJson, + Value? relayMinCountPerPubkey, + Value? direction, + Value? relaysMapJson, + Value? fallbackToBootstrapRelays, + Value? notCoveredPubkeysJson, Value? rowid, }) { - return ContactListsCompanion( + return RelaySetsCompanion( + id: id ?? this.id, + name: name ?? this.name, pubKey: pubKey ?? this.pubKey, - contactsJson: contactsJson ?? this.contactsJson, - contactRelaysJson: contactRelaysJson ?? this.contactRelaysJson, - petnamesJson: petnamesJson ?? this.petnamesJson, - followedTagsJson: followedTagsJson ?? this.followedTagsJson, - followedCommunitiesJson: - followedCommunitiesJson ?? this.followedCommunitiesJson, - followedEventsJson: followedEventsJson ?? this.followedEventsJson, - createdAt: createdAt ?? this.createdAt, - loadedTimestamp: loadedTimestamp ?? this.loadedTimestamp, - sourcesJson: sourcesJson ?? this.sourcesJson, + relayMinCountPerPubkey: + relayMinCountPerPubkey ?? this.relayMinCountPerPubkey, + direction: direction ?? this.direction, + relaysMapJson: relaysMapJson ?? this.relaysMapJson, + fallbackToBootstrapRelays: + fallbackToBootstrapRelays ?? this.fallbackToBootstrapRelays, + notCoveredPubkeysJson: + notCoveredPubkeysJson ?? this.notCoveredPubkeysJson, rowid: rowid ?? this.rowid, ); } @@ -2046,37 +1390,35 @@ class ContactListsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (pubKey.present) { - map['pub_key'] = Variable(pubKey.value); - } - if (contactsJson.present) { - map['contacts_json'] = Variable(contactsJson.value); - } - if (contactRelaysJson.present) { - map['contact_relays_json'] = Variable(contactRelaysJson.value); + if (id.present) { + map['id'] = Variable(id.value); } - if (petnamesJson.present) { - map['petnames_json'] = Variable(petnamesJson.value); + if (name.present) { + map['name'] = Variable(name.value); } - if (followedTagsJson.present) { - map['followed_tags_json'] = Variable(followedTagsJson.value); + if (pubKey.present) { + map['pub_key'] = Variable(pubKey.value); } - if (followedCommunitiesJson.present) { - map['followed_communities_json'] = Variable( - followedCommunitiesJson.value, + if (relayMinCountPerPubkey.present) { + map['relay_min_count_per_pubkey'] = Variable( + relayMinCountPerPubkey.value, ); } - if (followedEventsJson.present) { - map['followed_events_json'] = Variable(followedEventsJson.value); + if (direction.present) { + map['direction'] = Variable(direction.value); } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); + if (relaysMapJson.present) { + map['relays_map_json'] = Variable(relaysMapJson.value); } - if (loadedTimestamp.present) { - map['loaded_timestamp'] = Variable(loadedTimestamp.value); + if (fallbackToBootstrapRelays.present) { + map['fallback_to_bootstrap_relays'] = Variable( + fallbackToBootstrapRelays.value, + ); } - if (sourcesJson.present) { - map['sources_json'] = Variable(sourcesJson.value); + if (notCoveredPubkeysJson.present) { + map['not_covered_pubkeys_json'] = Variable( + notCoveredPubkeysJson.value, + ); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -2086,29 +1428,26 @@ class ContactListsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('ContactListsCompanion(') + return (StringBuffer('RelaySetsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') ..write('pubKey: $pubKey, ') - ..write('contactsJson: $contactsJson, ') - ..write('contactRelaysJson: $contactRelaysJson, ') - ..write('petnamesJson: $petnamesJson, ') - ..write('followedTagsJson: $followedTagsJson, ') - ..write('followedCommunitiesJson: $followedCommunitiesJson, ') - ..write('followedEventsJson: $followedEventsJson, ') - ..write('createdAt: $createdAt, ') - ..write('loadedTimestamp: $loadedTimestamp, ') - ..write('sourcesJson: $sourcesJson, ') + ..write('relayMinCountPerPubkey: $relayMinCountPerPubkey, ') + ..write('direction: $direction, ') + ..write('relaysMapJson: $relaysMapJson, ') + ..write('fallbackToBootstrapRelays: $fallbackToBootstrapRelays, ') + ..write('notCoveredPubkeysJson: $notCoveredPubkeysJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $UserRelayListsTable extends UserRelayLists - with TableInfo<$UserRelayListsTable, DbUserRelayList> { +class $Nip05sTable extends Nip05s with TableInfo<$Nip05sTable, DbNip05> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $UserRelayListsTable(this.attachedDatabase, [this._alias]); + $Nip05sTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); @override late final GeneratedColumn pubKey = GeneratedColumn( @@ -2118,26 +1457,37 @@ class $UserRelayListsTable extends UserRelayLists type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); + static const VerificationMeta _nip05Meta = const VerificationMeta('nip05'); @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', + late final GeneratedColumn nip05 = GeneratedColumn( + 'nip05', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _refreshedTimestampMeta = - const VerificationMeta('refreshedTimestamp'); + static const VerificationMeta _validMeta = const VerificationMeta('valid'); @override - late final GeneratedColumn refreshedTimestamp = GeneratedColumn( - 'refreshed_timestamp', + late final GeneratedColumn valid = GeneratedColumn( + 'valid', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.bool, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("valid" IN (0, 1))', + ), + ); + static const VerificationMeta _networkFetchTimeMeta = const VerificationMeta( + 'networkFetchTime', + ); + @override + late final GeneratedColumn networkFetchTime = GeneratedColumn( + 'network_fetch_time', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, ); static const VerificationMeta _relaysJsonMeta = const VerificationMeta( 'relaysJson', @@ -2153,18 +1503,19 @@ class $UserRelayListsTable extends UserRelayLists @override List get $columns => [ pubKey, - createdAt, - refreshedTimestamp, + nip05, + valid, + networkFetchTime, relaysJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'user_relay_lists'; + static const String $name = 'nip05s'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -2177,24 +1528,30 @@ class $UserRelayListsTable extends UserRelayLists } else if (isInserting) { context.missing(_pubKeyMeta); } - if (data.containsKey('created_at')) { + if (data.containsKey('nip05')) { context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + _nip05Meta, + nip05.isAcceptableOrUnknown(data['nip05']!, _nip05Meta), ); } else if (isInserting) { - context.missing(_createdAtMeta); + context.missing(_nip05Meta); } - if (data.containsKey('refreshed_timestamp')) { + if (data.containsKey('valid')) { context.handle( - _refreshedTimestampMeta, - refreshedTimestamp.isAcceptableOrUnknown( - data['refreshed_timestamp']!, - _refreshedTimestampMeta, - ), + _validMeta, + valid.isAcceptableOrUnknown(data['valid']!, _validMeta), ); } else if (isInserting) { - context.missing(_refreshedTimestampMeta); + context.missing(_validMeta); + } + if (data.containsKey('network_fetch_time')) { + context.handle( + _networkFetchTimeMeta, + networkFetchTime.isAcceptableOrUnknown( + data['network_fetch_time']!, + _networkFetchTimeMeta, + ), + ); } if (data.containsKey('relays_json')) { context.handle( @@ -2210,21 +1567,25 @@ class $UserRelayListsTable extends UserRelayLists @override Set get $primaryKey => {pubKey}; @override - DbUserRelayList map(Map data, {String? tablePrefix}) { + DbNip05 map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbUserRelayList( + return DbNip05( pubKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}pub_key'], )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}created_at'], + nip05: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}nip05'], )!, - refreshedTimestamp: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}refreshed_timestamp'], + valid: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}valid'], )!, + networkFetchTime: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}network_fetch_time'], + ), relaysJson: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}relays_json'], @@ -2233,50 +1594,59 @@ class $UserRelayListsTable extends UserRelayLists } @override - $UserRelayListsTable createAlias(String alias) { - return $UserRelayListsTable(attachedDatabase, alias); + $Nip05sTable createAlias(String alias) { + return $Nip05sTable(attachedDatabase, alias); } } -class DbUserRelayList extends DataClass implements Insertable { +class DbNip05 extends DataClass implements Insertable { final String pubKey; - final int createdAt; - final int refreshedTimestamp; + final String nip05; + final bool valid; + final int? networkFetchTime; final String relaysJson; - const DbUserRelayList({ + const DbNip05({ required this.pubKey, - required this.createdAt, - required this.refreshedTimestamp, + required this.nip05, + required this.valid, + this.networkFetchTime, required this.relaysJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['pub_key'] = Variable(pubKey); - map['created_at'] = Variable(createdAt); - map['refreshed_timestamp'] = Variable(refreshedTimestamp); + map['nip05'] = Variable(nip05); + map['valid'] = Variable(valid); + if (!nullToAbsent || networkFetchTime != null) { + map['network_fetch_time'] = Variable(networkFetchTime); + } map['relays_json'] = Variable(relaysJson); return map; } - UserRelayListsCompanion toCompanion(bool nullToAbsent) { - return UserRelayListsCompanion( + Nip05sCompanion toCompanion(bool nullToAbsent) { + return Nip05sCompanion( pubKey: Value(pubKey), - createdAt: Value(createdAt), - refreshedTimestamp: Value(refreshedTimestamp), + nip05: Value(nip05), + valid: Value(valid), + networkFetchTime: networkFetchTime == null && nullToAbsent + ? const Value.absent() + : Value(networkFetchTime), relaysJson: Value(relaysJson), ); } - factory DbUserRelayList.fromJson( + factory DbNip05.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbUserRelayList( + return DbNip05( pubKey: serializer.fromJson(json['pubKey']), - createdAt: serializer.fromJson(json['createdAt']), - refreshedTimestamp: serializer.fromJson(json['refreshedTimestamp']), + nip05: serializer.fromJson(json['nip05']), + valid: serializer.fromJson(json['valid']), + networkFetchTime: serializer.fromJson(json['networkFetchTime']), relaysJson: serializer.fromJson(json['relaysJson']), ); } @@ -2285,30 +1655,36 @@ class DbUserRelayList extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'pubKey': serializer.toJson(pubKey), - 'createdAt': serializer.toJson(createdAt), - 'refreshedTimestamp': serializer.toJson(refreshedTimestamp), + 'nip05': serializer.toJson(nip05), + 'valid': serializer.toJson(valid), + 'networkFetchTime': serializer.toJson(networkFetchTime), 'relaysJson': serializer.toJson(relaysJson), }; } - DbUserRelayList copyWith({ + DbNip05 copyWith({ String? pubKey, - int? createdAt, - int? refreshedTimestamp, + String? nip05, + bool? valid, + Value networkFetchTime = const Value.absent(), String? relaysJson, - }) => DbUserRelayList( + }) => DbNip05( pubKey: pubKey ?? this.pubKey, - createdAt: createdAt ?? this.createdAt, - refreshedTimestamp: refreshedTimestamp ?? this.refreshedTimestamp, + nip05: nip05 ?? this.nip05, + valid: valid ?? this.valid, + networkFetchTime: networkFetchTime.present + ? networkFetchTime.value + : this.networkFetchTime, relaysJson: relaysJson ?? this.relaysJson, ); - DbUserRelayList copyWithCompanion(UserRelayListsCompanion data) { - return DbUserRelayList( + DbNip05 copyWithCompanion(Nip05sCompanion data) { + return DbNip05( pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - refreshedTimestamp: data.refreshedTimestamp.present - ? data.refreshedTimestamp.value - : this.refreshedTimestamp, + nip05: data.nip05.present ? data.nip05.value : this.nip05, + valid: data.valid.present ? data.valid.value : this.valid, + networkFetchTime: data.networkFetchTime.present + ? data.networkFetchTime.value + : this.networkFetchTime, relaysJson: data.relaysJson.present ? data.relaysJson.value : this.relaysJson, @@ -2317,10 +1693,11 @@ class DbUserRelayList extends DataClass implements Insertable { @override String toString() { - return (StringBuffer('DbUserRelayList(') + return (StringBuffer('DbNip05(') ..write('pubKey: $pubKey, ') - ..write('createdAt: $createdAt, ') - ..write('refreshedTimestamp: $refreshedTimestamp, ') + ..write('nip05: $nip05, ') + ..write('valid: $valid, ') + ..write('networkFetchTime: $networkFetchTime, ') ..write('relaysJson: $relaysJson') ..write(')')) .toString(); @@ -2328,67 +1705,75 @@ class DbUserRelayList extends DataClass implements Insertable { @override int get hashCode => - Object.hash(pubKey, createdAt, refreshedTimestamp, relaysJson); + Object.hash(pubKey, nip05, valid, networkFetchTime, relaysJson); @override bool operator ==(Object other) => identical(this, other) || - (other is DbUserRelayList && + (other is DbNip05 && other.pubKey == this.pubKey && - other.createdAt == this.createdAt && - other.refreshedTimestamp == this.refreshedTimestamp && + other.nip05 == this.nip05 && + other.valid == this.valid && + other.networkFetchTime == this.networkFetchTime && other.relaysJson == this.relaysJson); } -class UserRelayListsCompanion extends UpdateCompanion { +class Nip05sCompanion extends UpdateCompanion { final Value pubKey; - final Value createdAt; - final Value refreshedTimestamp; + final Value nip05; + final Value valid; + final Value networkFetchTime; final Value relaysJson; final Value rowid; - const UserRelayListsCompanion({ + const Nip05sCompanion({ this.pubKey = const Value.absent(), - this.createdAt = const Value.absent(), - this.refreshedTimestamp = const Value.absent(), + this.nip05 = const Value.absent(), + this.valid = const Value.absent(), + this.networkFetchTime = const Value.absent(), this.relaysJson = const Value.absent(), this.rowid = const Value.absent(), }); - UserRelayListsCompanion.insert({ + Nip05sCompanion.insert({ required String pubKey, - required int createdAt, - required int refreshedTimestamp, + required String nip05, + required bool valid, + this.networkFetchTime = const Value.absent(), required String relaysJson, this.rowid = const Value.absent(), }) : pubKey = Value(pubKey), - createdAt = Value(createdAt), - refreshedTimestamp = Value(refreshedTimestamp), + nip05 = Value(nip05), + valid = Value(valid), relaysJson = Value(relaysJson); - static Insertable custom({ + static Insertable custom({ Expression? pubKey, - Expression? createdAt, - Expression? refreshedTimestamp, + Expression? nip05, + Expression? valid, + Expression? networkFetchTime, Expression? relaysJson, Expression? rowid, }) { return RawValuesInsertable({ if (pubKey != null) 'pub_key': pubKey, - if (createdAt != null) 'created_at': createdAt, - if (refreshedTimestamp != null) 'refreshed_timestamp': refreshedTimestamp, + if (nip05 != null) 'nip05': nip05, + if (valid != null) 'valid': valid, + if (networkFetchTime != null) 'network_fetch_time': networkFetchTime, if (relaysJson != null) 'relays_json': relaysJson, if (rowid != null) 'rowid': rowid, }); } - UserRelayListsCompanion copyWith({ + Nip05sCompanion copyWith({ Value? pubKey, - Value? createdAt, - Value? refreshedTimestamp, + Value? nip05, + Value? valid, + Value? networkFetchTime, Value? relaysJson, Value? rowid, }) { - return UserRelayListsCompanion( + return Nip05sCompanion( pubKey: pubKey ?? this.pubKey, - createdAt: createdAt ?? this.createdAt, - refreshedTimestamp: refreshedTimestamp ?? this.refreshedTimestamp, + nip05: nip05 ?? this.nip05, + valid: valid ?? this.valid, + networkFetchTime: networkFetchTime ?? this.networkFetchTime, relaysJson: relaysJson ?? this.relaysJson, rowid: rowid ?? this.rowid, ); @@ -2400,11 +1785,14 @@ class UserRelayListsCompanion extends UpdateCompanion { if (pubKey.present) { map['pub_key'] = Variable(pubKey.value); } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); + if (nip05.present) { + map['nip05'] = Variable(nip05.value); } - if (refreshedTimestamp.present) { - map['refreshed_timestamp'] = Variable(refreshedTimestamp.value); + if (valid.present) { + map['valid'] = Variable(valid.value); + } + if (networkFetchTime.present) { + map['network_fetch_time'] = Variable(networkFetchTime.value); } if (relaysJson.present) { map['relays_json'] = Variable(relaysJson.value); @@ -2417,10 +1805,11 @@ class UserRelayListsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('UserRelayListsCompanion(') + return (StringBuffer('Nip05sCompanion(') ..write('pubKey: $pubKey, ') - ..write('createdAt: $createdAt, ') - ..write('refreshedTimestamp: $refreshedTimestamp, ') + ..write('nip05: $nip05, ') + ..write('valid: $valid, ') + ..write('networkFetchTime: $networkFetchTime, ') ..write('relaysJson: $relaysJson, ') ..write('rowid: $rowid') ..write(')')) @@ -2428,500 +1817,571 @@ class UserRelayListsCompanion extends UpdateCompanion { } } -class $RelaySetsTable extends RelaySets - with TableInfo<$RelaySetsTable, DbRelaySet> { +class $FilterFetchedRangeRecordsTable extends FilterFetchedRangeRecords + with + TableInfo<$FilterFetchedRangeRecordsTable, DbFilterFetchedRangeRecord> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $RelaySetsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); + $FilterFetchedRangeRecordsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); @override - late final GeneratedColumn id = GeneratedColumn( - 'id', + late final GeneratedColumn key = GeneratedColumn( + 'key', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, + static const VerificationMeta _filterHashMeta = const VerificationMeta( + 'filterHash', ); - static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); @override - late final GeneratedColumn pubKey = GeneratedColumn( - 'pub_key', + late final GeneratedColumn filterHash = GeneratedColumn( + 'filter_hash', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _relayMinCountPerPubkeyMeta = - const VerificationMeta('relayMinCountPerPubkey'); + static const VerificationMeta _relayUrlMeta = const VerificationMeta( + 'relayUrl', + ); @override - late final GeneratedColumn relayMinCountPerPubkey = GeneratedColumn( - 'relay_min_count_per_pubkey', + late final GeneratedColumn relayUrl = GeneratedColumn( + 'relay_url', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _directionMeta = const VerificationMeta( - 'direction', + static const VerificationMeta _rangeStartMeta = const VerificationMeta( + 'rangeStart', ); @override - late final GeneratedColumn direction = GeneratedColumn( - 'direction', + late final GeneratedColumn rangeStart = GeneratedColumn( + 'range_start', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _relaysMapJsonMeta = const VerificationMeta( - 'relaysMapJson', + static const VerificationMeta _rangeEndMeta = const VerificationMeta( + 'rangeEnd', ); @override - late final GeneratedColumn relaysMapJson = GeneratedColumn( - 'relays_map_json', + late final GeneratedColumn rangeEnd = GeneratedColumn( + 'range_end', aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _fallbackToBootstrapRelaysMeta = - const VerificationMeta('fallbackToBootstrapRelays'); - @override - late final GeneratedColumn fallbackToBootstrapRelays = - GeneratedColumn( - 'fallback_to_bootstrap_relays', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("fallback_to_bootstrap_relays" IN (0, 1))', - ), - ); - static const VerificationMeta _notCoveredPubkeysJsonMeta = - const VerificationMeta('notCoveredPubkeysJson'); - @override - late final GeneratedColumn notCoveredPubkeysJson = - GeneratedColumn( - 'not_covered_pubkeys_json', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); @override List get $columns => [ - id, - name, - pubKey, - relayMinCountPerPubkey, - direction, - relaysMapJson, - fallbackToBootstrapRelays, - notCoveredPubkeysJson, + key, + filterHash, + relayUrl, + rangeStart, + rangeEnd, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'relay_sets'; + static const String $name = 'filter_fetched_range_records'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('name')) { + if (data.containsKey('key')) { context.handle( - _nameMeta, - name.isAcceptableOrUnknown(data['name']!, _nameMeta), + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), ); } else if (isInserting) { - context.missing(_nameMeta); + context.missing(_keyMeta); } - if (data.containsKey('pub_key')) { - context.handle( - _pubKeyMeta, - pubKey.isAcceptableOrUnknown(data['pub_key']!, _pubKeyMeta), - ); - } else if (isInserting) { - context.missing(_pubKeyMeta); - } - if (data.containsKey('relay_min_count_per_pubkey')) { - context.handle( - _relayMinCountPerPubkeyMeta, - relayMinCountPerPubkey.isAcceptableOrUnknown( - data['relay_min_count_per_pubkey']!, - _relayMinCountPerPubkeyMeta, - ), - ); - } else if (isInserting) { - context.missing(_relayMinCountPerPubkeyMeta); - } - if (data.containsKey('direction')) { + if (data.containsKey('filter_hash')) { context.handle( - _directionMeta, - direction.isAcceptableOrUnknown(data['direction']!, _directionMeta), + _filterHashMeta, + filterHash.isAcceptableOrUnknown(data['filter_hash']!, _filterHashMeta), ); } else if (isInserting) { - context.missing(_directionMeta); + context.missing(_filterHashMeta); } - if (data.containsKey('relays_map_json')) { + if (data.containsKey('relay_url')) { context.handle( - _relaysMapJsonMeta, - relaysMapJson.isAcceptableOrUnknown( - data['relays_map_json']!, - _relaysMapJsonMeta, - ), + _relayUrlMeta, + relayUrl.isAcceptableOrUnknown(data['relay_url']!, _relayUrlMeta), ); } else if (isInserting) { - context.missing(_relaysMapJsonMeta); + context.missing(_relayUrlMeta); } - if (data.containsKey('fallback_to_bootstrap_relays')) { + if (data.containsKey('range_start')) { context.handle( - _fallbackToBootstrapRelaysMeta, - fallbackToBootstrapRelays.isAcceptableOrUnknown( - data['fallback_to_bootstrap_relays']!, - _fallbackToBootstrapRelaysMeta, - ), + _rangeStartMeta, + rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), ); } else if (isInserting) { - context.missing(_fallbackToBootstrapRelaysMeta); + context.missing(_rangeStartMeta); } - if (data.containsKey('not_covered_pubkeys_json')) { + if (data.containsKey('range_end')) { context.handle( - _notCoveredPubkeysJsonMeta, - notCoveredPubkeysJson.isAcceptableOrUnknown( - data['not_covered_pubkeys_json']!, - _notCoveredPubkeysJsonMeta, - ), + _rangeEndMeta, + rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), ); } else if (isInserting) { - context.missing(_notCoveredPubkeysJsonMeta); + context.missing(_rangeEndMeta); } return context; } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {key}; @override - DbRelaySet map(Map data, {String? tablePrefix}) { + DbFilterFetchedRangeRecord map( + Map data, { + String? tablePrefix, + }) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbRelaySet( - id: attachedDatabase.typeMapping.read( + return DbFilterFetchedRangeRecord( + key: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}id'], + data['${effectivePrefix}key'], )!, - name: attachedDatabase.typeMapping.read( + filterHash: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}name'], + data['${effectivePrefix}filter_hash'], )!, - pubKey: attachedDatabase.typeMapping.read( + relayUrl: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}pub_key'], + data['${effectivePrefix}relay_url'], )!, - relayMinCountPerPubkey: attachedDatabase.typeMapping.read( + rangeStart: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}relay_min_count_per_pubkey'], + data['${effectivePrefix}range_start'], )!, - direction: attachedDatabase.typeMapping.read( + rangeEnd: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}direction'], + data['${effectivePrefix}range_end'], )!, - relaysMapJson: attachedDatabase.typeMapping.read( + ); + } + + @override + $FilterFetchedRangeRecordsTable createAlias(String alias) { + return $FilterFetchedRangeRecordsTable(attachedDatabase, alias); + } +} + +class DbFilterFetchedRangeRecord extends DataClass + implements Insertable { + final String key; + final String filterHash; + final String relayUrl; + final int rangeStart; + final int rangeEnd; + const DbFilterFetchedRangeRecord({ + required this.key, + required this.filterHash, + required this.relayUrl, + required this.rangeStart, + required this.rangeEnd, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['filter_hash'] = Variable(filterHash); + map['relay_url'] = Variable(relayUrl); + map['range_start'] = Variable(rangeStart); + map['range_end'] = Variable(rangeEnd); + return map; + } + + FilterFetchedRangeRecordsCompanion toCompanion(bool nullToAbsent) { + return FilterFetchedRangeRecordsCompanion( + key: Value(key), + filterHash: Value(filterHash), + relayUrl: Value(relayUrl), + rangeStart: Value(rangeStart), + rangeEnd: Value(rangeEnd), + ); + } + + factory DbFilterFetchedRangeRecord.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DbFilterFetchedRangeRecord( + key: serializer.fromJson(json['key']), + filterHash: serializer.fromJson(json['filterHash']), + relayUrl: serializer.fromJson(json['relayUrl']), + rangeStart: serializer.fromJson(json['rangeStart']), + rangeEnd: serializer.fromJson(json['rangeEnd']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'filterHash': serializer.toJson(filterHash), + 'relayUrl': serializer.toJson(relayUrl), + 'rangeStart': serializer.toJson(rangeStart), + 'rangeEnd': serializer.toJson(rangeEnd), + }; + } + + DbFilterFetchedRangeRecord copyWith({ + String? key, + String? filterHash, + String? relayUrl, + int? rangeStart, + int? rangeEnd, + }) => DbFilterFetchedRangeRecord( + key: key ?? this.key, + filterHash: filterHash ?? this.filterHash, + relayUrl: relayUrl ?? this.relayUrl, + rangeStart: rangeStart ?? this.rangeStart, + rangeEnd: rangeEnd ?? this.rangeEnd, + ); + DbFilterFetchedRangeRecord copyWithCompanion( + FilterFetchedRangeRecordsCompanion data, + ) { + return DbFilterFetchedRangeRecord( + key: data.key.present ? data.key.value : this.key, + filterHash: data.filterHash.present + ? data.filterHash.value + : this.filterHash, + relayUrl: data.relayUrl.present ? data.relayUrl.value : this.relayUrl, + rangeStart: data.rangeStart.present + ? data.rangeStart.value + : this.rangeStart, + rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, + ); + } + + @override + String toString() { + return (StringBuffer('DbFilterFetchedRangeRecord(') + ..write('key: $key, ') + ..write('filterHash: $filterHash, ') + ..write('relayUrl: $relayUrl, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(key, filterHash, relayUrl, rangeStart, rangeEnd); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DbFilterFetchedRangeRecord && + other.key == this.key && + other.filterHash == this.filterHash && + other.relayUrl == this.relayUrl && + other.rangeStart == this.rangeStart && + other.rangeEnd == this.rangeEnd); +} + +class FilterFetchedRangeRecordsCompanion + extends UpdateCompanion { + final Value key; + final Value filterHash; + final Value relayUrl; + final Value rangeStart; + final Value rangeEnd; + final Value rowid; + const FilterFetchedRangeRecordsCompanion({ + this.key = const Value.absent(), + this.filterHash = const Value.absent(), + this.relayUrl = const Value.absent(), + this.rangeStart = const Value.absent(), + this.rangeEnd = const Value.absent(), + this.rowid = const Value.absent(), + }); + FilterFetchedRangeRecordsCompanion.insert({ + required String key, + required String filterHash, + required String relayUrl, + required int rangeStart, + required int rangeEnd, + this.rowid = const Value.absent(), + }) : key = Value(key), + filterHash = Value(filterHash), + relayUrl = Value(relayUrl), + rangeStart = Value(rangeStart), + rangeEnd = Value(rangeEnd); + static Insertable custom({ + Expression? key, + Expression? filterHash, + Expression? relayUrl, + Expression? rangeStart, + Expression? rangeEnd, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (filterHash != null) 'filter_hash': filterHash, + if (relayUrl != null) 'relay_url': relayUrl, + if (rangeStart != null) 'range_start': rangeStart, + if (rangeEnd != null) 'range_end': rangeEnd, + if (rowid != null) 'rowid': rowid, + }); + } + + FilterFetchedRangeRecordsCompanion copyWith({ + Value? key, + Value? filterHash, + Value? relayUrl, + Value? rangeStart, + Value? rangeEnd, + Value? rowid, + }) { + return FilterFetchedRangeRecordsCompanion( + key: key ?? this.key, + filterHash: filterHash ?? this.filterHash, + relayUrl: relayUrl ?? this.relayUrl, + rangeStart: rangeStart ?? this.rangeStart, + rangeEnd: rangeEnd ?? this.rangeEnd, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (filterHash.present) { + map['filter_hash'] = Variable(filterHash.value); + } + if (relayUrl.present) { + map['relay_url'] = Variable(relayUrl.value); + } + if (rangeStart.present) { + map['range_start'] = Variable(rangeStart.value); + } + if (rangeEnd.present) { + map['range_end'] = Variable(rangeEnd.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FilterFetchedRangeRecordsCompanion(') + ..write('key: $key, ') + ..write('filterHash: $filterHash, ') + ..write('relayUrl: $relayUrl, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $EventSourcesTableTable extends EventSourcesTable + with TableInfo<$EventSourcesTableTable, DbEventSource> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $EventSourcesTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _eventIdMeta = const VerificationMeta( + 'eventId', + ); + @override + late final GeneratedColumn eventId = GeneratedColumn( + 'event_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _relayUrlMeta = const VerificationMeta( + 'relayUrl', + ); + @override + late final GeneratedColumn relayUrl = GeneratedColumn( + 'relay_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [eventId, relayUrl]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'event_sources_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('event_id')) { + context.handle( + _eventIdMeta, + eventId.isAcceptableOrUnknown(data['event_id']!, _eventIdMeta), + ); + } else if (isInserting) { + context.missing(_eventIdMeta); + } + if (data.containsKey('relay_url')) { + context.handle( + _relayUrlMeta, + relayUrl.isAcceptableOrUnknown(data['relay_url']!, _relayUrlMeta), + ); + } else if (isInserting) { + context.missing(_relayUrlMeta); + } + return context; + } + + @override + Set get $primaryKey => {eventId, relayUrl}; + @override + DbEventSource map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DbEventSource( + eventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}relays_map_json'], + data['${effectivePrefix}event_id'], )!, - fallbackToBootstrapRelays: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}fallback_to_bootstrap_relays'], - )!, - notCoveredPubkeysJson: attachedDatabase.typeMapping.read( + relayUrl: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}not_covered_pubkeys_json'], + data['${effectivePrefix}relay_url'], )!, ); } @override - $RelaySetsTable createAlias(String alias) { - return $RelaySetsTable(attachedDatabase, alias); + $EventSourcesTableTable createAlias(String alias) { + return $EventSourcesTableTable(attachedDatabase, alias); } } -class DbRelaySet extends DataClass implements Insertable { - final String id; - final String name; - final String pubKey; - final int relayMinCountPerPubkey; - final int direction; - final String relaysMapJson; - final bool fallbackToBootstrapRelays; - final String notCoveredPubkeysJson; - const DbRelaySet({ - required this.id, - required this.name, - required this.pubKey, - required this.relayMinCountPerPubkey, - required this.direction, - required this.relaysMapJson, - required this.fallbackToBootstrapRelays, - required this.notCoveredPubkeysJson, - }); +class DbEventSource extends DataClass implements Insertable { + final String eventId; + final String relayUrl; + const DbEventSource({required this.eventId, required this.relayUrl}); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['name'] = Variable(name); - map['pub_key'] = Variable(pubKey); - map['relay_min_count_per_pubkey'] = Variable(relayMinCountPerPubkey); - map['direction'] = Variable(direction); - map['relays_map_json'] = Variable(relaysMapJson); - map['fallback_to_bootstrap_relays'] = Variable( - fallbackToBootstrapRelays, - ); - map['not_covered_pubkeys_json'] = Variable(notCoveredPubkeysJson); + map['event_id'] = Variable(eventId); + map['relay_url'] = Variable(relayUrl); return map; } - RelaySetsCompanion toCompanion(bool nullToAbsent) { - return RelaySetsCompanion( - id: Value(id), - name: Value(name), - pubKey: Value(pubKey), - relayMinCountPerPubkey: Value(relayMinCountPerPubkey), - direction: Value(direction), - relaysMapJson: Value(relaysMapJson), - fallbackToBootstrapRelays: Value(fallbackToBootstrapRelays), - notCoveredPubkeysJson: Value(notCoveredPubkeysJson), + EventSourcesTableCompanion toCompanion(bool nullToAbsent) { + return EventSourcesTableCompanion( + eventId: Value(eventId), + relayUrl: Value(relayUrl), ); } - factory DbRelaySet.fromJson( + factory DbEventSource.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbRelaySet( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - pubKey: serializer.fromJson(json['pubKey']), - relayMinCountPerPubkey: serializer.fromJson( - json['relayMinCountPerPubkey'], - ), - direction: serializer.fromJson(json['direction']), - relaysMapJson: serializer.fromJson(json['relaysMapJson']), - fallbackToBootstrapRelays: serializer.fromJson( - json['fallbackToBootstrapRelays'], - ), - notCoveredPubkeysJson: serializer.fromJson( - json['notCoveredPubkeysJson'], - ), + return DbEventSource( + eventId: serializer.fromJson(json['eventId']), + relayUrl: serializer.fromJson(json['relayUrl']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'pubKey': serializer.toJson(pubKey), - 'relayMinCountPerPubkey': serializer.toJson(relayMinCountPerPubkey), - 'direction': serializer.toJson(direction), - 'relaysMapJson': serializer.toJson(relaysMapJson), - 'fallbackToBootstrapRelays': serializer.toJson( - fallbackToBootstrapRelays, - ), - 'notCoveredPubkeysJson': serializer.toJson(notCoveredPubkeysJson), + 'eventId': serializer.toJson(eventId), + 'relayUrl': serializer.toJson(relayUrl), }; } - DbRelaySet copyWith({ - String? id, - String? name, - String? pubKey, - int? relayMinCountPerPubkey, - int? direction, - String? relaysMapJson, - bool? fallbackToBootstrapRelays, - String? notCoveredPubkeysJson, - }) => DbRelaySet( - id: id ?? this.id, - name: name ?? this.name, - pubKey: pubKey ?? this.pubKey, - relayMinCountPerPubkey: - relayMinCountPerPubkey ?? this.relayMinCountPerPubkey, - direction: direction ?? this.direction, - relaysMapJson: relaysMapJson ?? this.relaysMapJson, - fallbackToBootstrapRelays: - fallbackToBootstrapRelays ?? this.fallbackToBootstrapRelays, - notCoveredPubkeysJson: notCoveredPubkeysJson ?? this.notCoveredPubkeysJson, + DbEventSource copyWith({String? eventId, String? relayUrl}) => DbEventSource( + eventId: eventId ?? this.eventId, + relayUrl: relayUrl ?? this.relayUrl, ); - DbRelaySet copyWithCompanion(RelaySetsCompanion data) { - return DbRelaySet( - id: data.id.present ? data.id.value : this.id, - name: data.name.present ? data.name.value : this.name, - pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, - relayMinCountPerPubkey: data.relayMinCountPerPubkey.present - ? data.relayMinCountPerPubkey.value - : this.relayMinCountPerPubkey, - direction: data.direction.present ? data.direction.value : this.direction, - relaysMapJson: data.relaysMapJson.present - ? data.relaysMapJson.value - : this.relaysMapJson, - fallbackToBootstrapRelays: data.fallbackToBootstrapRelays.present - ? data.fallbackToBootstrapRelays.value - : this.fallbackToBootstrapRelays, - notCoveredPubkeysJson: data.notCoveredPubkeysJson.present - ? data.notCoveredPubkeysJson.value - : this.notCoveredPubkeysJson, + DbEventSource copyWithCompanion(EventSourcesTableCompanion data) { + return DbEventSource( + eventId: data.eventId.present ? data.eventId.value : this.eventId, + relayUrl: data.relayUrl.present ? data.relayUrl.value : this.relayUrl, ); } @override String toString() { - return (StringBuffer('DbRelaySet(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('pubKey: $pubKey, ') - ..write('relayMinCountPerPubkey: $relayMinCountPerPubkey, ') - ..write('direction: $direction, ') - ..write('relaysMapJson: $relaysMapJson, ') - ..write('fallbackToBootstrapRelays: $fallbackToBootstrapRelays, ') - ..write('notCoveredPubkeysJson: $notCoveredPubkeysJson') + return (StringBuffer('DbEventSource(') + ..write('eventId: $eventId, ') + ..write('relayUrl: $relayUrl') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( - id, - name, - pubKey, - relayMinCountPerPubkey, - direction, - relaysMapJson, - fallbackToBootstrapRelays, - notCoveredPubkeysJson, - ); + int get hashCode => Object.hash(eventId, relayUrl); @override bool operator ==(Object other) => identical(this, other) || - (other is DbRelaySet && - other.id == this.id && - other.name == this.name && - other.pubKey == this.pubKey && - other.relayMinCountPerPubkey == this.relayMinCountPerPubkey && - other.direction == this.direction && - other.relaysMapJson == this.relaysMapJson && - other.fallbackToBootstrapRelays == this.fallbackToBootstrapRelays && - other.notCoveredPubkeysJson == this.notCoveredPubkeysJson); + (other is DbEventSource && + other.eventId == this.eventId && + other.relayUrl == this.relayUrl); } -class RelaySetsCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value pubKey; - final Value relayMinCountPerPubkey; - final Value direction; - final Value relaysMapJson; - final Value fallbackToBootstrapRelays; - final Value notCoveredPubkeysJson; +class EventSourcesTableCompanion extends UpdateCompanion { + final Value eventId; + final Value relayUrl; final Value rowid; - const RelaySetsCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.pubKey = const Value.absent(), - this.relayMinCountPerPubkey = const Value.absent(), - this.direction = const Value.absent(), - this.relaysMapJson = const Value.absent(), - this.fallbackToBootstrapRelays = const Value.absent(), - this.notCoveredPubkeysJson = const Value.absent(), + const EventSourcesTableCompanion({ + this.eventId = const Value.absent(), + this.relayUrl = const Value.absent(), this.rowid = const Value.absent(), }); - RelaySetsCompanion.insert({ - required String id, - required String name, - required String pubKey, - required int relayMinCountPerPubkey, - required int direction, - required String relaysMapJson, - required bool fallbackToBootstrapRelays, - required String notCoveredPubkeysJson, + EventSourcesTableCompanion.insert({ + required String eventId, + required String relayUrl, this.rowid = const Value.absent(), - }) : id = Value(id), - name = Value(name), - pubKey = Value(pubKey), - relayMinCountPerPubkey = Value(relayMinCountPerPubkey), - direction = Value(direction), - relaysMapJson = Value(relaysMapJson), - fallbackToBootstrapRelays = Value(fallbackToBootstrapRelays), - notCoveredPubkeysJson = Value(notCoveredPubkeysJson); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? pubKey, - Expression? relayMinCountPerPubkey, - Expression? direction, - Expression? relaysMapJson, - Expression? fallbackToBootstrapRelays, - Expression? notCoveredPubkeysJson, + }) : eventId = Value(eventId), + relayUrl = Value(relayUrl); + static Insertable custom({ + Expression? eventId, + Expression? relayUrl, Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (pubKey != null) 'pub_key': pubKey, - if (relayMinCountPerPubkey != null) - 'relay_min_count_per_pubkey': relayMinCountPerPubkey, - if (direction != null) 'direction': direction, - if (relaysMapJson != null) 'relays_map_json': relaysMapJson, - if (fallbackToBootstrapRelays != null) - 'fallback_to_bootstrap_relays': fallbackToBootstrapRelays, - if (notCoveredPubkeysJson != null) - 'not_covered_pubkeys_json': notCoveredPubkeysJson, + if (eventId != null) 'event_id': eventId, + if (relayUrl != null) 'relay_url': relayUrl, if (rowid != null) 'rowid': rowid, }); } - RelaySetsCompanion copyWith({ - Value? id, - Value? name, - Value? pubKey, - Value? relayMinCountPerPubkey, - Value? direction, - Value? relaysMapJson, - Value? fallbackToBootstrapRelays, - Value? notCoveredPubkeysJson, + EventSourcesTableCompanion copyWith({ + Value? eventId, + Value? relayUrl, Value? rowid, }) { - return RelaySetsCompanion( - id: id ?? this.id, - name: name ?? this.name, - pubKey: pubKey ?? this.pubKey, - relayMinCountPerPubkey: - relayMinCountPerPubkey ?? this.relayMinCountPerPubkey, - direction: direction ?? this.direction, - relaysMapJson: relaysMapJson ?? this.relaysMapJson, - fallbackToBootstrapRelays: - fallbackToBootstrapRelays ?? this.fallbackToBootstrapRelays, - notCoveredPubkeysJson: - notCoveredPubkeysJson ?? this.notCoveredPubkeysJson, + return EventSourcesTableCompanion( + eventId: eventId ?? this.eventId, + relayUrl: relayUrl ?? this.relayUrl, rowid: rowid ?? this.rowid, ); } @@ -2929,35 +2389,11 @@ class RelaySetsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (pubKey.present) { - map['pub_key'] = Variable(pubKey.value); - } - if (relayMinCountPerPubkey.present) { - map['relay_min_count_per_pubkey'] = Variable( - relayMinCountPerPubkey.value, - ); + if (eventId.present) { + map['event_id'] = Variable(eventId.value); } - if (direction.present) { - map['direction'] = Variable(direction.value); - } - if (relaysMapJson.present) { - map['relays_map_json'] = Variable(relaysMapJson.value); - } - if (fallbackToBootstrapRelays.present) { - map['fallback_to_bootstrap_relays'] = Variable( - fallbackToBootstrapRelays.value, - ); - } - if (notCoveredPubkeysJson.present) { - map['not_covered_pubkeys_json'] = Variable( - notCoveredPubkeysJson.value, - ); + if (relayUrl.present) { + map['relay_url'] = Variable(relayUrl.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -2967,353 +2403,453 @@ class RelaySetsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('RelaySetsCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('pubKey: $pubKey, ') - ..write('relayMinCountPerPubkey: $relayMinCountPerPubkey, ') - ..write('direction: $direction, ') - ..write('relaysMapJson: $relaysMapJson, ') - ..write('fallbackToBootstrapRelays: $fallbackToBootstrapRelays, ') - ..write('notCoveredPubkeysJson: $notCoveredPubkeysJson, ') + return (StringBuffer('EventSourcesTableCompanion(') + ..write('eventId: $eventId, ') + ..write('relayUrl: $relayUrl, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $Nip05sTable extends Nip05s with TableInfo<$Nip05sTable, DbNip05> { +class $EventDeliveryRecordsTableTable extends EventDeliveryRecordsTable + with TableInfo<$EventDeliveryRecordsTableTable, DbEventDeliveryRecord> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $Nip05sTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _pubKeyMeta = const VerificationMeta('pubKey'); + $EventDeliveryRecordsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _eventIdMeta = const VerificationMeta( + 'eventId', + ); @override - late final GeneratedColumn pubKey = GeneratedColumn( - 'pub_key', + late final GeneratedColumn eventId = GeneratedColumn( + 'event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _nip05Meta = const VerificationMeta('nip05'); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); @override - late final GeneratedColumn nip05 = GeneratedColumn( - 'nip05', + late final GeneratedColumn status = GeneratedColumn( + 'status', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _validMeta = const VerificationMeta('valid'); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override - late final GeneratedColumn valid = GeneratedColumn( - 'valid', + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, - type: DriftSqlType.bool, + type: DriftSqlType.int, requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("valid" IN (0, 1))', - ), ); - static const VerificationMeta _networkFetchTimeMeta = const VerificationMeta( - 'networkFetchTime', + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', ); @override - late final GeneratedColumn networkFetchTime = GeneratedColumn( - 'network_fetch_time', + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _signedAtMeta = const VerificationMeta( + 'signedAt', + ); + @override + late final GeneratedColumn signedAt = GeneratedColumn( + 'signed_at', aliasedName, true, type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _relaysJsonMeta = const VerificationMeta( - 'relaysJson', + static const VerificationMeta _completedAtMeta = const VerificationMeta( + 'completedAt', ); @override - late final GeneratedColumn relaysJson = GeneratedColumn( - 'relays_json', + late final GeneratedColumn completedAt = GeneratedColumn( + 'completed_at', aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, ); + static const VerificationMeta _requiresNetworkSignerMeta = + const VerificationMeta('requiresNetworkSigner'); + @override + late final GeneratedColumn requiresNetworkSigner = + GeneratedColumn( + 'requires_network_signer', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("requires_network_signer" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ - pubKey, - nip05, - valid, - networkFetchTime, - relaysJson, + eventId, + status, + createdAt, + updatedAt, + signedAt, + completedAt, + requiresNetworkSigner, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'nip05s'; + static const String $name = 'event_delivery_records_table'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('pub_key')) { + if (data.containsKey('event_id')) { context.handle( - _pubKeyMeta, - pubKey.isAcceptableOrUnknown(data['pub_key']!, _pubKeyMeta), + _eventIdMeta, + eventId.isAcceptableOrUnknown(data['event_id']!, _eventIdMeta), ); } else if (isInserting) { - context.missing(_pubKeyMeta); + context.missing(_eventIdMeta); } - if (data.containsKey('nip05')) { + if (data.containsKey('status')) { context.handle( - _nip05Meta, - nip05.isAcceptableOrUnknown(data['nip05']!, _nip05Meta), + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), ); } else if (isInserting) { - context.missing(_nip05Meta); + context.missing(_statusMeta); } - if (data.containsKey('valid')) { + if (data.containsKey('created_at')) { context.handle( - _validMeta, - valid.isAcceptableOrUnknown(data['valid']!, _validMeta), + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), ); } else if (isInserting) { - context.missing(_validMeta); + context.missing(_createdAtMeta); } - if (data.containsKey('network_fetch_time')) { + if (data.containsKey('updated_at')) { context.handle( - _networkFetchTimeMeta, - networkFetchTime.isAcceptableOrUnknown( - data['network_fetch_time']!, - _networkFetchTimeMeta, + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('signed_at')) { + context.handle( + _signedAtMeta, + signedAt.isAcceptableOrUnknown(data['signed_at']!, _signedAtMeta), + ); + } + if (data.containsKey('completed_at')) { + context.handle( + _completedAtMeta, + completedAt.isAcceptableOrUnknown( + data['completed_at']!, + _completedAtMeta, ), ); } - if (data.containsKey('relays_json')) { + if (data.containsKey('requires_network_signer')) { context.handle( - _relaysJsonMeta, - relaysJson.isAcceptableOrUnknown(data['relays_json']!, _relaysJsonMeta), + _requiresNetworkSignerMeta, + requiresNetworkSigner.isAcceptableOrUnknown( + data['requires_network_signer']!, + _requiresNetworkSignerMeta, + ), ); - } else if (isInserting) { - context.missing(_relaysJsonMeta); } return context; } @override - Set get $primaryKey => {pubKey}; + Set get $primaryKey => {eventId}; @override - DbNip05 map(Map data, {String? tablePrefix}) { + DbEventDeliveryRecord map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbNip05( - pubKey: attachedDatabase.typeMapping.read( + return DbEventDeliveryRecord( + eventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}pub_key'], + data['${effectivePrefix}event_id'], )!, - nip05: attachedDatabase.typeMapping.read( + status: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}nip05'], + data['${effectivePrefix}status'], )!, - valid: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}valid'], + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], )!, - networkFetchTime: attachedDatabase.typeMapping.read( + updatedAt: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}network_fetch_time'], + data['${effectivePrefix}updated_at'], + )!, + signedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}signed_at'], ), - relaysJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}relays_json'], + completedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}completed_at'], + ), + requiresNetworkSigner: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}requires_network_signer'], )!, ); } @override - $Nip05sTable createAlias(String alias) { - return $Nip05sTable(attachedDatabase, alias); + $EventDeliveryRecordsTableTable createAlias(String alias) { + return $EventDeliveryRecordsTableTable(attachedDatabase, alias); } } -class DbNip05 extends DataClass implements Insertable { - final String pubKey; - final String nip05; - final bool valid; - final int? networkFetchTime; - final String relaysJson; - const DbNip05({ - required this.pubKey, - required this.nip05, - required this.valid, - this.networkFetchTime, - required this.relaysJson, +class DbEventDeliveryRecord extends DataClass + implements Insertable { + final String eventId; + final String status; + final int createdAt; + final int updatedAt; + final int? signedAt; + final int? completedAt; + final bool requiresNetworkSigner; + const DbEventDeliveryRecord({ + required this.eventId, + required this.status, + required this.createdAt, + required this.updatedAt, + this.signedAt, + this.completedAt, + required this.requiresNetworkSigner, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['pub_key'] = Variable(pubKey); - map['nip05'] = Variable(nip05); - map['valid'] = Variable(valid); - if (!nullToAbsent || networkFetchTime != null) { - map['network_fetch_time'] = Variable(networkFetchTime); + map['event_id'] = Variable(eventId); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || signedAt != null) { + map['signed_at'] = Variable(signedAt); } - map['relays_json'] = Variable(relaysJson); + if (!nullToAbsent || completedAt != null) { + map['completed_at'] = Variable(completedAt); + } + map['requires_network_signer'] = Variable(requiresNetworkSigner); return map; } - Nip05sCompanion toCompanion(bool nullToAbsent) { - return Nip05sCompanion( - pubKey: Value(pubKey), - nip05: Value(nip05), - valid: Value(valid), - networkFetchTime: networkFetchTime == null && nullToAbsent + EventDeliveryRecordsTableCompanion toCompanion(bool nullToAbsent) { + return EventDeliveryRecordsTableCompanion( + eventId: Value(eventId), + status: Value(status), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + signedAt: signedAt == null && nullToAbsent ? const Value.absent() - : Value(networkFetchTime), - relaysJson: Value(relaysJson), + : Value(signedAt), + completedAt: completedAt == null && nullToAbsent + ? const Value.absent() + : Value(completedAt), + requiresNetworkSigner: Value(requiresNetworkSigner), ); } - factory DbNip05.fromJson( + factory DbEventDeliveryRecord.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbNip05( - pubKey: serializer.fromJson(json['pubKey']), - nip05: serializer.fromJson(json['nip05']), - valid: serializer.fromJson(json['valid']), - networkFetchTime: serializer.fromJson(json['networkFetchTime']), - relaysJson: serializer.fromJson(json['relaysJson']), + return DbEventDeliveryRecord( + eventId: serializer.fromJson(json['eventId']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + signedAt: serializer.fromJson(json['signedAt']), + completedAt: serializer.fromJson(json['completedAt']), + requiresNetworkSigner: serializer.fromJson( + json['requiresNetworkSigner'], + ), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'pubKey': serializer.toJson(pubKey), - 'nip05': serializer.toJson(nip05), - 'valid': serializer.toJson(valid), - 'networkFetchTime': serializer.toJson(networkFetchTime), - 'relaysJson': serializer.toJson(relaysJson), + 'eventId': serializer.toJson(eventId), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'signedAt': serializer.toJson(signedAt), + 'completedAt': serializer.toJson(completedAt), + 'requiresNetworkSigner': serializer.toJson(requiresNetworkSigner), }; } - DbNip05 copyWith({ - String? pubKey, - String? nip05, - bool? valid, - Value networkFetchTime = const Value.absent(), - String? relaysJson, - }) => DbNip05( - pubKey: pubKey ?? this.pubKey, - nip05: nip05 ?? this.nip05, - valid: valid ?? this.valid, - networkFetchTime: networkFetchTime.present - ? networkFetchTime.value - : this.networkFetchTime, - relaysJson: relaysJson ?? this.relaysJson, + DbEventDeliveryRecord copyWith({ + String? eventId, + String? status, + int? createdAt, + int? updatedAt, + Value signedAt = const Value.absent(), + Value completedAt = const Value.absent(), + bool? requiresNetworkSigner, + }) => DbEventDeliveryRecord( + eventId: eventId ?? this.eventId, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + signedAt: signedAt.present ? signedAt.value : this.signedAt, + completedAt: completedAt.present ? completedAt.value : this.completedAt, + requiresNetworkSigner: requiresNetworkSigner ?? this.requiresNetworkSigner, ); - DbNip05 copyWithCompanion(Nip05sCompanion data) { - return DbNip05( - pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, - nip05: data.nip05.present ? data.nip05.value : this.nip05, - valid: data.valid.present ? data.valid.value : this.valid, - networkFetchTime: data.networkFetchTime.present - ? data.networkFetchTime.value - : this.networkFetchTime, - relaysJson: data.relaysJson.present - ? data.relaysJson.value - : this.relaysJson, + DbEventDeliveryRecord copyWithCompanion( + EventDeliveryRecordsTableCompanion data, + ) { + return DbEventDeliveryRecord( + eventId: data.eventId.present ? data.eventId.value : this.eventId, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + signedAt: data.signedAt.present ? data.signedAt.value : this.signedAt, + completedAt: data.completedAt.present + ? data.completedAt.value + : this.completedAt, + requiresNetworkSigner: data.requiresNetworkSigner.present + ? data.requiresNetworkSigner.value + : this.requiresNetworkSigner, ); } @override String toString() { - return (StringBuffer('DbNip05(') - ..write('pubKey: $pubKey, ') - ..write('nip05: $nip05, ') - ..write('valid: $valid, ') - ..write('networkFetchTime: $networkFetchTime, ') - ..write('relaysJson: $relaysJson') + return (StringBuffer('DbEventDeliveryRecord(') + ..write('eventId: $eventId, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('signedAt: $signedAt, ') + ..write('completedAt: $completedAt, ') + ..write('requiresNetworkSigner: $requiresNetworkSigner') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(pubKey, nip05, valid, networkFetchTime, relaysJson); + int get hashCode => Object.hash( + eventId, + status, + createdAt, + updatedAt, + signedAt, + completedAt, + requiresNetworkSigner, + ); @override bool operator ==(Object other) => identical(this, other) || - (other is DbNip05 && - other.pubKey == this.pubKey && - other.nip05 == this.nip05 && - other.valid == this.valid && - other.networkFetchTime == this.networkFetchTime && - other.relaysJson == this.relaysJson); + (other is DbEventDeliveryRecord && + other.eventId == this.eventId && + other.status == this.status && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.signedAt == this.signedAt && + other.completedAt == this.completedAt && + other.requiresNetworkSigner == this.requiresNetworkSigner); } -class Nip05sCompanion extends UpdateCompanion { - final Value pubKey; - final Value nip05; - final Value valid; - final Value networkFetchTime; - final Value relaysJson; +class EventDeliveryRecordsTableCompanion + extends UpdateCompanion { + final Value eventId; + final Value status; + final Value createdAt; + final Value updatedAt; + final Value signedAt; + final Value completedAt; + final Value requiresNetworkSigner; final Value rowid; - const Nip05sCompanion({ - this.pubKey = const Value.absent(), - this.nip05 = const Value.absent(), - this.valid = const Value.absent(), - this.networkFetchTime = const Value.absent(), - this.relaysJson = const Value.absent(), + const EventDeliveryRecordsTableCompanion({ + this.eventId = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.signedAt = const Value.absent(), + this.completedAt = const Value.absent(), + this.requiresNetworkSigner = const Value.absent(), this.rowid = const Value.absent(), }); - Nip05sCompanion.insert({ - required String pubKey, - required String nip05, - required bool valid, - this.networkFetchTime = const Value.absent(), - required String relaysJson, + EventDeliveryRecordsTableCompanion.insert({ + required String eventId, + required String status, + required int createdAt, + required int updatedAt, + this.signedAt = const Value.absent(), + this.completedAt = const Value.absent(), + this.requiresNetworkSigner = const Value.absent(), this.rowid = const Value.absent(), - }) : pubKey = Value(pubKey), - nip05 = Value(nip05), - valid = Value(valid), - relaysJson = Value(relaysJson); - static Insertable custom({ - Expression? pubKey, - Expression? nip05, - Expression? valid, - Expression? networkFetchTime, - Expression? relaysJson, + }) : eventId = Value(eventId), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? eventId, + Expression? status, + Expression? createdAt, + Expression? updatedAt, + Expression? signedAt, + Expression? completedAt, + Expression? requiresNetworkSigner, Expression? rowid, }) { return RawValuesInsertable({ - if (pubKey != null) 'pub_key': pubKey, - if (nip05 != null) 'nip05': nip05, - if (valid != null) 'valid': valid, - if (networkFetchTime != null) 'network_fetch_time': networkFetchTime, - if (relaysJson != null) 'relays_json': relaysJson, + if (eventId != null) 'event_id': eventId, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (signedAt != null) 'signed_at': signedAt, + if (completedAt != null) 'completed_at': completedAt, + if (requiresNetworkSigner != null) + 'requires_network_signer': requiresNetworkSigner, if (rowid != null) 'rowid': rowid, }); } - Nip05sCompanion copyWith({ - Value? pubKey, - Value? nip05, - Value? valid, - Value? networkFetchTime, - Value? relaysJson, + EventDeliveryRecordsTableCompanion copyWith({ + Value? eventId, + Value? status, + Value? createdAt, + Value? updatedAt, + Value? signedAt, + Value? completedAt, + Value? requiresNetworkSigner, Value? rowid, }) { - return Nip05sCompanion( - pubKey: pubKey ?? this.pubKey, - nip05: nip05 ?? this.nip05, - valid: valid ?? this.valid, - networkFetchTime: networkFetchTime ?? this.networkFetchTime, - relaysJson: relaysJson ?? this.relaysJson, + return EventDeliveryRecordsTableCompanion( + eventId: eventId ?? this.eventId, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + signedAt: signedAt ?? this.signedAt, + completedAt: completedAt ?? this.completedAt, + requiresNetworkSigner: + requiresNetworkSigner ?? this.requiresNetworkSigner, rowid: rowid ?? this.rowid, ); } @@ -3321,20 +2857,28 @@ class Nip05sCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (pubKey.present) { - map['pub_key'] = Variable(pubKey.value); + if (eventId.present) { + map['event_id'] = Variable(eventId.value); } - if (nip05.present) { - map['nip05'] = Variable(nip05.value); + if (status.present) { + map['status'] = Variable(status.value); } - if (valid.present) { - map['valid'] = Variable(valid.value); + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); } - if (networkFetchTime.present) { - map['network_fetch_time'] = Variable(networkFetchTime.value); + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); } - if (relaysJson.present) { - map['relays_json'] = Variable(relaysJson.value); + if (signedAt.present) { + map['signed_at'] = Variable(signedAt.value); + } + if (completedAt.present) { + map['completed_at'] = Variable(completedAt.value); + } + if (requiresNetworkSigner.present) { + map['requires_network_signer'] = Variable( + requiresNetworkSigner.value, + ); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -3342,115 +2886,155 @@ class Nip05sCompanion extends UpdateCompanion { return map; } - @override - String toString() { - return (StringBuffer('Nip05sCompanion(') - ..write('pubKey: $pubKey, ') - ..write('nip05: $nip05, ') - ..write('valid: $valid, ') - ..write('networkFetchTime: $networkFetchTime, ') - ..write('relaysJson: $relaysJson, ') + @override + String toString() { + return (StringBuffer('EventDeliveryRecordsTableCompanion(') + ..write('eventId: $eventId, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('signedAt: $signedAt, ') + ..write('completedAt: $completedAt, ') + ..write('requiresNetworkSigner: $requiresNetworkSigner, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $FilterFetchedRangeRecordsTable extends FilterFetchedRangeRecords - with - TableInfo<$FilterFetchedRangeRecordsTable, DbFilterFetchedRangeRecord> { +class $RelayDeliveryTargetsTableTable extends RelayDeliveryTargetsTable + with TableInfo<$RelayDeliveryTargetsTableTable, DbRelayDeliveryTarget> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $FilterFetchedRangeRecordsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _keyMeta = const VerificationMeta('key'); + $RelayDeliveryTargetsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _eventIdMeta = const VerificationMeta( + 'eventId', + ); @override - late final GeneratedColumn key = GeneratedColumn( - 'key', + late final GeneratedColumn eventId = GeneratedColumn( + 'event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _filterHashMeta = const VerificationMeta( - 'filterHash', + static const VerificationMeta _relayUrlMeta = const VerificationMeta( + 'relayUrl', ); @override - late final GeneratedColumn filterHash = GeneratedColumn( - 'filter_hash', + late final GeneratedColumn relayUrl = GeneratedColumn( + 'relay_url', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _relayUrlMeta = const VerificationMeta( - 'relayUrl', + static const VerificationMeta _reasonMeta = const VerificationMeta('reason'); + @override + late final GeneratedColumn reason = GeneratedColumn( + 'reason', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, ); + static const VerificationMeta _stateMeta = const VerificationMeta('state'); @override - late final GeneratedColumn relayUrl = GeneratedColumn( - 'relay_url', + late final GeneratedColumn state = GeneratedColumn( + 'state', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _rangeStartMeta = const VerificationMeta( - 'rangeStart', + static const VerificationMeta _attemptCountMeta = const VerificationMeta( + 'attemptCount', ); @override - late final GeneratedColumn rangeStart = GeneratedColumn( - 'range_start', + late final GeneratedColumn attemptCount = GeneratedColumn( + 'attempt_count', aliasedName, false, type: DriftSqlType.int, - requiredDuringInsert: true, + requiredDuringInsert: false, + defaultValue: const Constant(0), ); - static const VerificationMeta _rangeEndMeta = const VerificationMeta( - 'rangeEnd', + static const VerificationMeta _lastAttemptAtMeta = const VerificationMeta( + 'lastAttemptAt', ); @override - late final GeneratedColumn rangeEnd = GeneratedColumn( - 'range_end', + late final GeneratedColumn lastAttemptAt = GeneratedColumn( + 'last_attempt_at', aliasedName, - false, + true, type: DriftSqlType.int, - requiredDuringInsert: true, + requiredDuringInsert: false, + ); + static const VerificationMeta _nextRetryAtMeta = const VerificationMeta( + 'nextRetryAt', + ); + @override + late final GeneratedColumn nextRetryAt = GeneratedColumn( + 'next_retry_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastErrorMeta = const VerificationMeta( + 'lastError', + ); + @override + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastOkMessageMeta = const VerificationMeta( + 'lastOkMessage', + ); + @override + late final GeneratedColumn lastOkMessage = GeneratedColumn( + 'last_ok_message', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, ); @override List get $columns => [ - key, - filterHash, + eventId, relayUrl, - rangeStart, - rangeEnd, + reason, + state, + attemptCount, + lastAttemptAt, + nextRetryAt, + lastError, + lastOkMessage, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'filter_fetched_range_records'; + static const String $name = 'relay_delivery_targets_table'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('key')) { - context.handle( - _keyMeta, - key.isAcceptableOrUnknown(data['key']!, _keyMeta), - ); - } else if (isInserting) { - context.missing(_keyMeta); - } - if (data.containsKey('filter_hash')) { + if (data.containsKey('event_id')) { context.handle( - _filterHashMeta, - filterHash.isAcceptableOrUnknown(data['filter_hash']!, _filterHashMeta), + _eventIdMeta, + eventId.isAcceptableOrUnknown(data['event_id']!, _eventIdMeta), ); } else if (isInserting) { - context.missing(_filterHashMeta); + context.missing(_eventIdMeta); } if (data.containsKey('relay_url')) { context.handle( @@ -3460,238 +3044,397 @@ class $FilterFetchedRangeRecordsTable extends FilterFetchedRangeRecords } else if (isInserting) { context.missing(_relayUrlMeta); } - if (data.containsKey('range_start')) { + if (data.containsKey('reason')) { context.handle( - _rangeStartMeta, - rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), + _reasonMeta, + reason.isAcceptableOrUnknown(data['reason']!, _reasonMeta), ); } else if (isInserting) { - context.missing(_rangeStartMeta); + context.missing(_reasonMeta); } - if (data.containsKey('range_end')) { + if (data.containsKey('state')) { context.handle( - _rangeEndMeta, - rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), + _stateMeta, + state.isAcceptableOrUnknown(data['state']!, _stateMeta), ); } else if (isInserting) { - context.missing(_rangeEndMeta); + context.missing(_stateMeta); + } + if (data.containsKey('attempt_count')) { + context.handle( + _attemptCountMeta, + attemptCount.isAcceptableOrUnknown( + data['attempt_count']!, + _attemptCountMeta, + ), + ); + } + if (data.containsKey('last_attempt_at')) { + context.handle( + _lastAttemptAtMeta, + lastAttemptAt.isAcceptableOrUnknown( + data['last_attempt_at']!, + _lastAttemptAtMeta, + ), + ); + } + if (data.containsKey('next_retry_at')) { + context.handle( + _nextRetryAtMeta, + nextRetryAt.isAcceptableOrUnknown( + data['next_retry_at']!, + _nextRetryAtMeta, + ), + ); + } + if (data.containsKey('last_error')) { + context.handle( + _lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta), + ); + } + if (data.containsKey('last_ok_message')) { + context.handle( + _lastOkMessageMeta, + lastOkMessage.isAcceptableOrUnknown( + data['last_ok_message']!, + _lastOkMessageMeta, + ), + ); } return context; } @override - Set get $primaryKey => {key}; + Set get $primaryKey => {eventId, relayUrl}; @override - DbFilterFetchedRangeRecord map( - Map data, { - String? tablePrefix, - }) { + DbRelayDeliveryTarget map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return DbFilterFetchedRangeRecord( - key: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}key'], - )!, - filterHash: attachedDatabase.typeMapping.read( + return DbRelayDeliveryTarget( + eventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}filter_hash'], + data['${effectivePrefix}event_id'], )!, relayUrl: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}relay_url'], )!, - rangeStart: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}range_start'], + reason: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}reason'], )!, - rangeEnd: attachedDatabase.typeMapping.read( + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + )!, + attemptCount: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}range_end'], + data['${effectivePrefix}attempt_count'], )!, + lastAttemptAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_attempt_at'], + ), + nextRetryAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}next_retry_at'], + ), + lastError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_error'], + ), + lastOkMessage: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_ok_message'], + ), ); } @override - $FilterFetchedRangeRecordsTable createAlias(String alias) { - return $FilterFetchedRangeRecordsTable(attachedDatabase, alias); + $RelayDeliveryTargetsTableTable createAlias(String alias) { + return $RelayDeliveryTargetsTableTable(attachedDatabase, alias); } } -class DbFilterFetchedRangeRecord extends DataClass - implements Insertable { - final String key; - final String filterHash; +class DbRelayDeliveryTarget extends DataClass + implements Insertable { + final String eventId; final String relayUrl; - final int rangeStart; - final int rangeEnd; - const DbFilterFetchedRangeRecord({ - required this.key, - required this.filterHash, + final String reason; + final String state; + final int attemptCount; + final int? lastAttemptAt; + final int? nextRetryAt; + final String? lastError; + final String? lastOkMessage; + const DbRelayDeliveryTarget({ + required this.eventId, required this.relayUrl, - required this.rangeStart, - required this.rangeEnd, + required this.reason, + required this.state, + required this.attemptCount, + this.lastAttemptAt, + this.nextRetryAt, + this.lastError, + this.lastOkMessage, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['key'] = Variable(key); - map['filter_hash'] = Variable(filterHash); + map['event_id'] = Variable(eventId); map['relay_url'] = Variable(relayUrl); - map['range_start'] = Variable(rangeStart); - map['range_end'] = Variable(rangeEnd); + map['reason'] = Variable(reason); + map['state'] = Variable(state); + map['attempt_count'] = Variable(attemptCount); + if (!nullToAbsent || lastAttemptAt != null) { + map['last_attempt_at'] = Variable(lastAttemptAt); + } + if (!nullToAbsent || nextRetryAt != null) { + map['next_retry_at'] = Variable(nextRetryAt); + } + if (!nullToAbsent || lastError != null) { + map['last_error'] = Variable(lastError); + } + if (!nullToAbsent || lastOkMessage != null) { + map['last_ok_message'] = Variable(lastOkMessage); + } return map; } - FilterFetchedRangeRecordsCompanion toCompanion(bool nullToAbsent) { - return FilterFetchedRangeRecordsCompanion( - key: Value(key), - filterHash: Value(filterHash), + RelayDeliveryTargetsTableCompanion toCompanion(bool nullToAbsent) { + return RelayDeliveryTargetsTableCompanion( + eventId: Value(eventId), relayUrl: Value(relayUrl), - rangeStart: Value(rangeStart), - rangeEnd: Value(rangeEnd), + reason: Value(reason), + state: Value(state), + attemptCount: Value(attemptCount), + lastAttemptAt: lastAttemptAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAttemptAt), + nextRetryAt: nextRetryAt == null && nullToAbsent + ? const Value.absent() + : Value(nextRetryAt), + lastError: lastError == null && nullToAbsent + ? const Value.absent() + : Value(lastError), + lastOkMessage: lastOkMessage == null && nullToAbsent + ? const Value.absent() + : Value(lastOkMessage), ); } - factory DbFilterFetchedRangeRecord.fromJson( + factory DbRelayDeliveryTarget.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return DbFilterFetchedRangeRecord( - key: serializer.fromJson(json['key']), - filterHash: serializer.fromJson(json['filterHash']), + return DbRelayDeliveryTarget( + eventId: serializer.fromJson(json['eventId']), relayUrl: serializer.fromJson(json['relayUrl']), - rangeStart: serializer.fromJson(json['rangeStart']), - rangeEnd: serializer.fromJson(json['rangeEnd']), + reason: serializer.fromJson(json['reason']), + state: serializer.fromJson(json['state']), + attemptCount: serializer.fromJson(json['attemptCount']), + lastAttemptAt: serializer.fromJson(json['lastAttemptAt']), + nextRetryAt: serializer.fromJson(json['nextRetryAt']), + lastError: serializer.fromJson(json['lastError']), + lastOkMessage: serializer.fromJson(json['lastOkMessage']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'key': serializer.toJson(key), - 'filterHash': serializer.toJson(filterHash), + 'eventId': serializer.toJson(eventId), 'relayUrl': serializer.toJson(relayUrl), - 'rangeStart': serializer.toJson(rangeStart), - 'rangeEnd': serializer.toJson(rangeEnd), + 'reason': serializer.toJson(reason), + 'state': serializer.toJson(state), + 'attemptCount': serializer.toJson(attemptCount), + 'lastAttemptAt': serializer.toJson(lastAttemptAt), + 'nextRetryAt': serializer.toJson(nextRetryAt), + 'lastError': serializer.toJson(lastError), + 'lastOkMessage': serializer.toJson(lastOkMessage), }; } - DbFilterFetchedRangeRecord copyWith({ - String? key, - String? filterHash, + DbRelayDeliveryTarget copyWith({ + String? eventId, String? relayUrl, - int? rangeStart, - int? rangeEnd, - }) => DbFilterFetchedRangeRecord( - key: key ?? this.key, - filterHash: filterHash ?? this.filterHash, + String? reason, + String? state, + int? attemptCount, + Value lastAttemptAt = const Value.absent(), + Value nextRetryAt = const Value.absent(), + Value lastError = const Value.absent(), + Value lastOkMessage = const Value.absent(), + }) => DbRelayDeliveryTarget( + eventId: eventId ?? this.eventId, relayUrl: relayUrl ?? this.relayUrl, - rangeStart: rangeStart ?? this.rangeStart, - rangeEnd: rangeEnd ?? this.rangeEnd, - ); - DbFilterFetchedRangeRecord copyWithCompanion( - FilterFetchedRangeRecordsCompanion data, + reason: reason ?? this.reason, + state: state ?? this.state, + attemptCount: attemptCount ?? this.attemptCount, + lastAttemptAt: lastAttemptAt.present + ? lastAttemptAt.value + : this.lastAttemptAt, + nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt, + lastError: lastError.present ? lastError.value : this.lastError, + lastOkMessage: lastOkMessage.present + ? lastOkMessage.value + : this.lastOkMessage, + ); + DbRelayDeliveryTarget copyWithCompanion( + RelayDeliveryTargetsTableCompanion data, ) { - return DbFilterFetchedRangeRecord( - key: data.key.present ? data.key.value : this.key, - filterHash: data.filterHash.present - ? data.filterHash.value - : this.filterHash, + return DbRelayDeliveryTarget( + eventId: data.eventId.present ? data.eventId.value : this.eventId, relayUrl: data.relayUrl.present ? data.relayUrl.value : this.relayUrl, - rangeStart: data.rangeStart.present - ? data.rangeStart.value - : this.rangeStart, - rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, + reason: data.reason.present ? data.reason.value : this.reason, + state: data.state.present ? data.state.value : this.state, + attemptCount: data.attemptCount.present + ? data.attemptCount.value + : this.attemptCount, + lastAttemptAt: data.lastAttemptAt.present + ? data.lastAttemptAt.value + : this.lastAttemptAt, + nextRetryAt: data.nextRetryAt.present + ? data.nextRetryAt.value + : this.nextRetryAt, + lastError: data.lastError.present ? data.lastError.value : this.lastError, + lastOkMessage: data.lastOkMessage.present + ? data.lastOkMessage.value + : this.lastOkMessage, ); } @override String toString() { - return (StringBuffer('DbFilterFetchedRangeRecord(') - ..write('key: $key, ') - ..write('filterHash: $filterHash, ') + return (StringBuffer('DbRelayDeliveryTarget(') + ..write('eventId: $eventId, ') ..write('relayUrl: $relayUrl, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd') + ..write('reason: $reason, ') + ..write('state: $state, ') + ..write('attemptCount: $attemptCount, ') + ..write('lastAttemptAt: $lastAttemptAt, ') + ..write('nextRetryAt: $nextRetryAt, ') + ..write('lastError: $lastError, ') + ..write('lastOkMessage: $lastOkMessage') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(key, filterHash, relayUrl, rangeStart, rangeEnd); + int get hashCode => Object.hash( + eventId, + relayUrl, + reason, + state, + attemptCount, + lastAttemptAt, + nextRetryAt, + lastError, + lastOkMessage, + ); @override bool operator ==(Object other) => identical(this, other) || - (other is DbFilterFetchedRangeRecord && - other.key == this.key && - other.filterHash == this.filterHash && + (other is DbRelayDeliveryTarget && + other.eventId == this.eventId && other.relayUrl == this.relayUrl && - other.rangeStart == this.rangeStart && - other.rangeEnd == this.rangeEnd); + other.reason == this.reason && + other.state == this.state && + other.attemptCount == this.attemptCount && + other.lastAttemptAt == this.lastAttemptAt && + other.nextRetryAt == this.nextRetryAt && + other.lastError == this.lastError && + other.lastOkMessage == this.lastOkMessage); } -class FilterFetchedRangeRecordsCompanion - extends UpdateCompanion { - final Value key; - final Value filterHash; +class RelayDeliveryTargetsTableCompanion + extends UpdateCompanion { + final Value eventId; final Value relayUrl; - final Value rangeStart; - final Value rangeEnd; + final Value reason; + final Value state; + final Value attemptCount; + final Value lastAttemptAt; + final Value nextRetryAt; + final Value lastError; + final Value lastOkMessage; final Value rowid; - const FilterFetchedRangeRecordsCompanion({ - this.key = const Value.absent(), - this.filterHash = const Value.absent(), + const RelayDeliveryTargetsTableCompanion({ + this.eventId = const Value.absent(), this.relayUrl = const Value.absent(), - this.rangeStart = const Value.absent(), - this.rangeEnd = const Value.absent(), + this.reason = const Value.absent(), + this.state = const Value.absent(), + this.attemptCount = const Value.absent(), + this.lastAttemptAt = const Value.absent(), + this.nextRetryAt = const Value.absent(), + this.lastError = const Value.absent(), + this.lastOkMessage = const Value.absent(), this.rowid = const Value.absent(), }); - FilterFetchedRangeRecordsCompanion.insert({ - required String key, - required String filterHash, + RelayDeliveryTargetsTableCompanion.insert({ + required String eventId, required String relayUrl, - required int rangeStart, - required int rangeEnd, + required String reason, + required String state, + this.attemptCount = const Value.absent(), + this.lastAttemptAt = const Value.absent(), + this.nextRetryAt = const Value.absent(), + this.lastError = const Value.absent(), + this.lastOkMessage = const Value.absent(), this.rowid = const Value.absent(), - }) : key = Value(key), - filterHash = Value(filterHash), + }) : eventId = Value(eventId), relayUrl = Value(relayUrl), - rangeStart = Value(rangeStart), - rangeEnd = Value(rangeEnd); - static Insertable custom({ - Expression? key, - Expression? filterHash, + reason = Value(reason), + state = Value(state); + static Insertable custom({ + Expression? eventId, Expression? relayUrl, - Expression? rangeStart, - Expression? rangeEnd, + Expression? reason, + Expression? state, + Expression? attemptCount, + Expression? lastAttemptAt, + Expression? nextRetryAt, + Expression? lastError, + Expression? lastOkMessage, Expression? rowid, }) { return RawValuesInsertable({ - if (key != null) 'key': key, - if (filterHash != null) 'filter_hash': filterHash, + if (eventId != null) 'event_id': eventId, if (relayUrl != null) 'relay_url': relayUrl, - if (rangeStart != null) 'range_start': rangeStart, - if (rangeEnd != null) 'range_end': rangeEnd, + if (reason != null) 'reason': reason, + if (state != null) 'state': state, + if (attemptCount != null) 'attempt_count': attemptCount, + if (lastAttemptAt != null) 'last_attempt_at': lastAttemptAt, + if (nextRetryAt != null) 'next_retry_at': nextRetryAt, + if (lastError != null) 'last_error': lastError, + if (lastOkMessage != null) 'last_ok_message': lastOkMessage, if (rowid != null) 'rowid': rowid, }); } - FilterFetchedRangeRecordsCompanion copyWith({ - Value? key, - Value? filterHash, + RelayDeliveryTargetsTableCompanion copyWith({ + Value? eventId, Value? relayUrl, - Value? rangeStart, - Value? rangeEnd, + Value? reason, + Value? state, + Value? attemptCount, + Value? lastAttemptAt, + Value? nextRetryAt, + Value? lastError, + Value? lastOkMessage, Value? rowid, }) { - return FilterFetchedRangeRecordsCompanion( - key: key ?? this.key, - filterHash: filterHash ?? this.filterHash, + return RelayDeliveryTargetsTableCompanion( + eventId: eventId ?? this.eventId, relayUrl: relayUrl ?? this.relayUrl, - rangeStart: rangeStart ?? this.rangeStart, - rangeEnd: rangeEnd ?? this.rangeEnd, + reason: reason ?? this.reason, + state: state ?? this.state, + attemptCount: attemptCount ?? this.attemptCount, + lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt, + nextRetryAt: nextRetryAt ?? this.nextRetryAt, + lastError: lastError ?? this.lastError, + lastOkMessage: lastOkMessage ?? this.lastOkMessage, rowid: rowid ?? this.rowid, ); } @@ -3699,20 +3442,32 @@ class FilterFetchedRangeRecordsCompanion @override Map toColumns(bool nullToAbsent) { final map = {}; - if (key.present) { - map['key'] = Variable(key.value); - } - if (filterHash.present) { - map['filter_hash'] = Variable(filterHash.value); + if (eventId.present) { + map['event_id'] = Variable(eventId.value); } if (relayUrl.present) { map['relay_url'] = Variable(relayUrl.value); } - if (rangeStart.present) { - map['range_start'] = Variable(rangeStart.value); + if (reason.present) { + map['reason'] = Variable(reason.value); } - if (rangeEnd.present) { - map['range_end'] = Variable(rangeEnd.value); + if (state.present) { + map['state'] = Variable(state.value); + } + if (attemptCount.present) { + map['attempt_count'] = Variable(attemptCount.value); + } + if (lastAttemptAt.present) { + map['last_attempt_at'] = Variable(lastAttemptAt.value); + } + if (nextRetryAt.present) { + map['next_retry_at'] = Variable(nextRetryAt.value); + } + if (lastError.present) { + map['last_error'] = Variable(lastError.value); + } + if (lastOkMessage.present) { + map['last_ok_message'] = Variable(lastOkMessage.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -3722,12 +3477,16 @@ class FilterFetchedRangeRecordsCompanion @override String toString() { - return (StringBuffer('FilterFetchedRangeRecordsCompanion(') - ..write('key: $key, ') - ..write('filterHash: $filterHash, ') + return (StringBuffer('RelayDeliveryTargetsTableCompanion(') + ..write('eventId: $eventId, ') ..write('relayUrl: $relayUrl, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd, ') + ..write('reason: $reason, ') + ..write('state: $state, ') + ..write('attemptCount: $attemptCount, ') + ..write('lastAttemptAt: $lastAttemptAt, ') + ..write('nextRetryAt: $nextRetryAt, ') + ..write('lastError: $lastError, ') + ..write('lastOkMessage: $lastOkMessage, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -6967,13 +6726,17 @@ abstract class _$NdkCacheDatabase extends GeneratedDatabase { _$NdkCacheDatabase(QueryExecutor e) : super(e); $NdkCacheDatabaseManager get managers => $NdkCacheDatabaseManager(this); late final $EventsTable events = $EventsTable(this); - late final $MetadatasTable metadatas = $MetadatasTable(this); - late final $ContactListsTable contactLists = $ContactListsTable(this); late final $UserRelayListsTable userRelayLists = $UserRelayListsTable(this); late final $RelaySetsTable relaySets = $RelaySetsTable(this); late final $Nip05sTable nip05s = $Nip05sTable(this); late final $FilterFetchedRangeRecordsTable filterFetchedRangeRecords = $FilterFetchedRangeRecordsTable(this); + late final $EventSourcesTableTable eventSourcesTable = + $EventSourcesTableTable(this); + late final $EventDeliveryRecordsTableTable eventDeliveryRecordsTable = + $EventDeliveryRecordsTableTable(this); + late final $RelayDeliveryTargetsTableTable relayDeliveryTargetsTable = + $RelayDeliveryTargetsTableTable(this); late final $CashuProofsTable cashuProofs = $CashuProofsTable(this); late final $CashuKeysetsTable cashuKeysets = $CashuKeysetsTable(this); late final $CashuMintInfosTable cashuMintInfos = $CashuMintInfosTable(this); @@ -6989,12 +6752,13 @@ abstract class _$NdkCacheDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [ events, - metadatas, - contactLists, userRelayLists, relaySets, nip05s, filterFetchedRangeRecords, + eventSourcesTable, + eventDeliveryRecordsTable, + relayDeliveryTargetsTable, cashuProofs, cashuKeysets, cashuMintInfos, @@ -7151,108 +6915,296 @@ class $$EventsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get pubKey => + $composableBuilder(column: $table.pubKey, builder: (column) => column); + + GeneratedColumn get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get sig => + $composableBuilder(column: $table.sig, builder: (column) => column); + + GeneratedColumn get validSig => + $composableBuilder(column: $table.validSig, builder: (column) => column); + + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + + GeneratedColumn get sourcesJson => $composableBuilder( + column: $table.sourcesJson, + builder: (column) => column, + ); +} + +class $$EventsTableTableManager + extends + RootTableManager< + _$NdkCacheDatabase, + $EventsTable, + DbEvent, + $$EventsTableFilterComposer, + $$EventsTableOrderingComposer, + $$EventsTableAnnotationComposer, + $$EventsTableCreateCompanionBuilder, + $$EventsTableUpdateCompanionBuilder, + (DbEvent, BaseReferences<_$NdkCacheDatabase, $EventsTable, DbEvent>), + DbEvent, + PrefetchHooks Function() + > { + $$EventsTableTableManager(_$NdkCacheDatabase db, $EventsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$EventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$EventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$EventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value pubKey = const Value.absent(), + Value kind = const Value.absent(), + Value createdAt = const Value.absent(), + Value content = const Value.absent(), + Value sig = const Value.absent(), + Value validSig = const Value.absent(), + Value tagsJson = const Value.absent(), + Value sourcesJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => EventsCompanion( + id: id, + pubKey: pubKey, + kind: kind, + createdAt: createdAt, + content: content, + sig: sig, + validSig: validSig, + tagsJson: tagsJson, + sourcesJson: sourcesJson, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String pubKey, + required int kind, + required int createdAt, + required String content, + Value sig = const Value.absent(), + Value validSig = const Value.absent(), + required String tagsJson, + required String sourcesJson, + Value rowid = const Value.absent(), + }) => EventsCompanion.insert( + id: id, + pubKey: pubKey, + kind: kind, + createdAt: createdAt, + content: content, + sig: sig, + validSig: validSig, + tagsJson: tagsJson, + sourcesJson: sourcesJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$EventsTableProcessedTableManager = + ProcessedTableManager< + _$NdkCacheDatabase, + $EventsTable, + DbEvent, + $$EventsTableFilterComposer, + $$EventsTableOrderingComposer, + $$EventsTableAnnotationComposer, + $$EventsTableCreateCompanionBuilder, + $$EventsTableUpdateCompanionBuilder, + (DbEvent, BaseReferences<_$NdkCacheDatabase, $EventsTable, DbEvent>), + DbEvent, + PrefetchHooks Function() + >; +typedef $$UserRelayListsTableCreateCompanionBuilder = + UserRelayListsCompanion Function({ + required String pubKey, + required int createdAt, + required int refreshedTimestamp, + required String relaysJson, + Value rowid, + }); +typedef $$UserRelayListsTableUpdateCompanionBuilder = + UserRelayListsCompanion Function({ + Value pubKey, + Value createdAt, + Value refreshedTimestamp, + Value relaysJson, + Value rowid, + }); + +class $$UserRelayListsTableFilterComposer + extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { + $$UserRelayListsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get pubKey => $composableBuilder( + column: $table.pubKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get refreshedTimestamp => $composableBuilder( + column: $table.refreshedTimestamp, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get relaysJson => $composableBuilder( + column: $table.relaysJson, + builder: (column) => ColumnFilters(column), + ); +} + +class $$UserRelayListsTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { + $$UserRelayListsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get pubKey => $composableBuilder( + column: $table.pubKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get refreshedTimestamp => $composableBuilder( + column: $table.refreshedTimestamp, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get relaysJson => $composableBuilder( + column: $table.relaysJson, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$UserRelayListsTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { + $$UserRelayListsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); GeneratedColumn get pubKey => $composableBuilder(column: $table.pubKey, builder: (column) => column); - GeneratedColumn get kind => - $composableBuilder(column: $table.kind, builder: (column) => column); - GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get content => - $composableBuilder(column: $table.content, builder: (column) => column); - - GeneratedColumn get sig => - $composableBuilder(column: $table.sig, builder: (column) => column); - - GeneratedColumn get validSig => - $composableBuilder(column: $table.validSig, builder: (column) => column); - - GeneratedColumn get tagsJson => - $composableBuilder(column: $table.tagsJson, builder: (column) => column); + GeneratedColumn get refreshedTimestamp => $composableBuilder( + column: $table.refreshedTimestamp, + builder: (column) => column, + ); - GeneratedColumn get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + GeneratedColumn get relaysJson => $composableBuilder( + column: $table.relaysJson, builder: (column) => column, ); } -class $$EventsTableTableManager +class $$UserRelayListsTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $EventsTable, - DbEvent, - $$EventsTableFilterComposer, - $$EventsTableOrderingComposer, - $$EventsTableAnnotationComposer, - $$EventsTableCreateCompanionBuilder, - $$EventsTableUpdateCompanionBuilder, - (DbEvent, BaseReferences<_$NdkCacheDatabase, $EventsTable, DbEvent>), - DbEvent, + $UserRelayListsTable, + DbUserRelayList, + $$UserRelayListsTableFilterComposer, + $$UserRelayListsTableOrderingComposer, + $$UserRelayListsTableAnnotationComposer, + $$UserRelayListsTableCreateCompanionBuilder, + $$UserRelayListsTableUpdateCompanionBuilder, + ( + DbUserRelayList, + BaseReferences< + _$NdkCacheDatabase, + $UserRelayListsTable, + DbUserRelayList + >, + ), + DbUserRelayList, PrefetchHooks Function() > { - $$EventsTableTableManager(_$NdkCacheDatabase db, $EventsTable table) - : super( + $$UserRelayListsTableTableManager( + _$NdkCacheDatabase db, + $UserRelayListsTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$EventsTableFilterComposer($db: db, $table: table), + $$UserRelayListsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$EventsTableOrderingComposer($db: db, $table: table), + $$UserRelayListsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$EventsTableAnnotationComposer($db: db, $table: table), + $$UserRelayListsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), Value pubKey = const Value.absent(), - Value kind = const Value.absent(), Value createdAt = const Value.absent(), - Value content = const Value.absent(), - Value sig = const Value.absent(), - Value validSig = const Value.absent(), - Value tagsJson = const Value.absent(), - Value sourcesJson = const Value.absent(), + Value refreshedTimestamp = const Value.absent(), + Value relaysJson = const Value.absent(), Value rowid = const Value.absent(), - }) => EventsCompanion( - id: id, + }) => UserRelayListsCompanion( pubKey: pubKey, - kind: kind, createdAt: createdAt, - content: content, - sig: sig, - validSig: validSig, - tagsJson: tagsJson, - sourcesJson: sourcesJson, + refreshedTimestamp: refreshedTimestamp, + relaysJson: relaysJson, rowid: rowid, ), createCompanionCallback: ({ - required String id, required String pubKey, - required int kind, required int createdAt, - required String content, - Value sig = const Value.absent(), - Value validSig = const Value.absent(), - required String tagsJson, - required String sourcesJson, + required int refreshedTimestamp, + required String relaysJson, Value rowid = const Value.absent(), - }) => EventsCompanion.insert( - id: id, + }) => UserRelayListsCompanion.insert( pubKey: pubKey, - kind: kind, createdAt: createdAt, - content: content, - sig: sig, - validSig: validSig, - tagsJson: tagsJson, - sourcesJson: sourcesJson, + refreshedTimestamp: refreshedTimestamp, + relaysJson: relaysJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -7263,70 +7215,63 @@ class $$EventsTableTableManager ); } -typedef $$EventsTableProcessedTableManager = +typedef $$UserRelayListsTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $EventsTable, - DbEvent, - $$EventsTableFilterComposer, - $$EventsTableOrderingComposer, - $$EventsTableAnnotationComposer, - $$EventsTableCreateCompanionBuilder, - $$EventsTableUpdateCompanionBuilder, - (DbEvent, BaseReferences<_$NdkCacheDatabase, $EventsTable, DbEvent>), - DbEvent, + $UserRelayListsTable, + DbUserRelayList, + $$UserRelayListsTableFilterComposer, + $$UserRelayListsTableOrderingComposer, + $$UserRelayListsTableAnnotationComposer, + $$UserRelayListsTableCreateCompanionBuilder, + $$UserRelayListsTableUpdateCompanionBuilder, + ( + DbUserRelayList, + BaseReferences< + _$NdkCacheDatabase, + $UserRelayListsTable, + DbUserRelayList + >, + ), + DbUserRelayList, PrefetchHooks Function() >; -typedef $$MetadatasTableCreateCompanionBuilder = - MetadatasCompanion Function({ +typedef $$RelaySetsTableCreateCompanionBuilder = + RelaySetsCompanion Function({ + required String id, + required String name, required String pubKey, - Value name, - Value displayName, - Value picture, - Value banner, - Value website, - Value about, - Value nip05, - Value lud16, - Value lud06, - Value updatedAt, - Value refreshedTimestamp, - required String sourcesJson, - Value tagsJson, - Value rawContentJson, + required int relayMinCountPerPubkey, + required int direction, + required String relaysMapJson, + required bool fallbackToBootstrapRelays, + required String notCoveredPubkeysJson, Value rowid, }); -typedef $$MetadatasTableUpdateCompanionBuilder = - MetadatasCompanion Function({ +typedef $$RelaySetsTableUpdateCompanionBuilder = + RelaySetsCompanion Function({ + Value id, + Value name, Value pubKey, - Value name, - Value displayName, - Value picture, - Value banner, - Value website, - Value about, - Value nip05, - Value lud16, - Value lud06, - Value updatedAt, - Value refreshedTimestamp, - Value sourcesJson, - Value tagsJson, - Value rawContentJson, + Value relayMinCountPerPubkey, + Value direction, + Value relaysMapJson, + Value fallbackToBootstrapRelays, + Value notCoveredPubkeysJson, Value rowid, }); -class $$MetadatasTableFilterComposer - extends Composer<_$NdkCacheDatabase, $MetadatasTable> { - $$MetadatasTableFilterComposer({ +class $$RelaySetsTableFilterComposer + extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { + $$RelaySetsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column), ); @@ -7335,83 +7280,48 @@ class $$MetadatasTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get picture => $composableBuilder( - column: $table.picture, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get banner => $composableBuilder( - column: $table.banner, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get website => $composableBuilder( - column: $table.website, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get about => $composableBuilder( - column: $table.about, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get nip05 => $composableBuilder( - column: $table.nip05, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lud16 => $composableBuilder( - column: $table.lud16, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lud06 => $composableBuilder( - column: $table.lud06, + ColumnFilters get pubKey => $composableBuilder( + column: $table.pubKey, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, + ColumnFilters get relayMinCountPerPubkey => $composableBuilder( + column: $table.relayMinCountPerPubkey, builder: (column) => ColumnFilters(column), ); - ColumnFilters get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + ColumnFilters get direction => $composableBuilder( + column: $table.direction, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + ColumnFilters get relaysMapJson => $composableBuilder( + column: $table.relaysMapJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get tagsJson => $composableBuilder( - column: $table.tagsJson, + ColumnFilters get fallbackToBootstrapRelays => $composableBuilder( + column: $table.fallbackToBootstrapRelays, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawContentJson => $composableBuilder( - column: $table.rawContentJson, + ColumnFilters get notCoveredPubkeysJson => $composableBuilder( + column: $table.notCoveredPubkeysJson, builder: (column) => ColumnFilters(column), ); } -class $$MetadatasTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $MetadatasTable> { - $$MetadatasTableOrderingComposer({ +class $$RelaySetsTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { + $$RelaySetsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column), ); @@ -7420,234 +7330,150 @@ class $$MetadatasTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get picture => $composableBuilder( - column: $table.picture, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get banner => $composableBuilder( - column: $table.banner, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get website => $composableBuilder( - column: $table.website, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get about => $composableBuilder( - column: $table.about, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get nip05 => $composableBuilder( - column: $table.nip05, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lud16 => $composableBuilder( - column: $table.lud16, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lud06 => $composableBuilder( - column: $table.lud06, + ColumnOrderings get pubKey => $composableBuilder( + column: $table.pubKey, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, + ColumnOrderings get relayMinCountPerPubkey => $composableBuilder( + column: $table.relayMinCountPerPubkey, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + ColumnOrderings get direction => $composableBuilder( + column: $table.direction, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + ColumnOrderings get relaysMapJson => $composableBuilder( + column: $table.relaysMapJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get tagsJson => $composableBuilder( - column: $table.tagsJson, + ColumnOrderings get fallbackToBootstrapRelays => $composableBuilder( + column: $table.fallbackToBootstrapRelays, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rawContentJson => $composableBuilder( - column: $table.rawContentJson, + ColumnOrderings get notCoveredPubkeysJson => $composableBuilder( + column: $table.notCoveredPubkeysJson, builder: (column) => ColumnOrderings(column), ); } -class $$MetadatasTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $MetadatasTable> { - $$MetadatasTableAnnotationComposer({ +class $$RelaySetsTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { + $$RelaySetsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get pubKey => - $composableBuilder(column: $table.pubKey, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); - GeneratedColumn get displayName => $composableBuilder( - column: $table.displayName, + GeneratedColumn get pubKey => + $composableBuilder(column: $table.pubKey, builder: (column) => column); + + GeneratedColumn get relayMinCountPerPubkey => $composableBuilder( + column: $table.relayMinCountPerPubkey, builder: (column) => column, ); - GeneratedColumn get picture => - $composableBuilder(column: $table.picture, builder: (column) => column); - - GeneratedColumn get banner => - $composableBuilder(column: $table.banner, builder: (column) => column); - - GeneratedColumn get website => - $composableBuilder(column: $table.website, builder: (column) => column); - - GeneratedColumn get about => - $composableBuilder(column: $table.about, builder: (column) => column); - - GeneratedColumn get nip05 => - $composableBuilder(column: $table.nip05, builder: (column) => column); - - GeneratedColumn get lud16 => - $composableBuilder(column: $table.lud16, builder: (column) => column); - - GeneratedColumn get lud06 => - $composableBuilder(column: $table.lud06, builder: (column) => column); - - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get direction => + $composableBuilder(column: $table.direction, builder: (column) => column); - GeneratedColumn get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + GeneratedColumn get relaysMapJson => $composableBuilder( + column: $table.relaysMapJson, builder: (column) => column, ); - GeneratedColumn get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + GeneratedColumn get fallbackToBootstrapRelays => $composableBuilder( + column: $table.fallbackToBootstrapRelays, builder: (column) => column, ); - GeneratedColumn get tagsJson => - $composableBuilder(column: $table.tagsJson, builder: (column) => column); - - GeneratedColumn get rawContentJson => $composableBuilder( - column: $table.rawContentJson, + GeneratedColumn get notCoveredPubkeysJson => $composableBuilder( + column: $table.notCoveredPubkeysJson, builder: (column) => column, ); } -class $$MetadatasTableTableManager +class $$RelaySetsTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $MetadatasTable, - DbMetadata, - $$MetadatasTableFilterComposer, - $$MetadatasTableOrderingComposer, - $$MetadatasTableAnnotationComposer, - $$MetadatasTableCreateCompanionBuilder, - $$MetadatasTableUpdateCompanionBuilder, + $RelaySetsTable, + DbRelaySet, + $$RelaySetsTableFilterComposer, + $$RelaySetsTableOrderingComposer, + $$RelaySetsTableAnnotationComposer, + $$RelaySetsTableCreateCompanionBuilder, + $$RelaySetsTableUpdateCompanionBuilder, ( - DbMetadata, - BaseReferences<_$NdkCacheDatabase, $MetadatasTable, DbMetadata>, + DbRelaySet, + BaseReferences<_$NdkCacheDatabase, $RelaySetsTable, DbRelaySet>, ), - DbMetadata, + DbRelaySet, PrefetchHooks Function() > { - $$MetadatasTableTableManager(_$NdkCacheDatabase db, $MetadatasTable table) + $$RelaySetsTableTableManager(_$NdkCacheDatabase db, $RelaySetsTable table) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$MetadatasTableFilterComposer($db: db, $table: table), + $$RelaySetsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$MetadatasTableOrderingComposer($db: db, $table: table), + $$RelaySetsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$MetadatasTableAnnotationComposer($db: db, $table: table), + $$RelaySetsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), Value pubKey = const Value.absent(), - Value name = const Value.absent(), - Value displayName = const Value.absent(), - Value picture = const Value.absent(), - Value banner = const Value.absent(), - Value website = const Value.absent(), - Value about = const Value.absent(), - Value nip05 = const Value.absent(), - Value lud16 = const Value.absent(), - Value lud06 = const Value.absent(), - Value updatedAt = const Value.absent(), - Value refreshedTimestamp = const Value.absent(), - Value sourcesJson = const Value.absent(), - Value tagsJson = const Value.absent(), - Value rawContentJson = const Value.absent(), + Value relayMinCountPerPubkey = const Value.absent(), + Value direction = const Value.absent(), + Value relaysMapJson = const Value.absent(), + Value fallbackToBootstrapRelays = const Value.absent(), + Value notCoveredPubkeysJson = const Value.absent(), Value rowid = const Value.absent(), - }) => MetadatasCompanion( - pubKey: pubKey, + }) => RelaySetsCompanion( + id: id, name: name, - displayName: displayName, - picture: picture, - banner: banner, - website: website, - about: about, - nip05: nip05, - lud16: lud16, - lud06: lud06, - updatedAt: updatedAt, - refreshedTimestamp: refreshedTimestamp, - sourcesJson: sourcesJson, - tagsJson: tagsJson, - rawContentJson: rawContentJson, + pubKey: pubKey, + relayMinCountPerPubkey: relayMinCountPerPubkey, + direction: direction, + relaysMapJson: relaysMapJson, + fallbackToBootstrapRelays: fallbackToBootstrapRelays, + notCoveredPubkeysJson: notCoveredPubkeysJson, rowid: rowid, ), createCompanionCallback: ({ + required String id, + required String name, required String pubKey, - Value name = const Value.absent(), - Value displayName = const Value.absent(), - Value picture = const Value.absent(), - Value banner = const Value.absent(), - Value website = const Value.absent(), - Value about = const Value.absent(), - Value nip05 = const Value.absent(), - Value lud16 = const Value.absent(), - Value lud06 = const Value.absent(), - Value updatedAt = const Value.absent(), - Value refreshedTimestamp = const Value.absent(), - required String sourcesJson, - Value tagsJson = const Value.absent(), - Value rawContentJson = const Value.absent(), + required int relayMinCountPerPubkey, + required int direction, + required String relaysMapJson, + required bool fallbackToBootstrapRelays, + required String notCoveredPubkeysJson, Value rowid = const Value.absent(), - }) => MetadatasCompanion.insert( - pubKey: pubKey, + }) => RelaySetsCompanion.insert( + id: id, name: name, - displayName: displayName, - picture: picture, - banner: banner, - website: website, - about: about, - nip05: nip05, - lud16: lud16, - lud06: lud06, - updatedAt: updatedAt, - refreshedTimestamp: refreshedTimestamp, - sourcesJson: sourcesJson, - tagsJson: tagsJson, - rawContentJson: rawContentJson, + pubKey: pubKey, + relayMinCountPerPubkey: relayMinCountPerPubkey, + direction: direction, + relaysMapJson: relaysMapJson, + fallbackToBootstrapRelays: fallbackToBootstrapRelays, + notCoveredPubkeysJson: notCoveredPubkeysJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -7658,55 +7484,45 @@ class $$MetadatasTableTableManager ); } -typedef $$MetadatasTableProcessedTableManager = +typedef $$RelaySetsTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $MetadatasTable, - DbMetadata, - $$MetadatasTableFilterComposer, - $$MetadatasTableOrderingComposer, - $$MetadatasTableAnnotationComposer, - $$MetadatasTableCreateCompanionBuilder, - $$MetadatasTableUpdateCompanionBuilder, + $RelaySetsTable, + DbRelaySet, + $$RelaySetsTableFilterComposer, + $$RelaySetsTableOrderingComposer, + $$RelaySetsTableAnnotationComposer, + $$RelaySetsTableCreateCompanionBuilder, + $$RelaySetsTableUpdateCompanionBuilder, ( - DbMetadata, - BaseReferences<_$NdkCacheDatabase, $MetadatasTable, DbMetadata>, + DbRelaySet, + BaseReferences<_$NdkCacheDatabase, $RelaySetsTable, DbRelaySet>, ), - DbMetadata, + DbRelaySet, PrefetchHooks Function() >; -typedef $$ContactListsTableCreateCompanionBuilder = - ContactListsCompanion Function({ +typedef $$Nip05sTableCreateCompanionBuilder = + Nip05sCompanion Function({ required String pubKey, - required String contactsJson, - required String contactRelaysJson, - required String petnamesJson, - required String followedTagsJson, - required String followedCommunitiesJson, - required String followedEventsJson, - required int createdAt, - Value loadedTimestamp, - required String sourcesJson, + required String nip05, + required bool valid, + Value networkFetchTime, + required String relaysJson, Value rowid, }); -typedef $$ContactListsTableUpdateCompanionBuilder = - ContactListsCompanion Function({ +typedef $$Nip05sTableUpdateCompanionBuilder = + Nip05sCompanion Function({ Value pubKey, - Value contactsJson, - Value contactRelaysJson, - Value petnamesJson, - Value followedTagsJson, - Value followedCommunitiesJson, - Value followedEventsJson, - Value createdAt, - Value loadedTimestamp, - Value sourcesJson, + Value nip05, + Value valid, + Value networkFetchTime, + Value relaysJson, Value rowid, }); -class $$ContactListsTableFilterComposer - extends Composer<_$NdkCacheDatabase, $ContactListsTable> { - $$ContactListsTableFilterComposer({ +class $$Nip05sTableFilterComposer + extends Composer<_$NdkCacheDatabase, $Nip05sTable> { + $$Nip05sTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -7718,55 +7534,30 @@ class $$ContactListsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get contactsJson => $composableBuilder( - column: $table.contactsJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contactRelaysJson => $composableBuilder( - column: $table.contactRelaysJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get petnamesJson => $composableBuilder( - column: $table.petnamesJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get followedTagsJson => $composableBuilder( - column: $table.followedTagsJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get followedCommunitiesJson => $composableBuilder( - column: $table.followedCommunitiesJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get followedEventsJson => $composableBuilder( - column: $table.followedEventsJson, + ColumnFilters get nip05 => $composableBuilder( + column: $table.nip05, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnFilters get valid => $composableBuilder( + column: $table.valid, builder: (column) => ColumnFilters(column), ); - ColumnFilters get loadedTimestamp => $composableBuilder( - column: $table.loadedTimestamp, + ColumnFilters get networkFetchTime => $composableBuilder( + column: $table.networkFetchTime, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + ColumnFilters get relaysJson => $composableBuilder( + column: $table.relaysJson, builder: (column) => ColumnFilters(column), ); } -class $$ContactListsTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $ContactListsTable> { - $$ContactListsTableOrderingComposer({ +class $$Nip05sTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $Nip05sTable> { + $$Nip05sTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -7778,55 +7569,30 @@ class $$ContactListsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get contactsJson => $composableBuilder( - column: $table.contactsJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contactRelaysJson => $composableBuilder( - column: $table.contactRelaysJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get petnamesJson => $composableBuilder( - column: $table.petnamesJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get followedTagsJson => $composableBuilder( - column: $table.followedTagsJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get followedCommunitiesJson => $composableBuilder( - column: $table.followedCommunitiesJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get followedEventsJson => $composableBuilder( - column: $table.followedEventsJson, + ColumnOrderings get nip05 => $composableBuilder( + column: $table.nip05, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnOrderings get valid => $composableBuilder( + column: $table.valid, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get loadedTimestamp => $composableBuilder( - column: $table.loadedTimestamp, + ColumnOrderings get networkFetchTime => $composableBuilder( + column: $table.networkFetchTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + ColumnOrderings get relaysJson => $composableBuilder( + column: $table.relaysJson, builder: (column) => ColumnOrderings(column), ); } -class $$ContactListsTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $ContactListsTable> { - $$ContactListsTableAnnotationComposer({ +class $$Nip05sTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $Nip05sTable> { + $$Nip05sTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -7836,135 +7602,79 @@ class $$ContactListsTableAnnotationComposer GeneratedColumn get pubKey => $composableBuilder(column: $table.pubKey, builder: (column) => column); - GeneratedColumn get contactsJson => $composableBuilder( - column: $table.contactsJson, - builder: (column) => column, - ); - - GeneratedColumn get contactRelaysJson => $composableBuilder( - column: $table.contactRelaysJson, - builder: (column) => column, - ); - - GeneratedColumn get petnamesJson => $composableBuilder( - column: $table.petnamesJson, - builder: (column) => column, - ); - - GeneratedColumn get followedTagsJson => $composableBuilder( - column: $table.followedTagsJson, - builder: (column) => column, - ); - - GeneratedColumn get followedCommunitiesJson => $composableBuilder( - column: $table.followedCommunitiesJson, - builder: (column) => column, - ); - - GeneratedColumn get followedEventsJson => $composableBuilder( - column: $table.followedEventsJson, - builder: (column) => column, - ); + GeneratedColumn get nip05 => + $composableBuilder(column: $table.nip05, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get valid => + $composableBuilder(column: $table.valid, builder: (column) => column); - GeneratedColumn get loadedTimestamp => $composableBuilder( - column: $table.loadedTimestamp, + GeneratedColumn get networkFetchTime => $composableBuilder( + column: $table.networkFetchTime, builder: (column) => column, ); - GeneratedColumn get sourcesJson => $composableBuilder( - column: $table.sourcesJson, + GeneratedColumn get relaysJson => $composableBuilder( + column: $table.relaysJson, builder: (column) => column, ); } -class $$ContactListsTableTableManager +class $$Nip05sTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $ContactListsTable, - DbContactList, - $$ContactListsTableFilterComposer, - $$ContactListsTableOrderingComposer, - $$ContactListsTableAnnotationComposer, - $$ContactListsTableCreateCompanionBuilder, - $$ContactListsTableUpdateCompanionBuilder, - ( - DbContactList, - BaseReferences< - _$NdkCacheDatabase, - $ContactListsTable, - DbContactList - >, - ), - DbContactList, + $Nip05sTable, + DbNip05, + $$Nip05sTableFilterComposer, + $$Nip05sTableOrderingComposer, + $$Nip05sTableAnnotationComposer, + $$Nip05sTableCreateCompanionBuilder, + $$Nip05sTableUpdateCompanionBuilder, + (DbNip05, BaseReferences<_$NdkCacheDatabase, $Nip05sTable, DbNip05>), + DbNip05, PrefetchHooks Function() > { - $$ContactListsTableTableManager( - _$NdkCacheDatabase db, - $ContactListsTable table, - ) : super( + $$Nip05sTableTableManager(_$NdkCacheDatabase db, $Nip05sTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$ContactListsTableFilterComposer($db: db, $table: table), + $$Nip05sTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$ContactListsTableOrderingComposer($db: db, $table: table), + $$Nip05sTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$ContactListsTableAnnotationComposer($db: db, $table: table), + $$Nip05sTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value pubKey = const Value.absent(), - Value contactsJson = const Value.absent(), - Value contactRelaysJson = const Value.absent(), - Value petnamesJson = const Value.absent(), - Value followedTagsJson = const Value.absent(), - Value followedCommunitiesJson = const Value.absent(), - Value followedEventsJson = const Value.absent(), - Value createdAt = const Value.absent(), - Value loadedTimestamp = const Value.absent(), - Value sourcesJson = const Value.absent(), + Value nip05 = const Value.absent(), + Value valid = const Value.absent(), + Value networkFetchTime = const Value.absent(), + Value relaysJson = const Value.absent(), Value rowid = const Value.absent(), - }) => ContactListsCompanion( - pubKey: pubKey, - contactsJson: contactsJson, - contactRelaysJson: contactRelaysJson, - petnamesJson: petnamesJson, - followedTagsJson: followedTagsJson, - followedCommunitiesJson: followedCommunitiesJson, - followedEventsJson: followedEventsJson, - createdAt: createdAt, - loadedTimestamp: loadedTimestamp, - sourcesJson: sourcesJson, + }) => Nip05sCompanion( + pubKey: pubKey, + nip05: nip05, + valid: valid, + networkFetchTime: networkFetchTime, + relaysJson: relaysJson, rowid: rowid, ), createCompanionCallback: ({ required String pubKey, - required String contactsJson, - required String contactRelaysJson, - required String petnamesJson, - required String followedTagsJson, - required String followedCommunitiesJson, - required String followedEventsJson, - required int createdAt, - Value loadedTimestamp = const Value.absent(), - required String sourcesJson, + required String nip05, + required bool valid, + Value networkFetchTime = const Value.absent(), + required String relaysJson, Value rowid = const Value.absent(), - }) => ContactListsCompanion.insert( + }) => Nip05sCompanion.insert( pubKey: pubKey, - contactsJson: contactsJson, - contactRelaysJson: contactRelaysJson, - petnamesJson: petnamesJson, - followedTagsJson: followedTagsJson, - followedCommunitiesJson: followedCommunitiesJson, - followedEventsJson: followedEventsJson, - createdAt: createdAt, - loadedTimestamp: loadedTimestamp, - sourcesJson: sourcesJson, + nip05: nip05, + valid: valid, + networkFetchTime: networkFetchTime, + relaysJson: relaysJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -7975,187 +7685,212 @@ class $$ContactListsTableTableManager ); } -typedef $$ContactListsTableProcessedTableManager = +typedef $$Nip05sTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $ContactListsTable, - DbContactList, - $$ContactListsTableFilterComposer, - $$ContactListsTableOrderingComposer, - $$ContactListsTableAnnotationComposer, - $$ContactListsTableCreateCompanionBuilder, - $$ContactListsTableUpdateCompanionBuilder, - ( - DbContactList, - BaseReferences<_$NdkCacheDatabase, $ContactListsTable, DbContactList>, - ), - DbContactList, + $Nip05sTable, + DbNip05, + $$Nip05sTableFilterComposer, + $$Nip05sTableOrderingComposer, + $$Nip05sTableAnnotationComposer, + $$Nip05sTableCreateCompanionBuilder, + $$Nip05sTableUpdateCompanionBuilder, + (DbNip05, BaseReferences<_$NdkCacheDatabase, $Nip05sTable, DbNip05>), + DbNip05, PrefetchHooks Function() >; -typedef $$UserRelayListsTableCreateCompanionBuilder = - UserRelayListsCompanion Function({ - required String pubKey, - required int createdAt, - required int refreshedTimestamp, - required String relaysJson, +typedef $$FilterFetchedRangeRecordsTableCreateCompanionBuilder = + FilterFetchedRangeRecordsCompanion Function({ + required String key, + required String filterHash, + required String relayUrl, + required int rangeStart, + required int rangeEnd, Value rowid, }); -typedef $$UserRelayListsTableUpdateCompanionBuilder = - UserRelayListsCompanion Function({ - Value pubKey, - Value createdAt, - Value refreshedTimestamp, - Value relaysJson, +typedef $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder = + FilterFetchedRangeRecordsCompanion Function({ + Value key, + Value filterHash, + Value relayUrl, + Value rangeStart, + Value rangeEnd, Value rowid, }); -class $$UserRelayListsTableFilterComposer - extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { - $$UserRelayListsTableFilterComposer({ +class $$FilterFetchedRangeRecordsTableFilterComposer + extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { + $$FilterFetchedRangeRecordsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnFilters get key => $composableBuilder( + column: $table.key, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnFilters get filterHash => $composableBuilder( + column: $table.filterHash, builder: (column) => ColumnFilters(column), ); - ColumnFilters get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + ColumnFilters get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnFilters(column), ); - ColumnFilters get relaysJson => $composableBuilder( - column: $table.relaysJson, + ColumnFilters get rangeStart => $composableBuilder( + column: $table.rangeStart, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rangeEnd => $composableBuilder( + column: $table.rangeEnd, builder: (column) => ColumnFilters(column), ); } -class $$UserRelayListsTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { - $$UserRelayListsTableOrderingComposer({ +class $$FilterFetchedRangeRecordsTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { + $$FilterFetchedRangeRecordsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnOrderings get key => $composableBuilder( + column: $table.key, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnOrderings get filterHash => $composableBuilder( + column: $table.filterHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + ColumnOrderings get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get relaysJson => $composableBuilder( - column: $table.relaysJson, + ColumnOrderings get rangeStart => $composableBuilder( + column: $table.rangeStart, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get rangeEnd => $composableBuilder( + column: $table.rangeEnd, builder: (column) => ColumnOrderings(column), ); } -class $$UserRelayListsTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $UserRelayListsTable> { - $$UserRelayListsTableAnnotationComposer({ +class $$FilterFetchedRangeRecordsTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { + $$FilterFetchedRangeRecordsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get pubKey => - $composableBuilder(column: $table.pubKey, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); - GeneratedColumn get refreshedTimestamp => $composableBuilder( - column: $table.refreshedTimestamp, + GeneratedColumn get filterHash => $composableBuilder( + column: $table.filterHash, builder: (column) => column, ); - GeneratedColumn get relaysJson => $composableBuilder( - column: $table.relaysJson, + GeneratedColumn get relayUrl => + $composableBuilder(column: $table.relayUrl, builder: (column) => column); + + GeneratedColumn get rangeStart => $composableBuilder( + column: $table.rangeStart, builder: (column) => column, ); + + GeneratedColumn get rangeEnd => + $composableBuilder(column: $table.rangeEnd, builder: (column) => column); } -class $$UserRelayListsTableTableManager +class $$FilterFetchedRangeRecordsTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $UserRelayListsTable, - DbUserRelayList, - $$UserRelayListsTableFilterComposer, - $$UserRelayListsTableOrderingComposer, - $$UserRelayListsTableAnnotationComposer, - $$UserRelayListsTableCreateCompanionBuilder, - $$UserRelayListsTableUpdateCompanionBuilder, + $FilterFetchedRangeRecordsTable, + DbFilterFetchedRangeRecord, + $$FilterFetchedRangeRecordsTableFilterComposer, + $$FilterFetchedRangeRecordsTableOrderingComposer, + $$FilterFetchedRangeRecordsTableAnnotationComposer, + $$FilterFetchedRangeRecordsTableCreateCompanionBuilder, + $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder, ( - DbUserRelayList, + DbFilterFetchedRangeRecord, BaseReferences< _$NdkCacheDatabase, - $UserRelayListsTable, - DbUserRelayList + $FilterFetchedRangeRecordsTable, + DbFilterFetchedRangeRecord >, ), - DbUserRelayList, + DbFilterFetchedRangeRecord, PrefetchHooks Function() > { - $$UserRelayListsTableTableManager( + $$FilterFetchedRangeRecordsTableTableManager( _$NdkCacheDatabase db, - $UserRelayListsTable table, + $FilterFetchedRangeRecordsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$UserRelayListsTableFilterComposer($db: db, $table: table), + $$FilterFetchedRangeRecordsTableFilterComposer( + $db: db, + $table: table, + ), createOrderingComposer: () => - $$UserRelayListsTableOrderingComposer($db: db, $table: table), + $$FilterFetchedRangeRecordsTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$UserRelayListsTableAnnotationComposer($db: db, $table: table), + $$FilterFetchedRangeRecordsTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ - Value pubKey = const Value.absent(), - Value createdAt = const Value.absent(), - Value refreshedTimestamp = const Value.absent(), - Value relaysJson = const Value.absent(), + Value key = const Value.absent(), + Value filterHash = const Value.absent(), + Value relayUrl = const Value.absent(), + Value rangeStart = const Value.absent(), + Value rangeEnd = const Value.absent(), Value rowid = const Value.absent(), - }) => UserRelayListsCompanion( - pubKey: pubKey, - createdAt: createdAt, - refreshedTimestamp: refreshedTimestamp, - relaysJson: relaysJson, + }) => FilterFetchedRangeRecordsCompanion( + key: key, + filterHash: filterHash, + relayUrl: relayUrl, + rangeStart: rangeStart, + rangeEnd: rangeEnd, rowid: rowid, ), createCompanionCallback: ({ - required String pubKey, - required int createdAt, - required int refreshedTimestamp, - required String relaysJson, + required String key, + required String filterHash, + required String relayUrl, + required int rangeStart, + required int rangeEnd, Value rowid = const Value.absent(), - }) => UserRelayListsCompanion.insert( - pubKey: pubKey, - createdAt: createdAt, - refreshedTimestamp: refreshedTimestamp, - relaysJson: relaysJson, + }) => FilterFetchedRangeRecordsCompanion.insert( + key: key, + filterHash: filterHash, + relayUrl: relayUrl, + rangeStart: rangeStart, + rangeEnd: rangeEnd, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -8166,265 +7901,152 @@ class $$UserRelayListsTableTableManager ); } -typedef $$UserRelayListsTableProcessedTableManager = +typedef $$FilterFetchedRangeRecordsTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $UserRelayListsTable, - DbUserRelayList, - $$UserRelayListsTableFilterComposer, - $$UserRelayListsTableOrderingComposer, - $$UserRelayListsTableAnnotationComposer, - $$UserRelayListsTableCreateCompanionBuilder, - $$UserRelayListsTableUpdateCompanionBuilder, + $FilterFetchedRangeRecordsTable, + DbFilterFetchedRangeRecord, + $$FilterFetchedRangeRecordsTableFilterComposer, + $$FilterFetchedRangeRecordsTableOrderingComposer, + $$FilterFetchedRangeRecordsTableAnnotationComposer, + $$FilterFetchedRangeRecordsTableCreateCompanionBuilder, + $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder, ( - DbUserRelayList, + DbFilterFetchedRangeRecord, BaseReferences< _$NdkCacheDatabase, - $UserRelayListsTable, - DbUserRelayList + $FilterFetchedRangeRecordsTable, + DbFilterFetchedRangeRecord >, - ), - DbUserRelayList, - PrefetchHooks Function() - >; -typedef $$RelaySetsTableCreateCompanionBuilder = - RelaySetsCompanion Function({ - required String id, - required String name, - required String pubKey, - required int relayMinCountPerPubkey, - required int direction, - required String relaysMapJson, - required bool fallbackToBootstrapRelays, - required String notCoveredPubkeysJson, + ), + DbFilterFetchedRangeRecord, + PrefetchHooks Function() + >; +typedef $$EventSourcesTableTableCreateCompanionBuilder = + EventSourcesTableCompanion Function({ + required String eventId, + required String relayUrl, Value rowid, }); -typedef $$RelaySetsTableUpdateCompanionBuilder = - RelaySetsCompanion Function({ - Value id, - Value name, - Value pubKey, - Value relayMinCountPerPubkey, - Value direction, - Value relaysMapJson, - Value fallbackToBootstrapRelays, - Value notCoveredPubkeysJson, +typedef $$EventSourcesTableTableUpdateCompanionBuilder = + EventSourcesTableCompanion Function({ + Value eventId, + Value relayUrl, Value rowid, }); -class $$RelaySetsTableFilterComposer - extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { - $$RelaySetsTableFilterComposer({ +class $$EventSourcesTableTableFilterComposer + extends Composer<_$NdkCacheDatabase, $EventSourcesTableTable> { + $$EventSourcesTableTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get name => $composableBuilder( - column: $table.name, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get pubKey => $composableBuilder( - column: $table.pubKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get relayMinCountPerPubkey => $composableBuilder( - column: $table.relayMinCountPerPubkey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get direction => $composableBuilder( - column: $table.direction, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get relaysMapJson => $composableBuilder( - column: $table.relaysMapJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get fallbackToBootstrapRelays => $composableBuilder( - column: $table.fallbackToBootstrapRelays, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get notCoveredPubkeysJson => $composableBuilder( - column: $table.notCoveredPubkeysJson, + ColumnFilters get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnFilters(column), ); } -class $$RelaySetsTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { - $$RelaySetsTableOrderingComposer({ +class $$EventSourcesTableTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $EventSourcesTableTable> { + $$EventSourcesTableTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get name => $composableBuilder( - column: $table.name, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get pubKey => $composableBuilder( - column: $table.pubKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get relayMinCountPerPubkey => $composableBuilder( - column: $table.relayMinCountPerPubkey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get direction => $composableBuilder( - column: $table.direction, + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get relaysMapJson => $composableBuilder( - column: $table.relaysMapJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get fallbackToBootstrapRelays => $composableBuilder( - column: $table.fallbackToBootstrapRelays, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get notCoveredPubkeysJson => $composableBuilder( - column: $table.notCoveredPubkeysJson, + ColumnOrderings get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnOrderings(column), ); } -class $$RelaySetsTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $RelaySetsTable> { - $$RelaySetsTableAnnotationComposer({ +class $$EventSourcesTableTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $EventSourcesTableTable> { + $$EventSourcesTableTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get name => - $composableBuilder(column: $table.name, builder: (column) => column); - - GeneratedColumn get pubKey => - $composableBuilder(column: $table.pubKey, builder: (column) => column); - - GeneratedColumn get relayMinCountPerPubkey => $composableBuilder( - column: $table.relayMinCountPerPubkey, - builder: (column) => column, - ); - - GeneratedColumn get direction => - $composableBuilder(column: $table.direction, builder: (column) => column); - - GeneratedColumn get relaysMapJson => $composableBuilder( - column: $table.relaysMapJson, - builder: (column) => column, - ); - - GeneratedColumn get fallbackToBootstrapRelays => $composableBuilder( - column: $table.fallbackToBootstrapRelays, - builder: (column) => column, - ); + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); - GeneratedColumn get notCoveredPubkeysJson => $composableBuilder( - column: $table.notCoveredPubkeysJson, - builder: (column) => column, - ); + GeneratedColumn get relayUrl => + $composableBuilder(column: $table.relayUrl, builder: (column) => column); } -class $$RelaySetsTableTableManager +class $$EventSourcesTableTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $RelaySetsTable, - DbRelaySet, - $$RelaySetsTableFilterComposer, - $$RelaySetsTableOrderingComposer, - $$RelaySetsTableAnnotationComposer, - $$RelaySetsTableCreateCompanionBuilder, - $$RelaySetsTableUpdateCompanionBuilder, + $EventSourcesTableTable, + DbEventSource, + $$EventSourcesTableTableFilterComposer, + $$EventSourcesTableTableOrderingComposer, + $$EventSourcesTableTableAnnotationComposer, + $$EventSourcesTableTableCreateCompanionBuilder, + $$EventSourcesTableTableUpdateCompanionBuilder, ( - DbRelaySet, - BaseReferences<_$NdkCacheDatabase, $RelaySetsTable, DbRelaySet>, + DbEventSource, + BaseReferences< + _$NdkCacheDatabase, + $EventSourcesTableTable, + DbEventSource + >, ), - DbRelaySet, + DbEventSource, PrefetchHooks Function() > { - $$RelaySetsTableTableManager(_$NdkCacheDatabase db, $RelaySetsTable table) - : super( + $$EventSourcesTableTableTableManager( + _$NdkCacheDatabase db, + $EventSourcesTableTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$RelaySetsTableFilterComposer($db: db, $table: table), + $$EventSourcesTableTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$RelaySetsTableOrderingComposer($db: db, $table: table), + $$EventSourcesTableTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$RelaySetsTableAnnotationComposer($db: db, $table: table), + $$EventSourcesTableTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value pubKey = const Value.absent(), - Value relayMinCountPerPubkey = const Value.absent(), - Value direction = const Value.absent(), - Value relaysMapJson = const Value.absent(), - Value fallbackToBootstrapRelays = const Value.absent(), - Value notCoveredPubkeysJson = const Value.absent(), + Value eventId = const Value.absent(), + Value relayUrl = const Value.absent(), Value rowid = const Value.absent(), - }) => RelaySetsCompanion( - id: id, - name: name, - pubKey: pubKey, - relayMinCountPerPubkey: relayMinCountPerPubkey, - direction: direction, - relaysMapJson: relaysMapJson, - fallbackToBootstrapRelays: fallbackToBootstrapRelays, - notCoveredPubkeysJson: notCoveredPubkeysJson, + }) => EventSourcesTableCompanion( + eventId: eventId, + relayUrl: relayUrl, rowid: rowid, ), createCompanionCallback: ({ - required String id, - required String name, - required String pubKey, - required int relayMinCountPerPubkey, - required int direction, - required String relaysMapJson, - required bool fallbackToBootstrapRelays, - required String notCoveredPubkeysJson, + required String eventId, + required String relayUrl, Value rowid = const Value.absent(), - }) => RelaySetsCompanion.insert( - id: id, - name: name, - pubKey: pubKey, - relayMinCountPerPubkey: relayMinCountPerPubkey, - direction: direction, - relaysMapJson: relaysMapJson, - fallbackToBootstrapRelays: fallbackToBootstrapRelays, - notCoveredPubkeysJson: notCoveredPubkeysJson, + }) => EventSourcesTableCompanion.insert( + eventId: eventId, + relayUrl: relayUrl, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -8435,197 +8057,257 @@ class $$RelaySetsTableTableManager ); } -typedef $$RelaySetsTableProcessedTableManager = +typedef $$EventSourcesTableTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $RelaySetsTable, - DbRelaySet, - $$RelaySetsTableFilterComposer, - $$RelaySetsTableOrderingComposer, - $$RelaySetsTableAnnotationComposer, - $$RelaySetsTableCreateCompanionBuilder, - $$RelaySetsTableUpdateCompanionBuilder, + $EventSourcesTableTable, + DbEventSource, + $$EventSourcesTableTableFilterComposer, + $$EventSourcesTableTableOrderingComposer, + $$EventSourcesTableTableAnnotationComposer, + $$EventSourcesTableTableCreateCompanionBuilder, + $$EventSourcesTableTableUpdateCompanionBuilder, ( - DbRelaySet, - BaseReferences<_$NdkCacheDatabase, $RelaySetsTable, DbRelaySet>, + DbEventSource, + BaseReferences< + _$NdkCacheDatabase, + $EventSourcesTableTable, + DbEventSource + >, ), - DbRelaySet, + DbEventSource, PrefetchHooks Function() >; -typedef $$Nip05sTableCreateCompanionBuilder = - Nip05sCompanion Function({ - required String pubKey, - required String nip05, - required bool valid, - Value networkFetchTime, - required String relaysJson, +typedef $$EventDeliveryRecordsTableTableCreateCompanionBuilder = + EventDeliveryRecordsTableCompanion Function({ + required String eventId, + required String status, + required int createdAt, + required int updatedAt, + Value signedAt, + Value completedAt, + Value requiresNetworkSigner, Value rowid, }); -typedef $$Nip05sTableUpdateCompanionBuilder = - Nip05sCompanion Function({ - Value pubKey, - Value nip05, - Value valid, - Value networkFetchTime, - Value relaysJson, +typedef $$EventDeliveryRecordsTableTableUpdateCompanionBuilder = + EventDeliveryRecordsTableCompanion Function({ + Value eventId, + Value status, + Value createdAt, + Value updatedAt, + Value signedAt, + Value completedAt, + Value requiresNetworkSigner, Value rowid, }); -class $$Nip05sTableFilterComposer - extends Composer<_$NdkCacheDatabase, $Nip05sTable> { - $$Nip05sTableFilterComposer({ +class $$EventDeliveryRecordsTableTableFilterComposer + extends Composer<_$NdkCacheDatabase, $EventDeliveryRecordsTableTable> { + $$EventDeliveryRecordsTableTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get nip05 => $composableBuilder( - column: $table.nip05, + ColumnFilters get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnFilters(column), ); - ColumnFilters get valid => $composableBuilder( - column: $table.valid, + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column), ); - ColumnFilters get networkFetchTime => $composableBuilder( - column: $table.networkFetchTime, + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnFilters(column), ); - ColumnFilters get relaysJson => $composableBuilder( - column: $table.relaysJson, + ColumnFilters get signedAt => $composableBuilder( + column: $table.signedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completedAt => $composableBuilder( + column: $table.completedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get requiresNetworkSigner => $composableBuilder( + column: $table.requiresNetworkSigner, builder: (column) => ColumnFilters(column), ); } -class $$Nip05sTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $Nip05sTable> { - $$Nip05sTableOrderingComposer({ +class $$EventDeliveryRecordsTableTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $EventDeliveryRecordsTableTable> { + $$EventDeliveryRecordsTableTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get pubKey => $composableBuilder( - column: $table.pubKey, + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get nip05 => $composableBuilder( - column: $table.nip05, + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get valid => $composableBuilder( - column: $table.valid, + ColumnOrderings get signedAt => $composableBuilder( + column: $table.signedAt, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get networkFetchTime => $composableBuilder( - column: $table.networkFetchTime, + ColumnOrderings get completedAt => $composableBuilder( + column: $table.completedAt, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get relaysJson => $composableBuilder( - column: $table.relaysJson, + ColumnOrderings get requiresNetworkSigner => $composableBuilder( + column: $table.requiresNetworkSigner, builder: (column) => ColumnOrderings(column), ); } -class $$Nip05sTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $Nip05sTable> { - $$Nip05sTableAnnotationComposer({ +class $$EventDeliveryRecordsTableTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $EventDeliveryRecordsTableTable> { + $$EventDeliveryRecordsTableTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get pubKey => - $composableBuilder(column: $table.pubKey, builder: (column) => column); + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); - GeneratedColumn get nip05 => - $composableBuilder(column: $table.nip05, builder: (column) => column); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); - GeneratedColumn get valid => - $composableBuilder(column: $table.valid, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get networkFetchTime => $composableBuilder( - column: $table.networkFetchTime, + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get signedAt => + $composableBuilder(column: $table.signedAt, builder: (column) => column); + + GeneratedColumn get completedAt => $composableBuilder( + column: $table.completedAt, builder: (column) => column, ); - GeneratedColumn get relaysJson => $composableBuilder( - column: $table.relaysJson, + GeneratedColumn get requiresNetworkSigner => $composableBuilder( + column: $table.requiresNetworkSigner, builder: (column) => column, ); } -class $$Nip05sTableTableManager +class $$EventDeliveryRecordsTableTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $Nip05sTable, - DbNip05, - $$Nip05sTableFilterComposer, - $$Nip05sTableOrderingComposer, - $$Nip05sTableAnnotationComposer, - $$Nip05sTableCreateCompanionBuilder, - $$Nip05sTableUpdateCompanionBuilder, - (DbNip05, BaseReferences<_$NdkCacheDatabase, $Nip05sTable, DbNip05>), - DbNip05, + $EventDeliveryRecordsTableTable, + DbEventDeliveryRecord, + $$EventDeliveryRecordsTableTableFilterComposer, + $$EventDeliveryRecordsTableTableOrderingComposer, + $$EventDeliveryRecordsTableTableAnnotationComposer, + $$EventDeliveryRecordsTableTableCreateCompanionBuilder, + $$EventDeliveryRecordsTableTableUpdateCompanionBuilder, + ( + DbEventDeliveryRecord, + BaseReferences< + _$NdkCacheDatabase, + $EventDeliveryRecordsTableTable, + DbEventDeliveryRecord + >, + ), + DbEventDeliveryRecord, PrefetchHooks Function() > { - $$Nip05sTableTableManager(_$NdkCacheDatabase db, $Nip05sTable table) - : super( + $$EventDeliveryRecordsTableTableTableManager( + _$NdkCacheDatabase db, + $EventDeliveryRecordsTableTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$Nip05sTableFilterComposer($db: db, $table: table), + $$EventDeliveryRecordsTableTableFilterComposer( + $db: db, + $table: table, + ), createOrderingComposer: () => - $$Nip05sTableOrderingComposer($db: db, $table: table), + $$EventDeliveryRecordsTableTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$Nip05sTableAnnotationComposer($db: db, $table: table), + $$EventDeliveryRecordsTableTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ - Value pubKey = const Value.absent(), - Value nip05 = const Value.absent(), - Value valid = const Value.absent(), - Value networkFetchTime = const Value.absent(), - Value relaysJson = const Value.absent(), + Value eventId = const Value.absent(), + Value status = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value signedAt = const Value.absent(), + Value completedAt = const Value.absent(), + Value requiresNetworkSigner = const Value.absent(), Value rowid = const Value.absent(), - }) => Nip05sCompanion( - pubKey: pubKey, - nip05: nip05, - valid: valid, - networkFetchTime: networkFetchTime, - relaysJson: relaysJson, + }) => EventDeliveryRecordsTableCompanion( + eventId: eventId, + status: status, + createdAt: createdAt, + updatedAt: updatedAt, + signedAt: signedAt, + completedAt: completedAt, + requiresNetworkSigner: requiresNetworkSigner, rowid: rowid, ), createCompanionCallback: ({ - required String pubKey, - required String nip05, - required bool valid, - Value networkFetchTime = const Value.absent(), - required String relaysJson, + required String eventId, + required String status, + required int createdAt, + required int updatedAt, + Value signedAt = const Value.absent(), + Value completedAt = const Value.absent(), + Value requiresNetworkSigner = const Value.absent(), Value rowid = const Value.absent(), - }) => Nip05sCompanion.insert( - pubKey: pubKey, - nip05: nip05, - valid: valid, - networkFetchTime: networkFetchTime, - relaysJson: relaysJson, + }) => EventDeliveryRecordsTableCompanion.insert( + eventId: eventId, + status: status, + createdAt: createdAt, + updatedAt: updatedAt, + signedAt: signedAt, + completedAt: completedAt, + requiresNetworkSigner: requiresNetworkSigner, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -8636,212 +8318,299 @@ class $$Nip05sTableTableManager ); } -typedef $$Nip05sTableProcessedTableManager = +typedef $$EventDeliveryRecordsTableTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $Nip05sTable, - DbNip05, - $$Nip05sTableFilterComposer, - $$Nip05sTableOrderingComposer, - $$Nip05sTableAnnotationComposer, - $$Nip05sTableCreateCompanionBuilder, - $$Nip05sTableUpdateCompanionBuilder, - (DbNip05, BaseReferences<_$NdkCacheDatabase, $Nip05sTable, DbNip05>), - DbNip05, + $EventDeliveryRecordsTableTable, + DbEventDeliveryRecord, + $$EventDeliveryRecordsTableTableFilterComposer, + $$EventDeliveryRecordsTableTableOrderingComposer, + $$EventDeliveryRecordsTableTableAnnotationComposer, + $$EventDeliveryRecordsTableTableCreateCompanionBuilder, + $$EventDeliveryRecordsTableTableUpdateCompanionBuilder, + ( + DbEventDeliveryRecord, + BaseReferences< + _$NdkCacheDatabase, + $EventDeliveryRecordsTableTable, + DbEventDeliveryRecord + >, + ), + DbEventDeliveryRecord, PrefetchHooks Function() >; -typedef $$FilterFetchedRangeRecordsTableCreateCompanionBuilder = - FilterFetchedRangeRecordsCompanion Function({ - required String key, - required String filterHash, +typedef $$RelayDeliveryTargetsTableTableCreateCompanionBuilder = + RelayDeliveryTargetsTableCompanion Function({ + required String eventId, required String relayUrl, - required int rangeStart, - required int rangeEnd, + required String reason, + required String state, + Value attemptCount, + Value lastAttemptAt, + Value nextRetryAt, + Value lastError, + Value lastOkMessage, Value rowid, }); -typedef $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder = - FilterFetchedRangeRecordsCompanion Function({ - Value key, - Value filterHash, +typedef $$RelayDeliveryTargetsTableTableUpdateCompanionBuilder = + RelayDeliveryTargetsTableCompanion Function({ + Value eventId, Value relayUrl, - Value rangeStart, - Value rangeEnd, + Value reason, + Value state, + Value attemptCount, + Value lastAttemptAt, + Value nextRetryAt, + Value lastError, + Value lastOkMessage, Value rowid, }); -class $$FilterFetchedRangeRecordsTableFilterComposer - extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { - $$FilterFetchedRangeRecordsTableFilterComposer({ +class $$RelayDeliveryTargetsTableTableFilterComposer + extends Composer<_$NdkCacheDatabase, $RelayDeliveryTargetsTableTable> { + $$RelayDeliveryTargetsTableTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get key => $composableBuilder( - column: $table.key, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get filterHash => $composableBuilder( - column: $table.filterHash, + ColumnFilters get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnFilters(column), ); - ColumnFilters get relayUrl => $composableBuilder( - column: $table.relayUrl, + ColumnFilters get reason => $composableBuilder( + column: $table.reason, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rangeStart => $composableBuilder( - column: $table.rangeStart, + ColumnFilters get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rangeEnd => $composableBuilder( - column: $table.rangeEnd, + ColumnFilters get attemptCount => $composableBuilder( + column: $table.attemptCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastOkMessage => $composableBuilder( + column: $table.lastOkMessage, builder: (column) => ColumnFilters(column), ); } -class $$FilterFetchedRangeRecordsTableOrderingComposer - extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { - $$FilterFetchedRangeRecordsTableOrderingComposer({ +class $$RelayDeliveryTargetsTableTableOrderingComposer + extends Composer<_$NdkCacheDatabase, $RelayDeliveryTargetsTableTable> { + $$RelayDeliveryTargetsTableTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get key => $composableBuilder( - column: $table.key, + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get filterHash => $composableBuilder( - column: $table.filterHash, + ColumnOrderings get relayUrl => $composableBuilder( + column: $table.relayUrl, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get relayUrl => $composableBuilder( - column: $table.relayUrl, + ColumnOrderings get reason => $composableBuilder( + column: $table.reason, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rangeStart => $composableBuilder( - column: $table.rangeStart, + ColumnOrderings get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rangeEnd => $composableBuilder( - column: $table.rangeEnd, + ColumnOrderings get attemptCount => $composableBuilder( + column: $table.attemptCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastOkMessage => $composableBuilder( + column: $table.lastOkMessage, builder: (column) => ColumnOrderings(column), ); } -class $$FilterFetchedRangeRecordsTableAnnotationComposer - extends Composer<_$NdkCacheDatabase, $FilterFetchedRangeRecordsTable> { - $$FilterFetchedRangeRecordsTableAnnotationComposer({ +class $$RelayDeliveryTargetsTableTableAnnotationComposer + extends Composer<_$NdkCacheDatabase, $RelayDeliveryTargetsTableTable> { + $$RelayDeliveryTargetsTableTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get key => - $composableBuilder(column: $table.key, builder: (column) => column); + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); - GeneratedColumn get filterHash => $composableBuilder( - column: $table.filterHash, + GeneratedColumn get relayUrl => + $composableBuilder(column: $table.relayUrl, builder: (column) => column); + + GeneratedColumn get reason => + $composableBuilder(column: $table.reason, builder: (column) => column); + + GeneratedColumn get state => + $composableBuilder(column: $table.state, builder: (column) => column); + + GeneratedColumn get attemptCount => $composableBuilder( + column: $table.attemptCount, builder: (column) => column, ); - GeneratedColumn get relayUrl => - $composableBuilder(column: $table.relayUrl, builder: (column) => column); + GeneratedColumn get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => column, + ); - GeneratedColumn get rangeStart => $composableBuilder( - column: $table.rangeStart, + GeneratedColumn get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, builder: (column) => column, ); - GeneratedColumn get rangeEnd => - $composableBuilder(column: $table.rangeEnd, builder: (column) => column); + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); + + GeneratedColumn get lastOkMessage => $composableBuilder( + column: $table.lastOkMessage, + builder: (column) => column, + ); } -class $$FilterFetchedRangeRecordsTableTableManager +class $$RelayDeliveryTargetsTableTableTableManager extends RootTableManager< _$NdkCacheDatabase, - $FilterFetchedRangeRecordsTable, - DbFilterFetchedRangeRecord, - $$FilterFetchedRangeRecordsTableFilterComposer, - $$FilterFetchedRangeRecordsTableOrderingComposer, - $$FilterFetchedRangeRecordsTableAnnotationComposer, - $$FilterFetchedRangeRecordsTableCreateCompanionBuilder, - $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder, + $RelayDeliveryTargetsTableTable, + DbRelayDeliveryTarget, + $$RelayDeliveryTargetsTableTableFilterComposer, + $$RelayDeliveryTargetsTableTableOrderingComposer, + $$RelayDeliveryTargetsTableTableAnnotationComposer, + $$RelayDeliveryTargetsTableTableCreateCompanionBuilder, + $$RelayDeliveryTargetsTableTableUpdateCompanionBuilder, ( - DbFilterFetchedRangeRecord, + DbRelayDeliveryTarget, BaseReferences< _$NdkCacheDatabase, - $FilterFetchedRangeRecordsTable, - DbFilterFetchedRangeRecord + $RelayDeliveryTargetsTableTable, + DbRelayDeliveryTarget >, ), - DbFilterFetchedRangeRecord, + DbRelayDeliveryTarget, PrefetchHooks Function() > { - $$FilterFetchedRangeRecordsTableTableManager( + $$RelayDeliveryTargetsTableTableTableManager( _$NdkCacheDatabase db, - $FilterFetchedRangeRecordsTable table, + $RelayDeliveryTargetsTableTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$FilterFetchedRangeRecordsTableFilterComposer( + $$RelayDeliveryTargetsTableTableFilterComposer( $db: db, $table: table, ), createOrderingComposer: () => - $$FilterFetchedRangeRecordsTableOrderingComposer( + $$RelayDeliveryTargetsTableTableOrderingComposer( $db: db, $table: table, ), createComputedFieldComposer: () => - $$FilterFetchedRangeRecordsTableAnnotationComposer( + $$RelayDeliveryTargetsTableTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ - Value key = const Value.absent(), - Value filterHash = const Value.absent(), + Value eventId = const Value.absent(), Value relayUrl = const Value.absent(), - Value rangeStart = const Value.absent(), - Value rangeEnd = const Value.absent(), + Value reason = const Value.absent(), + Value state = const Value.absent(), + Value attemptCount = const Value.absent(), + Value lastAttemptAt = const Value.absent(), + Value nextRetryAt = const Value.absent(), + Value lastError = const Value.absent(), + Value lastOkMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => FilterFetchedRangeRecordsCompanion( - key: key, - filterHash: filterHash, + }) => RelayDeliveryTargetsTableCompanion( + eventId: eventId, relayUrl: relayUrl, - rangeStart: rangeStart, - rangeEnd: rangeEnd, + reason: reason, + state: state, + attemptCount: attemptCount, + lastAttemptAt: lastAttemptAt, + nextRetryAt: nextRetryAt, + lastError: lastError, + lastOkMessage: lastOkMessage, rowid: rowid, ), createCompanionCallback: ({ - required String key, - required String filterHash, + required String eventId, required String relayUrl, - required int rangeStart, - required int rangeEnd, + required String reason, + required String state, + Value attemptCount = const Value.absent(), + Value lastAttemptAt = const Value.absent(), + Value nextRetryAt = const Value.absent(), + Value lastError = const Value.absent(), + Value lastOkMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => FilterFetchedRangeRecordsCompanion.insert( - key: key, - filterHash: filterHash, + }) => RelayDeliveryTargetsTableCompanion.insert( + eventId: eventId, relayUrl: relayUrl, - rangeStart: rangeStart, - rangeEnd: rangeEnd, + reason: reason, + state: state, + attemptCount: attemptCount, + lastAttemptAt: lastAttemptAt, + nextRetryAt: nextRetryAt, + lastError: lastError, + lastOkMessage: lastOkMessage, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -8852,25 +8621,25 @@ class $$FilterFetchedRangeRecordsTableTableManager ); } -typedef $$FilterFetchedRangeRecordsTableProcessedTableManager = +typedef $$RelayDeliveryTargetsTableTableProcessedTableManager = ProcessedTableManager< _$NdkCacheDatabase, - $FilterFetchedRangeRecordsTable, - DbFilterFetchedRangeRecord, - $$FilterFetchedRangeRecordsTableFilterComposer, - $$FilterFetchedRangeRecordsTableOrderingComposer, - $$FilterFetchedRangeRecordsTableAnnotationComposer, - $$FilterFetchedRangeRecordsTableCreateCompanionBuilder, - $$FilterFetchedRangeRecordsTableUpdateCompanionBuilder, + $RelayDeliveryTargetsTableTable, + DbRelayDeliveryTarget, + $$RelayDeliveryTargetsTableTableFilterComposer, + $$RelayDeliveryTargetsTableTableOrderingComposer, + $$RelayDeliveryTargetsTableTableAnnotationComposer, + $$RelayDeliveryTargetsTableTableCreateCompanionBuilder, + $$RelayDeliveryTargetsTableTableUpdateCompanionBuilder, ( - DbFilterFetchedRangeRecord, + DbRelayDeliveryTarget, BaseReferences< _$NdkCacheDatabase, - $FilterFetchedRangeRecordsTable, - DbFilterFetchedRangeRecord + $RelayDeliveryTargetsTableTable, + DbRelayDeliveryTarget >, ), - DbFilterFetchedRangeRecord, + DbRelayDeliveryTarget, PrefetchHooks Function() >; typedef $$CashuProofsTableCreateCompanionBuilder = @@ -10590,10 +10359,6 @@ class $NdkCacheDatabaseManager { $NdkCacheDatabaseManager(this._db); $$EventsTableTableManager get events => $$EventsTableTableManager(_db, _db.events); - $$MetadatasTableTableManager get metadatas => - $$MetadatasTableTableManager(_db, _db.metadatas); - $$ContactListsTableTableManager get contactLists => - $$ContactListsTableTableManager(_db, _db.contactLists); $$UserRelayListsTableTableManager get userRelayLists => $$UserRelayListsTableTableManager(_db, _db.userRelayLists); $$RelaySetsTableTableManager get relaySets => @@ -10605,6 +10370,18 @@ class $NdkCacheDatabaseManager { _db, _db.filterFetchedRangeRecords, ); + $$EventSourcesTableTableTableManager get eventSourcesTable => + $$EventSourcesTableTableTableManager(_db, _db.eventSourcesTable); + $$EventDeliveryRecordsTableTableTableManager get eventDeliveryRecordsTable => + $$EventDeliveryRecordsTableTableTableManager( + _db, + _db.eventDeliveryRecordsTable, + ); + $$RelayDeliveryTargetsTableTableTableManager get relayDeliveryTargetsTable => + $$RelayDeliveryTargetsTableTableTableManager( + _db, + _db.relayDeliveryTargetsTable, + ); $$CashuProofsTableTableManager get cashuProofs => $$CashuProofsTableTableManager(_db, _db.cashuProofs); $$CashuKeysetsTableTableManager get cashuKeysets => diff --git a/packages/drift/lib/src/database/tables.dart b/packages/drift/lib/src/database/tables.dart index 55402b16f..f742d4213 100644 --- a/packages/drift/lib/src/database/tables.dart +++ b/packages/drift/lib/src/database/tables.dart @@ -17,50 +17,6 @@ class Events extends Table { Set get primaryKey => {id}; } -/// Table for storing user metadata (NIP-01 kind 0) -@DataClassName('DbMetadata') -class Metadatas extends Table { - TextColumn get pubKey => text()(); - TextColumn get name => text().nullable()(); - TextColumn get displayName => text().nullable()(); - TextColumn get picture => text().nullable()(); - TextColumn get banner => text().nullable()(); - TextColumn get website => text().nullable()(); - TextColumn get about => text().nullable()(); - TextColumn get nip05 => text().nullable()(); - TextColumn get lud16 => text().nullable()(); - TextColumn get lud06 => text().nullable()(); - IntColumn get updatedAt => integer().nullable()(); - IntColumn get refreshedTimestamp => integer().nullable()(); - TextColumn get sourcesJson => text()(); // JSON encoded sources - TextColumn get tagsJson => - text().withDefault(const Constant('[]'))(); // JSON encoded tags - TextColumn get rawContentJson => - text().nullable()(); // JSON encoded rawContent - - @override - Set get primaryKey => {pubKey}; -} - -/// Table for storing contact lists (NIP-02) -@DataClassName('DbContactList') -class ContactLists extends Table { - TextColumn get pubKey => text()(); - TextColumn get contactsJson => text()(); // JSON encoded contacts - TextColumn get contactRelaysJson => text()(); // JSON encoded contact relays - TextColumn get petnamesJson => text()(); // JSON encoded petnames - TextColumn get followedTagsJson => text()(); // JSON encoded followed tags - TextColumn get followedCommunitiesJson => - text()(); // JSON encoded followed communities - TextColumn get followedEventsJson => text()(); // JSON encoded followed events - IntColumn get createdAt => integer()(); - IntColumn get loadedTimestamp => integer().nullable()(); - TextColumn get sourcesJson => text()(); // JSON encoded sources - - @override - Set get primaryKey => {pubKey}; -} - /// Table for storing user relay lists (NIP-65) @DataClassName('DbUserRelayList') class UserRelayLists extends Table { @@ -117,6 +73,49 @@ class FilterFetchedRangeRecords extends Table { Set get primaryKey => {key}; } +/// Table for storing event source provenance +@DataClassName('DbEventSource') +class EventSourcesTable extends Table { + TextColumn get eventId => text()(); + TextColumn get relayUrl => text()(); + + @override + Set get primaryKey => {eventId, relayUrl}; +} + +/// Table for storing event delivery records +@DataClassName('DbEventDeliveryRecord') +class EventDeliveryRecordsTable extends Table { + TextColumn get eventId => text()(); + TextColumn get status => text()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + IntColumn get signedAt => integer().nullable()(); + IntColumn get completedAt => integer().nullable()(); + BoolColumn get requiresNetworkSigner => + boolean().withDefault(const Constant(false))(); + + @override + Set get primaryKey => {eventId}; +} + +/// Table for storing relay delivery targets +@DataClassName('DbRelayDeliveryTarget') +class RelayDeliveryTargetsTable extends Table { + TextColumn get eventId => text()(); + TextColumn get relayUrl => text()(); + TextColumn get reason => text()(); + TextColumn get state => text()(); + IntColumn get attemptCount => integer().withDefault(const Constant(0))(); + IntColumn get lastAttemptAt => integer().nullable()(); + IntColumn get nextRetryAt => integer().nullable()(); + TextColumn get lastError => text().nullable()(); + TextColumn get lastOkMessage => text().nullable()(); + + @override + Set get primaryKey => {eventId, relayUrl}; +} + // ===================== // Cashu Tables // ===================== diff --git a/packages/drift/lib/src/drift_cache_manager.dart b/packages/drift/lib/src/drift_cache_manager.dart index 68229dd3b..23ceeeffc 100644 --- a/packages/drift/lib/src/drift_cache_manager.dart +++ b/packages/drift/lib/src/drift_cache_manager.dart @@ -6,6 +6,7 @@ import 'package:ndk/domain_layer/entities/cashu/cashu_keyset.dart'; import 'package:ndk/domain_layer/entities/cashu/cashu_mint_info.dart'; import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; import 'package:ndk/domain_layer/entities/nip_05.dart'; +import 'package:ndk/domain_layer/entities/nip_65.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'; @@ -17,6 +18,8 @@ import 'package:ndk/domain_layer/entities/wallet/wallet_transaction.dart'; import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/event_kind_classification.dart'; +import 'package:ndk/shared/nips/nip01/event_eviction_planner.dart'; import 'database/database.dart'; @@ -26,6 +29,9 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { 'default_wallet_for_receiving'; static const String _defaultWalletForSendingKey = 'default_wallet_for_sending'; + static const String _decryptedPayloadKeyPrefix = 'decrypted_payload:'; + static const String _eventDeliverySnapshotKeyPrefix = + 'event_delivery_snapshot:'; final NdkCacheDatabase _db; String? _defaultWalletIdForReceiving; @@ -64,6 +70,7 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { sig: Value(event.sig), validSig: Value(event.validSig), tagsJson: jsonEncode(event.tags), + // ignore: deprecated_member_use sourcesJson: jsonEncode(event.sources), ), ); @@ -85,6 +92,7 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { sig: Value(event.sig), validSig: Value(event.validSig), tagsJson: jsonEncode(event.tags), + // ignore: deprecated_member_use sourcesJson: jsonEncode(event.sources), ), ) @@ -152,10 +160,6 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { }) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]); - if (limit != null) { - query = query..limit(limit); - } - final rows = await query.get(); var events = rows.map(_eventFromRow).toList(); @@ -185,22 +189,143 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { }).toList(); } + final deletionRows = await (_db.select( + _db.events, + )..where((t) => t.kind.equals(5))).get(); + final deletions = deletionRows.map(_eventFromRow).toList(); + + events = _applyEventVisibilityRules(events, deletions); + events.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (limit != null && limit > 0 && events.length > limit) { + events = events.take(limit).toList(); + } + return events; } @override Future removeEvent(String id) async { + final removedEvent = await loadEvent(id); await (_db.delete(_db.events)..where((t) => t.id.equals(id))).go(); + await _removeEventSidecarsByIds([id]); + if (removedEvent != null) { + await _syncUserRelayListProjectionForEvent(removedEvent); + } } @override Future removeAllEventsByPubKey(String pubKey) async { + final rows = await (_db.select( + _db.events, + )..where((t) => t.pubKey.equals(pubKey))).get(); + final eventIds = rows.map((row) => row.id).toList(); await (_db.delete(_db.events)..where((t) => t.pubKey.equals(pubKey))).go(); + await _removeEventSidecarsByIds(eventIds); + await _syncUserRelayListProjection(pubKey); } @override Future removeAllEvents() async { - await _db.delete(_db.events).go(); + await Future.wait([ + _db.delete(_db.events).go(), + _db.delete(_db.eventSourcesTable).go(), + _db.delete(_db.eventDeliveryRecordsTable).go(), + _db.delete(_db.relayDeliveryTargetsTable).go(), + _db.delete(_db.userRelayLists).go(), + removeAllDecryptedEventPayloadRecords(), + _removeAllEventDeliverySnapshots(), + ]); + } + + @override + Future evict(EvictionPolicy policy) async { + final rawRows = await _db.select(_db.events).get(); + final rawEvents = rawRows.map(_eventFromRow).toList(); + final deliveryRecords = await loadEventDeliveryRecords(); + final relayTargets = await loadRelayDeliveryTargets(); + final activeDeliveryEventIds = deliveryRecords + .where((record) => record.status != EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final lockedEventIds = { + ...activeDeliveryEventIds, + ...relayTargets + .where((target) => target.state != RelayDeliveryState.acked) + .map((target) => target.eventId), + }; + final deliveredEventIds = deliveryRecords + .where((record) => record.status == EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final plan = EventEvictionPlanner.plan( + rawEvents: rawEvents, + lockedEventIds: lockedEventIds, + deliveredEventIds: deliveredEventIds, + policy: policy, + ); + + // Sweep leftover delivery records whose event was kept (or never stored). + // A terminally failed record swept here unpins its event for the next run. + final remainingDeliveryRecords = deliveryRecords + .where((record) => !plan.eventIdsToRemove.contains(record.eventId)) + .toList(); + final deliverySweep = EventEvictionPlanner.planDeliverySweep( + deliveryRecords: remainingDeliveryRecords, + policy: policy, + ); + + if (plan.eventIdsToRemove.isEmpty && + deliverySweep.deliveryEventIdsToRemove.isEmpty) { + return plan.toResult(); + } + + final idsToRemove = plan.eventIdsToRemove.toList(); + await _db.transaction(() async { + if (idsToRemove.isNotEmpty) { + await (_db.delete( + _db.events, + )..where((t) => t.id.isIn(idsToRemove))).go(); + await _removeEventSidecarsByIds(idsToRemove); + } + if (deliverySweep.deliveryEventIdsToRemove.isNotEmpty) { + await _removeDeliveryRecordsByIds( + deliverySweep.deliveryEventIdsToRemove, + ); + } + }); + + return plan.toResult().copyWith( + removedCompletedDeliveries: deliverySweep.removedCompletedDeliveries, + removedTerminalFailedDeliveries: + deliverySweep.removedTerminalFailedDeliveries, + ); + } + + /// Removes only the delivery record, relay delivery targets and delivery + /// snapshot for [eventIds]. Unlike [_removeEventSidecarsByIds] this leaves the + /// event, its provenance sources and decrypted payload sidecars intact, so it + /// is safe to call for events that are being kept in cache. + Future _removeDeliveryRecordsByIds(Iterable eventIds) async { + final ids = eventIds.toSet().toList(); + if (ids.isEmpty) return; + + await (_db.delete( + _db.eventDeliveryRecordsTable, + )..where((t) => t.eventId.isIn(ids))).go(); + await (_db.delete( + _db.relayDeliveryTargetsTable, + )..where((t) => t.eventId.isIn(ids))).go(); + await (_db.delete(_db.keyValues)..where((kv) { + final conditions = ids + .map((id) => kv.key.equals(_eventDeliverySnapshotKey(id))) + .toList(); + if (conditions.length == 1) { + return conditions.first; + } + return conditions.reduce((a, b) => a | b); + })) + .go(); } @override @@ -237,9 +362,39 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { final idsToRemove = eventsToRemove.map((e) => e.id).toList(); await (_db.delete(_db.events)..where((t) => t.id.isIn(idsToRemove))).go(); + await _removeEventSidecarsByIds(idsToRemove); + await _syncUserRelayListProjections( + eventsToRemove.map((event) => event.pubKey), + ); return; } + final rows = + await (_db.select(_db.events)..where((t) { + final conditions = >[]; + + if (ids != null && ids.isNotEmpty) { + conditions.add(t.id.isIn(ids)); + } + if (pubKeys != null && pubKeys.isNotEmpty) { + conditions.add(t.pubKey.isIn(pubKeys)); + } + if (kinds != null && kinds.isNotEmpty) { + conditions.add(t.kind.isIn(kinds)); + } + if (since != null) { + conditions.add(t.createdAt.isBiggerOrEqualValue(since)); + } + if (until != null) { + conditions.add(t.createdAt.isSmallerOrEqualValue(until)); + } + + return conditions.reduce((a, b) => a & b); + })) + .get(); + final removedEventIds = rows.map((row) => row.id).toList(); + final affectedPubKeys = rows.map((row) => row.pubKey).toSet(); + // No tags filter, delete directly without loading events await (_db.delete(_db.events)..where((t) { final conditions = >[]; @@ -263,6 +418,39 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { return conditions.reduce((a, b) => a & b); })) .go(); + + await _removeEventSidecarsByIds(removedEventIds); + await _syncUserRelayListProjections(affectedPubKeys); + } + + Future _removeEventSidecarsByIds(Iterable eventIds) async { + final ids = eventIds.toSet().toList(); + if (ids.isEmpty) return; + + await (_db.delete( + _db.eventSourcesTable, + )..where((t) => t.eventId.isIn(ids))).go(); + await (_db.delete( + _db.eventDeliveryRecordsTable, + )..where((t) => t.eventId.isIn(ids))).go(); + await (_db.delete( + _db.relayDeliveryTargetsTable, + )..where((t) => t.eventId.isIn(ids))).go(); + await (_db.delete(_db.keyValues)..where((kv) { + final conditions = ids + .expand( + (id) => [ + kv.key.like('$_decryptedPayloadKeyPrefix$id|%'), + kv.key.equals(_eventDeliverySnapshotKey(id)), + ], + ) + .toList(); + if (conditions.length == 1) { + return conditions.first; + } + return conditions.reduce((a, b) => a | b); + })) + .go(); } Nip01Event _eventFromRow(DbEvent row) { @@ -286,153 +474,143 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { ); } + List _applyEventVisibilityRules( + List events, + List deletions, + ) { + final visible = []; + final replaceableWinners = {}; + final now = Nip01Event.secondsSinceEpoch(); + + for (final event in events) { + if (_isExpired(event, now)) continue; + if (_isDeletedByAuthor(event, deletions)) continue; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey == null) { + visible.add(event); + continue; + } + + final current = replaceableWinners[coordinateKey]; + if (current == null || _isMoreRecentReplaceable(event, current)) { + replaceableWinners[coordinateKey] = event; + } + } + + visible.addAll(replaceableWinners.values); + return visible; + } + + bool _isDeletedByAuthor(Nip01Event target, List deletions) { + if (target.kind == 5) return false; + + // Addressable/replaceable events are deleted by coordinate (`a` tag), so a + // later version published after the deletion stays visible (NIP-09 only + // deletes coordinate matches with created_at <= the deletion). + final coordinate = _coordinateKey(target); + + for (final deletion in deletions) { + if (deletion.pubKey != target.pubKey) continue; + if (deletion.getTags('e').contains(target.id.toLowerCase())) { + return true; + } + if (coordinate != null && + deletion.createdAt >= target.createdAt && + deletion.getTags('a').contains(coordinate)) { + return true; + } + } + + return false; + } + + bool _isExpired(Nip01Event event, int now) { + final expirationValue = event.getFirstTag('expiration'); + if (expirationValue == null) return false; + final expiration = int.tryParse(expirationValue); + if (expiration == null) return false; + return expiration <= now; + } + + String? _coordinateKey(Nip01Event event) { + if (!EventKindClassification.isReplaceableKind(event.kind)) return null; + final dTag = event.getDtag() ?? ''; + return '${event.kind}:${event.pubKey}:$dTag'; + } + + bool _isMoreRecentReplaceable(Nip01Event candidate, Nip01Event current) { + if (candidate.createdAt != current.createdAt) { + return candidate.createdAt > current.createdAt; + } + + return candidate.id.compareTo(current.id) < 0; + } + + Future _loadLatestVisibleEvent({ + required String pubKey, + required int kind, + }) async { + final events = await loadEvents(pubKeys: [pubKey], kinds: [kind], limit: 1); + if (events.isEmpty) return null; + return events.first; + } + // ===================== // Metadata // ===================== @override Future saveMetadata(Metadata metadata) async { - await _db - .into(_db.metadatas) - .insertOnConflictUpdate( - MetadatasCompanion.insert( - pubKey: metadata.pubKey, - name: Value(metadata.name), - displayName: Value(metadata.displayName), - picture: Value(metadata.picture), - banner: Value(metadata.banner), - website: Value(metadata.website), - about: Value(metadata.about), - nip05: Value(metadata.nip05), - lud16: Value(metadata.lud16), - lud06: Value(metadata.lud06), - updatedAt: Value(metadata.updatedAt), - refreshedTimestamp: Value(metadata.refreshedTimestamp), - sourcesJson: jsonEncode(metadata.sources), - tagsJson: Value(jsonEncode(metadata.tags)), - rawContentJson: Value( - metadata.content.isNotEmpty ? jsonEncode(metadata.content) : null, - ), - ), - ); + await saveEvent(metadata.toEvent()); } @override Future saveMetadatas(List metadatas) async { - await _db.batch((batch) { - batch.insertAllOnConflictUpdate( - _db.metadatas, - metadatas - .map( - (metadata) => MetadatasCompanion.insert( - pubKey: metadata.pubKey, - name: Value(metadata.name), - displayName: Value(metadata.displayName), - picture: Value(metadata.picture), - banner: Value(metadata.banner), - website: Value(metadata.website), - about: Value(metadata.about), - nip05: Value(metadata.nip05), - lud16: Value(metadata.lud16), - lud06: Value(metadata.lud06), - updatedAt: Value(metadata.updatedAt), - refreshedTimestamp: Value(metadata.refreshedTimestamp), - sourcesJson: jsonEncode(metadata.sources), - tagsJson: Value(jsonEncode(metadata.tags)), - rawContentJson: Value( - metadata.content.isNotEmpty - ? jsonEncode(metadata.content) - : null, - ), - ), - ) - .toList(), - ); - }); + await saveEvents(metadatas.map((metadata) => metadata.toEvent()).toList()); } @override Future loadMetadata(String pubKey) async { - final row = await (_db.select( - _db.metadatas, - )..where((t) => t.pubKey.equals(pubKey))).getSingleOrNull(); - if (row == null) return null; - return _metadataFromRow(row); + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: Metadata.kKind, + ); + if (event == null) return null; + final metadata = Metadata.fromEvent(event); + metadata.refreshedTimestamp = Nip01Event.secondsSinceEpoch(); + return metadata; } @override Future> loadMetadatas(List pubKeys) async { - final rows = await (_db.select( - _db.metadatas, - )..where((t) => t.pubKey.isIn(pubKeys))).get(); - - final map = {for (var row in rows) row.pubKey: _metadataFromRow(row)}; - return pubKeys.map((pk) => map[pk]).toList(); + return Future.wait(pubKeys.map(loadMetadata)); } @override Future> searchMetadatas(String search, int limit) async { - final rows = - await (_db.select(_db.metadatas) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]) - ..limit(limit)) - .get(); + final events = await loadEvents(kinds: [Metadata.kKind]); + final normalizedSearch = search.trim().toLowerCase(); + final matches = events.map((event) => Metadata.fromEvent(event)).where(( + metadata, + ) { + if (normalizedSearch.isEmpty) return true; + return metadata.matchesSearch(normalizedSearch) || + (metadata.about?.toLowerCase().contains(normalizedSearch) ?? false) || + (metadata.cleanNip05?.contains(normalizedSearch) ?? false); + }).toList()..sort((a, b) => (b.updatedAt ?? 0).compareTo(a.updatedAt ?? 0)); - final metadatas = rows.map(_metadataFromRow).toList(); - - if (search.isNotEmpty) { - final searchLower = search.toLowerCase(); - return metadatas.where((metadata) { - return (metadata.name?.toLowerCase().contains(searchLower) ?? false) || - (metadata.displayName?.toLowerCase().contains(searchLower) ?? - false) || - (metadata.about?.toLowerCase().contains(searchLower) ?? false) || - (metadata.nip05?.toLowerCase().contains(searchLower) ?? false); - }); - } - - return metadatas; + return matches.take(limit); } @override Future removeMetadata(String pubKey) async { - await (_db.delete( - _db.metadatas, - )..where((t) => t.pubKey.equals(pubKey))).go(); + await removeEvents(pubKeys: [pubKey], kinds: [Metadata.kKind]); } @override Future removeAllMetadatas() async { - await _db.delete(_db.metadatas).go(); - } - - Metadata _metadataFromRow(DbMetadata row) { - final tags = (jsonDecode(row.tagsJson) as List) - .map((e) => (e as List).map((item) => item.toString()).toList()) - .toList(); - - final metadata = Metadata( - pubKey: row.pubKey, - name: row.name, - displayName: row.displayName, - picture: row.picture, - banner: row.banner, - website: row.website, - about: row.about, - nip05: row.nip05, - lud16: row.lud16, - lud06: row.lud06, - updatedAt: row.updatedAt, - refreshedTimestamp: row.refreshedTimestamp, - tags: tags, - content: row.rawContentJson != null - ? jsonDecode(row.rawContentJson!) as Map - : null, - ); - metadata.sources = (jsonDecode(row.sourcesJson) as List) - .map((e) => e.toString()) - .toList(); - return metadata; + await removeEvents(kinds: [Metadata.kKind]); } // ===================== @@ -441,103 +619,34 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { @override Future saveContactList(ContactList contactList) async { - await _db - .into(_db.contactLists) - .insertOnConflictUpdate( - ContactListsCompanion.insert( - pubKey: contactList.pubKey, - contactsJson: jsonEncode(contactList.contacts), - contactRelaysJson: jsonEncode(contactList.contactRelays), - petnamesJson: jsonEncode(contactList.petnames), - followedTagsJson: jsonEncode(contactList.followedTags), - followedCommunitiesJson: jsonEncode( - contactList.followedCommunities, - ), - followedEventsJson: jsonEncode(contactList.followedEvents), - createdAt: contactList.createdAt, - loadedTimestamp: Value(contactList.loadedTimestamp), - sourcesJson: jsonEncode(contactList.sources), - ), - ); + await saveEvent(contactList.toEvent()); } @override Future saveContactLists(List contactLists) async { - await _db.batch((batch) { - batch.insertAllOnConflictUpdate( - _db.contactLists, - contactLists - .map( - (contactList) => ContactListsCompanion.insert( - pubKey: contactList.pubKey, - contactsJson: jsonEncode(contactList.contacts), - contactRelaysJson: jsonEncode(contactList.contactRelays), - petnamesJson: jsonEncode(contactList.petnames), - followedTagsJson: jsonEncode(contactList.followedTags), - followedCommunitiesJson: jsonEncode( - contactList.followedCommunities, - ), - followedEventsJson: jsonEncode(contactList.followedEvents), - createdAt: contactList.createdAt, - loadedTimestamp: Value(contactList.loadedTimestamp), - sourcesJson: jsonEncode(contactList.sources), - ), - ) - .toList(), - ); - }); + await saveEvents( + contactLists.map((contactList) => contactList.toEvent()).toList(), + ); } @override Future loadContactList(String pubKey) async { - final row = await (_db.select( - _db.contactLists, - )..where((t) => t.pubKey.equals(pubKey))).getSingleOrNull(); - if (row == null) return null; - return _contactListFromRow(row); + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: ContactList.kKind, + ); + if (event == null) return null; + return ContactList.fromEvent(event); } @override Future removeContactList(String pubKey) async { - await (_db.delete( - _db.contactLists, - )..where((t) => t.pubKey.equals(pubKey))).go(); + await removeEvents(pubKeys: [pubKey], kinds: [ContactList.kKind]); } @override Future removeAllContactLists() async { - await _db.delete(_db.contactLists).go(); - } - - ContactList _contactListFromRow(DbContactList row) { - final contactList = ContactList( - pubKey: row.pubKey, - contacts: (jsonDecode(row.contactsJson) as List) - .map((e) => e.toString()) - .toList(), - ); - contactList.contactRelays = (jsonDecode(row.contactRelaysJson) as List) - .map((e) => e.toString()) - .toList(); - contactList.petnames = (jsonDecode(row.petnamesJson) as List) - .map((e) => e.toString()) - .toList(); - contactList.followedTags = (jsonDecode(row.followedTagsJson) as List) - .map((e) => e.toString()) - .toList(); - contactList.followedCommunities = - (jsonDecode(row.followedCommunitiesJson) as List) - .map((e) => e.toString()) - .toList(); - contactList.followedEvents = (jsonDecode(row.followedEventsJson) as List) - .map((e) => e.toString()) - .toList(); - contactList.createdAt = row.createdAt; - contactList.loadedTimestamp = row.loadedTimestamp; - contactList.sources = (jsonDecode(row.sourcesJson) as List) - .map((e) => e.toString()) - .toList(); - return contactList; + await removeEvents(kinds: [ContactList.kKind]); } // ===================== @@ -579,6 +688,11 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { @override Future loadUserRelayList(String pubKey) async { + final fromEvents = await _deriveUserRelayListFromEvents(pubKey); + if (fromEvents != null) { + return fromEvents; + } + final row = await (_db.select( _db.userRelayLists, )..where((t) => t.pubKey.equals(pubKey))).getSingleOrNull(); @@ -586,6 +700,63 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { return _userRelayListFromRow(row); } + Future _deriveUserRelayListFromEvents(String pubKey) async { + final events = await loadEvents( + pubKeys: [pubKey], + kinds: [Nip65.kKind, ContactList.kKind], + ); + + Nip01Event? latestNip65; + Nip01Event? latestContactListWithRelays; + for (final event in events) { + if (event.kind == Nip65.kKind) { + latestNip65 ??= event; + } else if (event.kind == ContactList.kKind && + ContactList.relaysFromContent(event).isNotEmpty) { + latestContactListWithRelays ??= event; + } + } + + if (latestNip65 != null) { + return UserRelayList.fromNip65(Nip65.fromEvent(latestNip65)); + } + + if (latestContactListWithRelays != null) { + return UserRelayList.fromNip02EventContent(latestContactListWithRelays); + } + + return null; + } + + Future _syncUserRelayListProjection(String pubKey) async { + final derived = await _deriveUserRelayListFromEvents(pubKey); + if (derived == null) { + await (_db.delete( + _db.userRelayLists, + )..where((t) => t.pubKey.equals(pubKey))).go(); + return; + } + + await saveUserRelayList(derived); + } + + Future _syncUserRelayListProjectionForEvent(Nip01Event event) async { + if (!_affectsUserRelayListProjection(event.kind)) { + return; + } + await _syncUserRelayListProjection(event.pubKey); + } + + Future _syncUserRelayListProjections(Iterable pubKeys) async { + for (final pubKey in pubKeys.toSet()) { + await _syncUserRelayListProjection(pubKey); + } + } + + bool _affectsUserRelayListProjection(int kind) { + return kind == ContactList.kKind || kind == Nip65.kKind; + } + @override Future removeUserRelayList(String pubKey) async { await (_db.delete( @@ -980,6 +1151,484 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { ); } + // ===================== + // Event Sources + // ===================== + + @override + Future addEventSource({ + required String eventId, + required String relayUrl, + }) async { + await _db + .into(_db.eventSourcesTable) + .insertOnConflictUpdate( + EventSourcesTableCompanion.insert( + eventId: eventId, + relayUrl: relayUrl, + ), + ); + } + + @override + Future addEventSources({ + required String eventId, + required Iterable relayUrls, + }) async { + await _db.batch((batch) { + for (final relayUrl in relayUrls) { + batch.insert( + _db.eventSourcesTable, + EventSourcesTableCompanion.insert( + eventId: eventId, + relayUrl: relayUrl, + ), + mode: InsertMode.insertOrIgnore, + ); + } + }); + } + + @override + Future> loadEventSources(String eventId) async { + final rows = await (_db.select( + _db.eventSourcesTable, + )..where((t) => t.eventId.equals(eventId))).get(); + return rows.map((r) => r.relayUrl).toList(); + } + + @override + Future removeEventSources(String eventId) async { + await (_db.delete( + _db.eventSourcesTable, + )..where((t) => t.eventId.equals(eventId))).go(); + } + + // ===================== + // Event Delivery Records + // ===================== + + @override + Future saveEventDeliveryRecord(EventDeliveryRecord record) async { + await _db + .into(_db.eventDeliveryRecordsTable) + .insertOnConflictUpdate( + EventDeliveryRecordsTableCompanion.insert( + eventId: record.eventId, + status: record.status.name, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + signedAt: Value(record.signedAt), + completedAt: Value(record.completedAt), + requiresNetworkSigner: Value(record.requiresInteractiveSigning), + ), + ); + await _saveEventDeliverySnapshot(record); + } + + @override + Future saveEventDeliveryRecords( + List records, + ) async { + await _db.batch((batch) { + batch.insertAllOnConflictUpdate( + _db.eventDeliveryRecordsTable, + records + .map( + (record) => EventDeliveryRecordsTableCompanion.insert( + eventId: record.eventId, + status: record.status.name, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + signedAt: Value(record.signedAt), + completedAt: Value(record.completedAt), + requiresNetworkSigner: Value(record.requiresInteractiveSigning), + ), + ) + .toList(), + ); + }); + await Future.wait(records.map(_saveEventDeliverySnapshot)); + } + + @override + Future loadEventDeliveryRecord(String eventId) async { + final row = await (_db.select( + _db.eventDeliveryRecordsTable, + )..where((t) => t.eventId.equals(eventId))).getSingleOrNull(); + if (row == null) return null; + return _withEventDeliverySnapshot(_eventDeliveryRecordFromRow(row)); + } + + @override + Future> loadEventDeliveryRecords({ + EventDeliveryStatus? status, + int? limit, + }) async { + var query = _db.select(_db.eventDeliveryRecordsTable); + if (status != null) { + query = query..where((t) => t.status.equals(status.name)); + } + if (limit != null) { + query = query..limit(limit); + } + final rows = await query.get(); + return Future.wait( + rows.map( + (row) => _withEventDeliverySnapshot(_eventDeliveryRecordFromRow(row)), + ), + ); + } + + @override + Future removeEventDeliveryRecord(String eventId) async { + await Future.wait([ + (_db.delete( + _db.eventDeliveryRecordsTable, + )..where((t) => t.eventId.equals(eventId))).go(), + _removeEventDeliverySnapshot(eventId), + ]); + } + + @override + Future removeAllEventDeliveryRecords() async { + await Future.wait([ + _db.delete(_db.eventDeliveryRecordsTable).go(), + _removeAllEventDeliverySnapshots(), + ]); + } + + EventDeliveryRecord _eventDeliveryRecordFromRow(DbEventDeliveryRecord row) { + return EventDeliveryRecord( + eventId: row.eventId, + status: EventDeliveryStatus.values.byName(row.status), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + signedAt: row.signedAt, + completedAt: row.completedAt, + requiresInteractiveSigning: row.requiresNetworkSigner, + ); + } + + Future _saveEventDeliverySnapshot(EventDeliveryRecord record) async { + final hasSnapshotPayload = + (record.serializedEventJson != null && + record.serializedEventJson!.isNotEmpty) || + record.signingState != EventSigningState.notNeeded || + record.signAttemptCount > 0 || + record.lastSignAttemptAt != null || + record.nextSignRetryAt != null || + record.lastSignError != null; + if (!hasSnapshotPayload) { + await _removeEventDeliverySnapshot(record.eventId); + return; + } + + final snapshotJson = jsonEncode(record.toJson()); + await _db + .into(_db.keyValues) + .insertOnConflictUpdate( + KeyValuesCompanion.insert( + key: _eventDeliverySnapshotKey(record.eventId), + value: Value(snapshotJson), + ), + ); + } + + Future _withEventDeliverySnapshot( + EventDeliveryRecord record, + ) async { + final snapshotRow = + await (_db.select(_db.keyValues)..where( + (kv) => kv.key.equals(_eventDeliverySnapshotKey(record.eventId)), + )) + .getSingleOrNull(); + final snapshotValue = snapshotRow?.value; + if (snapshotValue == null || snapshotValue.isEmpty) { + return record; + } + + try { + final decoded = jsonDecode(snapshotValue); + if (decoded is Map && + decoded.containsKey('eventId') && + decoded.containsKey('status')) { + return EventDeliveryRecord.fromJson(decoded); + } + } catch (_) { + // Older snapshots stored only the serialized event JSON string. + } + + return record.copyWith(serializedEventJson: snapshotValue); + } + + Future _removeEventDeliverySnapshot(String eventId) async { + await (_db.delete( + _db.keyValues, + )..where((kv) => kv.key.equals(_eventDeliverySnapshotKey(eventId)))).go(); + } + + Future _removeAllEventDeliverySnapshots() async { + await (_db.delete( + _db.keyValues, + )..where((kv) => kv.key.like('$_eventDeliverySnapshotKeyPrefix%'))).go(); + } + + String _eventDeliverySnapshotKey(String eventId) { + return '$_eventDeliverySnapshotKeyPrefix$eventId'; + } + + // ===================== + // Relay Delivery Targets + // ===================== + + @override + Future saveRelayDeliveryTarget(RelayDeliveryTarget target) async { + await _db + .into(_db.relayDeliveryTargetsTable) + .insertOnConflictUpdate( + RelayDeliveryTargetsTableCompanion.insert( + eventId: target.eventId, + relayUrl: target.relayUrl, + reason: target.reason.name, + state: target.state.name, + attemptCount: Value(target.attemptCount), + lastAttemptAt: Value(target.lastAttemptAt), + nextRetryAt: Value(target.nextRetryAt), + lastError: Value(target.lastError), + lastOkMessage: Value(target.lastOkMessage), + ), + ); + } + + @override + Future saveRelayDeliveryTargets( + List targets, + ) async { + await _db.batch((batch) { + batch.insertAllOnConflictUpdate( + _db.relayDeliveryTargetsTable, + targets + .map( + (target) => RelayDeliveryTargetsTableCompanion.insert( + eventId: target.eventId, + relayUrl: target.relayUrl, + reason: target.reason.name, + state: target.state.name, + attemptCount: Value(target.attemptCount), + lastAttemptAt: Value(target.lastAttemptAt), + nextRetryAt: Value(target.nextRetryAt), + lastError: Value(target.lastError), + lastOkMessage: Value(target.lastOkMessage), + ), + ) + .toList(), + ); + }); + } + + @override + Future loadRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + final row = + await (_db.select(_db.relayDeliveryTargetsTable)..where( + (t) => t.eventId.equals(eventId) & t.relayUrl.equals(relayUrl), + )) + .getSingleOrNull(); + if (row == null) return null; + return _relayDeliveryTargetFromRow(row); + } + + @override + Future> loadRelayDeliveryTargets({ + String? eventId, + String? relayUrl, + RelayDeliveryState? state, + bool excludeAcked = false, + int? limit, + }) async { + var query = _db.select(_db.relayDeliveryTargetsTable); + + query = query + ..where((t) { + final conditions = >[]; + if (eventId != null) { + conditions.add(t.eventId.equals(eventId)); + } + if (relayUrl != null) { + conditions.add(t.relayUrl.equals(relayUrl)); + } + if (state != null) { + conditions.add(t.state.equals(state.name)); + } + if (excludeAcked) { + conditions.add(t.state.equals(RelayDeliveryState.acked.name).not()); + } + if (conditions.isEmpty) return const Constant(true); + return conditions.reduce((a, b) => a & b); + }) + ..orderBy([ + (t) => OrderingTerm.asc(t.nextRetryAt), + (t) => OrderingTerm.asc(t.eventId), + (t) => OrderingTerm.asc(t.relayUrl), + ]); + + if (limit != null) { + query = query..limit(limit); + } + + final rows = await query.get(); + return rows.map(_relayDeliveryTargetFromRow).toList(); + } + + @override + Future removeRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + await (_db.delete(_db.relayDeliveryTargetsTable)..where( + (t) => t.eventId.equals(eventId) & t.relayUrl.equals(relayUrl), + )) + .go(); + } + + @override + Future removeRelayDeliveryTargets(String eventId) async { + await (_db.delete( + _db.relayDeliveryTargetsTable, + )..where((t) => t.eventId.equals(eventId))).go(); + } + + @override + Future removeAllRelayDeliveryTargets() async { + await _db.delete(_db.relayDeliveryTargetsTable).go(); + } + + RelayDeliveryTarget _relayDeliveryTargetFromRow(DbRelayDeliveryTarget row) { + return RelayDeliveryTarget( + eventId: row.eventId, + relayUrl: row.relayUrl, + reason: RelayDeliveryReason.values.byName(row.reason), + state: RelayDeliveryState.values.byName(row.state), + attemptCount: row.attemptCount, + lastAttemptAt: row.lastAttemptAt, + nextRetryAt: row.nextRetryAt, + lastError: row.lastError, + lastOkMessage: row.lastOkMessage, + ); + } + + String _decryptedPayloadKey(String eventId, String viewerPubKey) => + '$_decryptedPayloadKeyPrefix$eventId|$viewerPubKey'; + + @override + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ) async { + await _storeKeyValue( + key: _decryptedPayloadKey(record.eventId, record.viewerPubKey), + value: jsonEncode(record.toJson()), + ); + } + + @override + Future saveDecryptedEventPayloadRecords( + List records, + ) async { + await _db.batch((batch) { + for (final record in records) { + batch.insert( + _db.keyValues, + KeyValuesCompanion.insert( + key: _decryptedPayloadKey(record.eventId, record.viewerPubKey), + value: Value(jsonEncode(record.toJson())), + ), + mode: InsertMode.insertOrReplace, + ); + } + }); + } + + @override + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + final raw = await _getKeyValue(_decryptedPayloadKey(eventId, viewerPubKey)); + if (raw == null) { + return null; + } + return DecryptedEventPayloadRecord.fromJson( + jsonDecode(raw) as Map, + ); + } + + @override + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }) async { + final rows = await (_db.select( + _db.keyValues, + )..where((kv) => kv.key.like('$_decryptedPayloadKeyPrefix%'))).get(); + + var records = rows + .where((row) => row.value != null) + .map( + (row) => DecryptedEventPayloadRecord.fromJson( + jsonDecode(row.value!) as Map, + ), + ) + .where((record) { + if (eventId != null && record.eventId != eventId) { + return false; + } + if (viewerPubKey != null && record.viewerPubKey != viewerPubKey) { + return false; + } + if (status != null && record.status != status) { + return false; + } + return true; + }) + .toList(); + + records.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + if (limit != null && limit > 0 && records.length > limit) { + records = records.take(limit).toList(); + } + return records; + } + + @override + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + await (_db.delete(_db.keyValues)..where( + (kv) => kv.key.equals(_decryptedPayloadKey(eventId, viewerPubKey)), + )) + .go(); + } + + @override + Future removeDecryptedEventPayloadRecords(String eventId) async { + await (_db.delete(_db.keyValues) + ..where((kv) => kv.key.like('$_decryptedPayloadKeyPrefix$eventId|%'))) + .go(); + } + + @override + Future removeAllDecryptedEventPayloadRecords() async { + await (_db.delete( + _db.keyValues, + )..where((kv) => kv.key.like('$_decryptedPayloadKeyPrefix%'))).go(); + } + // ===================== // Cashu Methods // ===================== @@ -1425,12 +2074,13 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { Future clearAll() async { await Future.wait([ _db.delete(_db.events).go(), - _db.delete(_db.metadatas).go(), - _db.delete(_db.contactLists).go(), _db.delete(_db.userRelayLists).go(), _db.delete(_db.relaySets).go(), _db.delete(_db.nip05s).go(), _db.delete(_db.filterFetchedRangeRecords).go(), + _db.delete(_db.eventSourcesTable).go(), + _db.delete(_db.eventDeliveryRecordsTable).go(), + _db.delete(_db.relayDeliveryTargetsTable).go(), // Cashu tables _db.delete(_db.cashuProofs).go(), _db.delete(_db.cashuKeysets).go(), diff --git a/packages/drift/test/database_migration_v6_test.dart b/packages/drift/test/database_migration_v6_test.dart new file mode 100644 index 000000000..c730777d3 --- /dev/null +++ b/packages/drift/test/database_migration_v6_test.dart @@ -0,0 +1,232 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_drift/ndk_drift.dart'; + +/// Opens a database seeded with the pre-v6 schema (dedicated metadatas and +/// contact_lists tables) so that opening it triggers the v5 -> v6 migration. +NdkCacheDatabase openV5Database({ + List metadataInserts = const [], + List contactListInserts = const [], +}) { + return NdkCacheDatabase.forTesting( + NativeDatabase.memory( + setup: (db) { + db.execute(''' + CREATE TABLE events ( + id TEXT NOT NULL PRIMARY KEY, + pub_key TEXT NOT NULL, + kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + content TEXT NOT NULL, + sig TEXT, + valid_sig INTEGER, + tags_json TEXT NOT NULL, + sources_json TEXT NOT NULL + ); + '''); + db.execute(''' + CREATE TABLE metadatas ( + pub_key TEXT NOT NULL PRIMARY KEY, + name TEXT, + display_name TEXT, + picture TEXT, + banner TEXT, + website TEXT, + about TEXT, + nip05 TEXT, + lud16 TEXT, + lud06 TEXT, + updated_at INTEGER, + refreshed_timestamp INTEGER, + sources_json TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + raw_content_json TEXT + ); + '''); + db.execute(''' + CREATE TABLE contact_lists ( + pub_key TEXT NOT NULL PRIMARY KEY, + contacts_json TEXT NOT NULL, + contact_relays_json TEXT NOT NULL, + petnames_json TEXT NOT NULL, + followed_tags_json TEXT NOT NULL, + followed_communities_json TEXT NOT NULL, + followed_events_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + loaded_timestamp INTEGER, + sources_json TEXT NOT NULL + ); + '''); + // Present in every real v5 database; DriftCacheManager touches them + // on construction. + db.execute(''' + CREATE TABLE wallets ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + supported_units_json TEXT NOT NULL, + metadata_json TEXT NOT NULL + ); + '''); + db.execute(''' + CREATE TABLE key_values ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT + ); + '''); + for (final statement in metadataInserts) { + db.execute(statement); + } + for (final statement in contactListInserts) { + db.execute(statement); + } + db.execute('PRAGMA user_version = 5;'); + }, + ), + ); +} + +void main() { + const alice = 'alicePubKey'; + const bob = 'bobPubKey'; + + test('v5 -> v6 migrates legacy metadata rows into events', () async { + final db = openV5Database( + metadataInserts: [ + ''' + INSERT INTO metadatas + (pub_key, name, display_name, picture, about, updated_at, + sources_json, tags_json, raw_content_json) + VALUES + ('$alice', 'alice', 'Alice', 'https://pic', 'hi', 1700000000, + '["wss://relay1"]', '[]', + '{"name":"alice","display_name":"Alice","picture":"https://pic","about":"hi"}'); + ''', + ], + ); + final cacheManager = DriftCacheManager(db); + + final metadata = await cacheManager.loadMetadata(alice); + expect(metadata, isNotNull); + expect(metadata!.name, 'alice'); + expect(metadata.displayName, 'Alice'); + expect(metadata.picture, 'https://pic'); + expect(metadata.about, 'hi'); + expect(metadata.updatedAt, 1700000000); + + await db.close(); + }); + + test( + 'v5 -> v6 migrates legacy metadata without raw content from columns', + () async { + final db = openV5Database( + metadataInserts: [ + ''' + INSERT INTO metadatas + (pub_key, name, nip05, updated_at, sources_json, tags_json) + VALUES + ('$bob', 'bob', 'bob@example.com', 1700000001, '[]', '[]'); + ''', + ], + ); + final cacheManager = DriftCacheManager(db); + + final metadata = await cacheManager.loadMetadata(bob); + expect(metadata, isNotNull); + expect(metadata!.name, 'bob'); + expect(metadata.nip05, 'bob@example.com'); + + await db.close(); + }, + ); + + test('v5 -> v6 migrates legacy contact lists into events', () async { + final db = openV5Database( + contactListInserts: [ + ''' + INSERT INTO contact_lists + (pub_key, contacts_json, contact_relays_json, petnames_json, + followed_tags_json, followed_communities_json, followed_events_json, + created_at, sources_json) + VALUES + ('$alice', '["$bob"]', '["wss://relay1"]', '["bobby"]', + '["nostr"]', '[]', '[]', 1700000000, '["wss://relay1"]'); + ''', + ], + ); + final cacheManager = DriftCacheManager(db); + + final contactList = await cacheManager.loadContactList(alice); + expect(contactList, isNotNull); + expect(contactList!.contacts, [bob]); + expect(contactList.contactRelays, ['wss://relay1']); + expect(contactList.petnames, ['bobby']); + expect(contactList.followedTags, ['nostr']); + expect(contactList.createdAt, 1700000000); + + await db.close(); + }); + + test('v5 -> v6 drops the legacy tables after copying', () async { + final db = openV5Database(); + // Force the migration to run before inspecting sqlite_master. + await db.customSelect('SELECT 1').get(); + + final legacyTables = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name IN ('metadatas', 'contact_lists')", + ) + .get(); + expect(legacyTables, isEmpty); + + await db.close(); + }); + + test('newer signed event wins over a projected legacy row', () async { + final db = openV5Database( + metadataInserts: [ + ''' + INSERT INTO metadatas + (pub_key, name, updated_at, sources_json, tags_json, raw_content_json) + VALUES + ('$alice', 'old-name', 1700000000, '[]', '[]', '{"name":"old-name"}'); + ''', + ], + ); + final cacheManager = DriftCacheManager(db); + + await cacheManager.saveEvent( + Nip01Event( + pubKey: alice, + kind: Metadata.kKind, + tags: [], + content: '{"name":"new-name"}', + createdAt: 1700000500, + ), + ); + + final metadata = await cacheManager.loadMetadata(alice); + expect(metadata!.name, 'new-name'); + + await db.close(); + }); + + test('legacy rows without a usable timestamp are skipped', () async { + final db = openV5Database( + metadataInserts: [ + ''' + INSERT INTO metadatas (pub_key, name, sources_json, tags_json) + VALUES ('$alice', 'no-timestamp', '[]', '[]'); + ''', + ], + ); + final cacheManager = DriftCacheManager(db); + + expect(await cacheManager.loadMetadata(alice), isNull); + + await db.close(); + }); +} diff --git a/packages/drift/test/drift_cache_manager_test.dart b/packages/drift/test/drift_cache_manager_test.dart index f9acad47b..fd5676527 100644 --- a/packages/drift/test/drift_cache_manager_test.dart +++ b/packages/drift/test/drift_cache_manager_test.dart @@ -1,8 +1,55 @@ import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/domain_layer/entities/event_cache_records.dart'; import 'package:ndk_drift/ndk_drift.dart'; import 'package:ndk_cache_manager_test_suite/ndk_cache_manager_test_suite.dart'; void main() { + test('persists full event delivery record state', () async { + final db = NdkCacheDatabase.forTesting(NativeDatabase.memory()); + final cacheManager = DriftCacheManager(db); + + final original = EventDeliveryRecord( + eventId: 'event-1', + status: EventDeliveryStatus.needsAction, + signingState: EventSigningState.transientFailure, + createdAt: 1700000000, + updatedAt: 1700000100, + serializedEventJson: '{"id":"event-1"}', + signedAt: 1700000050, + completedAt: 1700000200, + requiresInteractiveSigning: true, + signAttemptCount: 3, + lastSignAttemptAt: 1700000090, + nextSignRetryAt: 1700000400, + lastSignError: 'timed out waiting for signer', + ); + + await cacheManager.saveEventDeliveryRecord(original); + final restored = await cacheManager.loadEventDeliveryRecord( + original.eventId, + ); + + expect(restored, isNotNull); + expect(restored!.status, original.status); + expect(restored.signingState, original.signingState); + expect(restored.createdAt, original.createdAt); + expect(restored.updatedAt, original.updatedAt); + expect(restored.serializedEventJson, original.serializedEventJson); + expect(restored.signedAt, original.signedAt); + expect(restored.completedAt, original.completedAt); + expect( + restored.requiresInteractiveSigning, + original.requiresInteractiveSigning, + ); + expect(restored.signAttemptCount, original.signAttemptCount); + expect(restored.lastSignAttemptAt, original.lastSignAttemptAt); + expect(restored.nextSignRetryAt, original.nextSignRetryAt); + expect(restored.lastSignError, original.lastSignError); + + await cacheManager.close(); + }); + runCacheManagerTestSuite( name: 'DriftCacheManager', createCacheManager: () async { diff --git a/packages/ndk/README.md b/packages/ndk/README.md index b8a5bd1b0..91ee3da44 100644 --- a/packages/ndk/README.md +++ b/packages/ndk/README.md @@ -11,16 +11,8 @@ Our Target is to make it easy to build constrained Nostr clients, particularly f ## Apps using NDK -- [sample app](https://dart-nostr.com/app/) -- [yana](https://github.com/frnandu/yana) -- [camelus](https://github.com/leo-lox/camelus) -- [zap.stream](https://github.com/nostrlabs-io/zap-stream-flutter) -- [zapstore](https://github.com/zapstore/zapstore) -- [freeflow](https://github.com/nostrlabs-io/freeflow) -- [hostr](https://github.com/sudonym-btc/hostr) -- [bitblik](https://github.com/bit-blik) -- [donow](https://github.com/nogringo/donow) -- [submarine](https://github.com/nogringo/submarine) +- [sample app](https://dart-nostr.com/app/), [yana](https://github.com/frnandu/yana), [camelus](https://github.com/leo-lox/camelus), [zap.stream](https://github.com/nostrlabs-io/zap-stream-flutter), [zapstore](https://github.com/zapstore/zapstore), [freeflow](https://github.com/nostrlabs-io/freeflow), [hostr](https://github.com/sudonym-btc/hostr), [bitblik](https://github.com/bit-blik), [donow](https://github.com/nogringo/donow), [submarine](https://github.com/nogringo/submarine), [nostr-mail-client](https://github.com/nogringo/nostr-mail-client) + # ➡️ [Getting Started 🔗](https://dart-nostr.com/guides/getting-started/) @@ -28,6 +20,34 @@ Our Target is to make it easy to build constrained Nostr clients, particularly f --- +## CLI + +NDK ships a CLI tool. Install it on Linux / macOS with a single command: + +```bash +curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash +``` + +By default this installs the `ndk` binary to `~/.local/bin/ndk` (user mode). + +```bash +# system-wide install (/usr/bin/ndk), may require sudo +curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash -s -- --system + +# pin a specific version +NDK_VERSION=v0.8.3 curl -fsSL https://raw.githubusercontent.com/relaystr/ndk/refs/heads/master/install.sh | bash +``` + +If `~/.local/bin` is not in your `PATH`, add it to your shell profile: + +```bash +export PATH="$PATH:$HOME/.local/bin" +``` + +Verify the installation with `ndk --version`. See [releases](https://github.com/relaystr/ndk/releases) for available versions. + +--- + ## Core Features ### Network & Data Management @@ -40,7 +60,7 @@ Our Target is to make it easy to build constrained Nostr clients, particularly f ### Account & Authentication -- **Multiple signer support**: Built-in (BIP-340), Amber, NIP-07 (web), and NIP-46 (remote signing/bunkers) +- **Multiple signer support**: Built-in (BIP-340), NIP-55 external signers, NIP-07 (web), and NIP-46 (remote signing/bunkers) - **Account management** with state tracking and multiple account support - **Relay authentication** (NIP-42) for private relay access diff --git a/packages/ndk/bin/ndk.dart b/packages/ndk/bin/ndk.dart index 98b21188d..0350c3598 100644 --- a/packages/ndk/bin/ndk.dart +++ b/packages/ndk/bin/ndk.dart @@ -1,16 +1,26 @@ import 'dart:io'; +import 'package:ndk/src/cli/accounts/accounts_cli_command.dart'; +import 'package:ndk/src/cli/blossom/blossom_cli_command.dart'; +import 'package:ndk/src/cli/broadcast/broadcast_cli_command.dart'; +import 'package:ndk/src/cli/files/files_cli_command.dart'; import 'package:ndk/src/cli/ndk_cli_app.dart'; -import 'package:ndk/src/cli/wallets/wallets_cli_command.dart'; import 'package:ndk/src/cli/req_cli_command.dart'; +import 'package:ndk/src/cli/wallets/wallets_cli_command.dart'; +import 'package:ndk/src/cli/zaps/zaps_cli_command.dart'; Future main(List args) async { final app = NdkCliApp( appName: 'ndk', description: 'Nostr Development Kit command line interface', commands: [ - WalletsCliCommand(), ReqCliCommand(), + BroadcastCliCommand(), + AccountsCliCommand(), + WalletsCliCommand(), + ZapsCliCommand(), + FilesCliCommand(), + BlossomCliCommand(), ], ); diff --git a/packages/ndk/example/account_test.dart b/packages/ndk/example/account_test.dart index 0756c221b..05827b70d 100644 --- a/packages/ndk/example/account_test.dart +++ b/packages/ndk/example/account_test.dart @@ -7,36 +7,36 @@ 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(); + 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(); + // generate a new key + KeyPair key1 = Bip340.generatePrivateKey(); - // login using private key - ndk.accounts - .loginPrivateKey(privkey: key1.privateKey!, pubkey: key1.publicKey); + // 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; + // 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(); + // logout + ndk.accounts.logout(); - // destroy ndk instance - ndk.destroy(); - }, - skip: true, - ); + // destroy ndk instance + ndk.destroy(); + }, skip: true); } diff --git a/packages/ndk/example/accounts_example.dart b/packages/ndk/example/accounts_example.dart index e65a8880a..cc800f15c 100644 --- a/packages/ndk/example/accounts_example.dart +++ b/packages/ndk/example/accounts_example.dart @@ -22,17 +22,21 @@ void main() async { KeyPair key1 = Bip340.generatePrivateKey(); // login using private key - ndk.accounts - .loginPrivateKey(privkey: key1.privateKey!, pubkey: key1.publicKey); + 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); + nostrEvent: Nip01Event( + pubKey: key1.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test", + ), + specificRelays: DEFAULT_BOOTSTRAP_RELAYS, + ); await response.broadcastDoneFuture; // generate a new key diff --git a/packages/ndk/example/basic_test.dart b/packages/ndk/example/basic_test.dart index 02cf2d466..45bd6645f 100644 --- a/packages/ndk/example/basic_test.dart +++ b/packages/ndk/example/basic_test.dart @@ -26,7 +26,7 @@ void main() async { Filter( // Query for fiatjaf npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 authors: [ - '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', ], // Query for text note events (kind 1) kinds: [Nip01Event.kTextNodeKind], @@ -45,7 +45,10 @@ void main() async { eventCount++; } - expect(eventCount, greaterThan(0), - reason: 'No events were emitted from the stream'); + expect( + eventCount, + greaterThan(0), + reason: 'No events were emitted from the stream', + ); }); } diff --git a/packages/ndk/example/bunkers_example.dart b/packages/ndk/example/bunkers_example.dart index 42dbd9e3d..39153bfd7 100644 --- a/packages/ndk/example/bunkers_example.dart +++ b/packages/ndk/example/bunkers_example.dart @@ -7,9 +7,10 @@ Future main() async { try { await ndk.accounts.loginWithBunkerUrl( - bunkerUrl: - "bunker://a1fe3664f7a2b24db97e5b63869e8011c947f9abd8c03f98befafd27c38467d2?relay=wss://relay.nsec.app&secret=devsecret123", - bunkers: ndk.bunkers); + bunkerUrl: + "bunker://a1fe3664f7a2b24db97e5b63869e8011c947f9abd8c03f98befafd27c38467d2?relay=wss://relay.nsec.app&secret=devsecret123", + bunkers: ndk.bunkers, + ); log('Successfully logged in with bunker!'); log('Logged in as: ${ndk.accounts.getPublicKey()}'); diff --git a/packages/ndk/example/contact_list_test.dart b/packages/ndk/example/contact_list_test.dart index 15f5d90ef..5b3a097e5 100644 --- a/packages/ndk/example/contact_list_test.dart +++ b/packages/ndk/example/contact_list_test.dart @@ -21,14 +21,18 @@ void main() async { // Use a prebuilt ndk usecase (contact list in this case) final response = await ndk.follows.getContactList( - '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'); + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', + ); // read entity print("CONTACTS:"); print(response?.contacts.length ?? "no contacts"); expect(response, isNotNull, reason: 'response is Null'); - expect(response!.contacts.length, greaterThan(0), - reason: 'contact list is empty'); + expect( + response!.contacts.length, + greaterThan(0), + reason: 'contact list is empty', + ); }); } diff --git a/packages/ndk/example/files/files_example_test.dart b/packages/ndk/example/files/files_example_test.dart index 9107db4df..c841032e1 100644 --- a/packages/ndk/example/files/files_example_test.dart +++ b/packages/ndk/example/files/files_example_test.dart @@ -8,11 +8,13 @@ void main() async { final ndk = Ndk.defaultConfig(); final downloadResult = await ndk.files.download( - url: - "https://cdn.hzrd149.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"); + url: + "https://cdn.hzrd149.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf", + ); print( - "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); + "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}", + ); expect(downloadResult.data.length, greaterThan(0)); }, skip: true); @@ -20,11 +22,13 @@ void main() async { test('download test - non blossom', () async { final ndk = Ndk.defaultConfig(); - final downloadResult = await ndk.files - .download(url: "https://camelus.app/.well-known/nostr.json"); + final downloadResult = await ndk.files.download( + url: "https://camelus.app/.well-known/nostr.json", + ); print( - "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); + "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}", + ); expect(downloadResult.data.length, greaterThan(0)); }, skip: true); diff --git a/packages/ndk/example/metadata_test.dart b/packages/ndk/example/metadata_test.dart index 410dad7cc..24cded3ad 100644 --- a/packages/ndk/example/metadata_test.dart +++ b/packages/ndk/example/metadata_test.dart @@ -21,7 +21,8 @@ void main() async { // Use a prebuilt ndk usecase (metadata in this case) final Metadata? response = await ndk.metadata.loadMetadata( - '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'); + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', + ); // read entity print("METADATA:"); @@ -32,5 +33,5 @@ void main() async { expect(response, isNotNull, reason: 'response is Null'); expect(response!.nip05, isNotEmpty, reason: 'nip05 is empty'); ndk.destroy(); - }); + }, skip: true); } diff --git a/packages/ndk/example/nip13_pow_example.dart b/packages/ndk/example/nip13_pow_example.dart index f8f403157..863a28b8e 100644 --- a/packages/ndk/example/nip13_pow_example.dart +++ b/packages/ndk/example/nip13_pow_example.dart @@ -17,8 +17,10 @@ void main() async { final ndk = Ndk.emptyBootstrapRelaysConfig(); /// pass your event to the proof of work usecase - final minedEvent = - await ndk.proofOfWork.minePoW(event: event, targetDifficulty: 10); + final minedEvent = await ndk.proofOfWork.minePoW( + event: event, + targetDifficulty: 10, + ); /// the id will start with "000" log(minedEvent.id); diff --git a/packages/ndk/example/nostrconnect_example.dart b/packages/ndk/example/nostrconnect_example.dart index d751b3eb1..10f1623f1 100644 --- a/packages/ndk/example/nostrconnect_example.dart +++ b/packages/ndk/example/nostrconnect_example.dart @@ -6,16 +6,19 @@ Future main() async { final ndk = Ndk.emptyBootstrapRelaysConfig(); final nostrConnect = NostrConnect( - relays: ["wss://relay.nsec.app"], - appName: "NDK nostr connect example", - appUrl: "https://dart-nostr.com/"); + relays: ["wss://relay.nsec.app"], + appName: "NDK nostr connect example", + appUrl: "https://dart-nostr.com/", + ); log('Logging in with ${nostrConnect.nostrConnectURL}'); log('Enter this URI into your Nostr Connect client to log in.'); try { await ndk.accounts.loginWithNostrConnect( - nostrConnect: nostrConnect, bunkers: ndk.bunkers); + nostrConnect: nostrConnect, + bunkers: ndk.bunkers, + ); log('Successfully logged in with bunker!'); log('Logged in as: ${ndk.accounts.getPublicKey()}'); diff --git a/packages/ndk/example/nwc/connect_get_info.dart b/packages/ndk/example/nwc/connect_get_info.dart index 8bbda6c20..1844e2daa 100644 --- a/packages/ndk/example/nwc/connect_get_info.dart +++ b/packages/ndk/example/nwc/connect_get_info.dart @@ -10,8 +10,7 @@ void main() async { // You need an NWC_URI env var or to replace with your NWC uri connection final nwcUri = Platform.environment['NWC_URI']!; - final connection = await ndk.nwc - .connect(nwcUri, doGetInfoMethod: true, timeout: Duration(seconds: 10)); + final connection = await ndk.nwc.connect(nwcUri, doGetInfoMethod: true); print("Connected, permissions: ${connection.permissions}"); diff --git a/packages/ndk/example/nwc/get_budget.dart b/packages/ndk/example/nwc/get_budget.dart index 500f2e662..6fd1eb910 100644 --- a/packages/ndk/example/nwc/get_budget.dart +++ b/packages/ndk/example/nwc/get_budget.dart @@ -2,7 +2,6 @@ import 'dart:io'; -import 'package:ndk/domain_layer/usecases/nwc/responses/get_budget_response.dart'; import 'package:ndk/ndk.dart'; void main() async { @@ -21,8 +20,9 @@ void main() async { print("Used budget: ${response.userBudgetSats} sats"); print("Total budget: ${response.totalBudgetSats} sats"); if (response.renewsAt != null) { - final renewsAtDate = - DateTime.fromMillisecondsSinceEpoch(response.renewsAt! * 1000); + final renewsAtDate = DateTime.fromMillisecondsSinceEpoch( + response.renewsAt! * 1000, + ); final formattedDate = "${renewsAtDate.year}-${renewsAtDate.month.toString().padLeft(2, '0')}-${renewsAtDate.day.toString().padLeft(2, '0')} " "${renewsAtDate.hour.toString().padLeft(2, '0')}:${renewsAtDate.minute.toString().padLeft(2, '0')}:${renewsAtDate.second.toString().padLeft(2, '0')}"; diff --git a/packages/ndk/example/nwc/list_transactions.dart b/packages/ndk/example/nwc/list_transactions.dart index 8c0bfc0f0..cb9bd24d7 100644 --- a/packages/ndk/example/nwc/list_transactions.dart +++ b/packages/ndk/example/nwc/list_transactions.dart @@ -13,12 +13,15 @@ void main() async { final nwcUri = Platform.environment['NWC_URI']!; final connection = await ndk.nwc.connect(nwcUri); - ListTransactionsResponse response = - await ndk.nwc.listTransactions(connection, unpaid: true); + ListTransactionsResponse response = await ndk.nwc.listTransactions( + connection, + unpaid: true, + ); for (final transaction in response.transactions) { print( - "Transaction ${transaction.type} state ${transaction.state} ${transaction.amountSat} sats ${transaction.description!}"); + "Transaction ${transaction.type} state ${transaction.state} ${transaction.amountSat} sats ${transaction.description!}", + ); } await ndk.destroy(); } diff --git a/packages/ndk/example/nwc/lookup_invoice.dart b/packages/ndk/example/nwc/lookup_invoice.dart index cad82b03a..ff5e96f19 100644 --- a/packages/ndk/example/nwc/lookup_invoice.dart +++ b/packages/ndk/example/nwc/lookup_invoice.dart @@ -16,8 +16,10 @@ void main() async { // use an INVOICE env var or replace with your bolt11 invoice final hash = Platform.environment['HASH']!; - final invoiceResponse = - await ndk.nwc.lookupInvoice(connection, paymentHash: hash); + final invoiceResponse = await ndk.nwc.lookupInvoice( + connection, + paymentHash: hash, + ); print("invoice response: $invoiceResponse"); diff --git a/packages/ndk/example/nwc/make_hold_invoice.dart b/packages/ndk/example/nwc/make_hold_invoice.dart index f30cac602..0c3b78cf6 100644 --- a/packages/ndk/example/nwc/make_hold_invoice.dart +++ b/packages/ndk/example/nwc/make_hold_invoice.dart @@ -24,10 +24,12 @@ void main() async { // Generate a random 32-byte preimage final random = Random.secure(); - final preimageBytes = - Uint8List.fromList(List.generate(32, (_) => random.nextInt(256))); - final preimageHex = - hex.encode(preimageBytes); // Optional: hex encode for printing + final preimageBytes = Uint8List.fromList( + List.generate(32, (_) => random.nextInt(256)), + ); + final preimageHex = hex.encode( + preimageBytes, + ); // Optional: hex encode for printing // Calculate the payment hash (SHA256 of the preimage) final paymentHashBytes = sha256.convert(preimageBytes).bytes; @@ -39,14 +41,19 @@ void main() async { try { // 1. Create the hold invoice print("Creating hold invoice..."); - final makeResponse = await ndk.nwc.makeHoldInvoice(connection, - amountSats: amount, description: description, paymentHash: paymentHash); + final makeResponse = await ndk.nwc.makeHoldInvoice( + connection, + amountSats: amount, + description: description, + paymentHash: paymentHash, + ); if (makeResponse.errorCode == null) { // Check if errorCode is null for success final invoice = makeResponse.invoice; print( - "Hold invoice created successfully. Invoice: $invoice Payment Hash: ${makeResponse.paymentHash}"); + "Hold invoice created successfully. Invoice: $invoice Payment Hash: ${makeResponse.paymentHash}", + ); // if (invoice.isNotEmpty) { // print("\nScan QR Code to pay/hold:"); @@ -61,19 +68,23 @@ void main() async { // print("\nOr copy Bolt11 invoice:\n$invoice\n"); // } - final duration = makeResponse.expiresAt! - + final duration = + makeResponse.expiresAt! - DateTime.now().millisecondsSinceEpoch ~/ 1000; print( - "Waiting for hold invoice acceptance notification (max $duration seconds)..."); + "Waiting for hold invoice acceptance notification (max $duration seconds)...", + ); try { - final acceptedNotification = - await connection.holdInvoiceStateStream.firstWhere((notification) { - return notification.notificationType == - NwcNotification.kHoldInvoiceAccepted; - }).timeout(Duration(seconds: duration.toInt())); + final acceptedNotification = await connection.holdInvoiceStateStream + .firstWhere((notification) { + return notification.notificationType == + NwcNotification.kHoldInvoiceAccepted; + }) + .timeout(Duration(seconds: duration.toInt())); print( - "Hold invoice accepted by wallet! (Notification: ${acceptedNotification.notificationType}, Settle deadline: ${acceptedNotification.settleDeadline})"); + "Hold invoice accepted by wallet! (Notification: ${acceptedNotification.notificationType}, Settle deadline: ${acceptedNotification.settleDeadline})", + ); // 3. Ask user whether to settle or cancel print("Settle this accepted invoice? (Y/N)"); @@ -82,42 +93,51 @@ void main() async { if (input == 'n') { // 4a. Cancel the hold invoice print("Canceling hold invoice with payment hash: $paymentHash..."); - final cancelResponse = await ndk.nwc - .cancelHoldInvoice(connection, paymentHash: paymentHash); + final cancelResponse = await ndk.nwc.cancelHoldInvoice( + connection, + paymentHash: paymentHash, + ); if (cancelResponse.errorCode == null) { // Check if errorCode is null for success print("Hold invoice canceled successfully."); } else { print( - "Failed to cancel hold invoice. Error: ${cancelResponse.errorMessage} (Code: ${cancelResponse.errorCode})"); + "Failed to cancel hold invoice. Error: ${cancelResponse.errorMessage} (Code: ${cancelResponse.errorCode})", + ); } } else if (input == 'y') { // 4b. Settle the hold invoice using the preimage print("Settling hold invoice with preimage: $preimageHex..."); - final settleResponse = await ndk.nwc - .settleHoldInvoice(connection, preimage: preimageHex); + final settleResponse = await ndk.nwc.settleHoldInvoice( + connection, + preimage: preimageHex, + ); if (settleResponse.errorCode == null) { print( - "Hold invoice settled successfully. Preimage used: $preimageHex"); + "Hold invoice settled successfully. Preimage used: $preimageHex", + ); } else { print( - "Failed to settle hold invoice. Error: ${settleResponse.errorMessage} (Code: ${settleResponse.errorCode})"); + "Failed to settle hold invoice. Error: ${settleResponse.errorMessage} (Code: ${settleResponse.errorCode})", + ); } } else { print("Invalid input. Not settling or canceling."); } } on TimeoutException { print( - "Timed out waiting for hold invoice acceptance notification. The invoice might not be held by the wallet."); + "Timed out waiting for hold invoice acceptance notification. The invoice might not be held by the wallet.", + ); // Optionally, try to cancel here as a fallback? } catch (e) { print("Error waiting for notification: $e"); } } else { print( - "Failed to create hold invoice. Error: ${makeResponse.errorMessage} (Code: ${makeResponse.errorCode})"); + "Failed to create hold invoice. Error: ${makeResponse.errorMessage} (Code: ${makeResponse.errorCode})", + ); } } catch (e) { print("An error occurred: $e"); diff --git a/packages/ndk/example/nwc/make_invoice.dart b/packages/ndk/example/nwc/make_invoice.dart index dccf02277..00375e0cf 100644 --- a/packages/ndk/example/nwc/make_invoice.dart +++ b/packages/ndk/example/nwc/make_invoice.dart @@ -15,8 +15,11 @@ void main() async { final amount = 21; final description = "hello"; - final response = await ndk.nwc - .makeInvoice(connection, amountSats: amount, description: description); + final response = await ndk.nwc.makeInvoice( + connection, + amountSats: amount, + description: description, + ); print("invoice: ${response.invoice}"); diff --git a/packages/ndk/example/nwc/notifications.dart b/packages/ndk/example/nwc/notifications.dart index d4ee844f5..2904d527d 100644 --- a/packages/ndk/example/nwc/notifications.dart +++ b/packages/ndk/example/nwc/notifications.dart @@ -14,7 +14,8 @@ void main() async { final connection = await ndk.nwc.connect(nwcUri); print( - "waiting for ${connection.isLegacyNotifications() ? "legacy " : ""}notifications"); + "waiting for ${connection.isLegacyNotifications() ? "legacy " : ""}notifications", + ); await for (final notification in connection.notificationStream.stream) { print('notification ${notification.type} amount: ${notification.amount}'); } diff --git a/packages/ndk/example/sembast/sembast_cache_manager_example.dart b/packages/ndk/example/sembast/sembast_cache_manager_example.dart index 9df05768b..5c918cd2b 100644 --- a/packages/ndk/example/sembast/sembast_cache_manager_example.dart +++ b/packages/ndk/example/sembast/sembast_cache_manager_example.dart @@ -1,5 +1,6 @@ +// ignore_for_file: avoid_print import 'dart:io'; -import 'package:ndk/data_layer/repositories/cache_manager/sembast_cache_manager.dart'; + import 'package:ndk/domain_layer/entities/nip_05.dart'; import 'package:ndk/ndk.dart'; import 'package:sembast/sembast_io.dart'; @@ -8,8 +9,9 @@ Future main() async { print('🚀 SembastCacheManager Example\n'); // 1. Create a temporary database - final tempDir = - await Directory.systemTemp.createTemp('sembast_cache_example_'); + final tempDir = await Directory.systemTemp.createTemp( + 'sembast_cache_example_', + ); final dbPath = '${tempDir.path}/cache.db'; print('📁 Database path: $dbPath'); @@ -32,7 +34,7 @@ Future main() async { tags: [ ['p', 'npub1other123456789'], ['t', 'nostr'], - ['t', 'bitcoin'] + ['t', 'bitcoin'], ], content: 'Hello Nostr! This is my first cached event using SembastCacheManager 🎉', @@ -94,16 +96,20 @@ Future main() async { final loadedMetadata = await cacheManager.loadMetadata(metadata.pubKey); print( - '✅ Metadata loaded: ${loadedMetadata?.displayName} (@${loadedMetadata?.name})'); + '✅ Metadata loaded: ${loadedMetadata?.displayName} (@${loadedMetadata?.name})', + ); - final loadedContactList = - await cacheManager.loadContactList(contactList.pubKey); + final loadedContactList = await cacheManager.loadContactList( + contactList.pubKey, + ); print( - '✅ Contact list loaded: ${loadedContactList?.contacts.length} contacts'); + '✅ Contact list loaded: ${loadedContactList?.contacts.length} contacts', + ); final loadedNip05 = await cacheManager.loadNip05(pubKey: nip05.pubKey); print( - '✅ NIP-05 loaded: ${loadedNip05?.nip05} (valid: ${loadedNip05?.valid})\n'); + '✅ NIP-05 loaded: ${loadedNip05?.nip05} (valid: ${loadedNip05?.valid})\n', + ); // 7. Demonstrate search functionality print('🔍 Demonstrating search functionality...'); @@ -113,9 +119,11 @@ Future main() async { print('✅ Found ${eventsByContent.length} events containing "Nostr"'); // Search events by tags - final eventsByTag = await cacheManager.searchEvents(tags: { - 't': ['bitcoin'] - }); + final eventsByTag = await cacheManager.searchEvents( + tags: { + 't': ['bitcoin'], + }, + ); print('✅ Found ${eventsByTag.length} events with #bitcoin tag'); // Search metadata @@ -130,7 +138,7 @@ Future main() async { pubKey: 'npub1user222333444', kind: 1, tags: [ - ['t', 'nostr'] + ['t', 'nostr'], ], content: 'Another event for batch demo', ), @@ -138,7 +146,7 @@ Future main() async { pubKey: 'npub1user555666777', kind: 1, tags: [ - ['t', 'bitcoin'] + ['t', 'bitcoin'], ], content: 'Yet another event for batch demo', ), @@ -159,7 +167,8 @@ Future main() async { print('🎉 Example completed successfully!'); print( - '💡 The cache persists data between runs - try running this example again!'); + '💡 The cache persists data between runs - try running this example again!', + ); } catch (error) { print('❌ Error: $error'); } finally { diff --git a/packages/ndk/example/user_relay_list_test.dart b/packages/ndk/example/user_relay_list_test.dart index cd4acf2c0..98fac1a42 100644 --- a/packages/ndk/example/user_relay_list_test.dart +++ b/packages/ndk/example/user_relay_list_test.dart @@ -21,7 +21,8 @@ void main() async { // Use a prebuilt ndk usecase (userRelayLists in this case) final response = await ndk.userRelayLists.getSingleUserRelayList( - '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177'); + '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177', + ); // read entity print("RELAYS:"); @@ -29,7 +30,10 @@ void main() async { print(response!.toNip65()); expect(response, isNotNull, reason: 'response is Null'); - expect(response.relays.length, greaterThan(0), - reason: 'relay list is empty'); + expect( + response.relays.length, + greaterThan(0), + reason: 'relay list is empty', + ); }); } diff --git a/packages/ndk/example/wallets/add_cashu_wallet.dart b/packages/ndk/example/wallets/add_cashu_wallet.dart index 67cd84525..6b1308a69 100644 --- a/packages/ndk/example/wallets/add_cashu_wallet.dart +++ b/packages/ndk/example/wallets/add_cashu_wallet.dart @@ -30,12 +30,10 @@ Future main() async { id: 'wallet_${DateTime.now().microsecondsSinceEpoch}', name: walletName, type: WalletType.CASHU, - supportedUnits: - mintInfo.supportedUnits.isEmpty ? {'sat'} : mintInfo.supportedUnits, - metadata: { - 'mintUrl': mintUrl, - 'mintInfo': mintInfo.toJson(), - }, + supportedUnits: mintInfo.supportedUnits.isEmpty + ? {'sat'} + : mintInfo.supportedUnits, + metadata: {'mintUrl': mintUrl, 'mintInfo': mintInfo.toJson()}, ); await ndk.wallets.addWallet(wallet); diff --git a/packages/ndk/example/wallets/send.dart b/packages/ndk/example/wallets/send.dart index 33cf05e14..519555513 100644 --- a/packages/ndk/example/wallets/send.dart +++ b/packages/ndk/example/wallets/send.dart @@ -29,10 +29,7 @@ Future main() async { final walletId = Platform.environment['WALLET_ID'] ?? wallets.first.id; - final result = await ndk.wallets.send( - walletId: walletId, - invoice: invoice, - ); + final result = await ndk.wallets.send(walletId: walletId, invoice: invoice); print('Payment result:'); print('- preimage: ${result.preimage}'); diff --git a/packages/ndk/example/zaps/receipts_for_event.dart b/packages/ndk/example/zaps/receipts_for_event.dart index 497fbbda5..e45b010ce 100644 --- a/packages/ndk/example/zaps/receipts_for_event.dart +++ b/packages/ndk/example/zaps/receipts_for_event.dart @@ -8,10 +8,11 @@ void main() async { print("fetching zap receipts for single event "); final receipts = await ndk.zaps .fetchZappedReceipts( - pubKey: - "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", - eventId: - "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a") + pubKey: + "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", + eventId: + "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a", + ) .toList(); // Sort eventReceipts by amountSats in descending order @@ -25,7 +26,8 @@ void main() async { sender = metadata?.name; } print( - "${sender != null ? "from $sender " : ""} ${receipt.amountSats} sats ${receipt.comment}"); + "${sender != null ? "from $sender " : ""} ${receipt.amountSats} sats ${receipt.comment}", + ); eventSum += receipt.amountSats ?? 0; } print("${receipts.length} receipts, total of $eventSum sats"); diff --git a/packages/ndk/example/zaps/receipts_for_profile.dart b/packages/ndk/example/zaps/receipts_for_profile.dart index 0d0ce0a2b..f2def9ac7 100644 --- a/packages/ndk/example/zaps/receipts_for_profile.dart +++ b/packages/ndk/example/zaps/receipts_for_profile.dart @@ -40,7 +40,8 @@ void main() async { } final paidAtFormatted = _formatPaidAt(receipt.paidAt); print( - "$paidAtFormatted ${sender != null ? "from $sender " : ""} ${receipt.amountSats} sats ${receipt.comment}"); + "$paidAtFormatted ${sender != null ? "from $sender " : ""} ${receipt.amountSats} sats ${receipt.comment}", + ); profileSum += receipt.amountSats ?? 0; } print("${profileReceipts.length} receipts, total of $profileSum sats"); diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart index 6abc0c8f6..fc1067083 100644 --- a/packages/ndk/example/zaps/zap.dart +++ b/packages/ndk/example/zaps/zap.dart @@ -20,24 +20,26 @@ void main() async { final comment = "enjoy this zap from NDK"; ZapResponse response = await ndk.zaps.zap( - nwcConnection: connection, - lnurl: lnurl, - comment: comment, - amountSats: amount, - fetchZapReceipt: true, - signer: Bip340EventSigner( - privateKey: key.privateKey, publicKey: key.publicKey), - relays: ["wss://relay.damus.io"], - pubKey: - "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", - eventId: - "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a"); + nwcConnection: connection, + lnurl: lnurl, + comment: comment, + amountSats: amount, + fetchZapReceipt: true, + signer: Bip340EventSigner( + privateKey: key.privateKey, + publicKey: key.publicKey, + ), + relays: ["wss://relay.damus.io"], + pubKey: "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", + eventId: "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a", + ); if (response.payInvoiceResponse != null && response.payInvoiceResponse!.preimage != null && response.payInvoiceResponse!.preimage!.isNotEmpty) { print( - "Payed $amount to $lnurl, preimage = ${response.payInvoiceResponse!.preimage}"); + "Payed $amount to $lnurl, preimage = ${response.payInvoiceResponse!.preimage}", + ); print("Waiting for Zap Receipt..."); ZapReceipt? receipt = await response.zapReceipt; 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 ab1b710ec..2ac6a90f7 100644 --- a/packages/ndk/lib/data_layer/data_sources/http_request.dart +++ b/packages/ndk/lib/data_layer/data_sources/http_request.dart @@ -51,8 +51,9 @@ class HttpRequestDS { /// make a get request to the given url Future> jsonRequest(String url) async { http.Response response = await _client.get( - Uri.parse(url).replace(scheme: 'https'), - headers: {"Accept": "application/json"}); + Uri.parse(url).replace(scheme: 'https'), + headers: {"Accept": "application/json"}, + ); if (!_isSuccessStatus(response.statusCode)) { throw HttpRequestException( @@ -77,7 +78,8 @@ class HttpRequestDS { if (!_isSuccessStatus(response.statusCode)) { throw Exception( - "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url", + ); } return response; @@ -115,10 +117,9 @@ class HttpRequestDS { final progressStream = body.map((chunk) { bytesSent += chunk.length; - progressSubject.add(UploadProgress( - sentBytes: bytesSent, - totalBytes: totalBytes, - )); + progressSubject.add( + UploadProgress(sentBytes: bytesSent, totalBytes: totalBytes), + ); return chunk; }); @@ -139,19 +140,22 @@ class HttpRequestDS { if (!_isSuccessStatus(response.statusCode)) { final error = Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, Link: $url", + ); progressSubject.addError(error); await progressSubject.close(); return; } // Upload complete - progressSubject.add(UploadProgress( - sentBytes: totalBytes, - totalBytes: totalBytes, - isComplete: true, - response: response, - )); + progressSubject.add( + UploadProgress( + sentBytes: totalBytes, + totalBytes: totalBytes, + isComplete: true, + response: response, + ), + ); await progressSubject.close(); } catch (error) { progressSubject.addError(error); @@ -177,41 +181,32 @@ class HttpRequestDS { if (!_isSuccessStatus(response.statusCode)) { throw Exception( - "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url, "); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url, ", + ); } return response; } - Future head({ - required Uri url, - headers, - }) async { - http.Response response = await _client.head( - url, - headers: headers, - ); + Future head({required Uri url, headers}) async { + http.Response response = await _client.head(url, headers: headers); if (!_isSuccessStatus(response.statusCode)) { throw Exception( - "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url", + ); } return response; } - Future get({ - required Uri url, - headers, - }) async { - http.Response response = await _client.get( - url, - headers: headers, - ); + Future get({required Uri url, headers}) async { + http.Response response = await _client.get(url, headers: headers); if (!_isSuccessStatus(response.statusCode)) { throw Exception( - "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url", + ); } return response; @@ -232,24 +227,20 @@ class HttpRequestDS { if (!_isSuccessStatus(streamedResponse.statusCode)) { throw Exception( - "error fetching STATUS: ${streamedResponse.statusCode}, Link: $url"); + "error fetching STATUS: ${streamedResponse.statusCode}, Link: $url", + ); } yield* streamedResponse.stream; } - Future delete({ - required Uri url, - required headers, - }) async { - http.Response response = await _client.delete( - url, - headers: headers, - ); + Future delete({required Uri url, required headers}) async { + http.Response response = await _client.delete(url, headers: headers); if (!_isSuccessStatus(response.statusCode)) { throw Exception( - "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url", + ); } return response; diff --git a/packages/ndk/lib/data_layer/data_sources/websocket.dart b/packages/ndk/lib/data_layer/data_sources/websocket.dart index 21cd765c0..4496a4faf 100644 --- a/packages/ndk/lib/data_layer/data_sources/websocket.dart +++ b/packages/ndk/lib/data_layer/data_sources/websocket.dart @@ -18,8 +18,11 @@ class WebsocketDS { Function? onError, void Function()? onDone, }) { - return webSocketChannel.stream - .listen(onData, onDone: onDone, onError: onError); + return webSocketChannel.stream.listen( + onData, + onDone: onDone, + onError: onError, + ); } /// send data to the websocket channel @@ -52,4 +55,5 @@ class WebsocketDS { return webSocketChannel.closeReason; } } + // coverage:ignore-end diff --git a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart index 25463e07c..d1da2119f 100644 --- a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart +++ b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart @@ -45,4 +45,5 @@ class WebsocketDSClient { return state is Disconnected ? state.reason : null; } } + // coverage:ignore-end diff --git a/packages/ndk/lib/data_layer/io/file_io_native.dart b/packages/ndk/lib/data_layer/io/file_io_native.dart index 9af7b4d36..2401ea319 100644 --- a/packages/ndk/lib/data_layer/io/file_io_native.dart +++ b/packages/ndk/lib/data_layer/io/file_io_native.dart @@ -25,7 +25,9 @@ class FileIONative implements FileIO { @override Future writeFileStream( - String filePath, Stream dataStream) async { + String filePath, + Stream dataStream, + ) async { final file = File(filePath); await file.create(recursive: true); final sink = file.openWrite(); @@ -57,10 +59,21 @@ class FileIONative implements FileIO { } @override - Stream computeFileHash(String filePath) { - return IsolateManager.instance + Stream computeFileHash(String filePath) async* { + final file = File(filePath); + if (!await file.exists()) { + throw PathNotFoundException( + 'Cannot open file', + OSError('No such file or directory', 2), + filePath, + ); + } + + yield* IsolateManager.instance .runInComputeIsolateStream( - _computeFileHashTask, filePath); + _computeFileHashTask, + filePath, + ); } } @@ -75,28 +88,28 @@ Future _computeFileHashTask( final input = sha256.startChunkedConversion(digestSink); int processedBytes = 0; - emit(FileHashProgress( - processedBytes: processedBytes, - totalBytes: totalBytes, - )); + emit( + FileHashProgress(processedBytes: processedBytes, totalBytes: totalBytes), + ); await for (final chunk in file.openRead()) { input.add(chunk); processedBytes += chunk.length; - emit(FileHashProgress( - processedBytes: processedBytes, - totalBytes: totalBytes, - )); + emit( + FileHashProgress(processedBytes: processedBytes, totalBytes: totalBytes), + ); } input.close(); - emit(FileHashProgress( - processedBytes: totalBytes, - totalBytes: totalBytes, - isComplete: true, - hash: digestSink.value?.toString(), - )); + emit( + FileHashProgress( + processedBytes: totalBytes, + totalBytes: totalBytes, + isComplete: true, + hash: digestSink.value?.toString(), + ), + ); } class _DigestSink implements Sink { diff --git a/packages/ndk/lib/data_layer/io/file_io_web.dart b/packages/ndk/lib/data_layer/io/file_io_web.dart index e67b9a54e..7bd795ef0 100644 --- a/packages/ndk/lib/data_layer/io/file_io_web.dart +++ b/packages/ndk/lib/data_layer/io/file_io_web.dart @@ -21,8 +21,10 @@ class FileIOWeb implements FileIO { final Map _fileCache = {}; @override - Stream readFileAsStream(String filePath, - {int chunkSize = 8192}) async* { + Stream readFileAsStream( + String filePath, { + int chunkSize = 8192, + }) async* { final file = await _getFile(filePath); if (file == null) { throw Exception('No file selected or file not found'); @@ -32,8 +34,9 @@ class FileIOWeb implements FileIO { final int totalSize = file.size; while (offset < totalSize) { - final int end = - (offset + chunkSize < totalSize) ? offset + chunkSize : totalSize; + final int end = (offset + chunkSize < totalSize) + ? offset + chunkSize + : totalSize; // Read chunk as blob slice final blob = file.slice(offset, end); @@ -52,7 +55,9 @@ class FileIOWeb implements FileIO { @override Future writeFileStream( - String filePath, Stream dataStream) async { + String filePath, + Stream dataStream, + ) async { // Collect all chunks into a single Uint8List for download final chunks = []; await for (final chunk in dataStream) { @@ -136,10 +141,9 @@ class FileIOWeb implements FileIO { /// Use File System Access API Future> _showOpenFilePicker() async { - final options = { - 'multiple': false, - 'excludeAcceptAllOption': false, - }.jsify() as JSObject; + final options = + {'multiple': false, 'excludeAcceptAllOption': false}.jsify() + as JSObject; final windowObj = window as JSObject; final showOpenFilePicker = windowObj['showOpenFilePicker'] as JSFunction; @@ -174,15 +178,16 @@ class FileIOWeb implements FileIO { final completer = Completer(); input.addEventListener( - 'change', - (Event event) { - final files = input.files; - if (files != null && files.length > 0) { - completer.complete(files.item(0)); - } else { - completer.complete(null); - } - }.toJS); + 'change', + (Event event) { + final files = input.files; + if (files != null && files.length > 0) { + completer.complete(files.item(0)); + } else { + completer.complete(null); + } + }.toJS, + ); return completer.future; } @@ -194,17 +199,19 @@ class FileIOWeb implements FileIO { final completer = Completer(); reader.addEventListener( - 'load', - (Event event) { - final result = reader.result as JSArrayBuffer; - completer.complete(result.toDart.asUint8List()); - }.toJS); + 'load', + (Event event) { + final result = reader.result as JSArrayBuffer; + completer.complete(result.toDart.asUint8List()); + }.toJS, + ); reader.addEventListener( - 'error', - (Event event) { - completer.completeError(Exception('Failed to read blob')); - }.toJS); + 'error', + (Event event) { + completer.completeError(Exception('Failed to read blob')); + }.toJS, + ); return completer.future; } 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 index 50d8f3173..1c5ced35e 100644 --- a/packages/ndk/lib/data_layer/models/cashu/cashu_event_model.dart +++ b/packages/ndk/lib/data_layer/models/cashu/cashu_event_model.dart @@ -13,15 +13,16 @@ class CashuEventModel extends CashuEvent { }); /// creates a nostr event based on the WalletCashuEvent data - Future createNostrEvent({ - required EventSigner signer, - }) async { + Future createNostrEvent({required EventSigner signer}) async { final encryptedContent = await signer.encryptNip44( - plaintext: jsonEncode( - CashuEventContent(privKey: walletPrivkey, mints: mints) - .toCashuEventContent(), - ), - recipientPubKey: userPubkey); + plaintext: jsonEncode( + CashuEventContent( + privKey: walletPrivkey, + mints: mints, + ).toCashuEventContent(), + ), + recipientPubKey: userPubkey, + ); if (encryptedContent == null) { throw Exception("could not encrypt cashu wallet event"); @@ -51,8 +52,9 @@ class CashuEventModel extends CashuEvent { } final jsonContent = jsonDecode(decryptedContent); - final extractedContent = - CashuEventContent.fromCashuEventContent(jsonContent); + final extractedContent = CashuEventContent.fromCashuEventContent( + jsonContent, + ); return CashuEventModel( walletPrivkey: extractedContent.privKey, 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 index 06dc96460..9ae99f6ae 100644 --- 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 @@ -25,8 +25,9 @@ class CashuSpendingHistoryEventModel extends CashuSpendingHistoryEvent { } final jsonContent = jsonDecode(decryptedContent); - final extractedContent = - CashuSpendingHistoryEventContent.fromJson(jsonContent); + final extractedContent = CashuSpendingHistoryEventContent.fromJson( + jsonContent, + ); return CashuSpendingHistoryEventModel( amount: extractedContent.amount, 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 index 513139324..f3193e3ca 100644 --- 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 @@ -6,10 +6,11 @@ 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}); + CashuTokenEventModel({ + required super.mintUrl, + required super.proofs, + required super.deletedIds, + }); Future fromNip01Event({ required Nip01Event nostrEvent, diff --git a/packages/ndk/lib/data_layer/models/nip_01_event_model.dart b/packages/ndk/lib/data_layer/models/nip_01_event_model.dart index 59188e36a..e085aa0d1 100644 --- a/packages/ndk/lib/data_layer/models/nip_01_event_model.dart +++ b/packages/ndk/lib/data_layer/models/nip_01_event_model.dart @@ -1,8 +1,12 @@ import 'dart:convert'; +import '../../domain_layer/entities/contact_list.dart'; +import '../../domain_layer/entities/metadata.dart'; import '../../domain_layer/entities/nip_01_event.dart'; import '../../shared/helpers/list_casting.dart'; +import '../../shared/nips/nip01/event_kind_classification.dart'; import '../../shared/nips/nip19/nip19.dart'; +import '../../shared/nips/nip28/channel_metadata.dart'; /// Data model for NIP-01 Event /// Extends [Nip01Event] entity with serialization methods to/from JSON and other formats @@ -35,15 +39,16 @@ class Nip01EventModel extends Nip01Event { List? sources, }) { return Nip01EventModel( - id: id ?? this.id, - pubKey: pubKey ?? this.pubKey, - createdAt: createdAt ?? this.createdAt, - kind: kind ?? this.kind, - tags: tags ?? this.tags, - content: content ?? this.content, - sig: sig ?? this.sig, - validSig: validSig ?? this.validSig, - sources: sources ?? this.sources); + id: id ?? this.id, + pubKey: pubKey ?? this.pubKey, + createdAt: createdAt ?? this.createdAt, + kind: kind ?? this.kind, + tags: tags ?? this.tags, + content: content ?? this.content, + sig: sig ?? this.sig, + validSig: validSig ?? this.validSig, + sources: sources ?? this.sources, + ); } /** @@ -97,7 +102,7 @@ class Nip01EventModel extends Nip01Event { 'kind': kind, 'tags': tags, 'content': content, - 'sig': sig + 'sig': sig, }; } @@ -127,7 +132,7 @@ class Nip01EventModel extends Nip01Event { /// Encode this event as an naddr (NIP-19 addressable event coordinate) /// - /// Only works for addressable/replaceable events (kind >= 10000 or kind 0, 3, 41) + /// Only works for coordinate-capable replaceable events. /// Requires a "d" tag to identify the event. /// /// Returns a bech32-encoded naddr string or null if: @@ -136,8 +141,9 @@ class Nip01EventModel extends Nip01Event { /// /// Usage: `final naddr = event.naddr;` String? get naddr { - // Check if this is an addressable event - if (!_isAddressableKind(kind)) { + // NIP-19 coordinates are valid for parameterized replaceables and + // singleton replaceable kinds that use an empty d-tag. + if (!_supportsCoordinate(kind)) { return null; } @@ -160,22 +166,10 @@ class Nip01EventModel extends Nip01Event { return base64Encode(utf8.encode(json.encode(toJson()))); } - /// Check if an event kind is addressable/replaceable - /// - /// According to NIP-01: - /// - Replaceable events: 0, 3, 41 - /// - Parameterized replaceable events: 10000-19999, 30000-39999 - bool _isAddressableKind(int kind) { - // Replaceable events - if (kind == 0 || kind == 3 || kind == 41) { - return true; - } - - // Parameterized replaceable events - if ((kind >= 10000 && kind <= 19999) || (kind >= 30000 && kind <= 39999)) { - return true; - } - - return false; + bool _supportsCoordinate(int kind) { + return EventKindClassification.isAddressableKind(kind) || + kind == Metadata.kKind || + kind == ContactList.kKind || + kind == ChannelMetadata.kKind; } } diff --git a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart index a38cfd8d1..b984060ec 100644 --- a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart +++ b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart @@ -118,16 +118,20 @@ class CashuWalletTransactionModel extends CashuWalletTransaction { factory CashuWalletTransactionModel.fromJson(Map json) { final metadata = Map.from(json['metadata'] as Map? ?? {}); - final rawQuote = json['qoute'] as Map? ?? + final rawQuote = + json['qoute'] as Map? ?? metadata['qoute'] as Map?; - final rawQuoteMelt = json['qouteMelt'] as Map? ?? + final rawQuoteMelt = + json['qouteMelt'] as Map? ?? metadata['qouteMelt'] as Map?; - final rawUsedKeysets = json['usedKeysets'] as List? ?? + final rawUsedKeysets = + json['usedKeysets'] as List? ?? metadata['usedKeyset'] as List? ?? metadata['usedKeysets'] as List?; - final rawProofPubKeys = json['proofPubKeys'] as List? ?? + final rawProofPubKeys = + json['proofPubKeys'] as List? ?? metadata['proofPubKeys'] as List?; return CashuWalletTransactionModel( @@ -144,8 +148,9 @@ class CashuWalletTransactionModel extends CashuWalletTransaction { note: json['note'] as String? ?? metadata['note'] as String?, method: json['method'] as String? ?? metadata['method'] as String?, qoute: rawQuote != null ? CashuQuote.fromJson(rawQuote) : null, - qouteMelt: - rawQuoteMelt != null ? CashuQuoteMelt.fromJson(rawQuoteMelt) : null, + qouteMelt: rawQuoteMelt != null + ? CashuQuoteMelt.fromJson(rawQuoteMelt) + : null, usedKeysets: rawUsedKeysets ?.map((entry) => CahsuKeyset.fromJson(entry as Map)) .toList(), @@ -156,10 +161,7 @@ class CashuWalletTransactionModel extends CashuWalletTransaction { } Map toJson() { - return { - ...WalletTransactionModel._baseJson(this), - 'metadata': metadata, - }; + return {...WalletTransactionModel._baseJson(this), 'metadata': metadata}; } } @@ -210,10 +212,7 @@ class NwcWalletTransactionModel extends NwcWalletTransaction { } Map toJson() { - return { - ...WalletTransactionModel._baseJson(this), - 'metadata': metadata, - }; + return {...WalletTransactionModel._baseJson(this), 'metadata': metadata}; } } @@ -264,9 +263,6 @@ class LnurlWalletTransactionModel extends LnurlWalletTransaction { } Map toJson() { - return { - ...WalletTransactionModel._baseJson(this), - 'metadata': metadata, - }; + return {...WalletTransactionModel._baseJson(this), 'metadata': metadata}; } } diff --git a/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart b/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart index f0aba6ac8..acd3b1d7d 100644 --- a/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart +++ b/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart @@ -21,10 +21,7 @@ class BlossomRepositoryImpl implements BlossomRepository { final HttpRequestDS client; final FileIO fileIO; - BlossomRepositoryImpl({ - required this.client, - required this.fileIO, - }); + BlossomRepositoryImpl({required this.client, required this.fileIO}); @override Stream uploadBlob({ @@ -149,8 +146,9 @@ class BlossomRepositoryImpl implements BlossomRepository { final result = BlobUploadResult( serverUrl: serverUrl, success: true, - descriptor: - BlobDescriptor.fromJson(jsonDecode(progress.response!.body)), + descriptor: BlobDescriptor.fromJson( + jsonDecode(progress.response!.body), + ), ); results.add(result); successfulUpload = result; @@ -213,8 +211,9 @@ class BlossomRepositoryImpl implements BlossomRepository { totalBytes: contentLength, completedUploads: List.from(results), phase: UploadPhase.mirroring, - progressPhase: - mirrorsTotal > 0 ? mirrorsCompleted / mirrorsTotal : 1, + progressPhase: mirrorsTotal > 0 + ? mirrorsCompleted / mirrorsTotal + : 1, mirrorsTotal: mirrorsTotal, mirrorsCompleted: mirrorsCompleted, ); @@ -267,32 +266,37 @@ class BlossomRepositoryImpl implements BlossomRepository { )) { // Emit intermediate per-server progress updates if (!progress.isComplete) { - progressSubject.add(BlobUploadProgress( - currentServer: serverUrl, - sentBytes: progress.sentBytes, - totalBytes: progress.totalBytes, - completedUploads: List.from(results), - phase: UploadPhase.uploading, - progressPhase: progress.progress, - )); + progressSubject.add( + BlobUploadProgress( + currentServer: serverUrl, + sentBytes: progress.sentBytes, + totalBytes: progress.totalBytes, + completedUploads: List.from(results), + phase: UploadPhase.uploading, + progressPhase: progress.progress, + ), + ); continue; } if (progress.isComplete && progress.response != null) { final result = BlobUploadResult( serverUrl: serverUrl, success: true, - descriptor: - BlobDescriptor.fromJson(jsonDecode(progress.response!.body)), + descriptor: BlobDescriptor.fromJson( + jsonDecode(progress.response!.body), + ), ); results.add(result); - progressSubject.add(BlobUploadProgress( - currentServer: serverUrl, - sentBytes: progress.sentBytes, - totalBytes: contentLength, - completedUploads: List.from(results), - phase: UploadPhase.uploading, - progressPhase: progress.progress, - )); + progressSubject.add( + BlobUploadProgress( + currentServer: serverUrl, + sentBytes: progress.sentBytes, + totalBytes: contentLength, + completedUploads: List.from(results), + phase: UploadPhase.uploading, + progressPhase: progress.progress, + ), + ); } else if (progress.isComplete && progress.error != null) { final result = BlobUploadResult( serverUrl: serverUrl, @@ -300,14 +304,16 @@ class BlossomRepositoryImpl implements BlossomRepository { error: progress.error.toString(), ); results.add(result); - progressSubject.add(BlobUploadProgress( - currentServer: serverUrl, - sentBytes: progress.sentBytes, - totalBytes: contentLength, - completedUploads: List.from(results), - phase: UploadPhase.uploading, - progressPhase: progress.progress, - )); + progressSubject.add( + BlobUploadProgress( + currentServer: serverUrl, + sentBytes: progress.sentBytes, + totalBytes: contentLength, + completedUploads: List.from(results), + phase: UploadPhase.uploading, + progressPhase: progress.progress, + ), + ); } } } catch (e) { @@ -318,28 +324,32 @@ class BlossomRepositoryImpl implements BlossomRepository { error: e.toString(), ); results.add(result); - progressSubject.add(BlobUploadProgress( - currentServer: serverUrl, - sentBytes: 0, - totalBytes: contentLength, - completedUploads: List.from(results), - phase: UploadPhase.uploading, - progressPhase: 0, - )); + progressSubject.add( + BlobUploadProgress( + currentServer: serverUrl, + sentBytes: 0, + totalBytes: contentLength, + completedUploads: List.from(results), + phase: UploadPhase.uploading, + progressPhase: 0, + ), + ); } }).toList(); // When all uploads complete, close the stream Future.wait(uploadFutures).then((_) async { - progressSubject.add(BlobUploadProgress( - currentServer: '', - sentBytes: contentLength, - totalBytes: contentLength, - completedUploads: List.from(results), - phase: UploadPhase.mirroring, - progressPhase: 1, - isComplete: true, - )); + progressSubject.add( + BlobUploadProgress( + currentServer: '', + sentBytes: contentLength, + totalBytes: contentLength, + completedUploads: List.from(results), + phase: UploadPhase.mirroring, + progressPhase: 1, + isComplete: true, + ), + ); await progressSubject.close(); }); @@ -380,8 +390,9 @@ class BlossomRepositoryImpl implements BlossomRepository { final result = BlobUploadResult( serverUrl: url, success: true, - descriptor: - BlobDescriptor.fromJson(jsonDecode(progress.response!.body)), + descriptor: BlobDescriptor.fromJson( + jsonDecode(progress.response!.body), + ), ); results.add(result); @@ -396,20 +407,20 @@ class BlossomRepositoryImpl implements BlossomRepository { ); return; } else if (progress.isComplete && progress.error != null) { - results.add(BlobUploadResult( - serverUrl: url, - success: false, - error: progress.error.toString(), - )); + results.add( + BlobUploadResult( + serverUrl: url, + success: false, + error: progress.error.toString(), + ), + ); } } } catch (e) { // Handle network exceptions (e.g., host lookup failures) - results.add(BlobUploadResult( - serverUrl: url, - success: false, - error: e.toString(), - )); + results.add( + BlobUploadResult(serverUrl: url, success: false, error: e.toString()), + ); } } @@ -435,8 +446,9 @@ class BlossomRepositoryImpl implements BlossomRepository { String? contentType, bool mediaOptimisation = false, }) { - final endpointUrl = - mediaOptimisation ? '$serverUrl/media' : '$serverUrl/upload'; + final endpointUrl = mediaOptimisation + ? '$serverUrl/media' + : '$serverUrl/upload'; return client.putStream( url: Uri.parse(endpointUrl), @@ -531,8 +543,9 @@ class BlossomRepositoryImpl implements BlossomRepository { return BlobResponse( data: response.bodyBytes, mimeType: response.headers['content-type'], - contentLength: - int.tryParse(response.headers['content-length'] ?? ''), + contentLength: int.tryParse( + response.headers['content-length'] ?? '', + ), contentRange: response.headers['content-range'] ?? '', ); } @@ -543,7 +556,8 @@ class BlossomRepositoryImpl implements BlossomRepository { } throw Exception( - 'Failed to get blob from any of the servers. Last error: $lastError'); + 'Failed to get blob from any of the servers. Last error: $lastError', + ); } @override @@ -563,9 +577,7 @@ class BlossomRepositoryImpl implements BlossomRepository { for (final url in serverUrls) { try { - final response = await client.head( - url: Uri.parse('$url/$sha256'), - ); + final response = await client.head(url: Uri.parse('$url/$sha256')); if (_isSuccessStatus(response.statusCode)) { return '$url/$sha256'; @@ -577,7 +589,8 @@ class BlossomRepositoryImpl implements BlossomRepository { } throw Exception( - 'Failed to check blob from any of the servers. Last error: $lastError'); + 'Failed to check blob from any of the servers. Last error: $lastError', + ); } /// first value is whether the server supports range requests \ @@ -588,13 +601,12 @@ class BlossomRepositoryImpl implements BlossomRepository { required String serverUrl, }) async { try { - final response = await client.head( - url: Uri.parse('$serverUrl/$sha256'), - ); + final response = await client.head(url: Uri.parse('$serverUrl/$sha256')); final acceptRanges = response.headers['accept-ranges']; - final contentLength = - int.tryParse(response.headers['content-length'] ?? ''); + final contentLength = int.tryParse( + response.headers['content-length'] ?? '', + ); return Tuple(acceptRanges?.toLowerCase() == 'bytes', contentLength); } catch (e) { return Tuple(false, null); @@ -685,8 +697,9 @@ class BlossomRepositoryImpl implements BlossomRepository { } final response = await client.get( - url: Uri.parse('$url/list/$pubkey') - .replace(queryParameters: queryParams), + url: Uri.parse( + '$url/list/$pubkey', + ).replace(queryParameters: queryParams), headers: headers, ); @@ -701,7 +714,8 @@ class BlossomRepositoryImpl implements BlossomRepository { } throw Exception( - 'Failed to list blobs from all servers. Last error: $lastError'); + 'Failed to list blobs from all servers. Last error: $lastError', + ); } @override @@ -710,11 +724,15 @@ class BlossomRepositoryImpl implements BlossomRepository { required List serverUrls, required Nip01Event authorization, }) async { - final results = await Future.wait(serverUrls.map((url) => _deleteFromServer( + final results = await Future.wait( + serverUrls.map( + (url) => _deleteFromServer( serverUrl: url, sha256: sha256, authorization: authorization, - ))); + ), + ), + ); return results; } @@ -749,9 +767,7 @@ class BlossomRepositoryImpl implements BlossomRepository { } @override - Future directDownload({ - required Uri url, - }) async { + Future directDownload({required Uri url}) async { final response = await client.get(url: url); return BlobResponse( data: response.bodyBytes, @@ -768,7 +784,9 @@ class BlossomRepositoryImpl implements BlossomRepository { }) async { final response = client.getStream(url: url); await fileIO.writeFileStream( - outputPath, response.map((chunk) => Uint8List.fromList(chunk))); + outputPath, + response.map((chunk) => Uint8List.fromList(chunk)), + ); } @override @@ -786,7 +804,9 @@ class BlossomRepositoryImpl implements BlossomRepository { ); await fileIO.writeFileStream( - outputPath, stream.map((response) => response.data)); + outputPath, + stream.map((response) => response.data), + ); } @override @@ -795,15 +815,14 @@ class BlossomRepositoryImpl implements BlossomRepository { required String sha256, required Nip01Event reportEvent, }) async { - final String myBody = - jsonEncode(Nip01EventModel.fromEntity(reportEvent).toJson()); + final String myBody = jsonEncode( + Nip01EventModel.fromEntity(reportEvent).toJson(), + ); final response = await client.put( url: Uri.parse('$serverUrl/report'), body: myBody, //reportEvent.toBase64(), - headers: { - 'Content-Type': 'application/json', - }, + headers: {'Content-Type': 'application/json'}, ); return response.statusCode; } 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 93d809ece..8aaf70adc 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 @@ -3,15 +3,20 @@ 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/cache_eviction.dart'; import '../../../domain_layer/entities/contact_list.dart'; +import '../../../domain_layer/entities/event_cache_records.dart'; import '../../../domain_layer/entities/filter_fetched_ranges.dart'; import '../../../domain_layer/entities/metadata.dart'; +import '../../../domain_layer/entities/nip_65.dart'; import '../../../domain_layer/entities/nip_01_event.dart'; import '../../../domain_layer/entities/nip_05.dart'; import '../../../domain_layer/entities/relay_set.dart'; import '../../../domain_layer/entities/user_relay_list.dart'; import '../../../domain_layer/entities/wallet/wallet.dart'; import '../../../domain_layer/repositories/cache_manager.dart'; +import '../../../shared/nips/nip01/event_kind_classification.dart'; +import '../../../shared/nips/nip01/event_eviction_planner.dart'; /// In memory database implementation /// benefits: very fast @@ -23,12 +28,6 @@ class MemCacheManager implements CacheManager { /// In memory storage Map relaySets = {}; - /// In memory storage - Map contactLists = {}; - - /// In memory storage - Map metadatas = {}; - /// In memory storage indexed by pubKey Map nip05s = {}; @@ -38,6 +37,21 @@ class MemCacheManager implements CacheManager { /// In memory storage Map events = {}; + /// In memory provenance storage keyed by "$eventId|$relayUrl" + Map eventSources = {}; + + /// In memory event delivery storage keyed by eventId + Map eventDeliveryRecords = {}; + + /// In memory relay delivery target storage keyed by "$eventId|$relayUrl" + Map relayDeliveryTargets = {}; + + /// In memory derived event-state storage keyed by eventId. + Map eventCacheStateRecords = {}; + + /// In memory decrypted payload sidecar storage keyed by "$eventId|$viewerPubKey" + Map decryptedEventPayloadRecords = {}; + /// String for mint Url Map> cashuKeysets = {}; @@ -64,6 +78,11 @@ class MemCacheManager implements CacheManager { @override Future loadUserRelayList(String pubKey) async { + final existing = userRelayLists[pubKey]; + if (existing != null) { + return existing; + } + await _refreshUserRelayListProjection(pubKey); return userRelayLists[pubKey]; } @@ -149,35 +168,51 @@ class MemCacheManager implements CacheManager { @override Future loadContactList(String pubKey) async { - return contactLists[pubKey]; + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: ContactList.kKind, + ); + if (event != null) { + return ContactList.fromEvent(event); + } + return null; } @override Future saveContactList(ContactList contactList) async { - contactLists[contactList.pubKey] = contactList; + await saveEvent(contactList.toEvent()); } @override Future saveContactLists(List list) async { for (var contactList in list) { - contactLists[contactList.pubKey] = contactList; + await saveContactList(contactList); } } @override Future loadMetadata(String pubKey) async { - return metadatas[pubKey]; + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: Metadata.kKind, + ); + if (event != null) { + final metadata = Metadata.fromEvent(event); + metadata.refreshedTimestamp = Nip01Event.secondsSinceEpoch(); + return metadata; + } + return null; } @override Future saveMetadata(Metadata metadata) async { - metadatas[metadata.pubKey] = metadata; + await saveEvent(metadata.toEvent()); } @override Future saveMetadatas(List list) async { for (var metadata in list) { - metadatas[metadata.pubKey] = metadata; + await saveMetadata(metadata); } } @@ -188,12 +223,12 @@ class MemCacheManager implements CacheManager { @override Future removeAllContactLists() async { - contactLists.clear(); + await removeEvents(kinds: [ContactList.kKind]); } @override Future removeAllMetadatas() async { - metadatas.clear(); + await removeEvents(kinds: [Metadata.kKind]); } @override @@ -203,19 +238,19 @@ class MemCacheManager implements CacheManager { @override Future removeContactList(String pubKey) async { - contactLists.remove(pubKey); + await removeEvents(pubKeys: [pubKey], kinds: [ContactList.kKind]); } @override Future removeMetadata(String pubKey) async { - metadatas.remove(pubKey); + await removeEvents(pubKeys: [pubKey], kinds: [Metadata.kKind]); } @override Future> loadMetadatas(List pubKeys) async { List result = []; for (String pubKey in pubKeys) { - result.add(metadatas[pubKey]); + result.add(await loadMetadata(pubKey)); } return result; } @@ -223,21 +258,17 @@ class MemCacheManager implements CacheManager { /// Search for metadata by name, nip05 @override Future> searchMetadatas(String search, int limit) async { - // Use a Set to track unique Metadata objects - final Set uniqueResults = {}; - - for (final metadata in metadatas.values) { - if ((metadata.name != null && metadata.name!.contains(search)) || - (metadata.nip05 != null && metadata.nip05!.contains(search))) { - uniqueResults.add(metadata); - } - } - - // Convert to list, sort by updatedAt, and take the limit - final sortedResults = uniqueResults.toList() - ..sort((a, b) => (b.updatedAt ?? 0).compareTo(a.updatedAt ?? 0)); - - return sortedResults.take(limit); + final events = await loadEvents(kinds: [Metadata.kKind]); + final normalizedSearch = search.trim().toLowerCase(); + final matches = events.map((event) => Metadata.fromEvent(event)).where(( + metadata, + ) { + if (normalizedSearch.isEmpty) return true; + return metadata.matchesSearch(normalizedSearch) || + (metadata.about?.toLowerCase().contains(normalizedSearch) ?? false) || + (metadata.cleanNip05?.contains(normalizedSearch) ?? false); + }).toList()..sort((a, b) => (b.updatedAt ?? 0).compareTo(a.updatedAt ?? 0)); + return matches.take(limit); } @override @@ -269,6 +300,295 @@ class MemCacheManager implements CacheManager { return events[id]; } + @override + Future addEventSource({ + required String eventId, + required String relayUrl, + }) async { + eventSources[_eventSourceKey(eventId, relayUrl)] = relayUrl; + } + + @override + Future addEventSources({ + required String eventId, + required Iterable relayUrls, + }) async { + for (final relayUrl in relayUrls) { + eventSources[_eventSourceKey(eventId, relayUrl)] = relayUrl; + } + } + + @override + Future> loadEventSources(String eventId) async { + final prefix = '$eventId|'; + final result = + eventSources.entries + .where((entry) => entry.key.startsWith(prefix)) + .map((entry) => entry.value) + .toList() + ..sort(); + return result; + } + + @override + Future removeEventSources(String eventId) async { + final prefix = '$eventId|'; + eventSources.removeWhere((key, value) => key.startsWith(prefix)); + } + + @override + Future saveEventDeliveryRecord(EventDeliveryRecord record) async { + eventDeliveryRecords[record.eventId] = record; + } + + @override + Future saveEventDeliveryRecords( + List records, + ) async { + for (final record in records) { + eventDeliveryRecords[record.eventId] = record; + } + } + + @override + Future loadEventDeliveryRecord(String eventId) async { + return eventDeliveryRecords[eventId]; + } + + @override + Future> loadEventDeliveryRecords({ + EventDeliveryStatus? status, + int? limit, + }) async { + var records = eventDeliveryRecords.values.where((record) { + return status == null || record.status == status; + }).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + if (limit != null && limit < records.length) { + records = records.take(limit).toList(); + } + + return records; + } + + @override + Future removeEventDeliveryRecord(String eventId) async { + eventDeliveryRecords.remove(eventId); + } + + @override + Future removeAllEventDeliveryRecords() async { + eventDeliveryRecords.clear(); + } + + @override + Future saveRelayDeliveryTarget(RelayDeliveryTarget target) async { + relayDeliveryTargets[target.key] = target; + } + + @override + Future saveRelayDeliveryTargets( + List targets, + ) async { + for (final target in targets) { + relayDeliveryTargets[target.key] = target; + } + } + + @override + Future loadRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + return relayDeliveryTargets[_eventSourceKey(eventId, relayUrl)]; + } + + @override + Future> loadRelayDeliveryTargets({ + String? eventId, + String? relayUrl, + RelayDeliveryState? state, + bool excludeAcked = false, + int? limit, + }) async { + var records = + relayDeliveryTargets.values.where((target) { + if (eventId != null && target.eventId != eventId) { + return false; + } + if (relayUrl != null && target.relayUrl != relayUrl) { + return false; + } + if (state != null && target.state != state) { + return false; + } + if (excludeAcked && target.state == RelayDeliveryState.acked) { + return false; + } + return true; + }).toList()..sort((a, b) { + final retryA = a.nextRetryAt ?? 0; + final retryB = b.nextRetryAt ?? 0; + if (retryA != retryB) { + return retryA.compareTo(retryB); + } + return a.key.compareTo(b.key); + }); + + if (limit != null && limit < records.length) { + records = records.take(limit).toList(); + } + + return records; + } + + @override + Future removeRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + relayDeliveryTargets.remove(_eventSourceKey(eventId, relayUrl)); + } + + @override + Future removeRelayDeliveryTargets(String eventId) async { + final prefix = '$eventId|'; + relayDeliveryTargets.removeWhere((key, value) => key.startsWith(prefix)); + } + + @override + Future removeAllRelayDeliveryTargets() async { + relayDeliveryTargets.clear(); + } + + @override + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ) async { + decryptedEventPayloadRecords[record.key] = record; + } + + @override + Future saveDecryptedEventPayloadRecords( + List records, + ) async { + for (final record in records) { + decryptedEventPayloadRecords[record.key] = record; + } + } + + @override + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + return decryptedEventPayloadRecords[_eventSourceKey(eventId, viewerPubKey)]; + } + + @override + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }) async { + var records = decryptedEventPayloadRecords.values.where((record) { + if (eventId != null && record.eventId != eventId) { + return false; + } + if (viewerPubKey != null && record.viewerPubKey != viewerPubKey) { + return false; + } + if (status != null && record.status != status) { + return false; + } + return true; + }).toList(); + + records.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + if (limit != null && limit > 0 && records.length > limit) { + records = records.take(limit).toList(); + } + + return records; + } + + @override + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + decryptedEventPayloadRecords.remove(_eventSourceKey(eventId, viewerPubKey)); + } + + @override + Future removeDecryptedEventPayloadRecords(String eventId) async { + final prefix = '$eventId|'; + decryptedEventPayloadRecords.removeWhere( + (key, value) => key.startsWith(prefix), + ); + } + + @override + Future removeAllDecryptedEventPayloadRecords() async { + decryptedEventPayloadRecords.clear(); + } + + @override + Future evict(EvictionPolicy policy) async { + final activeDeliveryEventIds = eventDeliveryRecords.values + .where((record) => record.status != EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final lockedEventIds = { + ...activeDeliveryEventIds, + ...relayDeliveryTargets.values + .where((target) => target.state != RelayDeliveryState.acked) + .map((target) => target.eventId), + }; + final deliveredEventIds = eventDeliveryRecords.values + .where((record) => record.status == EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final plan = EventEvictionPlanner.planFromStateRecords( + stateRecords: eventCacheStateRecords.values.toList(), + lockedEventIds: lockedEventIds, + deliveredEventIds: deliveredEventIds, + policy: policy, + ); + + if (plan.eventIdsToRemove.isNotEmpty) { + final removedEvents = events.values + .where((event) => plan.eventIdsToRemove.contains(event.id)) + .toList(); + events.removeWhere((key, value) => plan.eventIdsToRemove.contains(key)); + _removeEventSidecarsByIds(plan.eventIdsToRemove); + await _refreshDerivedStateForPubKeys( + removedEvents.map((event) => event.pubKey).toSet(), + ); + } + + // Sweep leftover delivery records whose event was kept (or never stored). + // A terminally failed record swept here unpins its event for the next run. + final deliverySweep = EventEvictionPlanner.planDeliverySweep( + deliveryRecords: eventDeliveryRecords.values.toList(), + policy: policy, + ); + for (final eventId in deliverySweep.deliveryEventIdsToRemove) { + eventDeliveryRecords.remove(eventId); + relayDeliveryTargets.removeWhere( + (key, target) => target.eventId == eventId, + ); + } + + return plan.toResult().copyWith( + removedCompletedDeliveries: deliverySweep.removedCompletedDeliveries, + removedTerminalFailedDeliveries: + deliverySweep.removedTerminalFailedDeliveries, + ); + } + @override Future> loadEvents({ List? ids, @@ -279,6 +599,30 @@ class MemCacheManager implements CacheManager { int? until, String? search, int? limit, + }) async { + return _loadEventsInternal( + ids: ids, + pubKeys: pubKeys, + kinds: kinds, + tags: tags, + since: since, + until: until, + search: search, + limit: limit, + applyVisibilityRules: true, + ); + } + + Future> _loadEventsInternal({ + List? ids, + List? pubKeys, + List? kinds, + Map>? tags, + int? since, + int? until, + String? search, + int? limit, + required bool applyVisibilityRules, }) async { List result = []; for (var event in events.values) { @@ -327,9 +671,11 @@ class MemCacheManager implements CacheManager { return true; } - return tagValues.any((value) => - eventTagValues.contains(value) || - eventTagValues.contains(value.toLowerCase())); + return tagValues.any( + (value) => + eventTagValues.contains(value) || + eventTagValues.contains(value.toLowerCase()), + ); }); if (!matchesTags) { continue; @@ -339,6 +685,10 @@ class MemCacheManager implements CacheManager { result.add(event); } + if (applyVisibilityRules) { + result = _applyEventVisibilityRules(result); + } + result.sort((a, b) => b.createdAt.compareTo(a.createdAt)); if (limit != null && limit > 0 && result.length > limit) { @@ -350,17 +700,33 @@ class MemCacheManager implements CacheManager { @override Future removeAllEventsByPubKey(String pubKey) async { + final eventIds = events.values + .where((event) => event.pubKey == pubKey) + .map((event) => event.id) + .toList(); events.removeWhere((key, value) => value.pubKey == pubKey); + _removeEventSidecarsByIds(eventIds); + await _refreshUserRelayListProjection(pubKey); } @override Future removeAllEvents() async { events.clear(); + eventCacheStateRecords.clear(); + eventSources.clear(); + eventDeliveryRecords.clear(); + relayDeliveryTargets.clear(); + decryptedEventPayloadRecords.clear(); + userRelayLists.clear(); } @override Future removeEvent(String id) async { - events.remove(id); + final removed = events.remove(id); + _removeEventSidecarsByIds([id]); + if (removed != null) { + await _refreshDerivedStateForEvent(removed); + } } @override @@ -382,22 +748,50 @@ class MemCacheManager implements CacheManager { return; } - final eventsToRemove = await loadEvents( + final rawEventsToRemove = await _loadEventsInternal( ids: ids, pubKeys: pubKeys, kinds: kinds, tags: tags, since: since, until: until, + applyVisibilityRules: false, + ); + final eventIdsToRemove = rawEventsToRemove + .map((event) => event.id) + .toList(); + final eventIdSet = eventIdsToRemove.toSet(); + events.removeWhere((key, value) => eventIdSet.contains(key)); + _removeEventSidecarsByIds(eventIdsToRemove); + await _refreshDerivedStateForPubKeys( + rawEventsToRemove.map((event) => event.pubKey).toSet(), + ); + } + + void _removeEventSidecarsByIds(Iterable eventIds) { + final eventIdSet = eventIds.toSet(); + if (eventIdSet.isEmpty) return; + + eventSources.removeWhere((key, value) { + final separator = key.indexOf('|'); + final eventId = separator == -1 ? key : key.substring(0, separator); + return eventIdSet.contains(eventId); + }); + eventDeliveryRecords.removeWhere( + (eventId, value) => eventIdSet.contains(eventId), + ); + relayDeliveryTargets.removeWhere( + (key, target) => eventIdSet.contains(target.eventId), + ); + decryptedEventPayloadRecords.removeWhere( + (key, record) => eventIdSet.contains(record.eventId), ); - for (final event in eventsToRemove) { - events.remove(event.id); - } } @override Future saveEvent(Nip01Event event) async { events[event.id] = event; + await _refreshDerivedStateForEvent(event); } @override @@ -405,6 +799,9 @@ class MemCacheManager implements CacheManager { for (var event in events) { this.events[event.id] = event; } + await _refreshDerivedStateForPubKeys( + events.map((event) => event.pubKey).toSet(), + ); } @override @@ -427,13 +824,15 @@ class MemCacheManager implements CacheManager { @override Future saveFilterFetchedRangeRecord( - FilterFetchedRangeRecord record) async { + FilterFetchedRangeRecord record, + ) async { filterFetchedRangeRecords[record.key] = record; } @override Future saveFilterFetchedRangeRecords( - List records) async { + List records, + ) async { for (final record in records) { filterFetchedRangeRecords[record.key] = record; } @@ -457,16 +856,20 @@ class MemCacheManager implements CacheManager { }) async { if (cashuProofs.containsKey(mintUrl)) { return cashuProofs[mintUrl]! - .where((proof) => - proof.state == state && - (keysetId == null || proof.keysetId == keysetId)) + .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)) + .where( + (proof) => + proof.state == state && + (keysetId == null || proof.keysetId == keysetId), + ) .toList(); } } @@ -485,8 +888,10 @@ class MemCacheManager implements CacheManager { } @override - Future removeProofs( - {required List proofs, required String mintUrl}) { + Future removeProofs({ + required List proofs, + required String mintUrl, + }) { if (cashuProofs.containsKey(mintUrl)) { final existingProofs = cashuProofs[mintUrl]!; for (final proof in proofs) { @@ -503,35 +908,28 @@ class MemCacheManager implements CacheManager { } @override - Future?> getMintInfos({ - List? mintUrls, - }) { + Future?> getMintInfos({List? mintUrls}) { if (mintUrls == null) { return Future.value(cashuMintInfos.toList()); } else { final result = cashuMintInfos - .where( - (info) => mintUrls.any((url) => info.isMintUrl(url)), - ) + .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))); + Future saveMintInfo({required CashuMintInfo mintInfo}) { + cashuMintInfos.removeWhere( + (info) => info.urls.any((url) => mintInfo.isMintUrl(url)), + ); cashuMintInfos.add(mintInfo); return Future.value(); } @override - Future removeMintInfo({ - required String mintUrl, - }) { + Future removeMintInfo({required String mintUrl}) { cashuMintInfos.removeWhere( (info) => info.urls.any((url) => info.isMintUrl(mintUrl)), ); @@ -561,7 +959,8 @@ class MemCacheManager implements CacheManager { @override Future> loadFilterFetchedRangeRecords( - String filterHash) async { + String filterHash, + ) async { return filterFetchedRangeRecords.values .where((r) => r.filterHash == filterHash) .toList(); @@ -569,7 +968,9 @@ class MemCacheManager implements CacheManager { @override Future> loadFilterFetchedRangeRecordsByRelay( - String filterHash, String relayUrl) async { + String filterHash, + String relayUrl, + ) async { return filterFetchedRangeRecords.values .where((r) => r.filterHash == filterHash && r.relayUrl == relayUrl) .toList(); @@ -577,7 +978,7 @@ class MemCacheManager implements CacheManager { @override Future> - loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { + loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { return filterFetchedRangeRecords.values .where((r) => r.relayUrl == relayUrl) .toList(); @@ -585,21 +986,27 @@ class MemCacheManager implements CacheManager { @override Future removeFilterFetchedRangeRecords(String filterHash) async { - filterFetchedRangeRecords - .removeWhere((key, value) => value.filterHash == filterHash); + filterFetchedRangeRecords.removeWhere( + (key, value) => value.filterHash == filterHash, + ); } @override Future removeFilterFetchedRangeRecordsByFilterAndRelay( - String filterHash, String relayUrl) async { - filterFetchedRangeRecords.removeWhere((key, value) => - value.filterHash == filterHash && value.relayUrl == relayUrl); + String filterHash, + String relayUrl, + ) async { + filterFetchedRangeRecords.removeWhere( + (key, value) => + value.filterHash == filterHash && value.relayUrl == relayUrl, + ); } @override Future removeFilterFetchedRangeRecordsByRelay(String relayUrl) async { - filterFetchedRangeRecords - .removeWhere((key, value) => value.relayUrl == relayUrl); + filterFetchedRangeRecords.removeWhere( + (key, value) => value.relayUrl == relayUrl, + ); } @override @@ -610,10 +1017,12 @@ class MemCacheManager implements CacheManager { @override Future clearAll() async { events.clear(); + eventSources.clear(); + eventDeliveryRecords.clear(); + relayDeliveryTargets.clear(); + decryptedEventPayloadRecords.clear(); userRelayLists.clear(); relaySets.clear(); - contactLists.clear(); - metadatas.clear(); nip05s.clear(); cashuKeysets.clear(); cashuProofs.clear(); @@ -621,4 +1030,145 @@ class MemCacheManager implements CacheManager { nip05sByIdentifier.clear(); filterFetchedRangeRecords.clear(); } + + String _eventSourceKey(String eventId, String relayUrl) { + return '$eventId|$relayUrl'; + } + + List _applyEventVisibilityRules(List events) { + final visible = []; + final replaceableWinners = {}; + final now = Nip01Event.secondsSinceEpoch(); + + for (final event in events) { + if (_isExpired(event, now)) continue; + if (_isDeletedByAuthor(event)) continue; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey == null) { + visible.add(event); + continue; + } + + final current = replaceableWinners[coordinateKey]; + if (current == null || _isMoreRecentReplaceable(event, current)) { + replaceableWinners[coordinateKey] = event; + } + } + + visible.addAll(replaceableWinners.values); + return visible; + } + + bool _isDeletedByAuthor(Nip01Event target) { + if (target.kind == 5) return false; + + // Addressable/replaceable events are deleted by coordinate (`a` tag), so a + // later version published after the deletion stays visible (NIP-09 only + // deletes coordinate matches with created_at <= the deletion). + final coordinate = _coordinateKey(target); + + for (final event in events.values) { + if (event.kind != 5) continue; + if (event.pubKey != target.pubKey) continue; + if (event.getTags('e').contains(target.id.toLowerCase())) { + return true; + } + if (coordinate != null && + event.createdAt >= target.createdAt && + event.getTags('a').contains(coordinate)) { + return true; + } + } + + return false; + } + + bool _isExpired(Nip01Event event, int now) { + final expirationValue = event.getFirstTag('expiration'); + if (expirationValue == null) return false; + final expiration = int.tryParse(expirationValue); + if (expiration == null) return false; + return expiration <= now; + } + + String? _coordinateKey(Nip01Event event) { + if (!_isReplaceableKind(event.kind)) return null; + final dTag = event.getDtag() ?? ''; + return '${event.kind}:${event.pubKey}:$dTag'; + } + + bool _isReplaceableKind(int kind) { + return EventKindClassification.isReplaceableKind(kind); + } + + bool _isMoreRecentReplaceable(Nip01Event candidate, Nip01Event current) { + if (candidate.createdAt != current.createdAt) { + return candidate.createdAt > current.createdAt; + } + + return candidate.id.compareTo(current.id) < 0; + } + + Future _loadLatestVisibleEvent({ + required String pubKey, + required int kind, + }) async { + final events = await loadEvents(pubKeys: [pubKey], kinds: [kind], limit: 1); + if (events.isEmpty) return null; + return events.first; + } + + Future _refreshDerivedStateForEvent(Nip01Event event) async { + await _refreshDerivedStateForPubKeys({event.pubKey}); + } + + Future _refreshDerivedStateForPubKeys(Set pubKeys) async { + for (final pubKey in pubKeys) { + eventCacheStateRecords.removeWhere( + (eventId, record) => record.pubKey == pubKey, + ); + final rawEvents = events.values + .where((event) => event.pubKey == pubKey) + .toList(growable: false); + for (final record in EventCacheStateRecord.buildForEvents(rawEvents)) { + eventCacheStateRecords[record.eventId] = record; + } + await _refreshUserRelayListProjection(pubKey); + } + } + + Future _refreshUserRelayListProjection(String pubKey) async { + final events = await loadEvents( + pubKeys: [pubKey], + kinds: [Nip65.kKind, ContactList.kKind], + ); + + Nip01Event? latestNip65; + Nip01Event? latestContactListWithRelays; + for (final event in events) { + if (event.kind == Nip65.kKind) { + latestNip65 ??= event; + } else if (event.kind == ContactList.kKind && + ContactList.relaysFromContent(event).isNotEmpty) { + latestContactListWithRelays ??= event; + } + } + + if (latestNip65 != null) { + userRelayLists[pubKey] = UserRelayList.fromNip65( + Nip65.fromEvent(latestNip65), + ); + return; + } + + if (latestContactListWithRelays != null) { + userRelayLists[pubKey] = UserRelayList.fromNip02EventContent( + latestContactListWithRelays, + ); + return; + } + + userRelayLists.remove(pubKey); + } } diff --git a/packages/ndk/lib/data_layer/repositories/cache_manager/ndk_extensions.dart b/packages/ndk/lib/data_layer/repositories/cache_manager/ndk_extensions.dart index b4434db96..ba4f96f54 100644 --- a/packages/ndk/lib/data_layer/repositories/cache_manager/ndk_extensions.dart +++ b/packages/ndk/lib/data_layer/repositories/cache_manager/ndk_extensions.dart @@ -53,14 +53,17 @@ extension ContactListExtension on ContactList { contacts: List.from(json['contacts'] as List), ); - contactList.contactRelays = - List.from(json['contactRelays'] as List); + contactList.contactRelays = List.from( + json['contactRelays'] as List, + ); contactList.petnames = List.from(json['petnames'] as List); contactList.followedTags = List.from(json['followedTags'] as List); - contactList.followedCommunities = - List.from(json['followedCommunities'] as List); - contactList.followedEvents = - List.from(json['followedEvents'] as List); + contactList.followedCommunities = List.from( + json['followedCommunities'] as List, + ); + contactList.followedEvents = List.from( + json['followedEvents'] as List, + ); contactList.createdAt = json['createdAt'] as int; contactList.loadedTimestamp = json['loadedTimestamp'] as int?; contactList.sources = List.from(json['sources'] as List); @@ -103,8 +106,9 @@ extension MetadataExtension on Metadata { // Create a mutable copy of content if it exists Map? contentCopy; if (json['content'] != null) { - contentCopy = - Map.from(json['content'] as Map); + contentCopy = Map.from( + json['content'] as Map, + ); } final metadata = Metadata( @@ -165,14 +169,17 @@ extension RelaySetExtension on RelaySet { 'pubKey': pubKey, 'relayMinCountPerPubkey': relayMinCountPerPubkey, 'direction': direction.index, - 'relaysMap': relaysMap.map((key, value) => MapEntry( - key, value.map((mapping) => mapping.toJsonForStorage()).toList())), + 'relaysMap': relaysMap.map( + (key, value) => MapEntry( + key, + value.map((mapping) => mapping.toJsonForStorage()).toList(), + ), + ), 'fallbackToBootstrapRelays': fallbackToBootstrapRelays, 'notCoveredPubkeys': notCoveredPubkeys - .map((pubkey) => { - 'pubKey': pubkey.pubKey, - 'coverage': pubkey.coverage, - }) + .map( + (pubkey) => {'pubKey': pubkey.pubKey, 'coverage': pubkey.coverage}, + ) .toList(), }; } @@ -192,10 +199,12 @@ extension RelaySetExtension on RelaySet { // Reconstruct notCoveredPubkeys final notCoveredJson = json['notCoveredPubkeys'] as List? ?? []; final notCoveredPubkeys = notCoveredJson - .map((item) => NotCoveredPubKey( - item['pubKey'] as String, - item['coverage'] as int, - )) + .map( + (item) => NotCoveredPubKey( + item['pubKey'] as String, + item['coverage'] as int, + ), + ) .toList(); return RelaySet( @@ -214,17 +223,15 @@ extension RelaySetExtension on RelaySet { // Extension for PubkeyMapping to add JSON serialization support extension PubkeyMappingExtension on PubkeyMapping { Map toJsonForStorage() { - return { - 'pubKey': pubKey, - 'rwMarker': rwMarker.toJsonForStorage(), - }; + return {'pubKey': pubKey, 'rwMarker': rwMarker.toJsonForStorage()}; } static PubkeyMapping fromJsonStorage(Map json) { return PubkeyMapping( pubKey: json['pubKey'] as String, rwMarker: ReadWriteMarkerExtension.fromJsonStorage( - json['rwMarker'] as Map), + json['rwMarker'] as Map, + ), ); } } @@ -232,10 +239,7 @@ extension PubkeyMappingExtension on PubkeyMapping { // Extension for ReadWriteMarker to add JSON serialization support extension ReadWriteMarkerExtension on ReadWriteMarker { Map toJsonForStorage() { - return { - 'read': isRead, - 'write': isWrite, - }; + return {'read': isRead, 'write': isWrite}; } static ReadWriteMarker fromJsonStorage(Map json) { @@ -253,8 +257,9 @@ extension UserRelayListExtension on UserRelayList { 'pubKey': pubKey, 'createdAt': createdAt, 'refreshedTimestamp': refreshedTimestamp, - 'relays': - relays.map((key, value) => MapEntry(key, value.toJsonForStorage())), + 'relays': relays.map( + (key, value) => MapEntry(key, value.toJsonForStorage()), + ), }; } @@ -265,7 +270,8 @@ extension UserRelayListExtension on UserRelayList { relaysJson.forEach((key, value) { relays[key] = ReadWriteMarkerExtension.fromJsonStorage( - value as Map); + value as Map, + ); }); return UserRelayList( @@ -301,10 +307,12 @@ extension CahsuKeysetExtension on CahsuKeyset { 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, - )) + .map( + (e) => CahsuMintKeyPair( + amount: e['amount'] as int, + pubkey: e['pubkey'] as String, + ), + ) .toSet(), fetchedAt: json['fetchedAt'] as int?, ); @@ -366,8 +374,9 @@ extension CashuMintInfoExtension on CashuMintInfo { if (v is List) { return; // skip non-spec compliant entries } - parsedNuts[key] = - CashuMintNut.fromJson((v ?? {}) as Map); + parsedNuts[key] = CashuMintNut.fromJson( + (v ?? {}) as Map, + ); } catch (e) { // skip entries that fail to parse } diff --git a/packages/ndk/lib/data_layer/repositories/cache_manager/sembast_cache_manager.dart b/packages/ndk/lib/data_layer/repositories/cache_manager/sembast_cache_manager.dart index e0f5875bd..1ab77ccb8 100644 --- a/packages/ndk/lib/data_layer/repositories/cache_manager/sembast_cache_manager.dart +++ b/packages/ndk/lib/data_layer/repositories/cache_manager/sembast_cache_manager.dart @@ -1,6 +1,9 @@ import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; import 'package:sembast/sembast.dart' as sembast; +import '../../../shared/nips/nip01/event_kind_classification.dart'; +import '../../../shared/nips/nip01/event_eviction_planner.dart'; +import '../../../shared/nips/nip01/helpers.dart'; import 'ndk_extensions.dart'; // Platform-specific imports @@ -35,13 +38,21 @@ class SembastCacheManager extends CacheManager { final sembast.Database _database; late final sembast.StoreRef> _eventsStore; + late final sembast.StoreRef> + _eventCacheStateStore; + late final sembast.StoreRef> _eventSourceStore; + late final sembast.StoreRef> _eventDeliveryStore; + late final sembast.StoreRef> + _relayDeliveryTargetStore; + late final sembast.StoreRef> + _decryptedEventPayloadStore; late final sembast.StoreRef> _metadataStore; late final sembast.StoreRef> _contactListStore; late final sembast.StoreRef> _relayListStore; late final sembast.StoreRef> _nip05Store; late final sembast.StoreRef> _relaySetStore; late final sembast.StoreRef> - _filterFetchedRangeStore; + _filterFetchedRangeStore; late final sembast.StoreRef> _keysetStore; late final sembast.StoreRef> _proofStore; @@ -50,6 +61,19 @@ class SembastCacheManager extends CacheManager { SembastCacheManager(this._database) { _eventsStore = sembast.stringMapStoreFactory.store('events'); + _eventCacheStateStore = sembast.stringMapStoreFactory.store( + 'event_cache_state', + ); + _eventSourceStore = sembast.stringMapStoreFactory.store('event_sources'); + _eventDeliveryStore = sembast.stringMapStoreFactory.store( + 'event_delivery_records', + ); + _relayDeliveryTargetStore = sembast.stringMapStoreFactory.store( + 'relay_delivery_targets', + ); + _decryptedEventPayloadStore = sembast.stringMapStoreFactory.store( + 'decrypted_event_payloads', + ); _metadataStore = sembast.stringMapStoreFactory.store('metadata'); _contactListStore = sembast.stringMapStoreFactory.store('contact_lists'); _relayListStore = sembast.stringMapStoreFactory.store('relay_lists'); @@ -58,10 +82,12 @@ class SembastCacheManager extends CacheManager { _keysetStore = sembast.stringMapStoreFactory.store('keysets'); _proofStore = sembast.stringMapStoreFactory.store('proofs'); _mintInfoStore = sembast.stringMapStoreFactory.store('mint_infos'); - _secretCounterStore = - sembast.stringMapStoreFactory.store('secret_counters'); - _filterFetchedRangeStore = - sembast.stringMapStoreFactory.store('filter_fetched_ranges'); + _secretCounterStore = sembast.stringMapStoreFactory.store( + 'secret_counters', + ); + _filterFetchedRangeStore = sembast.stringMapStoreFactory.store( + 'filter_fetched_ranges', + ); } @override @@ -71,6 +97,14 @@ class SembastCacheManager extends CacheManager { @override Future loadContactList(String pubKey) async { + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: ContactList.kKind, + ); + if (event != null) { + return ContactList.fromEvent(event); + } + final data = await _contactListStore.record(pubKey).get(_database); if (data == null) return null; return ContactListExtension.fromJsonStorage(data); @@ -83,6 +117,349 @@ class SembastCacheManager extends CacheManager { return Nip01EventExtension.fromJsonStorage(data); } + @override + Future addEventSource({ + required String eventId, + required String relayUrl, + }) async { + await addEventSources(eventId: eventId, relayUrls: [relayUrl]); + } + + @override + Future addEventSources({ + required String eventId, + required Iterable relayUrls, + }) async { + for (final relayUrl in relayUrls) { + await _eventSourceStore.record(_eventSourceKey(eventId, relayUrl)).put( + _database, + {'eventId': eventId, 'relayUrl': relayUrl}, + ); + } + } + + @override + Future> loadEventSources(String eventId) async { + final records = await _eventSourceStore.find( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('eventId', eventId)), + ); + + final sources = + records.map((record) => record.value['relayUrl'] as String).toList() + ..sort(); + return sources; + } + + @override + Future removeEventSources(String eventId) async { + await _eventSourceStore.delete( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('eventId', eventId)), + ); + } + + @override + Future saveEventDeliveryRecord(EventDeliveryRecord record) async { + await _eventDeliveryStore + .record(record.eventId) + .put(_database, record.toJson().cast()); + } + + @override + Future saveEventDeliveryRecords( + List records, + ) async { + final keys = records.map((record) => record.eventId).toList(); + final values = records + .map((record) => record.toJson().cast()) + .toList(); + await _eventDeliveryStore.records(keys).put(_database, values); + } + + @override + Future loadEventDeliveryRecord(String eventId) async { + final data = await _eventDeliveryStore.record(eventId).get(_database); + if (data == null) return null; + return EventDeliveryRecord.fromJson(data); + } + + @override + Future> loadEventDeliveryRecords({ + EventDeliveryStatus? status, + int? limit, + }) async { + final finder = sembast.Finder( + filter: status != null + ? sembast.Filter.equals('status', status.name) + : null, + sortOrders: [sembast.SortOrder('createdAt')], + limit: limit, + ); + final records = await _eventDeliveryStore.find(_database, finder: finder); + return records + .map((record) => EventDeliveryRecord.fromJson(record.value)) + .toList(); + } + + @override + Future removeEventDeliveryRecord(String eventId) async { + await _eventDeliveryStore.record(eventId).delete(_database); + } + + @override + Future removeAllEventDeliveryRecords() async { + await _eventDeliveryStore.delete(_database); + } + + @override + Future saveRelayDeliveryTarget(RelayDeliveryTarget target) async { + await _relayDeliveryTargetStore + .record(target.key) + .put(_database, target.toJson().cast()); + } + + @override + Future saveRelayDeliveryTargets( + List targets, + ) async { + final keys = targets.map((target) => target.key).toList(); + final values = targets + .map((target) => target.toJson().cast()) + .toList(); + await _relayDeliveryTargetStore.records(keys).put(_database, values); + } + + @override + Future loadRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + final data = await _relayDeliveryTargetStore + .record(_eventSourceKey(eventId, relayUrl)) + .get(_database); + if (data == null) return null; + return RelayDeliveryTarget.fromJson(data); + } + + @override + Future> loadRelayDeliveryTargets({ + String? eventId, + String? relayUrl, + RelayDeliveryState? state, + bool excludeAcked = false, + int? limit, + }) async { + final filters = []; + if (eventId != null) { + filters.add(sembast.Filter.equals('eventId', eventId)); + } + if (relayUrl != null) { + filters.add(sembast.Filter.equals('relayUrl', relayUrl)); + } + if (state != null) { + filters.add(sembast.Filter.equals('state', state.name)); + } + if (excludeAcked) { + filters.add( + sembast.Filter.notEquals('state', RelayDeliveryState.acked.name), + ); + } + + final finder = sembast.Finder( + filter: filters.isNotEmpty ? sembast.Filter.and(filters) : null, + sortOrders: [ + sembast.SortOrder('nextRetryAt'), + sembast.SortOrder('eventId'), + sembast.SortOrder('relayUrl'), + ], + limit: limit, + ); + final records = await _relayDeliveryTargetStore.find( + _database, + finder: finder, + ); + return records + .map((record) => RelayDeliveryTarget.fromJson(record.value)) + .toList(); + } + + @override + Future removeRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + await _relayDeliveryTargetStore + .record(_eventSourceKey(eventId, relayUrl)) + .delete(_database); + } + + @override + Future removeRelayDeliveryTargets(String eventId) async { + await _relayDeliveryTargetStore.delete( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('eventId', eventId)), + ); + } + + @override + Future removeAllRelayDeliveryTargets() async { + await _relayDeliveryTargetStore.delete(_database); + } + + @override + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ) async { + await _decryptedEventPayloadStore + .record(record.key) + .put(_database, record.toJson().cast()); + } + + @override + Future saveDecryptedEventPayloadRecords( + List records, + ) async { + final keys = records.map((record) => record.key).toList(); + final values = records + .map((record) => record.toJson().cast()) + .toList(); + await _decryptedEventPayloadStore.records(keys).put(_database, values); + } + + @override + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + final data = await _decryptedEventPayloadStore + .record(_eventSourceKey(eventId, viewerPubKey)) + .get(_database); + if (data == null) return null; + return DecryptedEventPayloadRecord.fromJson(data); + } + + @override + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }) async { + final filters = []; + if (eventId != null) { + filters.add(sembast.Filter.equals('eventId', eventId)); + } + if (viewerPubKey != null) { + filters.add(sembast.Filter.equals('viewerPubKey', viewerPubKey)); + } + if (status != null) { + filters.add(sembast.Filter.equals('status', status.name)); + } + + final finder = sembast.Finder( + filter: filters.isNotEmpty ? sembast.Filter.and(filters) : null, + sortOrders: [sembast.SortOrder('createdAt')], + limit: limit, + ); + + final records = await _decryptedEventPayloadStore.find( + _database, + finder: finder, + ); + return records + .map((record) => DecryptedEventPayloadRecord.fromJson(record.value)) + .toList(); + } + + @override + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + await _decryptedEventPayloadStore + .record(_eventSourceKey(eventId, viewerPubKey)) + .delete(_database); + } + + @override + Future removeDecryptedEventPayloadRecords(String eventId) async { + await _decryptedEventPayloadStore.delete( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('eventId', eventId)), + ); + } + + @override + Future removeAllDecryptedEventPayloadRecords() async { + await _decryptedEventPayloadStore.delete(_database); + } + + @override + Future evict(EvictionPolicy policy) async { + final rawEvents = await _loadEventsInternal(applyVisibilityRules: false); + final stateRecords = await _loadEventCacheStateRecords(); + final deliveryRecords = await loadEventDeliveryRecords(); + final relayTargets = await loadRelayDeliveryTargets(); + final activeDeliveryEventIds = deliveryRecords + .where((record) => record.status != EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final lockedEventIds = { + ...activeDeliveryEventIds, + ...relayTargets + .where((target) => target.state != RelayDeliveryState.acked) + .map((target) => target.eventId), + }; + final deliveredEventIds = deliveryRecords + .where((record) => record.status == EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final plan = EventEvictionPlanner.planFromStateRecords( + stateRecords: stateRecords, + lockedEventIds: lockedEventIds, + deliveredEventIds: deliveredEventIds, + policy: policy, + ); + + if (plan.eventIdsToRemove.isNotEmpty) { + final removedEvents = rawEvents + .where((event) => plan.eventIdsToRemove.contains(event.id)) + .toList(); + await _eventsStore + .records(plan.eventIdsToRemove.toList()) + .delete(_database); + await _removeEventSidecarsByIds(plan.eventIdsToRemove); + await _refreshDerivedStateForPubKeys( + removedEvents.map((event) => event.pubKey).toSet(), + ); + } + + // Sweep leftover delivery records whose event was kept (or never stored). + // A terminally failed record swept here unpins its event for the next run. + final remainingDeliveryRecords = deliveryRecords + .where((record) => !plan.eventIdsToRemove.contains(record.eventId)) + .toList(); + final deliverySweep = EventEvictionPlanner.planDeliverySweep( + deliveryRecords: remainingDeliveryRecords, + policy: policy, + ); + if (deliverySweep.deliveryEventIdsToRemove.isNotEmpty) { + final ids = deliverySweep.deliveryEventIdsToRemove.toList(); + await _eventDeliveryStore.records(ids).delete(_database); + await _relayDeliveryTargetStore.delete( + _database, + finder: sembast.Finder(filter: sembast.Filter.inList('eventId', ids)), + ); + } + + return plan.toResult().copyWith( + removedCompletedDeliveries: deliverySweep.removedCompletedDeliveries, + removedTerminalFailedDeliveries: + deliverySweep.removedTerminalFailedDeliveries, + ); + } + @override Future> loadEvents({ List? ids, @@ -93,6 +470,30 @@ class SembastCacheManager extends CacheManager { int? until, String? search, int? limit, + }) async { + return _loadEventsInternal( + ids: ids, + pubKeys: pubKeys, + kinds: kinds, + tags: tags, + since: since, + until: until, + search: search, + limit: limit, + applyVisibilityRules: true, + ); + } + + Future> _loadEventsInternal({ + List? ids, + List? pubKeys, + List? kinds, + Map>? tags, + int? since, + int? until, + String? search, + int? limit, + required bool applyVisibilityRules, }) async { // Build filter conditions final filters = []; @@ -137,9 +538,13 @@ class SembastCacheManager extends CacheManager { .map((record) => Nip01EventExtension.fromJsonStorage(record.value)) .toList(); + final visibleEvents = applyVisibilityRules + ? await _applyEventVisibilityRules(events) + : events; + // Filter by tags if specified (done in memory since Sembast doesn't support complex tag filtering) if (tags != null && tags.isNotEmpty) { - return events.where((event) { + return visibleEvents.where((event) { return tags.entries.every((tagEntry) { var tagName = tagEntry.key; final tagValues = tagEntry.value; @@ -163,11 +568,21 @@ class SembastCacheManager extends CacheManager { }).toList(); } - return events; + return visibleEvents; } @override Future loadMetadata(String pubKey) async { + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: Metadata.kKind, + ); + if (event != null) { + final metadata = Metadata.fromEvent(event); + metadata.refreshedTimestamp = Helpers.now; + return metadata; + } + final data = await _metadataStore.record(pubKey).get(_database); if (data == null) return null; return MetadataExtension.fromJsonStorage(data); @@ -175,11 +590,7 @@ class SembastCacheManager extends CacheManager { @override Future> loadMetadatas(List pubKeys) async { - final snapshots = await _metadataStore.records(pubKeys).get(_database); - return snapshots.map((data) { - if (data == null) return null; - return MetadataExtension.fromJsonStorage(data); - }).toList(); + return Future.wait(pubKeys.map(loadMetadata)); } @override @@ -220,31 +631,56 @@ class SembastCacheManager extends CacheManager { @override Future loadUserRelayList(String pubKey) async { final data = await _relayListStore.record(pubKey).get(_database); - if (data == null) return null; - return UserRelayListExtension.fromJsonStorage(data); + if (data != null) { + return UserRelayListExtension.fromJsonStorage(data); + } + + await _refreshUserRelayListProjection(pubKey); + final refreshed = await _relayListStore.record(pubKey).get(_database); + if (refreshed == null) return null; + return UserRelayListExtension.fromJsonStorage(refreshed); } @override Future removeAllContactLists() async { await _contactListStore.delete(_database); + await removeEvents(kinds: [ContactList.kKind]); } @override Future removeAllEvents() async { await _eventsStore.delete(_database); + await _eventCacheStateStore.delete(_database); + await _eventSourceStore.delete(_database); + await _eventDeliveryStore.delete(_database); + await _relayDeliveryTargetStore.delete(_database); + await _decryptedEventPayloadStore.delete(_database); + await _relayListStore.delete(_database); } @override Future removeAllEventsByPubKey(String pubKey) async { + final events = await _eventsStore.find( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('pubkey', pubKey)), + ); + final finder = sembast.Finder( filter: sembast.Filter.equals('pubkey', pubKey), ); await _eventsStore.delete(_database, finder: finder); + await _removeEventSidecarsByIds( + events.map((record) => record.key).toList(), + ); + await _contactListStore.record(pubKey).delete(_database); + await _metadataStore.record(pubKey).delete(_database); + await _refreshUserRelayListProjection(pubKey); } @override Future removeAllMetadatas() async { await _metadataStore.delete(_database); + await removeEvents(kinds: [Metadata.kKind]); } @override @@ -265,11 +701,17 @@ class SembastCacheManager extends CacheManager { @override Future removeContactList(String pubKey) async { await _contactListStore.record(pubKey).delete(_database); + await removeEvents(pubKeys: [pubKey], kinds: [ContactList.kKind]); } @override Future removeEvent(String id) async { + final removed = await loadEvent(id); await _eventsStore.record(id).delete(_database); + await _removeEventSidecarsByIds([id]); + if (removed != null) { + await _refreshDerivedStateForEvent(removed); + } } @override @@ -312,13 +754,15 @@ class SembastCacheManager extends CacheManager { // If tags are specified, we need to load events first and filter in memory if (tags != null && tags.isNotEmpty) { - final finder = sembast.Finder( - filter: filters.isNotEmpty ? sembast.Filter.and(filters) : null, + final events = await _loadEventsInternal( + ids: ids, + pubKeys: pubKeys, + kinds: kinds, + tags: null, + since: since, + until: until, + applyVisibilityRules: false, ); - final records = await _eventsStore.find(_database, finder: finder); - final events = records - .map((record) => Nip01EventExtension.fromJsonStorage(record.value)) - .toList(); // Filter by tags in memory final matchingEvents = events.where((event) { @@ -344,21 +788,64 @@ class SembastCacheManager extends CacheManager { }).toList(); // Delete matching events by ID - await _eventsStore - .records(matchingEvents.map((e) => e.id).toList()) - .delete(_database); + final eventIdsToRemove = matchingEvents.map((event) => event.id).toList(); + await _eventsStore.records(eventIdsToRemove).delete(_database); + await _removeEventSidecarsByIds(eventIdsToRemove); + await _refreshDerivedStateForPubKeys( + matchingEvents.map((event) => event.pubKey).toSet(), + ); } else { // No tags filter, delete directly with finder + final matchingEvents = await _loadEventsInternal( + ids: ids, + pubKeys: pubKeys, + kinds: kinds, + tags: tags, + since: since, + until: until, + applyVisibilityRules: false, + ); final finder = sembast.Finder( filter: filters.isNotEmpty ? sembast.Filter.and(filters) : null, ); await _eventsStore.delete(_database, finder: finder); + await _removeEventSidecarsByIds( + matchingEvents.map((event) => event.id).toList(), + ); + await _refreshDerivedStateForPubKeys( + matchingEvents.map((event) => event.pubKey).toSet(), + ); } } + Future _removeEventSidecarsByIds(Iterable eventIds) async { + final ids = eventIds.toSet().toList(); + if (ids.isEmpty) return; + + final filter = sembast.Filter.inList('eventId', ids); + await _eventSourceStore.delete( + _database, + finder: sembast.Finder(filter: filter), + ); + await _eventDeliveryStore.delete( + _database, + finder: sembast.Finder(filter: filter), + ); + await _relayDeliveryTargetStore.delete( + _database, + finder: sembast.Finder(filter: filter), + ); + await _decryptedEventPayloadStore.delete( + _database, + finder: sembast.Finder(filter: filter), + ); + await _eventCacheStateStore.records(ids).delete(_database); + } + @override Future removeMetadata(String pubKey) async { await _metadataStore.record(pubKey).delete(_database); + await removeEvents(pubKeys: [pubKey], kinds: [Metadata.kKind]); } @override @@ -379,16 +866,18 @@ class SembastCacheManager extends CacheManager { @override Future saveContactList(ContactList contactList) async { + final event = contactList.toEvent(); + await saveEvent(event); await _contactListStore .record(contactList.pubKey) - .put(_database, contactList.toJsonForStorage()); + .put(_database, ContactList.fromEvent(event).toJsonForStorage()); } @override Future saveContactLists(List contactLists) async { - final keys = contactLists.map((c) => c.pubKey).toList(); - final values = contactLists.map((c) => c.toJsonForStorage()).toList(); - await _contactListStore.records(keys).put(_database, values); + for (final contactList in contactLists) { + await saveContactList(contactList); + } } @override @@ -396,6 +885,7 @@ class SembastCacheManager extends CacheManager { await _eventsStore .record(event.id) .put(_database, event.toJsonForStorage()); + await _refreshDerivedStateForEvent(event); } @override @@ -403,20 +893,27 @@ class SembastCacheManager extends CacheManager { final keys = events.map((e) => e.id).toList(); final values = events.map((e) => e.toJsonForStorage()).toList(); await _eventsStore.records(keys).put(_database, values); + await _refreshDerivedStateForPubKeys( + events.map((event) => event.pubKey).toSet(), + ); } @override Future saveMetadata(Metadata metadata) async { + final event = metadata.toEvent(); + await saveEvent(event); + final normalized = Metadata.fromEvent(event); + normalized.refreshedTimestamp = metadata.refreshedTimestamp; await _metadataStore .record(metadata.pubKey) - .put(_database, metadata.toJsonForStorage()); + .put(_database, normalized.toJsonForStorage()); } @override Future saveMetadatas(List metadatas) async { - final keys = metadatas.map((m) => m.pubKey).toList(); - final values = metadatas.map((m) => m.toJsonForStorage()).toList(); - await _metadataStore.records(keys).put(_database, values); + for (final metadata in metadatas) { + await saveMetadata(metadata); + } } @override @@ -455,6 +952,95 @@ class SembastCacheManager extends CacheManager { await _relayListStore.records(keys).put(_database, values); } + Future> _applyEventVisibilityRules( + List events, + ) async { + final visible = []; + final replaceableWinners = {}; + final now = Nip01Event.secondsSinceEpoch(); + final deletionEvents = await _loadDeletionEvents(); + + for (final event in events) { + if (_isExpired(event, now)) continue; + if (_isDeletedByAuthor(event, deletionEvents)) continue; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey == null) { + visible.add(event); + continue; + } + + final current = replaceableWinners[coordinateKey]; + if (current == null || _isMoreRecentReplaceable(event, current)) { + replaceableWinners[coordinateKey] = event; + } + } + + visible.addAll(replaceableWinners.values); + return visible; + } + + bool _isDeletedByAuthor(Nip01Event target, List deletionEvents) { + if (target.kind == 5) return false; + + // Addressable/replaceable events are deleted by coordinate (`a` tag), so a + // later version published after the deletion stays visible (NIP-09 only + // deletes coordinate matches with created_at <= the deletion). + final coordinate = _coordinateKey(target); + + for (final event in deletionEvents) { + if (event.kind != 5) continue; + if (event.pubKey != target.pubKey) continue; + if (event.getTags('e').contains(target.id.toLowerCase())) { + return true; + } + if (coordinate != null && + event.createdAt >= target.createdAt && + event.getTags('a').contains(coordinate)) { + return true; + } + } + + return false; + } + + Future> _loadDeletionEvents() async { + final records = await _eventsStore.find( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('kind', 5)), + ); + + return records + .map((record) => Nip01EventExtension.fromJsonStorage(record.value)) + .toList(); + } + + bool _isExpired(Nip01Event event, int now) { + final expirationValue = event.getFirstTag('expiration'); + if (expirationValue == null) return false; + final expiration = int.tryParse(expirationValue); + if (expiration == null) return false; + return expiration <= now; + } + + String? _coordinateKey(Nip01Event event) { + if (!_isReplaceableKind(event.kind)) return null; + final dTag = event.getDtag() ?? ''; + return '${event.kind}:${event.pubKey}:$dTag'; + } + + bool _isReplaceableKind(int kind) { + return EventKindClassification.isReplaceableKind(kind); + } + + bool _isMoreRecentReplaceable(Nip01Event candidate, Nip01Event current) { + if (candidate.createdAt != current.createdAt) { + return candidate.createdAt > current.createdAt; + } + + return candidate.id.compareTo(current.id) < 0; + } + @override @Deprecated('Use loadEvents() instead') Future> searchEvents({ @@ -481,40 +1067,146 @@ class SembastCacheManager extends CacheManager { @override Future> searchMetadatas(String search, int limit) async { - sembast.Filter? filter; - if (search.isNotEmpty) { - final pattern = RegExp.escape(search); - filter = sembast.Filter.or([ - sembast.Filter.matchesRegExp( - 'name', RegExp(pattern, caseSensitive: false)), - sembast.Filter.matchesRegExp( - 'displayName', RegExp(pattern, caseSensitive: false)), - sembast.Filter.matchesRegExp( - 'about', RegExp(pattern, caseSensitive: false)), - sembast.Filter.matchesRegExp( - 'nip05', RegExp(pattern, caseSensitive: false)), - ]); - } + final events = await loadEvents(kinds: [Metadata.kKind]); + final normalizedSearch = search.trim().toLowerCase(); + final matches = events.map((event) => Metadata.fromEvent(event)).where(( + metadata, + ) { + if (normalizedSearch.isEmpty) return true; + return metadata.matchesSearch(normalizedSearch) || + (metadata.about?.toLowerCase().contains(normalizedSearch) ?? false) || + (metadata.cleanNip05?.contains(normalizedSearch) ?? false); + }).toList()..sort((a, b) => (b.updatedAt ?? 0).compareTo(a.updatedAt ?? 0)); + return matches.take(limit); + } - final finder = sembast.Finder( - filter: filter, - limit: limit, - sortOrders: [sembast.SortOrder('updatedAt', false)], - ); + Future _loadLatestVisibleEvent({ + required String pubKey, + required int kind, + }) async { + final events = await loadEvents(pubKeys: [pubKey], kinds: [kind], limit: 1); + if (events.isEmpty) return null; + return events.first; + } - final records = await _metadataStore.find(_database, finder: finder); + Future _refreshDerivedStateForEvent(Nip01Event event) async { + await _refreshDerivedStateForPubKeys({event.pubKey}); + } + + Future _refreshDerivedStateForPubKeys(Set pubKeys) async { + for (final pubKey in pubKeys) { + final rawPubKeyEvents = await _loadEventsInternal( + pubKeys: [pubKey], + applyVisibilityRules: false, + ); + await _eventCacheStateStore.delete( + _database, + finder: sembast.Finder(filter: sembast.Filter.equals('pubKey', pubKey)), + ); + final stateRecords = EventCacheStateRecord.buildForEvents( + rawPubKeyEvents, + ); + if (stateRecords.isNotEmpty) { + await _eventCacheStateStore + .records(stateRecords.map((record) => record.eventId).toList()) + .put( + _database, + stateRecords.map((record) => record.toJson()).toList(), + ); + } + + final metadataEvent = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: Metadata.kKind, + ); + if (metadataEvent == null) { + await _metadataStore.record(pubKey).delete(_database); + } else { + final metadata = Metadata.fromEvent(metadataEvent); + metadata.refreshedTimestamp = Helpers.now; + await _metadataStore + .record(pubKey) + .put(_database, metadata.toJsonForStorage()); + } + + final contactListEvent = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: ContactList.kKind, + ); + if (contactListEvent == null) { + await _contactListStore.record(pubKey).delete(_database); + } else { + await _contactListStore + .record(pubKey) + .put( + _database, + ContactList.fromEvent(contactListEvent).toJsonForStorage(), + ); + } + + await _refreshUserRelayListProjection(pubKey); + } + } + + Future> _loadEventCacheStateRecords() async { + final records = await _eventCacheStateStore.find(_database); return records - .map((record) => MetadataExtension.fromJsonStorage(record.value)) + .map((record) => EventCacheStateRecord.fromJson(record.value)) .toList(); } + Future _refreshUserRelayListProjection(String pubKey) async { + final events = await loadEvents( + pubKeys: [pubKey], + kinds: [Nip65.kKind, ContactList.kKind], + ); + + Nip01Event? latestNip65; + Nip01Event? latestContactListWithRelays; + for (final event in events) { + if (event.kind == Nip65.kKind) { + latestNip65 ??= event; + } else if (event.kind == ContactList.kKind && + ContactList.relaysFromContent(event).isNotEmpty) { + latestContactListWithRelays ??= event; + } + } + + if (latestNip65 != null) { + await _relayListStore + .record(pubKey) + .put( + _database, + UserRelayList.fromNip65( + Nip65.fromEvent(latestNip65), + ).toJsonForStorage(), + ); + return; + } + + if (latestContactListWithRelays != null) { + await _relayListStore + .record(pubKey) + .put( + _database, + UserRelayList.fromNip02EventContent( + latestContactListWithRelays, + ).toJsonForStorage(), + ); + return; + } + + await _relayListStore.record(pubKey).delete(_database); + } + // ===================== // Filter Fetched Ranges // ===================== @override Future saveFilterFetchedRangeRecord( - FilterFetchedRangeRecord record) async { + FilterFetchedRangeRecord record, + ) async { await _filterFetchedRangeStore .record(record.key) .put(_database, record.toJson()); @@ -522,7 +1214,8 @@ class SembastCacheManager extends CacheManager { @override Future saveFilterFetchedRangeRecords( - List records) async { + List records, + ) async { final keys = records.map((r) => r.key).toList(); final values = records.map((r) => r.toJson()).toList(); await _filterFetchedRangeStore.records(keys).put(_database, values); @@ -530,12 +1223,15 @@ class SembastCacheManager extends CacheManager { @override Future> loadFilterFetchedRangeRecords( - String filterHash) async { + String filterHash, + ) async { final finder = sembast.Finder( filter: sembast.Filter.equals('filterHash', filterHash), ); - final records = - await _filterFetchedRangeStore.find(_database, finder: finder); + final records = await _filterFetchedRangeStore.find( + _database, + finder: finder, + ); return records .map((r) => FilterFetchedRangeRecord.fromJson(r.value)) .toList(); @@ -543,15 +1239,19 @@ class SembastCacheManager extends CacheManager { @override Future> loadFilterFetchedRangeRecordsByRelay( - String filterHash, String relayUrl) async { + String filterHash, + String relayUrl, + ) async { final finder = sembast.Finder( filter: sembast.Filter.and([ sembast.Filter.equals('filterHash', filterHash), sembast.Filter.equals('relayUrl', relayUrl), ]), ); - final records = - await _filterFetchedRangeStore.find(_database, finder: finder); + final records = await _filterFetchedRangeStore.find( + _database, + finder: finder, + ); return records .map((r) => FilterFetchedRangeRecord.fromJson(r.value)) .toList(); @@ -559,12 +1259,14 @@ class SembastCacheManager extends CacheManager { @override Future> - loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { + loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { final finder = sembast.Finder( filter: sembast.Filter.equals('relayUrl', relayUrl), ); - final records = - await _filterFetchedRangeStore.find(_database, finder: finder); + final records = await _filterFetchedRangeStore.find( + _database, + finder: finder, + ); return records .map((r) => FilterFetchedRangeRecord.fromJson(r.value)) .toList(); @@ -580,7 +1282,9 @@ class SembastCacheManager extends CacheManager { @override Future removeFilterFetchedRangeRecordsByFilterAndRelay( - String filterHash, String relayUrl) async { + String filterHash, + String relayUrl, + ) async { final finder = sembast.Finder( filter: sembast.Filter.and([ sembast.Filter.equals('filterHash', filterHash), @@ -739,8 +1443,9 @@ class SembastCacheManager extends CacheManager { // 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); + final existingMintInfo = CashuMintInfoExtension.fromJsonStorage( + record.value, + ); if (existingMintInfo.urls.contains(mintInfo.urls.first)) { await _mintInfoStore.record(record.key).delete(_database); } @@ -753,16 +1458,16 @@ class SembastCacheManager extends CacheManager { } @override - Future removeMintInfo({ - required String mintUrl, - }) async { + Future removeMintInfo({required String mintUrl}) async { // Find and delete all records that contain this mintUrl final allRecords = await _mintInfoStore.find(_database); for (final record in allRecords) { - final existingMintInfo = - CashuMintInfoExtension.fromJsonStorage(record.value); - if (existingMintInfo.urls - .any((url) => existingMintInfo.isMintUrl(mintUrl))) { + final existingMintInfo = CashuMintInfoExtension.fromJsonStorage( + record.value, + ); + if (existingMintInfo.urls.any( + (url) => existingMintInfo.isMintUrl(mintUrl), + )) { await _mintInfoStore.record(record.key).delete(_database); } } @@ -797,6 +1502,10 @@ class SembastCacheManager extends CacheManager { Future clearAll() async { await Future.wait([ _eventsStore.delete(_database), + _eventSourceStore.delete(_database), + _eventDeliveryStore.delete(_database), + _relayDeliveryTargetStore.delete(_database), + _decryptedEventPayloadStore.delete(_database), _metadataStore.delete(_database), _contactListStore.delete(_database), _relayListStore.delete(_database), @@ -809,4 +1518,8 @@ class SembastCacheManager extends CacheManager { _secretCounterStore.delete(_database), ]); } + + String _eventSourceKey(String eventId, String relayUrl) { + return '$eventId|$relayUrl'; + } } 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 index e254ecb74..fd359a14a 100644 --- a/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart +++ b/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart @@ -20,9 +20,7 @@ final headers = {'Content-Type': 'application/json'}; class CashuRepoImpl implements CashuRepo { final HttpRequestDS client; - CashuRepoImpl({ - required this.client, - }); + CashuRepoImpl({required this.client}); @override Future> swap({ required String mintUrl, @@ -39,11 +37,7 @@ class CashuRepoImpl implements CashuRepo { }; final response = await client - .post( - url: Uri.parse(url), - body: jsonEncode(body), - headers: headers, - ) + .post(url: Uri.parse(url), body: jsonEncode(body), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -80,10 +74,7 @@ class CashuRepoImpl implements CashuRepo { final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'keysets'); final response = await client - .get( - url: Uri.parse(url), - headers: headers, - ) + .get(url: Uri.parse(url), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -103,10 +94,12 @@ class CashuRepoImpl implements CashuRepo { } final List keysetsUnparsed = responseBody['keysets']; return keysetsUnparsed - .map((e) => CahsuKeysetResponse.fromServerMap( - map: e as Map, - mintUrl: mintUrl, - )) + .map( + (e) => CahsuKeysetResponse.fromServerMap( + map: e as Map, + mintUrl: mintUrl, + ), + ) .toList(); } @@ -125,10 +118,7 @@ class CashuRepoImpl implements CashuRepo { } final response = await client - .get( - url: Uri.parse(url), - headers: headers, - ) + .get(url: Uri.parse(url), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -146,10 +136,12 @@ class CashuRepoImpl implements CashuRepo { } final List keysUnparsed = responseBody['keysets']; return keysUnparsed - .map((e) => CahsuKeysResponse.fromServerMap( - map: e as Map, - mintUrl: mintUrl, - )) + .map( + (e) => CahsuKeysResponse.fromServerMap( + map: e as Map, + mintUrl: mintUrl, + ), + ) .toList(); } @@ -163,8 +155,10 @@ class CashuRepoImpl implements CashuRepo { }) async { CashuKeypair quoteKey = CashuKeypair.generateCashuKeyPair(); - final url = - CashuTools.composeUrl(mintUrl: mintUrl, path: 'mint/quote/$method'); + final url = CashuTools.composeUrl( + mintUrl: mintUrl, + path: 'mint/quote/$method', + ); final body = { 'amount': amount, @@ -204,13 +198,12 @@ class CashuRepoImpl implements CashuRepo { 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, + 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}', @@ -222,9 +215,7 @@ class CashuRepoImpl implements CashuRepo { throw Exception('Invalid response format: $responseBody'); } - return CashuQuoteState.fromValue( - responseBody['state'] as String, - ); + return CashuQuoteState.fromValue(responseBody['state'] as String); } @override @@ -250,11 +241,7 @@ class CashuRepoImpl implements CashuRepo { final body = { 'quote': quote, 'outputs': blindedMessagesOutputs.map((e) { - return { - 'id': e.id, - 'amount': e.amount, - 'B_': e.blindedMessage, - }; + return {'id': e.id, 'amount': e.amount, 'B_': e.blindedMessage}; }).toList(), "signature": quoteSignature, }; @@ -294,20 +281,15 @@ class CashuRepoImpl implements CashuRepo { required String unit, required String method, }) async { - final url = - CashuTools.composeUrl(mintUrl: mintUrl, path: 'melt/quote/$method'); + final url = CashuTools.composeUrl( + mintUrl: mintUrl, + path: 'melt/quote/$method', + ); - final body = { - 'request': request, - 'unit': unit, - }; + final body = {'request': request, 'unit': unit}; final response = await client - .post( - url: Uri.parse(url), - body: jsonEncode(body), - headers: headers, - ) + .post(url: Uri.parse(url), body: jsonEncode(body), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -335,13 +317,12 @@ class CashuRepoImpl implements CashuRepo { 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, + 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}', @@ -353,10 +334,7 @@ class CashuRepoImpl implements CashuRepo { throw Exception('Invalid response format: $responseBody'); } - return CashuQuoteMelt.fromServerMap( - json: responseBody, - mintUrl: mintUrl, - ); + return CashuQuoteMelt.fromServerMap(json: responseBody, mintUrl: mintUrl); } @override @@ -370,16 +348,12 @@ class CashuRepoImpl implements CashuRepo { final body = { 'quote': quoteId, 'inputs': proofs.map((e) => e.toJson()).toList(), - 'outputs': outputs.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, - ) + .post(url: Uri.parse(url), body: jsonEncode(body), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -407,12 +381,7 @@ class CashuRepoImpl implements CashuRepo { Future getMintInfo({required String mintUrl}) { final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'info'); - return client - .get( - url: Uri.parse(url), - headers: headers, - ) - .then((response) { + 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}', @@ -433,15 +402,9 @@ class CashuRepoImpl implements CashuRepo { }) async { final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'checkstate'); - final body = { - 'Ys': proofPubkeys, - }; + final body = {'Ys': proofPubkeys}; final response = await client - .post( - url: Uri.parse(url), - body: jsonEncode(body), - headers: headers, - ) + .post(url: Uri.parse(url), body: jsonEncode(body), headers: headers) .timeout( CashuConfig.NETWORK_TIMEOUT, onTimeout: () => throw Exception( @@ -463,9 +426,10 @@ class CashuRepoImpl implements CashuRepo { } return statesUnparsed - .map((e) => CashuTokenStateResponse.fromServerMap( - e as Map, - )) + .map( + (e) => + CashuTokenStateResponse.fromServerMap(e as Map), + ) .toList(); } @@ -476,9 +440,7 @@ class CashuRepoImpl implements CashuRepo { }) async { final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'restore'); - final body = { - 'outputs': outputs.map((e) => e.toJson()).toList(), - }; + final body = {'outputs': outputs.map((e) => e.toJson()).toList()}; final response = await client.post( url: Uri.parse(url), 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 index 62af5ed7f..43feda13d 100644 --- 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 @@ -43,14 +43,21 @@ class DartCashuKeyDerivation implements CashuKeyDerivation { // Choose derivation method based on keyset version if (keysetId.startsWith('00')) { return _deriveDeprecatedWithSeed( - seed: seedBytes, keysetId: keysetId, counter: counter); + seed: seedBytes, + keysetId: keysetId, + counter: counter, + ); } else if (keysetId.startsWith('01')) { return _deriveModernWithSeed( - seed: seedBytes, keysetId: keysetId, counter: counter); + seed: seedBytes, + keysetId: keysetId, + counter: counter, + ); } throw Exception( - 'Unrecognized keyset ID version ${keysetId.substring(0, 2)}'); + 'Unrecognized keyset ID version ${keysetId.substring(0, 2)}', + ); } /// Modern derivation method with explicit seed parameter 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 index cbe8680c8..772aab682 100644 --- 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 @@ -17,9 +17,11 @@ class FakeCashuSeedGenerator implements CashuKeyDerivation { final fakeBlindingHex = 'cafebabe${counter.toRadixString(16).padLeft(24, '0')}'; - return Future.value(CashuSeedDeriveSecretResult( - secretHex: fakeSecretHex, - blindingHex: fakeBlindingHex, - )); + 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 db6ea7ef7..7ace68bb8 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,13 +19,17 @@ 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) { - Logger.log - .d(() => "${_websocketDS.url} connection state changed to $state"); + Logger.log.d( + () => "${_websocketDS.url} connection state changed to $state", + ); switch (state) { case Connected() || Reconnected(): completer.complete(); @@ -47,8 +51,10 @@ class WebSocketClientNostrTransport implements NostrTransport { // Do nothing, just waiting for reconnection to be established break; default: - Logger.log.w(() => - "${_websocketDS.url} connection state changed to unknown state: $state"); + Logger.log.w( + () => + "${_websocketDS.url} connection state changed to unknown state: $state", + ); } }); } @@ -74,8 +80,11 @@ class WebSocketClientNostrTransport implements NostrTransport { /// /// Returns a StreamSubscription that can be used to control the subscription. @override - StreamSubscription listen(void Function(dynamic p1) onData, - {Function? onError, void Function()? onDone}) { + StreamSubscription listen( + void Function(dynamic p1) onData, { + Function? onError, + void Function()? onDone, + }) { return _websocketDS.listen(onData, onError: onError, onDone: onDone); } 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 4c0cc36d3..3ea113161 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,8 +7,11 @@ 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 +19,22 @@ class WebSocketClientNostrTransportFactory implements NostrTransportFactory { } final backoff = BinaryExponentialBackoff( - initial: Duration(milliseconds: 500), maximumStep: 4); - final client = WebSocket(Uri.parse(myUrl), - backoff: backoff, - timeout: Duration(seconds: 3600), - pingInterval: Duration(seconds: 10), - binaryType: 'arraybuffer'); + initial: Duration(milliseconds: 500), + maximumStep: 4, + ); + final client = WebSocket( + Uri.parse(myUrl), + backoff: backoff, + timeout: Duration(seconds: 3600), + pingInterval: Duration(seconds: 10), + 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.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport.dart index d344cf5e1..e3243a45a 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport.dart @@ -39,8 +39,11 @@ class WebSocketNostrTransport implements NostrTransport { /// /// Returns a StreamSubscription that can be used to control the subscription. @override - StreamSubscription listen(void Function(dynamic p1) onData, - {Function? onError, void Function()? onDone}) { + StreamSubscription listen( + void Function(dynamic p1) onData, { + Function? onError, + void Function()? onDone, + }) { return _websocketDS.listen(onData, onError: onError, onDone: onDone); } 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 baca32e03..0d37ebfef 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,8 +7,11 @@ 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/signers/bip340_event_signer.dart b/packages/ndk/lib/data_layer/repositories/signers/bip340_event_signer.dart index d5419c7fa..63f8bc3db 100644 --- a/packages/ndk/lib/data_layer/repositories/signers/bip340_event_signer.dart +++ b/packages/ndk/lib/data_layer/repositories/signers/bip340_event_signer.dart @@ -12,10 +12,7 @@ import '../../../shared/nips/nip44/nip44.dart'; class Bip340EventSignerFactory implements LocalEventSignerFactory { const Bip340EventSignerFactory(); @override - EventSigner create({ - String? privateKey, - String? publicKey, - }) { + EventSigner create({String? privateKey, String? publicKey}) { final derivedPublicKey = publicKey ?? (privateKey != null ? derivePublicKey(privateKey) : null); @@ -57,10 +54,16 @@ class Bip340EventSigner implements EventSigner { String publicKey; /// Get a new event signer with the given keys - Bip340EventSigner({ - required this.privateKey, - required this.publicKey, - }); + Bip340EventSigner({required this.privateKey, required this.publicKey}); + + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; @override Future sign(Nip01Event event) async { @@ -95,11 +98,7 @@ class Bip340EventSigner implements EventSigner { required String plaintext, required String recipientPubKey, }) { - return Nip44.encryptMessage( - plaintext, - privateKey!, - recipientPubKey, - ); + return Nip44.encryptMessage(plaintext, privateKey!, recipientPubKey); } @override @@ -107,11 +106,7 @@ class Bip340EventSigner implements EventSigner { required String ciphertext, required String senderPubKey, }) { - return Nip44.decryptMessage( - ciphertext, - privateKey!, - senderPubKey, - ); + return Nip44.decryptMessage(ciphertext, privateKey!, senderPubKey); } // Local signer - no pending requests (operations are instant) diff --git a/packages/ndk/lib/data_layer/repositories/signers/nip46_event_signer.dart b/packages/ndk/lib/data_layer/repositories/signers/nip46_event_signer.dart index 0bf04aef3..431efdc7a 100644 --- a/packages/ndk/lib/data_layer/repositories/signers/nip46_event_signer.dart +++ b/packages/ndk/lib/data_layer/repositories/signers/nip46_event_signer.dart @@ -99,10 +99,12 @@ class Nip46EventSigner with ConcurrencyLimiterMixin implements EventSigner { _notifyPendingRequestsChange(); if (response["error"] != null && response["result"] != "auth_url") { - entry.completer.completeError(SignerRequestRejectedException( - requestId: response["id"], - originalMessage: response["error"], - )); + entry.completer.completeError( + SignerRequestRejectedException( + requestId: response["id"], + originalMessage: response["error"], + ), + ); } else { entry.completer.complete(response["result"]); } @@ -110,8 +112,9 @@ class Nip46EventSigner with ConcurrencyLimiterMixin implements EventSigner { } void _notifyPendingRequestsChange() { - _pendingRequestsController - .add(_pendingRequests.values.map((e) => e.request).toList()); + _pendingRequestsController.add( + _pendingRequests.values.map((e) => e.request).toList(), + ); } Future remoteRequest({ @@ -176,6 +179,15 @@ class Nip46EventSigner with ConcurrencyLimiterMixin implements EventSigner { return true; } + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => true; + + @override + Iterable get signerTransportRelayUrls => connection.relays; + @override Future decrypt(String msg, String destPubKey) async { final request = BunkerRequest( @@ -272,10 +284,7 @@ class Nip46EventSigner with ConcurrencyLimiterMixin implements EventSigner { params: [jsonEncode(eventMap)], ); - final signedEventJson = await remoteRequest( - request: request, - event: event, - ); + final signedEventJson = await remoteRequest(request: request, event: event); final signedEvent = Nip01EventModel.fromJson(jsonDecode(signedEventJson)); diff --git a/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_native.dart b/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_native.dart index ada348a80..8f2dc1cd8 100644 --- a/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_native.dart +++ b/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_native.dart @@ -36,10 +36,17 @@ class QsRustEventSigner implements EventSigner { /// Creates a [QsRustEventSigner] from an existing [QsKeypair]. /// /// Use [QsRustEventSigner.generate] to create a new keypair first. - QsRustEventSigner({ - required QsKeypair keypair, - this.level = 2, - }) : _keypair = keypair; + QsRustEventSigner({required QsKeypair keypair, this.level = 2}) + : _keypair = keypair; + + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; /// Generates a new Dilithium keypair. /// Its only added here for testing purposes! @@ -64,16 +71,19 @@ class QsRustEventSigner implements EventSigner { if (result != 1) { throw StateError( - 'Failed to generate Dilithium keypair at level $level'); + 'Failed to generate Dilithium keypair at level $level', + ); } final pkLen = outPk.ref.len; - final publicKeyBytes = - Uint8List.fromList(outPk.ref.data.asTypedList(pkLen)); + final publicKeyBytes = Uint8List.fromList( + outPk.ref.data.asTypedList(pkLen), + ); final skLen = outSk.ref.len; - final keypairBytes = - Uint8List.fromList(outSk.ref.data.asTypedList(skLen)); + final keypairBytes = Uint8List.fromList( + outSk.ref.data.asTypedList(skLen), + ); rust_lib.qsFreeBuffer(outPk.ref); rust_lib.qsFreeBuffer(outSk.ref); @@ -151,14 +161,16 @@ class QsRustEventSigner implements EventSigner { @Deprecated('Use nip44 decrypt instead. Deprecated by nostr spec. (nip04)') Future decrypt(String msg, String destPubKey) { throw UnimplementedError( - 'NIP-04 decrypt is not supported by QsRustEventSigner'); + 'NIP-04 decrypt is not supported by QsRustEventSigner', + ); } @override @Deprecated('Use nip44 encrypt instead. Deprecated by nostr spec. (nip04)') Future encrypt(String msg, String destPubKey) { throw UnimplementedError( - 'NIP-04 encrypt is not supported by QsRustEventSigner'); + 'NIP-04 encrypt is not supported by QsRustEventSigner', + ); } @override @@ -167,7 +179,8 @@ class QsRustEventSigner implements EventSigner { required String recipientPubKey, }) { throw UnimplementedError( - 'NIP-44 encrypt is not supported by QsRustEventSigner'); + 'NIP-44 encrypt is not supported by QsRustEventSigner', + ); } @override @@ -176,7 +189,8 @@ class QsRustEventSigner implements EventSigner { required String senderPubKey, }) { throw UnimplementedError( - 'NIP-44 decrypt is not supported by QsRustEventSigner'); + 'NIP-44 decrypt is not supported by QsRustEventSigner', + ); } @override diff --git a/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_stub.dart b/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_stub.dart index 5dfde7a22..56eadeadd 100644 --- a/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_stub.dart +++ b/packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_stub.dart @@ -26,6 +26,15 @@ class QsRustEventSigner implements EventSigner { QsRustEventSigner({required QsKeypair keypair, this.level = 2}); + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + static QsKeypair generateKeypair({int level = 2}) { throw UnsupportedError( 'QsRustEventSigner is not available on this platform. ' 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 772368216..df3fcfc97 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 @@ -19,8 +19,9 @@ class Bip340EventVerifier implements EventVerifier { } if (!Nip01Utils.isIdValid(event)) return false; return useIsolate - ? await IsolateManager.instance.runInComputeIsolate( - (event) { + ? 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/verifiers/rust_event_verifier_native.dart b/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart index af135711b..6fba6c03e 100644 --- a/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart +++ b/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart @@ -39,8 +39,9 @@ class RustEventVerifier implements EventVerifier { // Allocate arrays for tags final tagsLengths = calloc(tagsCount == 0 ? 1 : tagsCount); - final tagsData = - calloc>(totalStrings == 0 ? 1 : totalStrings); + final tagsData = calloc>( + totalStrings == 0 ? 1 : totalStrings, + ); try { // Fill tag data diff --git a/packages/ndk/lib/data_layer/repositories/wallets/mem_wallets_repo.dart b/packages/ndk/lib/data_layer/repositories/wallets/mem_wallets_repo.dart index f044453db..3b305550c 100644 --- a/packages/ndk/lib/data_layer/repositories/wallets/mem_wallets_repo.dart +++ b/packages/ndk/lib/data_layer/repositories/wallets/mem_wallets_repo.dart @@ -53,7 +53,8 @@ class MemWalletsRepo extends WalletsRepo { for (final transaction in transactions) { final existingIndex = this.transactions.indexWhere( - (t) => t.id == transaction.id && t.walletId == transaction.walletId); + (t) => t.id == transaction.id && t.walletId == transaction.walletId, + ); if (existingIndex != -1) { this.transactions[existingIndex] = transaction; } else { @@ -70,8 +71,9 @@ class MemWalletsRepo extends WalletsRepo { return Future.value(); } - transactions - .removeWhere((transaction) => transactionIds.contains(transaction.id)); + transactions.removeWhere( + (transaction) => transactionIds.contains(transaction.id), + ); return Future.value(); } @@ -80,8 +82,9 @@ class MemWalletsRepo extends WalletsRepo { if (ids == null || ids.isEmpty) { return Future.value(wallets.toList()); } else { - final result = - wallets.where((wallet) => ids.contains(wallet.id)).toList(); + final result = wallets + .where((wallet) => ids.contains(wallet.id)) + .toList(); return Future.value(result.isNotEmpty ? result : List.empty()); } } diff --git a/packages/ndk/lib/data_layer/repositories/wallets/sembast_wallets_repo.dart b/packages/ndk/lib/data_layer/repositories/wallets/sembast_wallets_repo.dart index a363f56d4..bc917cc95 100644 --- a/packages/ndk/lib/data_layer/repositories/wallets/sembast_wallets_repo.dart +++ b/packages/ndk/lib/data_layer/repositories/wallets/sembast_wallets_repo.dart @@ -40,8 +40,9 @@ class SembastWalletsRepo extends WalletsRepo { final receiving = await _keyValueStore .record(defaultWalletForReceivingKey) .get(_database); - final sending = - await _keyValueStore.record(defaultWalletForSendingKey).get(_database); + final sending = await _keyValueStore + .record(defaultWalletForSendingKey) + .get(_database); _defaultWalletIdForReceiving = receiving?['value'] as String?; _defaultWalletIdForSending = sending?['value'] as String?; @@ -95,15 +96,14 @@ class SembastWalletsRepo extends WalletsRepo { final records = await _transactionStore.find(_database, finder: finder); return records - .map((record) => - WalletTransactionExtension.fromJsonStorage(record.value)) + .map( + (record) => WalletTransactionExtension.fromJsonStorage(record.value), + ) .toList(); } @override - Future saveTransactions( - List transactions, - ) async { + Future saveTransactions(List transactions) async { await _database.transaction((txn) async { final idsToCheck = transactions.map((t) => t.id).toList(); final finder = sembast.Finder( @@ -141,9 +141,7 @@ class SembastWalletsRepo extends WalletsRepo { .toList(); } - final finder = sembast.Finder( - filter: sembast.Filter.inList('id', ids), - ); + final finder = sembast.Finder(filter: sembast.Filter.inList('id', ids)); final records = await _walletStore.find(_database, finder: finder); return records @@ -176,18 +174,19 @@ class SembastWalletsRepo extends WalletsRepo { @override void setDefaultWalletForReceiving(String? walletId) { _defaultWalletIdForReceiving = walletId; - unawaited(_storeDefaultWallet( - key: defaultWalletForReceivingKey, - walletId: walletId, - )); + unawaited( + _storeDefaultWallet( + key: defaultWalletForReceivingKey, + walletId: walletId, + ), + ); } @override void setDefaultWalletForSending(String? walletId) { _defaultWalletIdForSending = walletId; - unawaited(_storeDefaultWallet( - key: defaultWalletForSendingKey, - walletId: walletId, - )); + unawaited( + _storeDefaultWallet(key: defaultWalletForSendingKey, walletId: walletId), + ); } } diff --git a/packages/ndk/lib/data_layer/repositories/wallets/wallet_extensions.dart b/packages/ndk/lib/data_layer/repositories/wallets/wallet_extensions.dart index b1035bff8..a45071e74 100644 --- a/packages/ndk/lib/data_layer/repositories/wallets/wallet_extensions.dart +++ b/packages/ndk/lib/data_layer/repositories/wallets/wallet_extensions.dart @@ -49,9 +49,7 @@ extension WalletExtension on Wallet { return WalletFactory.fromStorage( id: json['id'] as String, name: json['name'] as String, - type: WalletType.values.firstWhere( - (e) => e.toString() == json['type'], - ), + type: WalletType.values.firstWhere((e) => e.toString() == json['type']), supportedUnits: Set.from(json['supportedUnits'] as List), metadata: Map.from(json['metadata'] as Map? ?? {}), ); diff --git a/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_stub.dart b/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_stub.dart index be2828d42..706d1b5f4 100644 --- a/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_stub.dart +++ b/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_stub.dart @@ -15,19 +15,22 @@ class StubWalletsRepo extends WalletsRepo { WalletType? walletType, }) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override Future saveTransactions(List transactions) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override Future removeTransactions(List? transactionIds) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override @@ -38,36 +41,42 @@ class StubWalletsRepo extends WalletsRepo { @override Future removeWallet(String id) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override Future storeWallet(Wallet wallet) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override String? getDefaultWalletIdForReceiving() { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override String? getDefaultWalletIdForSending() { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override void setDefaultWalletForReceiving(String? walletId) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } @override void setDefaultWalletForSending(String? walletId) { throw UnimplementedError( - "need to set a proper WalletsRepo in NdkConfig to use this method"); + "need to set a proper WalletsRepo in NdkConfig to use this method", + ); } } diff --git a/packages/ndk/lib/domain_layer/entities/blob_upload_progress.dart b/packages/ndk/lib/domain_layer/entities/blob_upload_progress.dart index 5b022e6d2..0e9b88340 100644 --- a/packages/ndk/lib/domain_layer/entities/blob_upload_progress.dart +++ b/packages/ndk/lib/domain_layer/entities/blob_upload_progress.dart @@ -28,17 +28,17 @@ class BlobUploadProgress { int? mirrorsTotal, int? mirrorsCompleted, this.isComplete = false, - }) : progressPhase = - ((progressPhase ?? (totalBytes > 0 ? sentBytes / totalBytes : 0)) - .clamp(0.0, 1.0)) - .toDouble(), - percentagePhase = - ((progressPhase ?? (totalBytes > 0 ? sentBytes / totalBytes : 0)) - .clamp(0.0, 1.0)) - .toDouble() * - 100, - mirrorsTotal = mirrorsTotal ?? 0, - mirrorsCompleted = mirrorsCompleted ?? 0; + }) : progressPhase = + ((progressPhase ?? (totalBytes > 0 ? sentBytes / totalBytes : 0)) + .clamp(0.0, 1.0)) + .toDouble(), + percentagePhase = + ((progressPhase ?? (totalBytes > 0 ? sentBytes / totalBytes : 0)) + .clamp(0.0, 1.0)) + .toDouble() * + 100, + mirrorsTotal = mirrorsTotal ?? 0, + mirrorsCompleted = mirrorsCompleted ?? 0; double get uploadProgress => totalBytes > 0 ? sentBytes / totalBytes : 0; diff --git a/packages/ndk/lib/domain_layer/entities/blossom_strategies.dart b/packages/ndk/lib/domain_layer/entities/blossom_strategies.dart index ffbe0a0f6..fa43f7cdb 100644 --- a/packages/ndk/lib/domain_layer/entities/blossom_strategies.dart +++ b/packages/ndk/lib/domain_layer/entities/blossom_strategies.dart @@ -6,5 +6,5 @@ enum UploadStrategy { allSimultaneous, /// Upload to first successful server only - firstSuccess + firstSuccess, } diff --git a/packages/ndk/lib/domain_layer/entities/broadcast_response.dart b/packages/ndk/lib/domain_layer/entities/broadcast_response.dart index aea8eae83..ec4956bd9 100644 --- a/packages/ndk/lib/domain_layer/entities/broadcast_response.dart +++ b/packages/ndk/lib/domain_layer/entities/broadcast_response.dart @@ -9,10 +9,11 @@ class NdkBroadcastResponse { final Nip01Event publishEvent; final Stream> _broadcastDoneStream; + final Future> _broadcastDoneFuture; /// completes when all relays have responded or timed out Future> get broadcastDoneFuture => - _broadcastDoneStream.last; + _broadcastDoneFuture; /// stream of state updates \ Stream> get broadcastDone => @@ -22,5 +23,7 @@ class NdkBroadcastResponse { NdkBroadcastResponse({ required this.publishEvent, required Stream> broadcastDoneStream, - }) : _broadcastDoneStream = broadcastDoneStream; + Future>? broadcastDoneFuture, + }) : _broadcastDoneStream = broadcastDoneStream, + _broadcastDoneFuture = broadcastDoneFuture ?? broadcastDoneStream.last; } diff --git a/packages/ndk/lib/domain_layer/entities/broadcast_state.dart b/packages/ndk/lib/domain_layer/entities/broadcast_state.dart index 986ac88ef..5f4c6a85e 100644 --- a/packages/ndk/lib/domain_layer/entities/broadcast_state.dart +++ b/packages/ndk/lib/domain_layer/entities/broadcast_state.dart @@ -87,29 +87,30 @@ class BroadcastState { } /// completes when state update controller closes - Future get publishDoneFuture => _stateUpdatesController.last; + Future get publishDoneFuture => _publishDoneCompleter.future; late final StreamSubscription _networkSubscription; Timer? _timeoutTimer; bool _isDisposed = false; + final Completer _publishDoneCompleter = Completer(); bool _timeoutStarted = false; /// creates a new [BroadcastState] instance - BroadcastState({ - required this.timeout, - this.considerDonePercent = 1, - }) { - _networkSubscription = networkController.stream.listen((response) { - // got a response from a relay - broadcasts[response.relayUrl] = response; - // send state update - _stateUpdatesController.add(this); - // check if all relays responded - _checkBroadcastDone(); - }, onDone: () { - _checkBroadcastDone(); - }); + BroadcastState({required this.timeout, this.considerDonePercent = 1}) { + _networkSubscription = networkController.stream.listen( + (response) { + // got a response from a relay + broadcasts[response.relayUrl] = response; + // send state update + _stateUpdatesController.add(this); + // check if all relays responded + _checkBroadcastDone(); + }, + onDone: () { + _checkBroadcastDone(); + }, + ); } /// Starts the timeout timer. Call this after signing is complete. @@ -139,13 +140,18 @@ class BroadcastState { _isDisposed = true; _timeoutTimer?.cancel(); _networkSubscription.cancel(); + if (!_publishDoneCompleter.isCompleted) { + _publishDoneCompleter.complete(this); + } _stateUpdatesController.close(); networkController.close(); } /// Add an error to the broadcast state stream and dispose void addError(Object error, [StackTrace? stackTrace]) { - _stateUpdatesController.addError(error, stackTrace); + if (!_publishDoneCompleter.isCompleted) { + _publishDoneCompleter.completeError(error, stackTrace); + } _dispose(); } diff --git a/packages/ndk/lib/domain_layer/entities/cache_eviction.dart b/packages/ndk/lib/domain_layer/entities/cache_eviction.dart new file mode 100644 index 000000000..b0717b4fe --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cache_eviction.dart @@ -0,0 +1,186 @@ +import 'contact_list.dart'; +import 'metadata.dart'; +import 'nip_51_list.dart'; +import 'nip_65.dart'; +import '../../shared/nips/nip28/channel_metadata.dart'; + +/// Policy used by [CacheManager.evict] and by the background eviction scheduler. +/// +/// The policy is split into two conceptual phases: +/// - structural cleanup: +/// - expired events +/// - author-deleted events +/// - superseded replaceable/addressable events +/// - cap-based cleanup: +/// - keep at most N visible events for some kinds +/// +/// Protection applies to cap-based cleanup, not to structural cleanup. This is +/// deliberate: protected metadata or contact list kinds should still have old +/// superseded versions removed. +class EvictionPolicy { + // TODO doc this + final bool sweepExpired; + final bool sweepDeleted; + final bool sweepSuperseded; + final bool sweepDeliveredEphemeral; + + /// Remove [EventDeliveryRecord]s (and their [RelayDeliveryTarget]s) that + /// reached [EventDeliveryStatus.delivered] longer than + /// [completedDeliveryRetention] ago. + /// + /// Delivery records are otherwise cleaned only when their parent event is + /// physically evicted. Non-ephemeral events usually stay in cache forever, so + /// without this sweep their delivery records — including the redundant + /// serialized event copy — accumulate indefinitely. + final bool sweepCompletedDeliveries; + + /// Minimum age (since `completedAt`) before a delivered record is swept. + final Duration completedDeliveryRetention; + + /// Remove terminally failed [EventDeliveryRecord]s (status + /// [EventDeliveryStatus.failed]) older than + /// [terminalFailedDeliveryRetention]. + /// + /// Off by default: a failed record may still hold the only local copy of an + /// event that never reached any relay, so removing it is potential data loss. + /// A non-acked target also pins its event against eviction, so leaving failed + /// records around forever keeps the event and its sidecars pinned too. Enable + /// this to reclaim that space once failures are considered permanent. + final bool sweepTerminalFailedDeliveries; + + /// Minimum age (since `updatedAt`) before a failed record is swept. + final Duration terminalFailedDeliveryRetention; + + final Map kindCaps; + final Set protectedKinds; + final Set protectedEventIds; + final Set protectedPubKeys; + final Set protectedCoordinates; + + const EvictionPolicy({ + this.sweepExpired = true, + this.sweepDeleted = true, + this.sweepSuperseded = true, + this.sweepDeliveredEphemeral = true, + this.sweepCompletedDeliveries = true, + this.completedDeliveryRetention = const Duration(hours: 8), + this.sweepTerminalFailedDeliveries = false, + this.terminalFailedDeliveryRetention = const Duration(hours: 24), + this.kindCaps = const {}, + this.protectedKinds = kDefaultProtectedKinds, + this.protectedEventIds = const {}, + this.protectedPubKeys = const {}, + this.protectedCoordinates = const {}, + }); + + const EvictionPolicy.safeSweep() + : sweepExpired = true, + sweepDeleted = true, + sweepSuperseded = true, + sweepDeliveredEphemeral = true, + sweepCompletedDeliveries = true, + completedDeliveryRetention = const Duration(hours: 8), + sweepTerminalFailedDeliveries = false, + terminalFailedDeliveryRetention = const Duration(hours: 24), + kindCaps = const {}, + protectedKinds = kDefaultProtectedKinds, + protectedEventIds = const {}, + protectedPubKeys = const {}, + protectedCoordinates = const {}; + + static const Set kDefaultProtectedKinds = { + Metadata.kKind, + ContactList.kKind, + Nip65.kKind, + ChannelMetadata.kKind, + Nip51List.kMute, + Nip51List.kPin, + Nip51List.kBookmarks, + Nip51List.kCommunities, + Nip51List.kPublicChats, + Nip51List.kBlockedRelays, + Nip51List.kSearchRelays, + Nip51List.kInterests, + Nip51List.kEmojis, + Nip51List.kDmRelays, + Nip51List.kFollowSet, + Nip51List.kRelaySet, + Nip51List.kBookmarksSet, + Nip51List.kCurationSet, + Nip51List.kCurationVideoSet, + Nip51List.kKindMuteSet, + Nip51List.kInterestsSet, + Nip51List.kEmojisSet, + Nip51List.kReleaseArtifactSet, + Nip51List.kAppCurationSet, + Nip51List.kCalendar, + Nip51List.kStarterPacks, + Nip51List.kStarterPacksMedia, + }; +} + +/// Summary returned from one eviction run. +class EvictionResult { + final int removedEvents; + final int removedExpired; + final int removedDeleted; + final int removedSuperseded; + final int removedDeliveredEphemeral; + final int removedByKindCap; + final int keptDueToDeliveryState; + final int keptProtected; + + /// Standalone delivery records swept because they were delivered and aged + /// past [EvictionPolicy.completedDeliveryRetention]. + final int removedCompletedDeliveries; + + /// Standalone delivery records swept because they terminally failed and aged + /// past [EvictionPolicy.terminalFailedDeliveryRetention]. + final int removedTerminalFailedDeliveries; + + const EvictionResult({ + required this.removedEvents, + this.removedExpired = 0, + this.removedDeleted = 0, + this.removedSuperseded = 0, + this.removedDeliveredEphemeral = 0, + this.removedByKindCap = 0, + this.keptDueToDeliveryState = 0, + this.keptProtected = 0, + this.removedCompletedDeliveries = 0, + this.removedTerminalFailedDeliveries = 0, + }); + + static const empty = EvictionResult(removedEvents: 0); + + EvictionResult copyWith({ + int? removedEvents, + int? removedExpired, + int? removedDeleted, + int? removedSuperseded, + int? removedDeliveredEphemeral, + int? removedByKindCap, + int? keptDueToDeliveryState, + int? keptProtected, + int? removedCompletedDeliveries, + int? removedTerminalFailedDeliveries, + }) { + return EvictionResult( + removedEvents: removedEvents ?? this.removedEvents, + removedExpired: removedExpired ?? this.removedExpired, + removedDeleted: removedDeleted ?? this.removedDeleted, + removedSuperseded: removedSuperseded ?? this.removedSuperseded, + removedDeliveredEphemeral: + removedDeliveredEphemeral ?? this.removedDeliveredEphemeral, + removedByKindCap: removedByKindCap ?? this.removedByKindCap, + keptDueToDeliveryState: + keptDueToDeliveryState ?? this.keptDueToDeliveryState, + keptProtected: keptProtected ?? this.keptProtected, + removedCompletedDeliveries: + removedCompletedDeliveries ?? this.removedCompletedDeliveries, + removedTerminalFailedDeliveries: + removedTerminalFailedDeliveries ?? + this.removedTerminalFailedDeliveries, + ); + } +} 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 index c611a0e5c..74e1f9cd2 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart @@ -22,11 +22,7 @@ class CashuBlindedMessage { } Map toJson() { - return { - 'id': id, - 'amount': amount, - 'B_': blindedMessage, - }; + return {'id': id, 'amount': amount, 'B_': blindedMessage}; } @override 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 index 8e65cc73e..30e7d3bcb 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_event_content.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_event_content.dart @@ -2,15 +2,12 @@ class CashuEventContent { final String privKey; final Set mints; - CashuEventContent({ - required this.privKey, - required this.mints, - }); + CashuEventContent({required this.privKey, required this.mints}); /// converts to plain list data from WalletCashuEvent List> toCashuEventContent() { final jsonList = [ - ["privkey", privKey] + ["privkey", privKey], ]; jsonList.addAll(mints.map((mint) => ["mint", mint])); @@ -19,9 +16,7 @@ class CashuEventContent { } /// extracts data from plain lists - factory CashuEventContent.fromCashuEventContent( - List> jsonList, - ) { + factory CashuEventContent.fromCashuEventContent(List> jsonList) { String? privKey; final Set 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 index b810e614e..c909779c5 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_keyset.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_keyset.dart @@ -47,10 +47,12 @@ class CahsuKeyset { 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, - )) + .map( + (e) => CahsuMintKeyPair( + amount: e['amount'] as int, + pubkey: e['pubkey'] as String, + ), + ) .toSet(), ); } @@ -74,10 +76,7 @@ class CahsuMintKeyPair { final int amount; final String pubkey; - CahsuMintKeyPair({ - required this.amount, - required this.pubkey, - }); + CahsuMintKeyPair({required this.amount, required this.pubkey}); } class CahsuKeysetResponse { @@ -135,10 +134,7 @@ class CahsuKeysResponse { /// => skipped final amount = int.tryParse(entry.key); if (amount != null) { - mintKeyPairs.add(CahsuMintKeyPair( - amount: amount, - pubkey: entry.value, - )); + mintKeyPairs.add(CahsuMintKeyPair(amount: amount, pubkey: entry.value)); } } 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 index c34dd696d..0d56a1a8b 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_melt_response.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_melt_response.dart @@ -26,9 +26,13 @@ class CashuMeltResponse { 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)) + 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 index 147da5f1a..e80bd2606 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_balance.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_balance.dart @@ -2,10 +2,7 @@ class CashuMintBalance { final String mintUrl; final Map balances; - CashuMintBalance({ - required this.mintUrl, - required this.balances, - }); + CashuMintBalance({required this.mintUrl, required this.balances}); @override String toString() { 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 index e36c236aa..1866ea975 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_info.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_info.dart @@ -53,10 +53,7 @@ class CashuMintInfo { } /// [mintUrl] is used when json['urls'] is not present \ - factory CashuMintInfo.fromJson( - Map json, { - String? mintUrl, - }) { + factory CashuMintInfo.fromJson(Map json, {String? mintUrl}) { final nutsJson = (json['nuts'] as Map?) ?? {}; final parsedNuts = {}; nutsJson.forEach((k, v) { @@ -65,16 +62,20 @@ class CashuMintInfo { try { if (v is List) { // skip (non-spec compliant) - Logger.log.w(() => - 'Warning: Skipping nut $key - received List instead of Map (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); + parsedNuts[key] = CashuMintNut.fromJson( + (v ?? {}) as Map, + ); } catch (e) { - Logger.log - .w(() => 'Warning: Skipping nut $key due to parsing error: $e'); + Logger.log.w( + () => 'Warning: Skipping nut $key due to parsing error: $e', + ); } } }); @@ -122,10 +123,7 @@ class CashuMintContact { final String method; final String info; - CashuMintContact({ - required this.method, - required this.info, - }); + CashuMintContact({required this.method, required this.info}); factory CashuMintContact.fromJson(Map json) { return CashuMintContact( @@ -134,10 +132,7 @@ class CashuMintContact { ); } - Map toJson() => { - 'method': method, - 'info': info, - }; + Map toJson() => {'method': method, 'info': info}; } class CashuMintNut { @@ -167,7 +162,8 @@ class CashuMintNut { if (methodsJson is List) { parsedMethods = methodsJson .map( - (e) => CashuMintPaymentMethod.fromJson(e as Map)) + (e) => CashuMintPaymentMethod.fromJson(e as Map), + ) .toList(); } @@ -179,7 +175,8 @@ class CashuMintNut { } else if (supportedJson is List) { supportedList = supportedJson .map( - (e) => CashuMintPaymentMethod.fromJson(e as Map)) + (e) => CashuMintPaymentMethod.fromJson(e as Map), + ) .toList(); } @@ -187,8 +184,9 @@ class CashuMintNut { final ce = json['cached_endpoints']; if (ce is List) { endpoints = ce - .map((e) => - CashuMintCachedEndpoint.fromJson(e as Map)) + .map( + (e) => CashuMintCachedEndpoint.fromJson(e as Map), + ) .toList(); } @@ -248,8 +246,9 @@ class CashuMintPaymentMethod { maxAmount: (json['max_amount'] is num) ? (json['max_amount'] as num).toInt() : null, - description: - json['description'] is bool ? json['description'] as bool : 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, @@ -284,8 +283,5 @@ class CashuMintCachedEndpoint { ); } - Map toJson() => { - 'method': method, - 'path': path, - }; + 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 index f53a02573..9163ab49c 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_proof.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_proof.dart @@ -20,9 +20,7 @@ class CashuProof { }); /// Y derived public key - String get Y => CashuTools.ecPointToHex( - CashuTools.hashToCurve(secret), - ); + String get Y => CashuTools.ecPointToHex(CashuTools.hashToCurve(secret)); Map toJson() { return { @@ -34,11 +32,7 @@ class CashuProof { } Map toV4Json() { - return { - 'a': amount, - 's': secret, - 'c': CashuTools.hexToBytes(unblindedSig), - }; + return {'a': amount, 's': secret, 'c': CashuTools.hexToBytes(unblindedSig)}; } factory CashuProof.fromV4Json({ @@ -52,11 +46,12 @@ class CashuProof { } return CashuProof( - keysetId: keysetId, - amount: json['a'] ?? 0, - secret: json['s']?.toString() ?? '', - unblindedSig: unblindedSig, - state: state); + keysetId: keysetId, + amount: json['a'] ?? 0, + secret: json['s']?.toString() ?? '', + unblindedSig: unblindedSig, + state: state, + ); } @override 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 index 55a57a1c7..192e0f3ae 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote_melt.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote_melt.dart @@ -35,8 +35,9 @@ class CashuQuoteMelt { 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), + feeReserve: (json['fee_reserve'] != null + ? json['fee_reserve'] as int + : 0), request: request ?? (json['request'] != null ? json['request'] as String : ''), mintUrl: mintUrl, 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 index 17c8fff8d..f125d2452 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_result.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_result.dart @@ -4,8 +4,5 @@ class CashuSpendingResult { final CashuToken token; final CashuWalletTransaction transaction; - CashuSpendingResult({ - required this.token, - required this.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 index 35ee05c79..eceb9b380 100644 --- a/packages/ndk/lib/domain_layer/entities/cashu/cashu_token.dart +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token.dart @@ -29,10 +29,9 @@ class CashuToken { } final proofMap = allProofs.entries - .map((entry) => { - "i": CashuTools.hexToBytes(entry.key), - "p": entry.value, - }) + .map( + (entry) => {"i": CashuTools.hexToBytes(entry.key), "p": entry.value}, + ) .toList(); return { @@ -44,9 +43,7 @@ class CashuToken { } String toV4TokenString() { - return CashuTokenEncoder.encodeTokenV4( - token: this, - ); + return CashuTokenEncoder.encodeTokenV4(token: this); } factory CashuToken.fromV4Json(Map json) { @@ -75,11 +72,6 @@ class CashuToken { } } - return CashuToken( - mintUrl: mint, - proofs: myProofs, - memo: memo, - unit: unit, - ); + return CashuToken(mintUrl: mint, proofs: myProofs, memo: memo, unit: unit); } } 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 index 594702163..db24138db 100644 --- 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 @@ -12,16 +12,17 @@ class CashuTokenEventContent { }); /// extracts data from plain lists - factory CashuTokenEventContent.fromJson( - Map jsonList, - ) { + factory CashuTokenEventContent.fromJson(Map jsonList) { return CashuTokenEventContent( mintUrl: jsonList['mint'] as String, proofs: (jsonList['proofs'] as List) - .map((proofJson) => - CashuProof.fromJson(proofJson as Map)) + .map( + (proofJson) => + CashuProof.fromJson(proofJson as Map), + ) .toList(), - deletedIds: (jsonList['del'] as List?) + 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 index 4cc54c3ab..c51eda3c0 100644 --- 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 @@ -5,11 +5,7 @@ class CashuTokenStateResponse { final CashuProofState state; final String? witness; - CashuTokenStateResponse({ - required this.Y, - required this.state, - this.witness, - }); + CashuTokenStateResponse({required this.Y, required this.state, this.witness}); factory CashuTokenStateResponse.fromServerMap(Map json) { return CashuTokenStateResponse( diff --git a/packages/ndk/lib/domain_layer/entities/connection_source.dart b/packages/ndk/lib/domain_layer/entities/connection_source.dart index e69c6730d..0450a1ef2 100644 --- a/packages/ndk/lib/domain_layer/entities/connection_source.dart +++ b/packages/ndk/lib/domain_layer/entities/connection_source.dart @@ -7,5 +7,5 @@ enum ConnectionSource { broadcastSpecific, explicit, connectionProbe, - nip51Search + nip51Search, } diff --git a/packages/ndk/lib/domain_layer/entities/contact_list.dart b/packages/ndk/lib/domain_layer/entities/contact_list.dart index 0939ff25b..2252b3eb2 100644 --- a/packages/ndk/lib/domain_layer/entities/contact_list.dart +++ b/packages/ndk/lib/domain_layer/entities/contact_list.dart @@ -95,16 +95,22 @@ class ContactList { bool read = decodedValue["read"] ?? false; bool write = decodedValue["write"] ?? false; if (read || write) { - map[entry.key] = - ReadWriteMarker.from(read: read, write: write); + map[entry.key] = ReadWriteMarker.from( + read: read, + write: write, + ); } continue; } catch (e) { - Logger.log.d(() => - "Could not parse relay ${entry.key} , entry : ${entry.value}"); + Logger.log.d( + () => + "Could not parse relay ${entry.key} , entry : ${entry.value}", + ); } - Logger.log.d(() => - "Could not parse relay ${entry.key} , content : ${event.content}"); + Logger.log.d( + () => + "Could not parse relay ${entry.key} , content : ${event.content}", + ); } } } @@ -122,7 +128,7 @@ class ContactList { "p", contact, contactRelays.length > idx ? contactRelays[idx] : "", - petnames.length > idx ? petnames[idx] : "" + petnames.length > idx ? petnames[idx] : "", ]; return list; }).toList(); @@ -130,10 +136,7 @@ class ContactList { List> tagListToJson(final List list, String tag) { return list.map((value) { - List list = [ - tag, - value, - ]; + List list = [tag, value]; return list; }).toList(); } diff --git a/packages/ndk/lib/domain_layer/entities/event_cache_records.dart b/packages/ndk/lib/domain_layer/entities/event_cache_records.dart new file mode 100644 index 000000000..5f64d839f --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/event_cache_records.dart @@ -0,0 +1,577 @@ +import 'nip_01_event.dart'; +import '../../shared/nips/nip01/event_kind_classification.dart'; + +const _noChange = Object(); + +/// Aggregate delivery state for one event across all of its relay targets. +enum EventDeliveryStatus { + pending, + inProgress, + partiallyDelivered, + delivered, + needsAction, + failed, +} + +/// Persisted signing state for one event before relay delivery can proceed. +enum EventSigningState { + notNeeded, + pending, + attempting, + signed, + transientFailure, + needsAction, + permanentFailure, +} + +/// Per-relay delivery state for one `(eventId, relayUrl)` pair. +enum RelayDeliveryState { + pending, + attempting, + acked, + transientFailure, + permanentFailure, + authRequired, +} + +/// Why a relay became a delivery target for an event. +enum RelayDeliveryReason { + authorWrite, + authorRead, + replyAuthorRead, + mentionRead, + explicit, + hint, +} + +enum DecryptedPayloadScheme { nip04, nip44, giftWrap, seal, unknown } + +/// Result of attempting to obtain plaintext for an encrypted payload sidecar. +enum DecryptedPayloadStatus { ready, transientFailure, permanentFailure } + +/// Persisted relay-specific broadcast target. +/// +/// NDK stores one record per `(eventId, relayUrl)` pair instead of keeping a +/// mutable list inside an event record. This makes concurrent updates safer and +/// allows retries, acknowledgements, and failures to be tracked independently. +class RelayDeliveryTarget { + final String eventId; + final String relayUrl; + final RelayDeliveryReason reason; + final RelayDeliveryState state; + final int attemptCount; + final int? lastAttemptAt; + final int? nextRetryAt; + final String? lastError; + final String? lastOkMessage; + + const RelayDeliveryTarget({ + required this.eventId, + required this.relayUrl, + required this.reason, + this.state = RelayDeliveryState.pending, + this.attemptCount = 0, + this.lastAttemptAt, + this.nextRetryAt, + this.lastError, + this.lastOkMessage, + }); + + /// Stable primary key used by cache backends. + String get key => '$eventId|$relayUrl'; + + RelayDeliveryTarget copyWith({ + String? eventId, + String? relayUrl, + RelayDeliveryReason? reason, + RelayDeliveryState? state, + int? attemptCount, + Object? lastAttemptAt = _noChange, + Object? nextRetryAt = _noChange, + Object? lastError = _noChange, + Object? lastOkMessage = _noChange, + }) { + return RelayDeliveryTarget( + eventId: eventId ?? this.eventId, + relayUrl: relayUrl ?? this.relayUrl, + reason: reason ?? this.reason, + state: state ?? this.state, + attemptCount: attemptCount ?? this.attemptCount, + lastAttemptAt: identical(lastAttemptAt, _noChange) + ? this.lastAttemptAt + : lastAttemptAt as int?, + nextRetryAt: identical(nextRetryAt, _noChange) + ? this.nextRetryAt + : nextRetryAt as int?, + lastError: identical(lastError, _noChange) + ? this.lastError + : lastError as String?, + lastOkMessage: identical(lastOkMessage, _noChange) + ? this.lastOkMessage + : lastOkMessage as String?, + ); + } + + Map toJson() { + return { + 'eventId': eventId, + 'relayUrl': relayUrl, + 'reason': reason.name, + 'state': state.name, + 'attemptCount': attemptCount, + 'lastAttemptAt': lastAttemptAt, + 'nextRetryAt': nextRetryAt, + 'lastError': lastError, + 'lastOkMessage': lastOkMessage, + }; + } + + factory RelayDeliveryTarget.fromJson(Map json) { + return RelayDeliveryTarget( + eventId: json['eventId'] as String, + relayUrl: json['relayUrl'] as String, + reason: RelayDeliveryReason.values.byName(json['reason'] as String), + state: RelayDeliveryState.values.byName(json['state'] as String), + attemptCount: json['attemptCount'] as int? ?? 0, + lastAttemptAt: json['lastAttemptAt'] as int?, + nextRetryAt: json['nextRetryAt'] as int?, + lastError: json['lastError'] as String?, + lastOkMessage: json['lastOkMessage'] as String?, + ); + } +} + +/// Persisted aggregate delivery record for one event. +/// +/// This record answers "what is the overall delivery state of this event?" and +/// survives process restarts. Detailed per-relay information lives separately in +/// [RelayDeliveryTarget]. +class EventDeliveryRecord { + final String eventId; + final EventDeliveryStatus status; + final EventSigningState signingState; + final int createdAt; + final int updatedAt; + final String? serializedEventJson; + final int? signedAt; + final int? completedAt; + final bool requiresInteractiveSigning; + final int signAttemptCount; + final int? lastSignAttemptAt; + final int? nextSignRetryAt; + final String? lastSignError; + + const EventDeliveryRecord({ + required this.eventId, + this.status = EventDeliveryStatus.pending, + this.signingState = EventSigningState.notNeeded, + required this.createdAt, + required this.updatedAt, + this.serializedEventJson, + this.signedAt, + this.completedAt, + this.requiresInteractiveSigning = false, + this.signAttemptCount = 0, + this.lastSignAttemptAt, + this.nextSignRetryAt, + this.lastSignError, + }); + + /// True once all known targets have been acknowledged. + bool get isComplete => status == EventDeliveryStatus.delivered; + + /// True once the event is signed or never required a remote signing phase. + bool get isSigned => + signingState == EventSigningState.signed || + (!requiresInteractiveSigning && signedAt == null) || + signedAt != null; + + EventDeliveryRecord copyWith({ + String? eventId, + EventDeliveryStatus? status, + EventSigningState? signingState, + int? createdAt, + int? updatedAt, + Object? serializedEventJson = _noChange, + Object? signedAt = _noChange, + Object? completedAt = _noChange, + bool? requiresInteractiveSigning, + int? signAttemptCount, + Object? lastSignAttemptAt = _noChange, + Object? nextSignRetryAt = _noChange, + Object? lastSignError = _noChange, + }) { + return EventDeliveryRecord( + eventId: eventId ?? this.eventId, + status: status ?? this.status, + signingState: signingState ?? this.signingState, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + serializedEventJson: identical(serializedEventJson, _noChange) + ? this.serializedEventJson + : serializedEventJson as String?, + signedAt: identical(signedAt, _noChange) + ? this.signedAt + : signedAt as int?, + completedAt: identical(completedAt, _noChange) + ? this.completedAt + : completedAt as int?, + requiresInteractiveSigning: + requiresInteractiveSigning ?? this.requiresInteractiveSigning, + signAttemptCount: signAttemptCount ?? this.signAttemptCount, + lastSignAttemptAt: identical(lastSignAttemptAt, _noChange) + ? this.lastSignAttemptAt + : lastSignAttemptAt as int?, + nextSignRetryAt: identical(nextSignRetryAt, _noChange) + ? this.nextSignRetryAt + : nextSignRetryAt as int?, + lastSignError: identical(lastSignError, _noChange) + ? this.lastSignError + : lastSignError as String?, + ); + } + + Map toJson() { + return { + 'eventId': eventId, + 'status': status.name, + 'signingState': signingState.name, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'serializedEventJson': serializedEventJson, + 'signedAt': signedAt, + 'completedAt': completedAt, + 'requiresInteractiveSigning': requiresInteractiveSigning, + 'signAttemptCount': signAttemptCount, + 'lastSignAttemptAt': lastSignAttemptAt, + 'nextSignRetryAt': nextSignRetryAt, + 'lastSignError': lastSignError, + }; + } + + factory EventDeliveryRecord.fromJson(Map json) { + final requiresInteractiveSigning = + json['requiresInteractiveSigning'] as bool? ?? + json['requiresNetworkSigner'] as bool? ?? + false; + return EventDeliveryRecord( + eventId: json['eventId'] as String, + status: EventDeliveryStatus.values.byName(json['status'] as String), + signingState: json['signingState'] != null + ? EventSigningState.values.byName(json['signingState'] as String) + : (requiresInteractiveSigning + ? EventSigningState.pending + : EventSigningState.notNeeded), + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + serializedEventJson: json['serializedEventJson'] as String?, + signedAt: json['signedAt'] as int?, + completedAt: json['completedAt'] as int?, + requiresInteractiveSigning: requiresInteractiveSigning, + signAttemptCount: json['signAttemptCount'] as int? ?? 0, + lastSignAttemptAt: json['lastSignAttemptAt'] as int?, + nextSignRetryAt: json['nextSignRetryAt'] as int?, + lastSignError: json['lastSignError'] as String?, + ); + } +} + +/// Sidecar cache record for decrypted event plaintext. +/// +/// NDK intentionally does not mutate the original encrypted [Nip01Event] with +/// decrypted content. Instead it stores a separate plaintext sidecar keyed by +/// `(eventId, viewerPubKey)`. +class DecryptedEventPayloadRecord { + final String eventId; + final String viewerPubKey; + final DecryptedPayloadScheme scheme; + final DecryptedPayloadStatus status; + final String? plaintextContent; + final int createdAt; + final int updatedAt; + final int? decryptedAt; + final String? failureReason; + final String? sourceEventPubKey; + final int? sourceEventKind; + + const DecryptedEventPayloadRecord({ + required this.eventId, + required this.viewerPubKey, + this.scheme = DecryptedPayloadScheme.unknown, + this.status = DecryptedPayloadStatus.ready, + this.plaintextContent, + required this.createdAt, + required this.updatedAt, + this.decryptedAt, + this.failureReason, + this.sourceEventPubKey, + this.sourceEventKind, + }); + + /// Stable primary key used by cache backends. + String get key => '$eventId|$viewerPubKey'; + + DecryptedEventPayloadRecord copyWith({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadScheme? scheme, + DecryptedPayloadStatus? status, + Object? plaintextContent = _noChange, + int? createdAt, + int? updatedAt, + Object? decryptedAt = _noChange, + Object? failureReason = _noChange, + Object? sourceEventPubKey = _noChange, + Object? sourceEventKind = _noChange, + }) { + return DecryptedEventPayloadRecord( + eventId: eventId ?? this.eventId, + viewerPubKey: viewerPubKey ?? this.viewerPubKey, + scheme: scheme ?? this.scheme, + status: status ?? this.status, + plaintextContent: identical(plaintextContent, _noChange) + ? this.plaintextContent + : plaintextContent as String?, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + decryptedAt: identical(decryptedAt, _noChange) + ? this.decryptedAt + : decryptedAt as int?, + failureReason: identical(failureReason, _noChange) + ? this.failureReason + : failureReason as String?, + sourceEventPubKey: identical(sourceEventPubKey, _noChange) + ? this.sourceEventPubKey + : sourceEventPubKey as String?, + sourceEventKind: identical(sourceEventKind, _noChange) + ? this.sourceEventKind + : sourceEventKind as int?, + ); + } + + Map toJson() { + return { + 'eventId': eventId, + 'viewerPubKey': viewerPubKey, + 'scheme': scheme.name, + 'status': status.name, + 'plaintextContent': plaintextContent, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'decryptedAt': decryptedAt, + 'failureReason': failureReason, + 'sourceEventPubKey': sourceEventPubKey, + 'sourceEventKind': sourceEventKind, + }; + } + + factory DecryptedEventPayloadRecord.fromJson(Map json) { + return DecryptedEventPayloadRecord( + eventId: json['eventId'] as String, + viewerPubKey: json['viewerPubKey'] as String, + scheme: json['scheme'] != null + ? DecryptedPayloadScheme.values.byName(json['scheme'] as String) + : DecryptedPayloadScheme.unknown, + status: json['status'] != null + ? DecryptedPayloadStatus.values.byName(json['status'] as String) + : DecryptedPayloadStatus.ready, + plaintextContent: json['plaintextContent'] as String?, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + decryptedAt: json['decryptedAt'] as int?, + failureReason: json['failureReason'] as String?, + sourceEventPubKey: json['sourceEventPubKey'] as String?, + sourceEventKind: json['sourceEventKind'] as int?, + ); + } +} + +int? _extractExpirationAt(Nip01Event event) { + final rawValue = event.getFirstTag('expiration'); + if (rawValue == null) return null; + return int.tryParse(rawValue); +} + +String? _buildCoordinateKey( + Nip01Event event, + String? dTag, + bool isAddressable, +) { + // Coordinate keys identify the conflict domain for replaceable events. + // For addressable kinds this becomes `kind:pubkey:d-tag`. + if (!isAddressable) return null; + return '${event.kind}:${event.pubKey}:${dTag ?? ''}'; +} + +String? _buildReplaceableConflictKey(Nip01Event event, String? dTag) { + if (EventKindClassification.isParameterizedReplaceableKind(event.kind)) { + return '${event.kind}:${event.pubKey}:${dTag ?? ''}'; + } + if (EventKindClassification.isReplaceableKind(event.kind)) { + return '${event.kind}:${event.pubKey}'; + } + return null; +} + +bool _isAddressableKind(int kind) { + return EventKindClassification.isAddressableKind(kind); +} + +bool _isMoreRecentEventThan(Nip01Event candidate, Nip01Event current) { + // Replaceable tie-breaker: + // 1. newer created_at wins + // 2. stable event id ordering breaks same-timestamp ties deterministically + if (candidate.createdAt != current.createdAt) { + return candidate.createdAt > current.createdAt; + } + + return candidate.id.compareTo(current.id) < 0; +} + +/// Lean derived cache metadata for one event. +/// +/// This intentionally does not embed the full [Nip01Event]. It persists only +/// the extra state useful for cache maintenance tasks such as eviction +/// planning. +class EventCacheStateRecord { + final String eventId; + final String pubKey; + final int kind; + final int createdAt; + final String? coordinateKey; + final bool isCurrent; + final int? expirationAt; + final String? deletedByEventId; + + const EventCacheStateRecord({ + required this.eventId, + required this.pubKey, + required this.kind, + required this.createdAt, + this.coordinateKey, + required this.isCurrent, + this.expirationAt, + this.deletedByEventId, + }); + + bool get isDeleted => deletedByEventId != null; + + bool isExpiredAt(int timestamp) => + expirationAt != null && expirationAt! <= timestamp; + + Map toJson() { + return { + 'eventId': eventId, + 'pubKey': pubKey, + 'kind': kind, + 'createdAt': createdAt, + 'coordinateKey': coordinateKey, + 'isCurrent': isCurrent, + 'expirationAt': expirationAt, + 'deletedByEventId': deletedByEventId, + }; + } + + factory EventCacheStateRecord.fromJson(Map json) { + return EventCacheStateRecord( + eventId: json['eventId'] as String, + pubKey: json['pubKey'] as String, + kind: json['kind'] as int, + createdAt: json['createdAt'] as int, + coordinateKey: json['coordinateKey'] as String?, + isCurrent: json['isCurrent'] as bool? ?? true, + expirationAt: json['expirationAt'] as int?, + deletedByEventId: json['deletedByEventId'] as String?, + ); + } + + /// Builds derived state records from raw events using the same visibility + /// semantics as cache reads and eviction planning. + static List buildForEvents( + List rawEvents, { + int? now, + }) { + final currentTime = now ?? Nip01Event.secondsSinceEpoch(); + final deletionEvents = rawEvents + .where((event) => event.kind == 5) + .toList(growable: false); + final visibleWinners = {}; + + for (final event in rawEvents) { + final replaceableConflictKey = _buildReplaceableConflictKey( + event, + event.getDtag(), + ); + if (replaceableConflictKey == null) { + continue; + } + if (_extractExpirationAt(event) case final expirationAt? + when expirationAt <= currentTime) { + continue; + } + if (_findDeletingEvent(event, deletionEvents) != null) { + continue; + } + + final current = visibleWinners[replaceableConflictKey]; + if (current == null || _isMoreRecentEventThan(event, current)) { + visibleWinners[replaceableConflictKey] = event; + } + } + + final records = []; + for (final event in rawEvents) { + final coordinateKey = _buildCoordinateKey( + event, + event.getDtag(), + _isAddressableKind(event.kind), + ); + final replaceableConflictKey = _buildReplaceableConflictKey( + event, + event.getDtag(), + ); + final deletingEvent = _findDeletingEvent(event, deletionEvents); + records.add( + EventCacheStateRecord( + eventId: event.id, + pubKey: event.pubKey, + kind: event.kind, + createdAt: event.createdAt, + coordinateKey: coordinateKey, + isCurrent: replaceableConflictKey == null + ? true + : visibleWinners[replaceableConflictKey]?.id == event.id, + expirationAt: _extractExpirationAt(event), + deletedByEventId: deletingEvent?.id, + ), + ); + } + + return records; + } + + static Nip01Event? _findDeletingEvent( + Nip01Event target, + List deletionEvents, + ) { + if (target.kind == 5) return null; + final replaceableConflictKey = _buildReplaceableConflictKey( + target, + target.getDtag(), + ); + + for (final event in deletionEvents) { + if (event.pubKey != target.pubKey) continue; + if (event.getTags('e').contains(target.id.toLowerCase())) { + return event; + } + if (replaceableConflictKey != null && + event.createdAt >= target.createdAt && + event.getTags('a').contains(replaceableConflictKey)) { + return event; + } + } + + return null; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/event_delivery_inspection.dart b/packages/ndk/lib/domain_layer/entities/event_delivery_inspection.dart new file mode 100644 index 000000000..300bcaa25 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/event_delivery_inspection.dart @@ -0,0 +1,31 @@ +import 'event_cache_records.dart'; +import 'nip_01_event.dart'; + +/// Read-only joined view of one locally persisted delivery item. +/// +/// This is intended for app-facing inspection of local-first state without +/// requiring callers to manually join cache records and relay targets. +class EventDeliverySnapshot { + /// Raw event, if it is still present in the event cache. + final Nip01Event? event; + + /// Aggregate delivery/signing state for the event. + final EventDeliveryRecord record; + + /// Relay-specific delivery state for each persisted target. + final List relayTargets; + + const EventDeliverySnapshot({ + required this.event, + required this.record, + required this.relayTargets, + }); + + /// True when the event is still not fully delivered to all known targets. + bool get isPendingDelivery => record.status != EventDeliveryStatus.delivered; + + /// True when the event still exists only in local cache for at least one + /// relay target. + bool get isOnlyLocal => + relayTargets.any((target) => target.state != RelayDeliveryState.acked); +} diff --git a/packages/ndk/lib/domain_layer/entities/filter_fetched_ranges.dart b/packages/ndk/lib/domain_layer/entities/filter_fetched_ranges.dart index 4659d8bc7..1cdec2f56 100644 --- a/packages/ndk/lib/domain_layer/entities/filter_fetched_ranges.dart +++ b/packages/ndk/lib/domain_layer/entities/filter_fetched_ranges.dart @@ -13,10 +13,7 @@ class TimeRange { /// End timestamp (inclusive) final int until; - const TimeRange({ - required this.since, - required this.until, - }); + const TimeRange({required this.since, required this.until}); /// Check if this range overlaps or is adjacent to another range bool canMergeWith(TimeRange other) { @@ -53,15 +50,10 @@ class TimeRange { @override int get hashCode => since.hashCode ^ until.hashCode; - Map toJson() => { - 'since': since, - 'until': until, - }; + Map toJson() => {'since': since, 'until': until}; - factory TimeRange.fromJson(Map json) => TimeRange( - since: json['since'] as int, - until: json['until'] as int, - ); + factory TimeRange.fromJson(Map json) => + TimeRange(since: json['since'] as int, until: json['until'] as int); } /// A gap in fetched ranges (missing time range) @@ -128,10 +120,9 @@ class RelayFetchedRanges { // Gap before this range? if (currentPos < range.since) { - gaps.add(TimeRange( - since: currentPos, - until: min(range.since - 1, until), - )); + gaps.add( + TimeRange(since: currentPos, until: min(range.since - 1, until)), + ); } // Move position past this range @@ -140,10 +131,7 @@ class RelayFetchedRanges { // Gap after last range? if (currentPos <= until) { - gaps.add(TimeRange( - since: currentPos, - until: until, - )); + gaps.add(TimeRange(since: currentPos, until: until)); } return gaps; @@ -152,11 +140,13 @@ class RelayFetchedRanges { /// Get gaps as FetchedRangesGap objects List getGaps(int since, int until) { return findGaps(since, until) - .map((g) => FetchedRangesGap( - relayUrl: relayUrl, - since: g.since, - until: g.until, - )) + .map( + (g) => FetchedRangesGap( + relayUrl: relayUrl, + since: g.since, + until: g.until, + ), + ) .toList(); } @@ -194,11 +184,11 @@ class FilterFetchedRangeRecord { 'FilterFetchedRangeRecord($filterHash, $relayUrl, $rangeStart-$rangeEnd)'; Map toJson() => { - 'filterHash': filterHash, - 'relayUrl': relayUrl, - 'rangeStart': rangeStart, - 'rangeEnd': rangeEnd, - }; + 'filterHash': filterHash, + 'relayUrl': relayUrl, + 'rangeStart': rangeStart, + 'rangeEnd': rangeEnd, + }; factory FilterFetchedRangeRecord.fromJson(Map json) => FilterFetchedRangeRecord( diff --git a/packages/ndk/lib/domain_layer/entities/jit_engine_relay_connectivity_data.dart b/packages/ndk/lib/domain_layer/entities/jit_engine_relay_connectivity_data.dart index d50985660..98c2fb388 100644 --- a/packages/ndk/lib/domain_layer/entities/jit_engine_relay_connectivity_data.dart +++ b/packages/ndk/lib/domain_layer/entities/jit_engine_relay_connectivity_data.dart @@ -7,7 +7,9 @@ class JitEngineRelayConnectivityData { /// adds pubkeys with a direction to assigned Pubkeys void addPubkeysToAssignedPubkeys( - List pubkeys, ReadWriteMarker direction) { + List pubkeys, + ReadWriteMarker direction, + ) { for (var pubkey in pubkeys) { assignedPubkeys.add(RelayJitAssignedPubkey(pubkey, direction)); } diff --git a/packages/ndk/lib/domain_layer/entities/metadata.dart b/packages/ndk/lib/domain_layer/entities/metadata.dart index a06903d02..4be5030a8 100644 --- a/packages/ndk/lib/domain_layer/entities/metadata.dart +++ b/packages/ndk/lib/domain_layer/entities/metadata.dart @@ -114,8 +114,8 @@ class Metadata { this.refreshedTimestamp, List>? tags, Map? content, - }) : tags = tags ?? [], - content = content ?? {} { + }) : tags = tags ?? [], + content = content ?? {} { // Initialize content with provided known fields if (name != null) this.name = name; if (displayName != null) this.displayName = displayName; @@ -223,11 +223,12 @@ class Metadata { /// convert to nip01 event Nip01Event toEvent() { return Nip01Event( - pubKey: pubKey, - content: jsonEncode(content), - kind: kKind, - tags: tags, - createdAt: updatedAt ?? 0); + pubKey: pubKey, + content: jsonEncode(content), + kind: kKind, + tags: tags, + createdAt: updatedAt ?? 0, + ); } /// Set a custom field in the content diff --git a/packages/ndk/lib/domain_layer/entities/ndk_file.dart b/packages/ndk/lib/domain_layer/entities/ndk_file.dart index 6f8e36838..99dfdf845 100644 --- a/packages/ndk/lib/domain_layer/entities/ndk_file.dart +++ b/packages/ndk/lib/domain_layer/entities/ndk_file.dart @@ -6,10 +6,5 @@ class NdkFile { final String? mimeType; final int? size; - NdkFile({ - required this.data, - this.mimeType, - this.size, - this.sha256, - }); + NdkFile({required this.data, this.mimeType, this.size, this.sha256}); } diff --git a/packages/ndk/lib/domain_layer/entities/ndk_request.dart b/packages/ndk/lib/domain_layer/entities/ndk_request.dart index e6fa40415..620db48a3 100644 --- a/packages/ndk/lib/domain_layer/entities/ndk_request.dart +++ b/packages/ndk/lib/domain_layer/entities/ndk_request.dart @@ -78,4 +78,5 @@ class NdkRequest { this.authenticateAs, }); } + // coverage:ignore-end diff --git a/packages/ndk/lib/domain_layer/entities/nevent.dart b/packages/ndk/lib/domain_layer/entities/nevent.dart index 3be53b1e9..0263a1d3a 100644 --- a/packages/ndk/lib/domain_layer/entities/nevent.dart +++ b/packages/ndk/lib/domain_layer/entities/nevent.dart @@ -5,12 +5,7 @@ class Nevent { final int? kind; final List? relays; - Nevent({ - required this.eventId, - this.author, - this.kind, - this.relays, - }); + Nevent({required this.eventId, this.author, this.kind, this.relays}); @override String toString() => diff --git a/packages/ndk/lib/domain_layer/entities/nip77_state.dart b/packages/ndk/lib/domain_layer/entities/nip77_state.dart index b44311dd6..db4308025 100644 --- a/packages/ndk/lib/domain_layer/entities/nip77_state.dart +++ b/packages/ndk/lib/domain_layer/entities/nip77_state.dart @@ -59,8 +59,10 @@ class Nip77State { /// Returns the response message bytes to send back, or null if done Uint8List? processMessage(Uint8List messageBytes) { try { - final (response, newNeedIds, newHaveIds) = - NegentropyEncoder.reconcile(messageBytes, localItems); + final (response, newNeedIds, newHaveIds) = NegentropyEncoder.reconcile( + messageBytes, + localItems, + ); // Add newly discovered IDs for (final id in newNeedIds) { @@ -90,10 +92,12 @@ class Nip77State { _isCompleted = true; _needController.close(); _haveController.close(); - _completer.complete(Nip77Result( - needIds: List.unmodifiable(needIds), - haveIds: List.unmodifiable(haveIds), - )); + _completer.complete( + Nip77Result( + needIds: List.unmodifiable(needIds), + haveIds: List.unmodifiable(haveIds), + ), + ); } /// Complete the session with an error @@ -113,10 +117,12 @@ class Nip77State { _needController.close(); _haveController.close(); if (!_completer.isCompleted) { - _completer.complete(Nip77Result( - needIds: List.unmodifiable(needIds), - haveIds: List.unmodifiable(haveIds), - )); + _completer.complete( + Nip77Result( + needIds: List.unmodifiable(needIds), + haveIds: List.unmodifiable(haveIds), + ), + ); } } } @@ -129,10 +135,7 @@ class Nip77Result { /// IDs that we have that the relay doesn't final List haveIds; - Nip77Result({ - required this.needIds, - required this.haveIds, - }); + Nip77Result({required this.needIds, required this.haveIds}); @override String toString() => 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 da1a22121..e726ccf7a 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart @@ -28,7 +28,13 @@ class Nip01Event { /// has signature been validated? final bool? validSig; - /// Relay that an event was received from + /// Relay hints attached to the event object. + /// + /// Deprecated: use cache provenance APIs such as + /// `CacheManager.addEventSource(s)` / `loadEventSources()` instead. + @Deprecated( + 'Use CacheManager provenance methods instead of Nip01Event.sources.', + ) final List sources; /// Creates a new Nostr event. @@ -54,13 +60,15 @@ class Nip01Event { this.createdAt = (createdAt == 0) ? DateTime.now().millisecondsSinceEpoch ~/ 1000 : createdAt; - this.id = id ?? + this.id = + id ?? Nip01Utils.calculateEventIdSync( - pubKey: pubKey, - createdAt: createdAt, - kind: kind, - tags: tags, - content: content); + pubKey: pubKey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + ); } Nip01Event copyWith({ @@ -75,15 +83,16 @@ class Nip01Event { List? sources, }) { return Nip01Event( - id: id ?? this.id, - pubKey: pubKey ?? this.pubKey, - createdAt: createdAt ?? this.createdAt, - kind: kind ?? this.kind, - tags: tags ?? this.tags, - content: content ?? this.content, - sig: sig ?? this.sig, - validSig: validSig ?? this.validSig, - sources: sources ?? this.sources); + id: id ?? this.id, + pubKey: pubKey ?? this.pubKey, + createdAt: createdAt ?? this.createdAt, + kind: kind ?? this.kind, + tags: tags ?? this.tags, + content: content ?? this.content, + sig: sig ?? this.sig, + validSig: validSig ?? this.validSig, + sources: sources ?? this.sources, + ); } // Individual events with the same "id" are equivalent diff --git a/packages/ndk/lib/domain_layer/entities/nip_01_utils.dart b/packages/ndk/lib/domain_layer/entities/nip_01_utils.dart index b91dc8097..cca526ec6 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_01_utils.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_01_utils.dart @@ -73,8 +73,9 @@ class Nip01Utils { pubKey: pubKey, createdAt: createdAt, kind: kind, - tags: - List>.from(tags.map((tag) => List.from(tag))), + tags: List>.from( + tags.map((tag) => List.from(tag)), + ), content: content, sig: null, validSig: null, @@ -91,18 +92,19 @@ class Nip01Utils { required List tags, required String content, }) async { - final id = - await IsolateManager.instance.runInComputeIsolate( - calculateId, - Nip01Event( - pubKey: publicKey, - createdAt: createdAt, - kind: kind, - tags: - List>.from(tags.map((tag) => List.from(tag))), - content: content, - ), - ); + final id = await IsolateManager.instance + .runInComputeIsolate( + calculateId, + Nip01Event( + pubKey: publicKey, + createdAt: createdAt, + kind: kind, + tags: List>.from( + tags.map((tag) => List.from(tag)), + ), + content: content, + ), + ); return id; } @@ -114,7 +116,7 @@ class Nip01Utils { model.createdAt, model.kind, model.tags, - model.content + model.content, ]); final bytes = utf8.encode(jsonData); final digest = sha256.convert(bytes); @@ -129,10 +131,7 @@ class Nip01Utils { }) { String id = calculateId(event); - final signature = Bip340.sign( - id, - privateKey, - ); + final signature = Bip340.sign(id, privateKey); return event.copyWith(sig: signature, validSig: true); } } diff --git a/packages/ndk/lib/domain_layer/entities/nip_17_conversation.dart b/packages/ndk/lib/domain_layer/entities/nip_17_conversation.dart new file mode 100644 index 000000000..91814576e --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/nip_17_conversation.dart @@ -0,0 +1,18 @@ +import 'nip_17_message.dart'; + +/// Group of NIP-17 messages exchanged with one peer. +/// +/// Messages are expected to be sorted in ascending creation order inside the +/// conversation. +class Nip17Conversation { + final String peerPubKey; + final List messages; + + const Nip17Conversation({required this.peerPubKey, required this.messages}); + + /// Most recent message in the conversation. + Nip17Message get latestMessage => messages.last; + + /// Creation timestamp of the most recent message. + int get latestCreatedAt => latestMessage.createdAt; +} diff --git a/packages/ndk/lib/domain_layer/entities/nip_17_message.dart b/packages/ndk/lib/domain_layer/entities/nip_17_message.dart new file mode 100644 index 000000000..e1bf9fc69 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/nip_17_message.dart @@ -0,0 +1,33 @@ +import 'nip_01_event.dart'; + +/// Parsed direct-message item produced by the NIP-17 DM usecase. +/// +/// [wrappedEvent] is the original gift-wrapped event stored and transported on +/// relays. +/// +/// [rumor] is the decrypted inner message event. +/// +/// [peerPubKey] is the other participant in the conversation relative to the +/// logged-in user. +class Nip17Message { + final Nip01Event wrappedEvent; + final Nip01Event rumor; + final String peerPubKey; + final bool isOutgoing; + + const Nip17Message({ + required this.wrappedEvent, + required this.rumor, + required this.peerPubKey, + required this.isOutgoing, + }); + + /// Stable message id derived from the decrypted rumor event. + String get id => rumor.id; + + /// Decrypted message content. + String get content => rumor.content; + + /// Message creation timestamp from the decrypted rumor event. + int get createdAt => rumor.createdAt; +} diff --git a/packages/ndk/lib/domain_layer/entities/nip_51_list.dart b/packages/ndk/lib/domain_layer/entities/nip_51_list.dart index 216853b88..ed92d3708 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_51_list.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_51_list.dart @@ -75,7 +75,7 @@ class Nip51List { kThread, kResource, kEmoji, - kA + kA, ]; late String? id; @@ -100,15 +100,22 @@ class Nip51List { set privateRelays(List list) { elements.removeWhere((element) => element.tag == kRelay && element.private); - elements.addAll(list.map( - (url) => Nip51ListElement(tag: kRelay, value: url, private: true))); + elements.addAll( + list.map( + (url) => Nip51ListElement(tag: kRelay, value: url, private: true), + ), + ); } set publicRelays(List list) { - elements - .removeWhere((element) => element.tag == kRelay && !element.private); - elements.addAll(list.map( - (url) => Nip51ListElement(tag: kRelay, value: url, private: false))); + elements.removeWhere( + (element) => element.tag == kRelay && !element.private, + ); + elements.addAll( + list.map( + (url) => Nip51ListElement(tag: kRelay, value: url, private: false), + ), + ); } late int createdAt; @@ -135,23 +142,27 @@ class Nip51List { List get allRelays => relays.map((e) => e.value).toList(); - Nip51List( - {required this.pubKey, - required this.kind, - required this.createdAt, - required this.elements}); + Nip51List({ + required this.pubKey, + required this.kind, + required this.createdAt, + required this.elements, + }); static Future fromEvent( - Nip01Event event, EventSigner? signer) async { + Nip01Event event, + EventSigner? signer, + ) async { // if (event.kind == Nip51List.SEARCH_RELAYS || event.kind == Nip51List.BLOCKED_RELAYS) { // privateRelays = []; // publicRelays = []; // } Nip51List list = Nip51List( - pubKey: event.pubKey, - kind: event.kind, - createdAt: event.createdAt, - elements: []); + pubKey: event.pubKey, + kind: event.kind, + createdAt: event.createdAt, + elements: [], + ); list.id = event.id; list.parseTags(event.tags, private: false); @@ -164,10 +175,7 @@ class Nip51List { final isNip04 = event.content.contains('?iv='); final json = isNip04 // ignore: deprecated_member_use_from_same_package - ? await signer.decrypt( - event.content, - signer.getPublicKey(), - ) + ? await signer.decrypt(event.content, signer.getPublicKey()) : await signer.decryptNip44( ciphertext: event.content, senderPubKey: signer.getPublicKey(), @@ -190,21 +198,26 @@ class Nip51List { final value = tag[1]; if (kPossibleTags.contains(tagName)) { elements.add( - Nip51ListElement(tag: tagName, value: value, private: private)); + Nip51ListElement(tag: tagName, value: value, private: private), + ); } } } Future toEvent(EventSigner? signer) async { String content = ""; - List privateElements = - elements.where((element) => element.private).toList(); + List privateElements = elements + .where((element) => element.private) + .toList(); if (privateElements.isNotEmpty && signer != null) { - String json = jsonEncode(privateElements - .map((element) => [element.tag, element.value]) - .toList()); - content = await signer.encryptNip44( - plaintext: json, recipientPubKey: signer.getPublicKey()) ?? + String json = jsonEncode( + privateElements.map((element) => [element.tag, element.value]).toList(), + ); + content = + await signer.encryptNip44( + plaintext: json, + recipientPubKey: signer.getPublicKey(), + ) ?? ''; } Nip01Event event = Nip01Event( @@ -221,8 +234,9 @@ class Nip51List { } void addRelay(String relayUrl, bool private) { - elements - .add(Nip51ListElement(tag: kRelay, value: relayUrl, private: private)); + elements.add( + Nip51ListElement(tag: kRelay, value: relayUrl, private: private), + ); } void addElement(String tag, String value, bool private) { @@ -231,12 +245,14 @@ class Nip51List { void removeRelay(String relayUrl) { elements.removeWhere( - (element) => element.tag == kRelay && element.value == relayUrl); + (element) => element.tag == kRelay && element.value == relayUrl, + ); } void removeElement(String tag, String value) { - elements - .removeWhere((element) => element.tag == tag && element.value == value); + elements.removeWhere( + (element) => element.tag == tag && element.value == value, + ); } } @@ -245,8 +261,11 @@ class Nip51ListElement { String tag; String value; - Nip51ListElement( - {required this.tag, required this.value, required this.private}); + Nip51ListElement({ + required this.tag, + required this.value, + required this.private, + }); } class Nip51Set extends Nip51List { @@ -302,10 +321,7 @@ class Nip51Set extends Nip51List { final isNip04 = event.content.contains('?iv='); final json = isNip04 // ignore: deprecated_member_use_from_same_package - ? await signer.decrypt( - event.content, - signer.getPublicKey(), - ) + ? await signer.decrypt(event.content, signer.getPublicKey()) : await signer.decryptNip44( ciphertext: event.content, senderPubKey: signer.getPublicKey(), @@ -323,7 +339,7 @@ class Nip51Set extends Nip51List { Future toEvent(EventSigner? signer) async { Nip01Event event = await super.toEvent(signer); List tags = [ - ["d", name] + ["d", name], ]; if (Helpers.isNotBlank(description)) { tags.add(["description", description]); diff --git a/packages/ndk/lib/domain_layer/entities/nip_65.dart b/packages/ndk/lib/domain_layer/entities/nip_65.dart index e95393898..fc31f085f 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_65.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_65.dart @@ -22,11 +22,7 @@ class Nip65 { } /// creates a new [Nip65] instance - Nip65({ - required this.pubKey, - required this.relays, - required this.createdAt, - }); + Nip65({required this.pubKey, required this.relays, required this.createdAt}); Nip65.fromEvent(Nip01Event event) { pubKey = event.pubKey; @@ -78,10 +74,11 @@ class Nip65 { ); } -// coverage:ignore-start + // coverage:ignore-start @override String toString() { return 'Nip65{urls: $relays}'; } -// coverage:ignore-end + + // coverage:ignore-end } diff --git a/packages/ndk/lib/domain_layer/entities/nprofile.dart b/packages/ndk/lib/domain_layer/entities/nprofile.dart index 74eafff6c..2e19203af 100644 --- a/packages/ndk/lib/domain_layer/entities/nprofile.dart +++ b/packages/ndk/lib/domain_layer/entities/nprofile.dart @@ -3,10 +3,7 @@ class Nprofile { final String pubkey; final List? relays; - Nprofile({ - required this.pubkey, - this.relays, - }); + Nprofile({required this.pubkey, this.relays}); @override String toString() => 'Nprofile(pubkey: $pubkey, relays: $relays)'; diff --git a/packages/ndk/lib/domain_layer/entities/pending_signer_request.dart b/packages/ndk/lib/domain_layer/entities/pending_signer_request.dart index 08e7dc52d..93c7a9def 100644 --- a/packages/ndk/lib/domain_layer/entities/pending_signer_request.dart +++ b/packages/ndk/lib/domain_layer/entities/pending_signer_request.dart @@ -19,7 +19,7 @@ enum SignerMethod { /// Represents a pending request waiting for user approval on a signer. /// /// This is used by signers that require human approval (NIP-46 bunkers, -/// NIP-07 browser extensions, Amber, etc.) to expose their pending +/// NIP-07 browser extensions, NIP-55 signer apps, etc.) to expose their pending /// operations to the UI. class PendingSignerRequest { /// Unique identifier for this request diff --git a/packages/ndk/lib/domain_layer/entities/pubkey_mapping.dart b/packages/ndk/lib/domain_layer/entities/pubkey_mapping.dart index 1f2ed4f1d..3ef49b9ec 100644 --- a/packages/ndk/lib/domain_layer/entities/pubkey_mapping.dart +++ b/packages/ndk/lib/domain_layer/entities/pubkey_mapping.dart @@ -8,12 +8,9 @@ class PubkeyMapping { /// read write marker ReadWriteMarker rwMarker; - PubkeyMapping({ - required this.pubKey, - required this.rwMarker, - }); + PubkeyMapping({required this.pubKey, required this.rwMarker}); -// coverage:ignore-start + // coverage:ignore-start @override String toString() { String result = '$pubKey '; @@ -25,7 +22,7 @@ class PubkeyMapping { } return result; } -// coverage:ignore-end + // coverage:ignore-end @override bool operator ==(Object other) => diff --git a/packages/ndk/lib/domain_layer/entities/relay.dart b/packages/ndk/lib/domain_layer/entities/relay.dart index 54e347fe8..514d98dcd 100644 --- a/packages/ndk/lib/domain_layer/entities/relay.dart +++ b/packages/ndk/lib/domain_layer/entities/relay.dart @@ -17,10 +17,7 @@ class Relay { ConnectionSource connectionSource; /// default constructor - Relay({ - required this.url, - required this.connectionSource, - }); + Relay({required this.url, required this.connectionSource}); /// set trying to connect void tryingToConnect() { diff --git a/packages/ndk/lib/domain_layer/entities/relay_connectivity.dart b/packages/ndk/lib/domain_layer/entities/relay_connectivity.dart index aafc05aa0..f38e335ba 100644 --- a/packages/ndk/lib/domain_layer/entities/relay_connectivity.dart +++ b/packages/ndk/lib/domain_layer/entities/relay_connectivity.dart @@ -29,20 +29,31 @@ class RelayConnectivity { Function? onError, void Function()? onDone, }) { - _streamSubscription = - relayTransport!.listen(onData, onDone: onDone, onError: onError); + _streamSubscription = relayTransport!.listen( + onData, + onDone: onDone, + onError: onError, + ); } /// cancels stream subscription and closes relay transport Future close() async { - if (_streamSubscription != null) { - await _streamSubscription!.cancel(); + final streamSubscription = _streamSubscription; + final transport = relayTransport; + + _streamSubscription = null; + relayTransport = null; + + if (streamSubscription != null) { + await streamSubscription.cancel(); } - if (relayTransport != null) { - await relayTransport!.close().timeout(const Duration(seconds: 3), - onTimeout: () { - Logger.log.w(() => "timeout while trying to close socket $url"); - }); + if (transport != null) { + await transport.close().timeout( + const Duration(seconds: 3), + onTimeout: () { + Logger.log.w(() => "timeout while trying to close socket $url"); + }, + ); } } diff --git a/packages/ndk/lib/domain_layer/entities/relay_set.dart b/packages/ndk/lib/domain_layer/entities/relay_set.dart index 31931cdb9..578e76d4d 100644 --- a/packages/ndk/lib/domain_layer/entities/relay_set.dart +++ b/packages/ndk/lib/domain_layer/entities/relay_set.dart @@ -25,14 +25,15 @@ class RelaySet { List notCoveredPubkeys = []; - RelaySet( - {required this.name, - required this.pubKey, - this.relayMinCountPerPubkey = 0, - required this.relaysMap, - this.notCoveredPubkeys = const [], - required this.direction, - this.fallbackToBootstrapRelays = true}); + RelaySet({ + required this.name, + required this.pubKey, + this.relayMinCountPerPubkey = 0, + required this.relaysMap, + this.notCoveredPubkeys = const [], + required this.direction, + this.fallbackToBootstrapRelays = true, + }); static String buildId(String name, String pubKey) { return "$name,$pubKey"; @@ -51,30 +52,38 @@ class RelaySet { direction == RelayDirection.outbox) { List pubKeysForRelay = []; for (String pubKey in filter.authors!) { - if (pubKeyMappings.any((pubKeyMapping) => - pubKey == pubKeyMapping.pubKey || - notCoveredPubkeys.any((element) => element.pubKey == pubKey))) { + if (pubKeyMappings.any( + (pubKeyMapping) => + pubKey == pubKeyMapping.pubKey || + notCoveredPubkeys.any((element) => element.pubKey == pubKey), + )) { pubKeysForRelay.add(pubKey); } } if (pubKeysForRelay.isNotEmpty) { - groupRequest.addRequest(url, - sliceFilterAuthors(filter.cloneWithAuthors(pubKeysForRelay))); + groupRequest.addRequest( + url, + sliceFilterAuthors(filter.cloneWithAuthors(pubKeysForRelay)), + ); } } else if (filter.pTags != null && filter.pTags!.isNotEmpty && direction == RelayDirection.inbox) { List pubKeysForRelay = []; for (String pubKey in filter.pTags!) { - if (pubKeyMappings.any((pubKeyMapping) => - pubKey == pubKeyMapping.pubKey || - notCoveredPubkeys.any((element) => element.pubKey == pubKey))) { + if (pubKeyMappings.any( + (pubKeyMapping) => + pubKey == pubKeyMapping.pubKey || + notCoveredPubkeys.any((element) => element.pubKey == pubKey), + )) { pubKeysForRelay.add(pubKey); } } if (pubKeysForRelay.isNotEmpty) { groupRequest.addRequest( - url, sliceFilterAuthors(filter.cloneWithPTags(pubKeysForRelay))); + url, + sliceFilterAuthors(filter.cloneWithPTags(pubKeysForRelay)), + ); } } else if (filter.eTags != null && direction == RelayDirection.inbox) { groupRequest.addRequest(url, [filter]); diff --git a/packages/ndk/lib/domain_layer/entities/request_response.dart b/packages/ndk/lib/domain_layer/entities/request_response.dart index aae0bf12f..7ce026602 100644 --- a/packages/ndk/lib/domain_layer/entities/request_response.dart +++ b/packages/ndk/lib/domain_layer/entities/request_response.dart @@ -22,4 +22,5 @@ class NdkResponse { /// Creates a new [NdkResponse] instance. NdkResponse(this.requestId, this.stream); } + // coverage:ignore-end diff --git a/packages/ndk/lib/domain_layer/entities/request_state.dart b/packages/ndk/lib/domain_layer/entities/request_state.dart index 605182a5a..985c923dc 100644 --- a/packages/ndk/lib/domain_layer/entities/request_state.dart +++ b/packages/ndk/lib/domain_layer/entities/request_state.dart @@ -80,12 +80,15 @@ class RequestState { timeoutDuration = request.timeoutDuration; _startTimeout(timeoutDuration!); } - _streamSubscription = controller.listen((e) {}, onDone: () { - if (_timeout != null) { - _timeout!.cancel(); - } - _streamSubscription.cancel(); - }); + _streamSubscription = controller.listen( + (e) {}, + onDone: () { + if (_timeout != null) { + _timeout!.cancel(); + } + _streamSubscription.cancel(); + }, + ); } void _startTimeout(Duration duration) { @@ -121,8 +124,9 @@ class RequestState { !requests.values.any((element) => !element.receivedEOSE); /// checks if all requests finished (received EOSE or CLOSED) - bool get didAllRequestsFinish => requests.values - .every((element) => element.receivedEOSE || element.receivedClosed); + bool get didAllRequestsFinish => requests.values.every( + (element) => element.receivedEOSE || element.receivedClosed, + ); /// Adds single relay request to the state void addRequest(String url, List filters) { diff --git a/packages/ndk/lib/domain_layer/entities/signer_request_rejected_exception.dart b/packages/ndk/lib/domain_layer/entities/signer_request_rejected_exception.dart index dcb0953b4..1ecc78a3a 100644 --- a/packages/ndk/lib/domain_layer/entities/signer_request_rejected_exception.dart +++ b/packages/ndk/lib/domain_layer/entities/signer_request_rejected_exception.dart @@ -1,7 +1,7 @@ /// Exception thrown when a signer request is rejected by the remote signer. /// /// This occurs when the user explicitly rejects/denies the request on the -/// remote signer (bunker, browser extension, Amber, etc.). +/// remote signer (bunker, browser extension, NIP-55 signer app, etc.). class SignerRequestRejectedException implements Exception { /// The ID of the request that was rejected (if available) final String? requestId; diff --git a/packages/ndk/lib/domain_layer/entities/user_relay_list.dart b/packages/ndk/lib/domain_layer/entities/user_relay_list.dart index 6871060f0..065a9bbed 100644 --- a/packages/ndk/lib/domain_layer/entities/user_relay_list.dart +++ b/packages/ndk/lib/domain_layer/entities/user_relay_list.dart @@ -29,23 +29,27 @@ class UserRelayList { static UserRelayList fromNip65(Nip65 nip65) { return UserRelayList( - pubKey: nip65.pubKey, - relays: { - for (var entry in nip65.relays.entries) entry.key: entry.value - }, - createdAt: nip65.createdAt, - refreshedTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000); + pubKey: nip65.pubKey, + relays: {for (var entry in nip65.relays.entries) entry.key: entry.value}, + createdAt: nip65.createdAt, + refreshedTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Nip65 toNip65() { - return Nip65.fromMap(pubKey, relays); + return Nip65( + pubKey: pubKey, + relays: {for (final entry in relays.entries) entry.key: entry.value}, + createdAt: createdAt, + ); } static UserRelayList fromNip02EventContent(Nip01Event event) { return UserRelayList( - pubKey: event.pubKey, - relays: ContactList.relaysFromContent(event), - createdAt: event.createdAt, - refreshedTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000); + pubKey: event.pubKey, + relays: ContactList.relaysFromContent(event), + createdAt: event.createdAt, + refreshedTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart index 580e33fbd..0b28d0c88 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart @@ -17,12 +17,12 @@ class CashuWallet extends Wallet { required this.mintInfo, Map? metadata, }) : super( - metadata: Map.unmodifiable({ - ...(metadata ?? const {}), - 'mintUrl': mintUrl, - 'mintInfo': mintInfo.toJson(), - }), - ); + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'mintUrl': mintUrl, + 'mintInfo': mintInfo.toJson(), + }), + ); @override Map toMetadata() => metadata; diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart index e674bfdc5..093d9eba1 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart @@ -77,11 +77,13 @@ class CashuWalletProvider implements WalletProvider { return _cashuUseCase.balances.map((balances) { return balances.where((b) => b.mintUrl == wallet.mintUrl).expand((b) { - return b.balances.entries.map((entry) => WalletBalance( - unit: entry.key, - amount: entry.value, - walletId: wallet.id, - )); + return b.balances.entries.map( + (entry) => WalletBalance( + unit: entry.key, + amount: entry.value, + walletId: wallet.id, + ), + ); }).toList(); }); } @@ -108,8 +110,11 @@ class CashuWalletProvider implements WalletProvider { } @override - Future send(Wallet wallet, String invoice, - {Duration? timeout}) async { + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { if (wallet is! CashuWallet) { throw ArgumentError('Expected a CashuWallet'); } @@ -149,13 +154,15 @@ class CashuWalletProvider implements WalletProvider { Stream> get discoveredWallets { return _cashuUseCase.knownMints.map((mints) { return mints - .map((mint) => CashuWallet( - id: mint.urls.first, - name: mint.name ?? mint.urls.first, - supportedUnits: mint.supportedUnits, - mintUrl: mint.urls.first, - mintInfo: mint, - )) + .map( + (mint) => CashuWallet( + id: mint.urls.first, + name: mint.name ?? mint.urls.first, + supportedUnits: mint.supportedUnits, + mintUrl: mint.urls.first, + mintInfo: mint, + ), + ) .toList(); }); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart index db3d15c84..007c09cb8 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart @@ -31,15 +31,15 @@ class LnurlWallet extends Wallet { this.metadataFetchedAt, Map? metadata, }) : super( - metadata: Map.unmodifiable({ - ...(metadata ?? const {}), - 'identifier': identifier, - 'lnurlPayUrl': lnurlPayUrl, - 'minSendable': minSendable, - 'maxSendable': maxSendable, - 'metadataFetchedAt': metadataFetchedAt, - }), - ); + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'identifier': identifier, + 'lnurlPayUrl': lnurlPayUrl, + 'minSendable': minSendable, + 'maxSendable': maxSendable, + 'metadataFetchedAt': metadataFetchedAt, + }), + ); @override Map toMetadata() => metadata; @@ -55,13 +55,15 @@ class LnurlWallet extends Wallet { final identifier = metadata['identifier'] as String?; if (identifier == null || identifier.isEmpty) { throw ArgumentError( - 'LnurlWallet storage requires metadata["identifier"]'); + 'LnurlWallet storage requires metadata["identifier"]', + ); } final lnurlPayUrl = metadata['lnurlPayUrl'] as String?; if (lnurlPayUrl == null || lnurlPayUrl.isEmpty) { throw ArgumentError( - 'LnurlWallet storage requires metadata["lnurlPayUrl"]'); + 'LnurlWallet storage requires metadata["lnurlPayUrl"]', + ); } return LnurlWallet( diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart index e8f43173b..1ac17f700 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart @@ -35,20 +35,23 @@ class LnurlWalletProvider implements WalletProvider { final identifier = metadata['identifier'] as String?; if (identifier == null || identifier.isEmpty) { throw ArgumentError( - 'LnurlWallet requires metadata["identifier"] in user@domain.com format'); + 'LnurlWallet requires metadata["identifier"] in user@domain.com format', + ); } // Validate identifier format (user@domain.com) if (!_isValidIdentifier(identifier)) { throw ArgumentError( - 'LnurlWallet identifier must be in user@domain.com format'); + 'LnurlWallet identifier must be in user@domain.com format', + ); } // Resolve to LNURL endpoint final lnurlPayUrl = Lnurl.getLud16LinkFromLud16(identifier); if (lnurlPayUrl == null) { throw ArgumentError( - 'Could not resolve LNURL endpoint from identifier: $identifier'); + 'Could not resolve LNURL endpoint from identifier: $identifier', + ); } return LnurlWallet( @@ -107,10 +110,15 @@ class LnurlWalletProvider implements WalletProvider { } @override - Future send(Wallet wallet, String invoice, {Duration? timeout}) async { + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { // LNURL wallet is receive-only, cannot pay invoices throw UnsupportedError( - 'LNURL wallet is receive-only and cannot pay invoices'); + 'LNURL wallet is receive-only and cannot pay invoices', + ); } @override @@ -122,8 +130,9 @@ class LnurlWalletProvider implements WalletProvider { final cached = _metadataCache[lnurlWallet.identifier]; if (cached != null && !cached.isExpired) { metadata = cached.response; - Logger.log - .d(() => 'Using cached LNURL metadata for ${lnurlWallet.identifier}'); + Logger.log.d( + () => 'Using cached LNURL metadata for ${lnurlWallet.identifier}', + ); } else { metadata = await _fetchAndCacheMetadata(lnurlWallet); } @@ -133,12 +142,14 @@ class LnurlWalletProvider implements WalletProvider { if (metadata.minSendable != null && amountMillisats < metadata.minSendable!) { throw ArgumentError( - 'Amount $amountSats sats is below minimum ${metadata.minSendable! ~/ 1000} sats'); + 'Amount $amountSats sats is below minimum ${metadata.minSendable! ~/ 1000} sats', + ); } if (metadata.maxSendable != null && amountMillisats > metadata.maxSendable!) { throw ArgumentError( - 'Amount $amountSats sats exceeds maximum ${metadata.maxSendable! ~/ 1000} sats'); + 'Amount $amountSats sats exceeds maximum ${metadata.maxSendable! ~/ 1000} sats', + ); } // Generate invoice via callback @@ -188,7 +199,8 @@ class LnurlWalletProvider implements WalletProvider { final response = await _lnurlUseCase.getLnurlResponse(wallet.lnurlPayUrl); if (response == null) { throw Exception( - 'Failed to fetch LNURL metadata from ${wallet.lnurlPayUrl}'); + 'Failed to fetch LNURL metadata from ${wallet.lnurlPayUrl}', + ); } // Cache the metadata @@ -210,10 +222,7 @@ class _CachedMetadata { final LnurlResponse response; final DateTime fetchedAt; - _CachedMetadata({ - required this.response, - required this.fetchedAt, - }); + _CachedMetadata({required this.response, required this.fetchedAt}); /// Check if cache is expired (10 minutes) bool get isExpired { diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index 41f6b3867..dfd8482b6 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -33,14 +33,14 @@ class NwcWallet extends Wallet { required this.nwcUrl, Set cachedPermissions = const {}, Map? metadata, - }) : cachedPermissions = Set.unmodifiable(cachedPermissions), - super( - metadata: Map.unmodifiable({ - ...(metadata ?? const {}), - 'nwcUrl': nwcUrl, - kPermissionsMetadataKey: cachedPermissions.toList(), - }), - ); + }) : cachedPermissions = Set.unmodifiable(cachedPermissions), + super( + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'nwcUrl': nwcUrl, + kPermissionsMetadataKey: cachedPermissions.toList(), + }), + ); @override Map toMetadata() => metadata; @@ -96,8 +96,8 @@ class NwcWallet extends Wallet { Set get _effectivePermissions => connection?.permissions.isNotEmpty == true - ? connection!.permissions - : cachedPermissions; + ? connection!.permissions + : cachedPermissions; @override bool get canReceive => diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index effec3bab..12de67fae 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart @@ -24,7 +24,8 @@ class NwcWalletProvider implements WalletProvider { /// Subscriptions to NWC notification streams, one per wallet. /// Cancelled in [removeWallet]. - final Map> _notificationSubscriptions = {}; + final Map> _notificationSubscriptions = + {}; NwcWalletProvider(this._nwcUseCase); @@ -143,7 +144,11 @@ class NwcWalletProvider implements WalletProvider { } @override - Future send(Wallet wallet, String invoice, {Duration? timeout}) async { + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { final nwcWallet = wallet as NwcWallet; await initialize(wallet); @@ -152,7 +157,7 @@ class NwcWalletProvider implements WalletProvider { final response = await _nwcUseCase.payInvoice( connection, invoice: invoice, - timeout: timeout + timeout: timeout, ); await _refreshAll(nwcWallet); return response; @@ -274,8 +279,11 @@ class NwcWalletProvider implements WalletProvider { changeAmount: e.isIncoming ? e.amountSat : -e.amountSat, unit: "sat", walletType: WalletType.NWC, - state: - _mapState(e.state, defaultPending: false, settledAt: e.settledAt), + state: _mapState( + e.state, + defaultPending: false, + settledAt: e.settledAt, + ), metadata: e.metadata ?? {}, transactionDate: e.settledAt ?? e.createdAt, initiatedDate: e.createdAt, @@ -284,8 +292,11 @@ class NwcWalletProvider implements WalletProvider { ); } - WalletTransactionState _mapState(String? state, - {required bool defaultPending, int? settledAt}) { + WalletTransactionState _mapState( + String? state, { + required bool defaultPending, + int? settledAt, + }) { if (state == null) { if (settledAt != null) { return WalletTransactionState.completed; @@ -326,11 +337,14 @@ class NwcWalletProvider implements WalletProvider { // (payment_sent, payment_received, hold_invoice_accepted). // This keeps ndk.wallets.getBalance() accurate without polling. _notificationSubscriptions[wallet.id]?.cancel(); - _notificationSubscriptions[wallet.id] = - wallet.connection!.notificationStream.stream.listen((_) { - _refreshBalance(wallet).catchError((_) {}); - _refreshBudget(wallet).catchError((_) {}); - }); + _notificationSubscriptions[wallet.id] = wallet + .connection! + .notificationStream + .stream + .listen((_) { + _refreshBalance(wallet).catchError((_) {}); + _refreshBudget(wallet).catchError((_) {}); + }); // Pre-cache budget immediately on connect so callers can read // cachedRemainingBudgetSats without issuing a separate live query. diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart index 1fc66889a..78175f125 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart @@ -40,7 +40,11 @@ abstract class WalletProvider { /// Pays a Lightning invoice using this wallet /// Returns payment result with preimage and fees - Future send(Wallet wallet, String invoice, {Duration? timeout}); + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }); /// Receive by creating a Lightning Invoice Future receive(Wallet wallet, int amountSats); diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart index 2d746c19a..2a25747d9 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -72,18 +72,19 @@ abstract class WalletTransaction { : null, qouteMelt: metadata['qouteMelt'] != null ? CashuQuoteMelt.fromJson( - metadata['qouteMelt'] as Map) + metadata['qouteMelt'] as Map, + ) : null, usedKeysets: metadata['usedKeyset'] != null ? (metadata['usedKeyset'] as List) - .map((k) => CahsuKeyset.fromJson(k as Map)) - .toList() + .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() + .map((p) => p.toString()) + .toList() : proofPubKeys, ); case WalletType.NWC: @@ -148,18 +149,19 @@ class CashuWalletTransaction extends WalletTransaction { 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, - }, - ); + 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) => diff --git a/packages/ndk/lib/domain_layer/repositories/blossom.dart b/packages/ndk/lib/domain_layer/repositories/blossom.dart index 95eb714da..a75763d82 100644 --- a/packages/ndk/lib/domain_layer/repositories/blossom.dart +++ b/packages/ndk/lib/domain_layer/repositories/blossom.dart @@ -66,9 +66,7 @@ abstract class BlossomRepository { }); /// Directly downloads a blob from the url, without blossom - Future directDownload({ - required Uri url, - }); + Future directDownload({required Uri url}); /// Directly downloads a blob from the url to a file, without blossom Future directDownloadToFile({ diff --git a/packages/ndk/lib/domain_layer/repositories/cache_manager.dart b/packages/ndk/lib/domain_layer/repositories/cache_manager.dart index bcdab849d..42532a66a 100644 --- a/packages/ndk/lib/domain_layer/repositories/cache_manager.dart +++ b/packages/ndk/lib/domain_layer/repositories/cache_manager.dart @@ -1,14 +1,33 @@ import '../entities/cashu/cashu_keyset.dart'; import '../entities/cashu/cashu_mint_info.dart'; import '../entities/cashu/cashu_proof.dart'; +import '../entities/cache_eviction.dart'; import '../entities/contact_list.dart'; +import '../entities/event_cache_records.dart'; import '../entities/filter_fetched_ranges.dart'; +import '../entities/metadata.dart'; import '../entities/nip_01_event.dart'; import '../entities/nip_05.dart'; import '../entities/relay_set.dart'; import '../entities/user_relay_list.dart'; -import '../entities/metadata.dart'; +/// Storage contract used by NDK. +/// +/// This is intentionally broader than a plain event store. Modern NDK cache +/// backends persist several related data domains: +/// - canonical Nostr events +/// - event provenance (`source relays`) +/// - delivery state (`EventDeliveryRecord` and `RelayDeliveryTarget`) +/// - decrypted plaintext sidecars for encrypted events +/// - convenience projections like metadata/contact list/user relay list +/// - optional fetched-ranges state +/// +/// Backend authors should treat this class as a behavior contract, not just a +/// list of CRUD methods. The most important behavioral expectations are: +/// - [loadEvents] returns *visible* events only +/// - metadata/contact list loaders are convenience views over the generic event +/// store, not separate authoritative silos +/// - provenance and delivery targets are separate concerns abstract class CacheManager { /// closes the cache manger \ /// used to close the db @@ -16,18 +35,106 @@ abstract class CacheManager { Future saveEvent(Nip01Event event); Future saveEvents(List events); + + /// Loads the raw stored event by id. + /// + /// Prefer [loadEvents] in app-facing read paths because `loadEvent` does not + /// itself promise visibility filtering. Future loadEvent(String id); - /// Load events from cache with flexible filtering \ - /// [ids] - list of event ids \ - /// [pubKeys] - list of authors pubKeys \ - /// [kinds] - list of kinds \ - /// [tags] - map of tags (e.g. {'p': ['pubkey1'], 'e': ['eventid1']}) \ - /// [since] - timestamp \ - /// [until] - timestamp \ - /// [search] - search string to match against content \ - /// [limit] - limit of results \ - /// returns list of events + /// Adds one provenance relay for a stored event. + /// + /// Provenance is intentionally stored separately from broadcast delivery + /// targets. A relay can be a source of truth for where an event was observed + /// without being a relay NDK should later broadcast back to. + Future addEventSource({ + required String eventId, + required String relayUrl, + }); + Future addEventSources({ + required String eventId, + required Iterable relayUrls, + }); + Future> loadEventSources(String eventId); + Future removeEventSources(String eventId); + + /// Persist aggregate delivery state for one event. + Future saveEventDeliveryRecord(EventDeliveryRecord record); + Future saveEventDeliveryRecords(List records); + Future loadEventDeliveryRecord(String eventId); + Future> loadEventDeliveryRecords({ + EventDeliveryStatus? status, + int? limit, + }); + Future removeEventDeliveryRecord(String eventId); + Future removeAllEventDeliveryRecords(); + + /// Persist one relay-specific delivery target. + Future saveRelayDeliveryTarget(RelayDeliveryTarget target); + Future saveRelayDeliveryTargets(List targets); + Future loadRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }); + Future> loadRelayDeliveryTargets({ + String? eventId, + String? relayUrl, + RelayDeliveryState? state, + bool excludeAcked = false, + int? limit, + }); + Future removeRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }); + Future removeRelayDeliveryTargets(String eventId); + Future removeAllRelayDeliveryTargets(); + + /// Persist one plaintext sidecar for an encrypted event. + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ); + Future saveDecryptedEventPayloadRecords( + List records, + ); + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }); + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }); + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }); + Future removeDecryptedEventPayloadRecords(String eventId); + Future removeAllDecryptedEventPayloadRecords(); + + /// Run one eviction pass according to [policy]. + /// + /// Backends should remove associated sidecars, provenance, and delivery state + /// for any event they physically delete. + Future evict(EvictionPolicy policy); + + /// Load visible events from cache with flexible filtering. + /// + /// Parameters: + /// - [ids]: event ids + /// - [pubKeys]: author pubkeys + /// - [kinds]: event kinds + /// - [tags]: tag filters, e.g. `{'p': ['pubkey1'], 'e': ['eventid1']}` + /// - [since]/[until]: created_at bounds + /// - [search]: content search string + /// - [limit]: maximum number of returned events + /// + /// Visibility rules apply here: + /// - only the latest visible replaceable/addressable winner is returned + /// - expired events are filtered out + /// - author-deleted events are filtered out Future> loadEvents({ List? ids, List? pubKeys, @@ -40,14 +147,10 @@ abstract class CacheManager { }); Future removeEvent(String id); - /// Remove events from cache with flexible filtering \ - /// [ids] - list of event ids \ - /// [pubKeys] - list of authors pubKeys \ - /// [kinds] - list of kinds \ - /// [tags] - map of tags (e.g. {'p': ['pubkey1'], 'e': ['eventid1']}) \ - /// [since] - timestamp \ - /// [until] - timestamp \ - /// If all parameters are empty, returns early (doesn't delete everything) + /// Remove events from cache with flexible filtering. + /// + /// If all parameters are empty, implementations should return early instead + /// of deleting everything by accident. Future removeEvents({ List? ids, List? pubKeys, @@ -59,6 +162,11 @@ abstract class CacheManager { Future removeAllEventsByPubKey(String pubKey); Future removeAllEvents(); + /// Store a precomputed user relay list projection. + /// + /// This remains a convenience projection. The authoritative data still comes + /// from the generic event store (for example kind `3` and kind `10002` + /// inputs). Future saveUserRelayList(UserRelayList userRelayList); Future saveUserRelayLists(List userRelayLists); Future loadUserRelayList(String pubKey); @@ -70,16 +178,34 @@ abstract class CacheManager { Future removeRelaySet(String name, String pubKey); Future removeAllRelaySets(); + @Deprecated( + 'Use saveEvent()/saveEvents() with kind 3 events; ContactList is now a convenience view over the generic event cache.', + ) Future saveContactList(ContactList contactList); + @Deprecated( + 'Use saveEvent()/saveEvents() with kind 3 events; ContactList is now a convenience view over the generic event cache.', + ) Future saveContactLists(List contactLists); Future loadContactList(String pubKey); + @Deprecated( + 'Use removeEvents(pubKeys: ..., kinds: [ContactList.kKind]); ContactList is now a convenience view over the generic event cache.', + ) Future removeContactList(String pubKey); Future removeAllContactLists(); + @Deprecated( + 'Use saveEvent()/saveEvents() with kind 0 events; Metadata is now a convenience view over the generic event cache.', + ) Future saveMetadata(Metadata metadata); + @Deprecated( + 'Use saveEvent()/saveEvents() with kind 0 events; Metadata is now a convenience view over the generic event cache.', + ) Future saveMetadatas(List metadatas); Future loadMetadata(String pubKey); Future> loadMetadatas(List pubKeys); + @Deprecated( + 'Use removeEvents(pubKeys: ..., kinds: [Metadata.kKind]); Metadata is now a convenience view over the generic event cache.', + ) Future removeMetadata(String pubKey); Future removeAllMetadatas(); @@ -120,9 +246,7 @@ abstract class CacheManager { Future saveKeyset(CahsuKeyset keyset); /// get all keysets if no mintUrl is provided \ - Future> getKeysets({ - String? mintUrl, - }); + Future> getKeysets({String? mintUrl}); Future saveProofs({ required List proofs, @@ -140,18 +264,12 @@ abstract class CacheManager { required String mintUrl, }); - Future saveMintInfo({ - required CashuMintInfo mintInfo, - }); + Future saveMintInfo({required CashuMintInfo mintInfo}); - Future removeMintInfo({ - required String mintUrl, - }); + Future removeMintInfo({required String mintUrl}); /// return all if no mintUrls are provided - Future?> getMintInfos({ - List? mintUrls, - }); + Future?> getMintInfos({List? mintUrls}); Future getCashuSecretCounter({ required String mintUrl, @@ -172,26 +290,32 @@ abstract class CacheManager { /// Save multiple filter fetched range records Future saveFilterFetchedRangeRecords( - List records); + List records, + ); /// Load all fetched range records for a filter hash Future> loadFilterFetchedRangeRecords( - String filterHash); + String filterHash, + ); /// Load all fetched range records for a filter hash and relay Future> loadFilterFetchedRangeRecordsByRelay( - String filterHash, String relayUrl); + String filterHash, + String relayUrl, + ); /// Load all fetched range records for a relay (all filters) Future> - loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl); + loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl); /// Remove all fetched range records for a filter hash Future removeFilterFetchedRangeRecords(String filterHash); /// Remove fetched range records for a specific filter hash and relay Future removeFilterFetchedRangeRecordsByFilterAndRelay( - String filterHash, String relayUrl); + String filterHash, + String relayUrl, + ); /// Remove all fetched range records for a relay Future removeFilterFetchedRangeRecordsByRelay(String relayUrl); diff --git a/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart b/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart index 424e8fc36..cc681b0f4 100644 --- a/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart +++ b/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart @@ -16,9 +16,7 @@ abstract class CashuRepo { required List outputs, }); - Future> getKeysets({ - required String mintUrl, - }); + Future> getKeysets({required String mintUrl}); Future> getKeys({ required String mintUrl, @@ -82,9 +80,7 @@ abstract class CashuRepo { required String method, }); - Future getMintInfo({ - required String mintUrl, - }); + Future getMintInfo({required String mintUrl}); Future> checkTokenState({ required List proofPubkeys, diff --git a/packages/ndk/lib/domain_layer/repositories/event_signer.dart b/packages/ndk/lib/domain_layer/repositories/event_signer.dart index 678971bca..2a77a6e3f 100644 --- a/packages/ndk/lib/domain_layer/repositories/event_signer.dart +++ b/packages/ndk/lib/domain_layer/repositories/event_signer.dart @@ -10,10 +10,7 @@ abstract class LocalEventSignerFactory { /// /// If [publicKey] is null, implementations MUST derive it from [privateKey]. /// At least one of [privateKey] or [publicKey] must be provided! - EventSigner create({ - String? privateKey, - String? publicKey, - }); + EventSigner create({String? privateKey, String? publicKey}); /// Derives a public key from a private key. /// Implementations MUST provide the derivation logic. @@ -31,6 +28,35 @@ abstract class LocalEventSignerFactory { } abstract class EventSigner { + /// Whether signing depends on an external or user-mediated signer workflow + /// instead of being completed locally and synchronously from key material + /// already available in-process. + /// + /// Interactive signers can still report [canSign] as `true`; this flag + /// exists so higher-level local-first queues can distinguish "needs a live + /// signer interaction/approval flow" from "can be signed locally on any + /// retry". + bool get requiresInteractiveSigning => false; + + /// Whether the signer flow itself depends on network connectivity. + /// + /// This is narrower than [requiresInteractiveSigning]: + /// - NIP-46 bunker signers: `true` + /// - NIP-07 browser extensions: `false` + /// - NIP-55 external signer apps: `false` + /// - local private-key signers: `false` + /// + /// Local-first delivery should treat this as a scheduling hint, not as the + /// sole retry trigger. + bool get requiresSignerNetwork => false; + + /// Relay URLs used as the transport path to reach this signer, if any. + /// + /// This is primarily relevant for networked interactive signers such as + /// NIP-46 bunkers, where relay connectivity can be used as a hint for when + /// retrying signing work makes sense. + Iterable get signerTransportRelayUrls => const []; + /// Signs the given event and returns the signed event Future sign(Nip01Event event); @@ -57,9 +83,9 @@ abstract class EventSigner { /// Stream of pending requests waiting for user approval. /// Emits whenever the list changes (request added, completed, or cancelled). /// - /// For local signers (like Bip340EventSigner), this will always emit an empty list. - /// For remote signers (NIP-46, NIP-07, Amber), this will emit the current - /// pending requests that are waiting for user approval. + /// For local signers (like Bip340EventSigner), this will always emit an + /// empty list. For interactive signers (NIP-46, NIP-07, NIP-55), this emits + /// requests currently waiting for user approval or external completion. Stream> get pendingRequestsStream; /// Current list of pending requests (synchronous snapshot). diff --git a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart index 488980921..043dad8d0 100644 --- a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart +++ b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart @@ -17,6 +17,9 @@ 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/usecases/accounts/accounts.dart b/packages/ndk/lib/domain_layer/usecases/accounts/accounts.dart index da89c84cf..de1fb926a 100644 --- a/packages/ndk/lib/domain_layer/usecases/accounts/accounts.dart +++ b/packages/ndk/lib/domain_layer/usecases/accounts/accounts.dart @@ -34,10 +34,10 @@ class Accounts { throw Exception("Cannot login, pubkey already logged in"); } addAccount( - pubkey: pubkey, - type: AccountType.privateKey, - signer: - eventSignerFactory.create(privateKey: privkey, publicKey: pubkey)); + pubkey: pubkey, + type: AccountType.privateKey, + signer: eventSignerFactory.create(privateKey: privkey, publicKey: pubkey), + ); _loggedPubkey = pubkey; _notifyAuthStateChange(); } @@ -53,9 +53,10 @@ class Accounts { throw Exception("Cannot login, pubkey already logged in"); } addAccount( - pubkey: pubkey, - type: AccountType.publicKey, - signer: eventSignerFactory.create(privateKey: null, publicKey: pubkey)); + pubkey: pubkey, + type: AccountType.publicKey, + signer: eventSignerFactory.create(privateKey: null, publicKey: pubkey), + ); _loggedPubkey = pubkey; _notifyAuthStateChange(); } @@ -67,7 +68,10 @@ class Accounts { throw Exception("Cannot login, pubkey already logged in"); } addAccount( - pubkey: pubkey, type: AccountType.externalSigner, signer: signer); + pubkey: pubkey, + type: AccountType.externalSigner, + signer: signer, + ); _loggedPubkey = pubkey; _notifyAuthStateChange(); } @@ -139,10 +143,11 @@ class Accounts { } /// adds an Account - void addAccount( - {required String pubkey, - required AccountType type, - required EventSigner signer}) { + void addAccount({ + required String pubkey, + required AccountType type, + required EventSigner signer, + }) { accounts[pubkey] = Account(type: type, pubkey: pubkey, signer: signer); } diff --git a/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast.dart b/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast.dart index 067275520..07dd0c1c5 100644 --- a/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast.dart +++ b/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast.dart @@ -3,38 +3,31 @@ import 'package:ndk/ndk.dart'; import '../../../shared/helpers/relay_helper.dart'; import '../../../shared/nips/nip09/deletion.dart'; import '../../../shared/nips/nip25/reactions.dart'; -import '../../entities/broadcast_state.dart'; -import '../../entities/global_state.dart'; -import '../engines/network_engine.dart'; +import 'broadcast_sender.dart'; +import 'pending_broadcast_delivery.dart'; -/// class for low level nostr broadcasts / publish \ -/// wraps the engines to inject singer +/// Public-facing broadcast facade. \ +/// Delegates immediate sending to [BroadcastSender] and coordinates pending +/// delivery enrollment for specific-relay broadcasts. \ +/// The retry path in [PendingBroadcastDelivery] calls [BroadcastSender] directly, +/// so it never re-enters this facade's enrollment logic. class Broadcast { - final NetworkEngine _engine; + final BroadcastSender _sender; final Accounts _accounts; final CacheManager _cacheManager; - final GlobalState _globalState; - final double _considerDonePercent; - final Duration _timeout; - final bool _saveToCache; + final PendingBroadcastDelivery? _pendingDelivery; - /// creates a new [Broadcast] instance + /// creates a new [Broadcast] facade instance /// Broadcast({ - required GlobalState globalState, - required CacheManager cacheManager, - required NetworkEngine networkEngine, + required BroadcastSender broadcastSender, required Accounts accounts, - required double considerDonePercent, - required Duration timeout, - required bool saveToCache, - }) : _accounts = accounts, - _cacheManager = cacheManager, - _engine = networkEngine, - _globalState = globalState, - _considerDonePercent = considerDonePercent, - _timeout = timeout, - _saveToCache = saveToCache; + required CacheManager cacheManager, + required PendingBroadcastDelivery pendingDelivery, + }) : _sender = broadcastSender, + _accounts = accounts, + _cacheManager = cacheManager, + _pendingDelivery = pendingDelivery; /// [throws] if the default signer and the custom signer are null \ /// [returns] the signer that is not null, if both are provided returns [customSigner] @@ -60,34 +53,116 @@ class Broadcast { Duration? timeout, bool? saveToCache, }) { - final myConsiderDonePercent = considerDonePercent ?? _considerDonePercent; - final myTimeout = timeout ?? _timeout; - final mySaveToCache = saveToCache ?? _saveToCache; + // prep for pending delivery enrollment + final cleanedSpecificRelays = specificRelays != null + ? cleanRelayUrls(specificRelays.toList()) + : null; + final signer = nostrEvent.sig == null + ? _checkSinger(customSigner: customSigner) + : null; - final broadcastState = BroadcastState( - considerDonePercent: myConsiderDonePercent, - timeout: myTimeout, + // delegate immediate send to the sender + final response = _sender.broadcast( + nostrEvent: nostrEvent, + specificRelays: specificRelays, + customSigner: customSigner, + considerDonePercent: considerDonePercent, + timeout: timeout, + saveToCache: saveToCache, ); - // register broadcast state - _globalState.inFlightBroadcasts[nostrEvent.id] = broadcastState; + final responseDoneFuture = response.broadcastDoneFuture; - // save event to cache if enabled - if (mySaveToCache) { - _cacheManager.saveEvent(nostrEvent); + // enroll in pending delivery for specific-relay broadcasts + final pendingDelivery = _pendingDelivery; + Future? pendingEnrollment; + if (pendingDelivery != null && + cleanedSpecificRelays != null && + cleanedSpecificRelays.isNotEmpty) { + pendingEnrollment = pendingDelivery.enqueueSpecificRelayBroadcast( + event: nostrEvent, + relayUrls: cleanedSpecificRelays, + requiresInteractiveSigning: + signer != null && signer.requiresInteractiveSigning, + ); } - final signer = nostrEvent.sig == null - ? _checkSinger(customSigner: customSigner) - : null; + if (pendingEnrollment == null) { + return response; + } - final cleanedSpecificRelays = - specificRelays != null ? cleanRelayUrls(specificRelays.toList()) : null; + final broadcastDoneFuture = responseDoneFuture.then((responses) async { + try { + await pendingEnrollment!; + await pendingDelivery!.persistSpecificRelayBroadcastResult( + nostrEvent, + responses, + ); + } catch (_, __) {} + return responses; + }); + return NdkBroadcastResponse( + publishEvent: response.publishEvent, + broadcastDoneStream: response.broadcastDone, + broadcastDoneFuture: broadcastDoneFuture, + ); + } - return _engine.handleEventBroadcast( - nostrEvent: nostrEvent, - signer: signer, - specificRelays: cleanedSpecificRelays, - broadcastState: broadcastState, + /// Returns the joined persisted local-first delivery state for one event id. + /// + /// This lets apps inspect events that may still exist only locally after a + /// restart, including the last persisted per-relay outcome. + Future loadEventDelivery(String eventId) async { + final record = await _cacheManager.loadEventDeliveryRecord(eventId); + if (record == null) { + return null; + } + + return _loadSnapshotForRecord(record); + } + + /// Lists persisted delivery items. + /// + /// By default this returns only events that are not fully delivered yet. + Future> loadDeliveries({ + bool pendingOnly = true, + EventDeliveryStatus? status, + int? limit, + }) async { + final records = await _cacheManager.loadEventDeliveryRecords( + status: status, + limit: limit, + ); + records.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + final snapshots = []; + for (final record in records) { + if (pendingOnly && record.status == EventDeliveryStatus.delivered) { + continue; + } + snapshots.add(await _loadSnapshotForRecord(record)); + } + + return snapshots; + } + + /// Convenience alias for app UIs that want items still pending broadcast. + Future> loadPendingDeliveries({int? limit}) { + return loadDeliveries(pendingOnly: true, limit: limit); + } + + Future _loadSnapshotForRecord( + EventDeliveryRecord record, + ) async { + final event = await _cacheManager.loadEvent(record.eventId); + final relayTargets = await _cacheManager.loadRelayDeliveryTargets( + eventId: record.eventId, + ); + relayTargets.sort((a, b) => a.relayUrl.compareTo(b.relayUrl)); + + return EventDeliverySnapshot( + event: event, + record: record, + relayTargets: relayTargets, ); } @@ -106,13 +181,14 @@ class Broadcast { }) { final signer = _checkSinger(); Nip01Event event = Nip01Event( - pubKey: signer.getPublicKey(), - kind: Reaction.kKind, - tags: [ - ["e", eventId] - ], - content: reaction, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + pubKey: signer.getPublicKey(), + kind: Reaction.kKind, + tags: [ + ["e", eventId], + ], + content: reaction, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); return broadcast(nostrEvent: event, specificRelays: customRelays); } @@ -178,7 +254,8 @@ class Broadcast { allEventsAndAllVersions.isEmpty && allEventIds.isEmpty) { throw ArgumentError( - "At least one event or eventId must be provided for deletion."); + "At least one event or eventId must be provided for deletion.", + ); } // Build tags @@ -216,11 +293,12 @@ class Broadcast { } Nip01Event deletionEvent = Nip01Event( - pubKey: mySigner.getPublicKey(), - kind: Deletion.kKind, - tags: tags, - content: reason, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + pubKey: mySigner.getPublicKey(), + kind: Deletion.kKind, + tags: tags, + content: reason, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); // Remove events from cache if (idsToRemoveFromCache.isNotEmpty) { @@ -235,7 +313,7 @@ class Broadcast { kinds: [e.kind], tags: dTag != null ? { - 'd': [dTag] + 'd': [dTag], } : null, ); diff --git a/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast_sender.dart b/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast_sender.dart new file mode 100644 index 000000000..bd8211444 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/broadcast/broadcast_sender.dart @@ -0,0 +1,107 @@ +import 'package:ndk/ndk.dart'; + +import '../../../shared/helpers/relay_helper.dart'; +import '../../entities/broadcast_state.dart'; +import '../../entities/global_state.dart'; +import '../engines/network_engine.dart'; + +/// Low-level broadcaster that sends events to relays immediately. \ +/// Has no queue, retry, or persistence logic. \ +/// Both the public [Broadcast] facade and [PendingBroadcastDelivery] retry path +/// delegate here so the retry path never re-enters queue enrollment. +class BroadcastSender { + final NetworkEngine _engine; + final Accounts _accounts; + final CacheManager _cacheManager; + final GlobalState _globalState; + final double _considerDonePercent; + final Duration _timeout; + final bool _saveToCache; + + BroadcastSender({ + required GlobalState globalState, + required CacheManager cacheManager, + required NetworkEngine networkEngine, + required Accounts accounts, + required double considerDonePercent, + required Duration timeout, + required bool saveToCache, + }) : _accounts = accounts, + _cacheManager = cacheManager, + _engine = networkEngine, + _globalState = globalState, + _considerDonePercent = considerDonePercent, + _timeout = timeout, + _saveToCache = saveToCache; + + bool isEventInFlight(String eventId) { + return _globalState.inFlightBroadcasts.containsKey(eventId); + } + + /// [throws] if the default signer and the custom signer are null \ + /// [returns] the signer that is not null, if both are provided returns [customSigner] + EventSigner _checkSinger({EventSigner? customSigner}) { + if (_accounts.isNotLoggedIn && customSigner == null) { + throw "cannot broadcast without a signer!"; + } + return customSigner ?? _accounts.getLoggedAccount()!.signer; + } + + /// Sends [nostrEvent] to relays now. No queue enrollment, no retry. \ + /// [specificRelays] disables inbox/outbox (gossip) and broadcasts to the relays specified \ + /// [customSigner] if you want to use a different signer than the one from currently logged in user in [Accounts] \ + /// [considerDonePercent] the percentage (0.0, 1.0) of relays that need to respond with "OK" for the broadcast to be considered done (overrides the default value) \ + /// [timeout] the timeout for the broadcast (overrides the default timeout) \ + /// [saveToCache] whether to save the event to cache (overrides the default value from config) \ + /// [returns] a [NdkBroadcastResponse] object containing the result => success per relay + NdkBroadcastResponse broadcast({ + required Nip01Event nostrEvent, + Iterable? specificRelays, + EventSigner? customSigner, + double? considerDonePercent, + Duration? timeout, + bool? saveToCache, + }) { + final myConsiderDonePercent = considerDonePercent ?? _considerDonePercent; + final myTimeout = timeout ?? _timeout; + final mySaveToCache = saveToCache ?? _saveToCache; + + final broadcastState = BroadcastState( + considerDonePercent: myConsiderDonePercent, + timeout: myTimeout, + ); + _globalState.inFlightBroadcasts[nostrEvent.id] = broadcastState; + void cleanupInFlightBroadcastState() { + if (identical( + _globalState.inFlightBroadcasts[nostrEvent.id], + broadcastState, + )) { + _globalState.inFlightBroadcasts.remove(nostrEvent.id); + } + } + + broadcastState.publishDoneFuture.then( + (_) => cleanupInFlightBroadcastState(), + onError: (_, __) => cleanupInFlightBroadcastState(), + ); + + if (mySaveToCache) { + _cacheManager.saveEvent(nostrEvent); + } + + final signer = nostrEvent.sig == null + ? _checkSinger(customSigner: customSigner) + : null; + + final cleanedSpecificRelays = specificRelays != null + ? cleanRelayUrls(specificRelays.toList()) + : null; + + return _engine.handleEventBroadcast( + nostrEvent: nostrEvent, + signer: signer, + specificRelays: cleanedSpecificRelays, + broadcastState: broadcastState, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/broadcast/delivery_policy.dart b/packages/ndk/lib/domain_layer/usecases/broadcast/delivery_policy.dart new file mode 100644 index 000000000..4d391e1e0 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/broadcast/delivery_policy.dart @@ -0,0 +1,191 @@ +import '../../entities/broadcast_state.dart'; +import '../../entities/event_cache_records.dart'; +import '../../entities/nip_01_event.dart'; +import '../../../shared/nips/nip01/event_kind_classification.dart'; +import '../../../shared/nips/nip09/deletion.dart'; + +/// Internal retry/delivery strategy used by local-first broadcast persistence. +/// +/// NDK currently derives this policy from the event kind. There is no public +/// broadcast parameter or config hook that lets apps override the policy for a +/// single event. +enum DeliveryPolicyKind { + persistentEventual, + latestStateOnly, + highPriorityControl, + doNotRetry, +} + +/// Internal classifier for how one event should be retried after broadcast. +/// +/// Current mapping: +/// - ephemeral events -> [DeliveryPolicyKind.doNotRetry] +/// - deletion events -> [DeliveryPolicyKind.highPriorityControl] +/// - replaceable/addressable events -> [DeliveryPolicyKind.latestStateOnly] +/// - all other events -> [DeliveryPolicyKind.persistentEventual] +/// +/// Apps do not configure this directly today. To change behavior, they must +/// change the event kind semantics rather than pass a custom policy. +class DeliveryPolicy { + static const Set _permanentFailurePrefixes = { + 'blocked', + 'invalid', + 'pow', + 'restricted', + }; + + static const Set _transientFailurePrefixes = { + 'error', + 'rate-limited', + }; + + static const List _permanentFailureMarkers = [ + 'bad signature', + 'bad event id', + 'invalid signature', + 'invalid event', + 'invalid id', + 'invalid kind', + 'invalid expiration', + 'too many tags', + 'event too large', + 'event exceeded max size', + 'message too large', + 'too large', + 'forbidden', + 'policy violation', + ]; + + final DeliveryPolicyKind kind; + + const DeliveryPolicy._(this.kind); + + /// Classifies the delivery policy for [event] from its Nostr event kind. + factory DeliveryPolicy.forEvent(Nip01Event event) { + if (EventKindClassification.isEphemeralKind(event.kind)) { + return const DeliveryPolicy._(DeliveryPolicyKind.doNotRetry); + } + if (event.kind == Deletion.kKind) { + return const DeliveryPolicy._(DeliveryPolicyKind.highPriorityControl); + } + if (EventKindClassification.isReplaceableKind(event.kind)) { + return const DeliveryPolicy._(DeliveryPolicyKind.latestStateOnly); + } + return const DeliveryPolicy._(DeliveryPolicyKind.persistentEventual); + } + + bool get retainsOnlyLatest => kind == DeliveryPolicyKind.latestStateOnly; + + RelayDeliveryState resolveNextState(RelayBroadcastResponse response) { + if (response.okReceived && response.broadcastSuccessful) { + return RelayDeliveryState.acked; + } + + final normalizedMsg = response.msg.trim().toLowerCase(); + final prefix = _machineReadablePrefix(normalizedMsg); + + // A relay answering `duplicate:` already holds the event, so delivery to it + // is effectively done even when OK was false. Treat it as acked rather than + // a permanent failure so it is not reported as undelivered. + if (prefix == 'duplicate') { + return RelayDeliveryState.acked; + } + + if (prefix == 'auth-required' || + normalizedMsg.startsWith('auth-required')) { + return RelayDeliveryState.authRequired; + } + + if (_looksPermanent(response.msg)) { + return RelayDeliveryState.permanentFailure; + } + + if (kind == DeliveryPolicyKind.doNotRetry) { + return RelayDeliveryState.permanentFailure; + } + + if (response.msg.isNotEmpty) { + return RelayDeliveryState.transientFailure; + } + + return RelayDeliveryState.attempting; + } + + bool shouldRetryState(RelayDeliveryState state) { + if (state == RelayDeliveryState.acked || + state == RelayDeliveryState.permanentFailure) { + return false; + } + if (kind == DeliveryPolicyKind.doNotRetry) { + return false; + } + return true; + } + + Duration retryDelayFor({ + required RelayDeliveryState state, + required int attemptCount, + }) { + if (!shouldRetryState(state)) { + return Duration.zero; + } + + if (state == RelayDeliveryState.authRequired) { + return const Duration(minutes: 1); + } + + final retrySeconds = switch (kind) { + DeliveryPolicyKind.highPriorityControl => switch (attemptCount) { + <= 1 => 2, + 2 => 5, + 3 => 15, + 4 => 60, + _ => 300, + }, + DeliveryPolicyKind.persistentEventual => switch (attemptCount) { + <= 1 => 5, + 2 => 15, + 3 => 60, + 4 => 300, + _ => 900, + }, + DeliveryPolicyKind.latestStateOnly => switch (attemptCount) { + <= 1 => 5, + 2 => 15, + 3 => 60, + 4 => 300, + _ => 900, + }, + DeliveryPolicyKind.doNotRetry => 0, + }; + return Duration(seconds: retrySeconds); + } + + static bool _looksPermanent(String message) { + final normalized = message.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + + final prefix = _machineReadablePrefix(normalized); + if (prefix != null) { + if (_permanentFailurePrefixes.contains(prefix)) { + return true; + } + if (_transientFailurePrefixes.contains(prefix)) { + return false; + } + } + + return _permanentFailureMarkers.any(normalized.contains); + } + + static String? _machineReadablePrefix(String normalizedMessage) { + final separatorIndex = normalizedMessage.indexOf(':'); + if (separatorIndex <= 0) { + return null; + } + + return normalizedMessage.substring(0, separatorIndex).trim(); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/broadcast/pending_broadcast_delivery.dart b/packages/ndk/lib/domain_layer/usecases/broadcast/pending_broadcast_delivery.dart new file mode 100644 index 000000000..21d491035 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/broadcast/pending_broadcast_delivery.dart @@ -0,0 +1,1109 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../../data_layer/models/nip_01_event_model.dart'; +import '../../entities/broadcast_state.dart'; +import '../../entities/event_cache_records.dart'; +import '../../entities/nip_01_event.dart'; +import '../../entities/pending_signer_request.dart'; +import '../../entities/signer_request_cancelled_exception.dart'; +import '../../entities/signer_request_rejected_exception.dart'; +import '../../repositories/cache_manager.dart'; +import '../../repositories/event_signer.dart'; +import '../accounts/accounts.dart'; +import '../../../shared/logger/logger.dart'; +import '../../../shared/nips/nip01/event_kind_classification.dart'; +import 'broadcast_sender.dart'; +import 'delivery_policy.dart'; + +/// Background manager for persisted broadcast delivery. +/// +/// This usecase persists relay-specific delivery targets, retries due targets, +/// and converges replaceable delivery so only the latest visible version keeps +/// being retried. +/// +/// Conceptually: +/// - [Broadcast] decides that an event should be sent +/// - [PendingBroadcastDelivery] remembers where it still needs to go and when +/// it should be retried +class PendingBroadcastDelivery { + static const Duration defaultRetryInterval = Duration(seconds: 15); + static const Duration defaultSignAttemptTimeout = Duration(seconds: 15); + final CacheManager _cacheManager; + final BroadcastSender _sender; + final Accounts _accounts; + final Duration _signAttemptTimeout; + final Set _flushInProgress = {}; + final Map _activeSignAttemptIds = {}; + final Map _latestSignAttemptIds = {}; + Iterable Function()? _connectedRelayUrlsProvider; + Timer? _retryTimer; + bool _stopped = false; + final Set> _inFlightOperations = {}; + + PendingBroadcastDelivery({ + required CacheManager cacheManager, + required BroadcastSender broadcastSender, + required Accounts accounts, + Duration signAttemptTimeout = defaultSignAttemptTimeout, + }) : _cacheManager = cacheManager, + _sender = broadcastSender, + _accounts = accounts, + _signAttemptTimeout = signAttemptTimeout; + + /// Starts periodic due-retry processing. + /// + /// The callbacks are injected so this class can stay storage-focused and not + /// depend directly on relay manager internals. + void startPeriodicRetry({ + required Iterable Function() connectedRelayUrls, + required Future Function(String relayUrl) reconnectRelay, + Duration retryInterval = defaultRetryInterval, + }) { + _stopped = false; + _connectedRelayUrlsProvider = connectedRelayUrls; + _retryTimer?.cancel(); + _retryTimer = Timer.periodic( + retryInterval, + (_) => _trackOperation( + retryDueDeliveries( + connectedRelayUrls: connectedRelayUrls, + reconnectRelay: reconnectRelay, + ), + ), + ); + _trackOperation( + retryDueDeliveries( + connectedRelayUrls: connectedRelayUrls, + reconnectRelay: reconnectRelay, + ), + ); + } + + Future stop() async { + _stopped = true; + _retryTimer?.cancel(); + _retryTimer = null; + final operations = _inFlightOperations.toList(growable: false); + if (operations.isNotEmpty) { + await Future.wait(operations); + } + } + + void _trackOperation(Future operation) { + if (_stopped) { + return; + } + _inFlightOperations.add(operation); + operation.whenComplete(() { + _inFlightOperations.remove(operation); + }); + } + + Future retryDueDeliveries({ + required Iterable Function() connectedRelayUrls, + required Future Function(String relayUrl) reconnectRelay, + }) async { + if (_stopped) { + return; + } + await _retryDueSigning(reconnectRelay: reconnectRelay); + + if (_stopped) { + return; + } + + final connectedRelayUrlSet = connectedRelayUrls().toSet(); + final dueRelayUrlSet = await _relayUrlsWithDuePendingTargets(); + final relayUrls = dueRelayUrlSet.toList()..sort(); + + for (final relayUrl in relayUrls) { + if (_stopped) { + return; + } + if (connectedRelayUrlSet.contains(relayUrl)) { + await flushForRelay(relayUrl, onlyDue: true); + continue; + } + + final connected = await reconnectRelay(relayUrl); + if (!connected) { + continue; + } + + await flushForRelay(relayUrl, onlyDue: true); + } + } + + /// Trigger immediate processing of due signing and delivery work. + /// + /// Useful as an accelerator when the host app detects network restoration, + /// foreground resume, or other conditions that may unblock pending signer + /// approval/completion paths. + Future retryDueNow({ + required Iterable Function() connectedRelayUrls, + required Future Function(String relayUrl) reconnectRelay, + }) async { + await retryDueDeliveries( + connectedRelayUrls: connectedRelayUrls, + reconnectRelay: reconnectRelay, + ); + } + + /// Retry unsigned interactive-signing work associated with a signer + /// transport relay that just became reachable. + Future retryInteractiveSigningForTransportRelay(String relayUrl) async { + if (_stopped) { + return; + } + final records = await _cacheManager.loadEventDeliveryRecords(); + records.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + for (final record in records) { + if (_stopped) { + return; + } + if (!record.requiresInteractiveSigning) { + continue; + } + if (record.signingState == EventSigningState.permanentFailure || + record.signingState == EventSigningState.needsAction || + record.signingState == EventSigningState.signed || + record.signedAt != null) { + continue; + } + + final event = await _cacheManager.loadEvent(record.eventId); + if (event == null) { + await _discardEventDelivery(record.eventId); + continue; + } + + final signer = _resolveSignerForEvent(event); + if (signer == null || + !signer.requiresSignerNetwork || + !signer.signerTransportRelayUrls.contains(relayUrl)) { + continue; + } + + await _ensureEventSigned(record, event: event); + } + } + + Future enqueueSpecificRelayBroadcast({ + required Nip01Event event, + required Iterable relayUrls, + required bool requiresInteractiveSigning, + }) async { + if (_stopped) { + return; + } + // One durable aggregate record plus one durable target per relay gives NDK + // enough state to recover delivery after restart without mutating a shared + // in-memory list. + final relayUrlList = relayUrls.toSet().toList()..sort(); + Logger.log.d(() => 'enqueue pending delivery ${event.id} -> $relayUrlList'); + final existing = await _cacheManager.loadEventDeliveryRecord(event.id); + final now = Nip01Event.secondsSinceEpoch(); + + await _cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: existing?.status ?? EventDeliveryStatus.pending, + signingState: + existing?.signingState ?? + (requiresInteractiveSigning + ? EventSigningState.pending + : EventSigningState.notNeeded), + createdAt: existing?.createdAt ?? event.createdAt, + updatedAt: now, + serializedEventJson: + _serializeEvent(event) ?? existing?.serializedEventJson, + signedAt: existing?.signedAt ?? (event.sig != null ? now : null), + completedAt: existing?.completedAt, + requiresInteractiveSigning: requiresInteractiveSigning, + signAttemptCount: existing?.signAttemptCount ?? 0, + lastSignAttemptAt: existing?.lastSignAttemptAt, + nextSignRetryAt: existing?.nextSignRetryAt, + lastSignError: existing?.lastSignError, + ), + ); + + final existingTargets = await _cacheManager.loadRelayDeliveryTargets( + eventId: event.id, + ); + final existingByRelay = { + for (final target in existingTargets) target.relayUrl: target, + }; + + await _cacheManager.saveRelayDeliveryTargets( + relayUrlList.map((relayUrl) { + final existingTarget = existingByRelay[relayUrl]; + return existingTarget ?? + RelayDeliveryTarget( + eventId: event.id, + relayUrl: relayUrl, + reason: RelayDeliveryReason.explicit, + ); + }).toList(), + ); + } + + Future persistSpecificRelayBroadcastResult( + Nip01Event event, + List responses, + ) async { + if (_stopped) { + return; + } + Logger.log.d( + () => + 'persist broadcast result ${event.id} -> ${responses.map((r) => r.relayUrl).toList()}', + ); + + final existing = await _cacheManager.loadEventDeliveryRecord(event.id); + if (existing == null) { + return; + } + + final existingTargets = await _cacheManager.loadRelayDeliveryTargets( + eventId: event.id, + ); + final targetsByRelay = { + for (final target in existingTargets) target.relayUrl: target, + }; + + final updatedTargets = []; + final policy = DeliveryPolicy.forEvent(event); + final attemptTimestamp = Nip01Event.secondsSinceEpoch(); + for (final response in responses) { + final current = targetsByRelay[response.relayUrl]; + if (current == null) { + continue; + } + + final isAcked = response.okReceived && response.broadcastSuccessful; + final nextState = policy.resolveNextState(response); + final nextRetryAt = policy.shouldRetryState(nextState) + ? attemptTimestamp + + policy + .retryDelayFor( + state: nextState, + attemptCount: current.attemptCount + 1, + ) + .inSeconds + : null; + + updatedTargets.add( + current.copyWith( + state: nextState, + attemptCount: current.attemptCount + 1, + lastAttemptAt: attemptTimestamp, + nextRetryAt: nextRetryAt, + lastOkMessage: isAcked ? response.msg : current.lastOkMessage, + lastError: isAcked ? null : response.msg, + ), + ); + } + + if (policy.kind == DeliveryPolicyKind.doNotRetry) { + final updatedRelayUrls = { + for (final target in updatedTargets) target.relayUrl, + }; + for (final current in existingTargets) { + if (updatedRelayUrls.contains(current.relayUrl)) { + continue; + } + if (current.state == RelayDeliveryState.acked || + current.state == RelayDeliveryState.permanentFailure) { + continue; + } + updatedTargets.add( + current.copyWith( + state: RelayDeliveryState.permanentFailure, + attemptCount: current.attemptCount + 1, + lastAttemptAt: attemptTimestamp, + nextRetryAt: null, + lastError: 'broadcast completed without relay acknowledgement', + ), + ); + } + } + + if (updatedTargets.isNotEmpty) { + await _cacheManager.saveRelayDeliveryTargets(updatedTargets); + } + + final allTargets = await _cacheManager.loadRelayDeliveryTargets( + eventId: event.id, + ); + final deliveryStatus = _resolveDeliveryStatus(existing, allTargets); + final completionTimestamp = + deliveryStatus == EventDeliveryStatus.delivered || + deliveryStatus == EventDeliveryStatus.failed + ? Nip01Event.secondsSinceEpoch() + : null; + + await _cacheManager.saveEventDeliveryRecord( + existing.copyWith( + status: deliveryStatus, + updatedAt: Nip01Event.secondsSinceEpoch(), + completedAt: completionTimestamp, + ), + ); + await _purgeEphemeralIfResolved( + event.id, + deliveryStatus, + event.kind, + allTargets, + ); + } + + Future flushForRelay(String relayUrl, {bool onlyDue = false}) async { + if (_stopped) { + return; + } + // Per-relay flush serialization avoids two concurrent retry paths trying to + // re-send the same relay targets at once. + if (!_flushInProgress.add(relayUrl)) { + return; + } + + try { + if (_stopped) { + return; + } + final persistedTargets = await _cacheManager.loadRelayDeliveryTargets( + relayUrl: relayUrl, + excludeAcked: true, + ); + final now = Nip01Event.secondsSinceEpoch(); + final targets = persistedTargets.where((target) { + if (target.state == RelayDeliveryState.permanentFailure) { + return false; + } + + if (!onlyDue) { + return true; + } + + return target.nextRetryAt == null || target.nextRetryAt! <= now; + }).toList(); + Logger.log.d( + () => + 'flush pending delivery for $relayUrl${onlyDue ? " (due only)" : ""} -> ${targets.map((t) => t.eventId).toList()}', + ); + + for (final target in targets) { + if (_stopped) { + return; + } + if (_sender.isEventInFlight(target.eventId)) { + continue; + } + + final deliveryRecord = await _cacheManager.loadEventDeliveryRecord( + target.eventId, + ); + if (deliveryRecord == null) { + await _cacheManager.removeRelayDeliveryTarget( + eventId: target.eventId, + relayUrl: relayUrl, + ); + continue; + } + + final loadedEvent = await _loadRecoverableEvent( + target.eventId, + record: deliveryRecord, + ); + if (loadedEvent == null) { + await _cacheManager.removeRelayDeliveryTarget( + eventId: target.eventId, + relayUrl: relayUrl, + ); + continue; + } + var event = loadedEvent; + + if (_isExpiredEvent(event)) { + Logger.log.d( + () => 'drop expired pending delivery ${event.id} for $relayUrl', + ); + await _discardEventDelivery(event.id); + continue; + } + + if (await _isObsoleteReplaceableOrAddressableEvent(event)) { + Logger.log.d( + () => 'drop obsolete pending delivery ${event.id} for $relayUrl', + ); + await _discardEventDelivery(event.id); + continue; + } + + if (deliveryRecord.requiresInteractiveSigning && event.sig == null) { + final signed = await _ensureEventSigned(deliveryRecord, event: event); + if (!signed) { + continue; + } + final signedEvent = await _cacheManager.loadEvent(target.eventId); + if (signedEvent == null || signedEvent.sig == null) { + continue; + } + event = signedEvent; + } + + final policy = DeliveryPolicy.forEvent(event); + if (!policy.shouldRetryState(target.state)) { + continue; + } + + if (target.state == RelayDeliveryState.attempting && + policy.retainsOnlyLatest) { + Logger.log.d( + () => + 'skip retry for in-flight replaceable delivery ${event.id} on $relayUrl', + ); + continue; + } + + await _sender + .broadcast(nostrEvent: event, specificRelays: [relayUrl]) + .broadcastDoneFuture; + } + } finally { + _flushInProgress.remove(relayUrl); + } + } + + /// A NIP-40 expired event has no delivery value: relays reject it and it will + /// be swept from cache, so stop retrying and drop the durable delivery state. + bool _isExpiredEvent(Nip01Event event) { + final rawValue = event.getFirstTag('expiration'); + if (rawValue == null) { + return false; + } + final expiration = int.tryParse(rawValue); + if (expiration == null) { + return false; + } + return expiration <= Nip01Event.secondsSinceEpoch(); + } + + Future _isObsoleteReplaceableOrAddressableEvent( + Nip01Event event, + ) async { + // Replaceable/addressable retries should follow the currently visible cache + // winner, not historical offline versions that were later superseded. + final policy = DeliveryPolicy.forEvent(event); + if (!policy.retainsOnlyLatest) { + return false; + } + + final visibleEvents = await _cacheManager.loadEvents( + pubKeys: [event.pubKey], + kinds: [event.kind], + tags: _isAddressableKind(event.kind) && event.getDtag() != null + ? { + 'd': [event.getDtag()!], + } + : null, + limit: 1, + ); + + if (visibleEvents.isEmpty) { + return true; + } + + return visibleEvents.single.id != event.id; + } + + Future _discardEventDelivery(String eventId) async { + await _cacheManager.removeRelayDeliveryTargets(eventId); + await _cacheManager.removeEventDeliveryRecord(eventId); + } + + Future> _relayUrlsWithDuePendingTargets() async { + final now = Nip01Event.secondsSinceEpoch(); + final targets = await _cacheManager.loadRelayDeliveryTargets( + excludeAcked: true, + ); + + final relayUrls = {}; + for (final target in targets) { + if (target.state == RelayDeliveryState.permanentFailure) { + continue; + } + if (target.nextRetryAt != null && target.nextRetryAt! > now) { + continue; + } + relayUrls.add(target.relayUrl); + } + + return relayUrls; + } + + Future _retryDueSigning({ + required Future Function(String relayUrl) reconnectRelay, + }) async { + if (_stopped) { + return; + } + final now = Nip01Event.secondsSinceEpoch(); + final records = await _cacheManager.loadEventDeliveryRecords(); + records.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + for (final record in records) { + if (_stopped) { + return; + } + if (!record.requiresInteractiveSigning) { + continue; + } + if (record.signingState == EventSigningState.permanentFailure || + record.signingState == EventSigningState.needsAction) { + continue; + } + if (record.signedAt != null && + record.signingState == EventSigningState.signed) { + continue; + } + if (record.nextSignRetryAt != null && record.nextSignRetryAt! > now) { + continue; + } + + final event = await _loadRecoverableEvent(record.eventId, record: record); + if (event == null) { + await _discardEventDelivery(record.eventId); + continue; + } + + if (_isExpiredEvent(event)) { + await _discardEventDelivery(record.eventId); + continue; + } + + if (await _isObsoleteReplaceableOrAddressableEvent(event)) { + await _discardEventDelivery(record.eventId); + continue; + } + + final signer = _resolveSignerForEvent(event); + if (signer != null && + signer.requiresSignerNetwork && + !_isSignerTransportReachable(signer)) { + final signerRelayUrls = signer.signerTransportRelayUrls.toSet().toList() + ..sort(); + for (final relayUrl in signerRelayUrls) { + await reconnectRelay(relayUrl); + if (_isSignerTransportReachable(signer)) { + break; + } + } + } + + await _ensureEventSigned(record, event: event); + } + } + + Future _ensureEventSigned( + EventDeliveryRecord record, { + required Nip01Event event, + }) async { + if (_stopped) { + return false; + } + if (!record.requiresInteractiveSigning) { + return true; + } + if (event.sig != null) { + await _markEventSigned(record); + return true; + } + + final signer = _resolveSignerForEvent(event); + if (signer != null && + signer.requiresSignerNetwork && + !_isSignerTransportReachable(signer)) { + return false; + } + + if (_activeSignAttemptIds.containsKey(event.id)) { + return false; + } + final now = Nip01Event.secondsSinceEpoch(); + final attemptId = (_latestSignAttemptIds[event.id] ?? 0) + 1; + _latestSignAttemptIds[event.id] = attemptId; + _activeSignAttemptIds[event.id] = attemptId; + if (signer == null || + !signer.requiresInteractiveSigning || + !signer.canSign()) { + await _saveSigningOutcome( + record.copyWith( + signingState: EventSigningState.needsAction, + updatedAt: now, + lastSignError: + 'No matching available remote signer account for ${event.pubKey}', + nextSignRetryAt: null, + ), + ); + _activeSignAttemptIds.remove(event.id); + return false; + } + + final attemptingRecord = record.copyWith( + signingState: EventSigningState.attempting, + updatedAt: now, + signAttemptCount: record.signAttemptCount + 1, + lastSignAttemptAt: now, + lastSignError: null, + ); + await _saveSigningOutcome(attemptingRecord); + + final signFuture = signer.sign(event); + try { + final signedEvent = await signFuture.timeout(_signAttemptTimeout); + await _handleSignSuccess( + attemptId: attemptId, + record: attemptingRecord, + event: event, + signedEvent: signedEvent, + ); + _clearActiveSignAttempt(event.id, attemptId: attemptId); + return true; + } on TimeoutException catch (_) { + _clearActiveSignAttempt(event.id, attemptId: attemptId); + _trackLateSigningCompletion( + attemptId: attemptId, + event: event, + record: attemptingRecord, + future: signFuture, + ); + + final waitingForApproval = _hasPendingSignerRequest(signer, event.id); + await _saveSigningOutcome( + attemptingRecord.copyWith( + signingState: waitingForApproval + ? EventSigningState.needsAction + : EventSigningState.transientFailure, + updatedAt: Nip01Event.secondsSinceEpoch(), + nextSignRetryAt: waitingForApproval + ? null + : Nip01Event.secondsSinceEpoch() + + _signRetryDelayFor( + attemptCount: attemptingRecord.signAttemptCount, + requiresSignerNetwork: signer.requiresSignerNetwork, + ).inSeconds, + lastSignError: waitingForApproval + ? 'Waiting for signer approval' + : 'Timed out waiting for signer', + ), + ); + return false; + } catch (error, stackTrace) { + await _handleSignFailure( + attemptId: attemptId, + record: attemptingRecord, + event: event, + signer: signer, + error: error, + stackTrace: stackTrace, + ); + _clearActiveSignAttempt(event.id, attemptId: attemptId); + return false; + } + } + + void _trackLateSigningCompletion({ + required int attemptId, + required Nip01Event event, + required EventDeliveryRecord record, + required Future future, + }) { + unawaited( + future + .then( + (signedEvent) async { + await _handleSignSuccess( + attemptId: attemptId, + record: record, + event: event, + signedEvent: signedEvent, + ); + }, + onError: (Object error, StackTrace stackTrace) async { + await _handleSignFailure( + attemptId: attemptId, + record: record, + event: event, + signer: _resolveSignerForEvent(event), + error: error, + stackTrace: stackTrace, + ); + }, + ) + .whenComplete(() { + _clearActiveSignAttempt(event.id, attemptId: attemptId); + }), + ); + } + + Future _handleSignSuccess({ + required int attemptId, + required EventDeliveryRecord record, + required Nip01Event event, + required Nip01Event signedEvent, + }) async { + if (_stopped) { + return; + } + if (!_isLatestSignAttempt(event.id, attemptId)) { + return; + } + if (await _isObsoleteReplaceableOrAddressableEvent(event)) { + await _discardEventDelivery(event.id); + return; + } + + final now = Nip01Event.secondsSinceEpoch(); + await _cacheManager.saveEvent(signedEvent); + await _saveSigningOutcome( + record.copyWith( + signingState: EventSigningState.signed, + updatedAt: now, + serializedEventJson: _serializeEvent(signedEvent), + signedAt: now, + nextSignRetryAt: null, + lastSignError: null, + ), + ); + + await _flushConnectedTargetsForEvent(event.id); + } + + Future _handleSignFailure({ + required int attemptId, + required EventDeliveryRecord record, + required Nip01Event event, + required EventSigner? signer, + required Object error, + required StackTrace stackTrace, + }) async { + if (_stopped) { + return; + } + if (!_isLatestSignAttempt(event.id, attemptId)) { + return; + } + Logger.log.w( + () => 'remote signing failed for ${event.id}', + error: error, + stackTrace: stackTrace, + ); + + final outcome = _classifySigningFailure(error); + final now = Nip01Event.secondsSinceEpoch(); + final nextRetryAt = outcome == EventSigningState.transientFailure + ? now + + _signRetryDelayFor( + attemptCount: record.signAttemptCount, + requiresSignerNetwork: signer?.requiresSignerNetwork ?? false, + ).inSeconds + : null; + + await _saveSigningOutcome( + record.copyWith( + signingState: outcome, + updatedAt: now, + nextSignRetryAt: nextRetryAt, + lastSignError: error.toString(), + ), + ); + } + + bool _isLatestSignAttempt(String eventId, int attemptId) { + return _latestSignAttemptIds[eventId] == attemptId; + } + + void _clearActiveSignAttempt(String eventId, {required int attemptId}) { + if (_activeSignAttemptIds[eventId] == attemptId) { + _activeSignAttemptIds.remove(eventId); + } + } + + EventSigningState _classifySigningFailure(Object error) { + if (error is SignerRequestCancelledException || + error is SignerRequestRejectedException) { + return EventSigningState.needsAction; + } + + final normalized = error.toString().toLowerCase(); + if (normalized.contains('not available') || + normalized.contains('not installed') || + normalized.contains('requires action') || + normalized.contains('approval') || + normalized.contains('permission') || + normalized.contains('use getpublickeyasync') || + normalized.contains('cannot sign') || + normalized.contains('unknown account')) { + return EventSigningState.needsAction; + } + + if (normalized.contains('unsupported') || + normalized.contains('invalid event') || + normalized.contains('bad signature')) { + return EventSigningState.permanentFailure; + } + + return EventSigningState.transientFailure; + } + + Duration _signRetryDelayFor({ + required int attemptCount, + required bool requiresSignerNetwork, + }) { + final retrySeconds = requiresSignerNetwork + ? switch (attemptCount) { + <= 1 => 15, + 2 => 60, + 3 => 300, + 4 => 900, + _ => 1800, + } + : switch (attemptCount) { + <= 1 => 5, + 2 => 15, + 3 => 60, + 4 => 300, + _ => 900, + }; + return Duration(seconds: retrySeconds); + } + + bool _hasPendingSignerRequest(EventSigner signer, String eventId) { + return signer.pendingRequests.any( + (request) => + request.method == SignerMethod.signEvent && + request.event?.id == eventId, + ); + } + + EventSigner? _resolveSignerForEvent(Nip01Event event) { + final account = _accounts.accounts[event.pubKey]; + return account?.signer; + } + + bool _isSignerTransportReachable(EventSigner signer) { + final transportRelayUrls = signer.signerTransportRelayUrls.toSet(); + if (transportRelayUrls.isEmpty) { + return true; + } + + final connectedRelayUrls = + _connectedRelayUrlsProvider?.call().toSet() ?? {}; + return transportRelayUrls.any(connectedRelayUrls.contains); + } + + Future _flushConnectedTargetsForEvent(String eventId) async { + if (_stopped) { + return; + } + final connectedRelayUrls = _connectedRelayUrlsProvider?.call().toSet(); + if (connectedRelayUrls == null || connectedRelayUrls.isEmpty) { + return; + } + + final targets = await _cacheManager.loadRelayDeliveryTargets( + eventId: eventId, + ); + final targetRelayUrls = + targets + .where((target) => connectedRelayUrls.contains(target.relayUrl)) + .map((target) => target.relayUrl) + .toSet() + .toList() + ..sort(); + + for (final relayUrl in targetRelayUrls) { + await flushForRelay(relayUrl); + } + } + + Future _markEventSigned(EventDeliveryRecord record) async { + final now = Nip01Event.secondsSinceEpoch(); + await _saveSigningOutcome( + record.copyWith( + signingState: EventSigningState.signed, + updatedAt: now, + signedAt: now, + nextSignRetryAt: null, + lastSignError: null, + ), + ); + } + + Future _saveSigningOutcome(EventDeliveryRecord record) async { + final targets = await _cacheManager.loadRelayDeliveryTargets( + eventId: record.eventId, + ); + final resolvedStatus = _resolveDeliveryStatus(record, targets); + final now = Nip01Event.secondsSinceEpoch(); + await _cacheManager.saveEventDeliveryRecord( + record.copyWith( + status: resolvedStatus, + completedAt: resolvedStatus == EventDeliveryStatus.delivered + ? (record.completedAt ?? now) + : null, + ), + ); + await _purgeEphemeralIfResolved( + record.eventId, + resolvedStatus, + _resolveEventKindFromRecord(record), + targets, + ); + } + + /// Ephemeral events carry no lasting cache value. Once their delivery reaches + /// a fully-resolved state, drop the event and every + /// eventId-keyed sidecar immediately, instead of waiting for a background + /// eviction pass. [removeEvent] wipes the event plus its sources, delivery + /// record, relay delivery targets, decrypted payloads and state record. + Future _purgeEphemeralIfResolved( + String eventId, + EventDeliveryStatus status, + int? kind, + List targets, + ) async { + final isResolved = + status == EventDeliveryStatus.delivered || + status == EventDeliveryStatus.failed || + (status == EventDeliveryStatus.partiallyDelivered && + targets.isNotEmpty && + targets.every( + (t) => + t.state == RelayDeliveryState.acked || + t.state == RelayDeliveryState.permanentFailure, + )); + if (!isResolved) { + return; + } + if (kind == null || !EventKindClassification.isEphemeralKind(kind)) { + return; + } + await _cacheManager.removeEvent(eventId); + } + + int? _resolveEventKindFromRecord(EventDeliveryRecord record) { + final json = record.serializedEventJson; + if (json == null || json.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(json); + if (decoded is Map && decoded['kind'] is int) { + return decoded['kind'] as int; + } + } catch (_) {} + return null; + } + + bool _isAddressableKind(int kind) { + return EventKindClassification.isAddressableKind(kind); + } + + Future _loadRecoverableEvent( + String eventId, { + required EventDeliveryRecord record, + }) async { + final cachedEvent = await _cacheManager.loadEvent(eventId); + if (cachedEvent != null) { + return cachedEvent; + } + + final serializedEventJson = record.serializedEventJson; + if (serializedEventJson == null || serializedEventJson.isEmpty) { + return null; + } + + try { + final parsedEvent = Nip01EventModel.fromJson( + jsonDecode(serializedEventJson) as Map, + ); + await _cacheManager.saveEvent(parsedEvent); + return parsedEvent; + } catch (error, stackTrace) { + Logger.log.w( + () => 'failed to restore serialized pending-delivery event $eventId', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + String? _serializeEvent(Nip01Event event) { + try { + return Nip01EventModel.fromEntity(event).toJsonString(); + } catch (_) { + return null; + } + } + + EventDeliveryStatus _resolveDeliveryStatus( + EventDeliveryRecord record, + List targets, + ) { + if (record.requiresInteractiveSigning && + record.signingState != EventSigningState.signed && + record.signedAt == null) { + return switch (record.signingState) { + EventSigningState.needsAction => EventDeliveryStatus.needsAction, + EventSigningState.permanentFailure => EventDeliveryStatus.failed, + EventSigningState.pending => EventDeliveryStatus.pending, + EventSigningState.notNeeded => EventDeliveryStatus.pending, + EventSigningState.attempting || + EventSigningState.transientFailure => EventDeliveryStatus.inProgress, + EventSigningState.signed => EventDeliveryStatus.inProgress, + }; + } + + if (targets.isEmpty) { + return EventDeliveryStatus.pending; + } + + final allAcked = targets.every((t) => t.state == RelayDeliveryState.acked); + if (allAcked) { + return EventDeliveryStatus.delivered; + } + + if (targets.any((t) => t.state == RelayDeliveryState.authRequired)) { + return EventDeliveryStatus.needsAction; + } + + final ackedCount = targets + .where((t) => t.state == RelayDeliveryState.acked) + .length; + final permanentFailureCount = targets + .where((t) => t.state == RelayDeliveryState.permanentFailure) + .length; + + final allTerminal = targets.every( + (t) => + t.state == RelayDeliveryState.acked || + t.state == RelayDeliveryState.permanentFailure, + ); + if (allTerminal) { + return ackedCount > 0 + ? EventDeliveryStatus.partiallyDelivered + : EventDeliveryStatus.failed; + } + + if (ackedCount > 0 || permanentFailureCount > 0) { + return EventDeliveryStatus.partiallyDelivered; + } + + return EventDeliveryStatus.inProgress; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/bunkers/bunkers.dart b/packages/ndk/lib/domain_layer/usecases/bunkers/bunkers.dart index 118042879..bcf9723e3 100644 --- a/packages/ndk/lib/domain_layer/usecases/bunkers/bunkers.dart +++ b/packages/ndk/lib/domain_layer/usecases/bunkers/bunkers.dart @@ -24,9 +24,9 @@ class Bunkers { required Broadcast broadcast, required Requests requests, required LocalEventSignerFactory eventSignerFactory, - }) : _broadcast = broadcast, - _requests = requests, - _eventSignerFactory = eventSignerFactory; + }) : _broadcast = broadcast, + _requests = requests, + _eventSignerFactory = eventSignerFactory; /// Connects to a bunker using a bunker URL (bunker://) /// authCallback is called with the auth URL if the bunker requires authentication @@ -99,8 +99,9 @@ class Bunkers { BunkerConnection? result; - await for (final event in subscription.stream - .timeout(Duration(seconds: kMaxWaitingTimeForConnectionSeconds))) { + await for (final event in subscription.stream.timeout( + Duration(seconds: kMaxWaitingTimeForConnectionSeconds), + )) { final decryptedContent = await localEventSigner.decryptNip44( ciphertext: event.content, senderPubKey: remotePubkey, @@ -159,8 +160,9 @@ class Bunkers { ); BunkerConnection? result; - await for (final event in subscription.stream - .timeout(Duration(seconds: kMaxWaitingTimeForConnectionSeconds))) { + await for (final event in subscription.stream.timeout( + Duration(seconds: kMaxWaitingTimeForConnectionSeconds), + )) { final decryptedContent = await localEventSigner.decryptNip44( ciphertext: event.content, senderPubKey: event.pubKey, @@ -186,8 +188,10 @@ class Bunkers { } /// Creates a simple signer that delegates to this bunker instance - Nip46EventSigner createSigner(BunkerConnection connection, - {Function(String)? authCallback}) { + Nip46EventSigner createSigner( + BunkerConnection connection, { + Function(String)? authCallback, + }) { return Nip46EventSigner( connection: connection, requests: _requests, diff --git a/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_connection.dart b/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_connection.dart index ce42dee5c..4585488b6 100644 --- a/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_connection.dart +++ b/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_connection.dart @@ -10,10 +10,10 @@ class BunkerConnection { }); Map toJson() => { - 'privateKey': privateKey, - 'remotePubkey': remotePubkey, - 'relays': relays, - }; + 'privateKey': privateKey, + 'remotePubkey': remotePubkey, + 'relays': relays, + }; factory BunkerConnection.fromJson(Map json) { return BunkerConnection( diff --git a/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_request.dart b/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_request.dart index 2be4ef06e..aa9abe1f5 100644 --- a/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/bunkers/models/bunker_request.dart @@ -12,14 +12,10 @@ class BunkerRequest { final List params; BunkerRequest({required this.method, String? id, List? params}) - : id = id ?? Helpers.getRandomString(16), - params = params ?? []; + : id = id ?? Helpers.getRandomString(16), + params = params ?? []; Map toJson() { - return { - 'id': id, - 'method': method.protocolString, - 'params': params, - }; + return {'id': id, 'method': method.protocolString, 'params': params}; } } diff --git a/packages/ndk/lib/domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart b/packages/ndk/lib/domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart new file mode 100644 index 000000000..ff9032103 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import '../../../shared/logger/logger.dart'; +import '../../entities/cache_eviction.dart'; +import '../../repositories/cache_manager.dart'; + +/// Background scheduler that periodically runs [CacheManager.evict]. +/// +/// This intentionally does only scheduling and overlap protection. The actual +/// eviction logic stays inside cache backends and [EvictionPolicy]. +class CacheEvictionScheduler { + final CacheManager _cacheManager; + final EvictionPolicy _policy; + final Duration _startupDelay; + final Duration _interval; + final bool _runOnStartup; + final void Function(EvictionResult result)? _onRunCompleted; + + Timer? _startupTimer; + Timer? _intervalTimer; + Future? _runInFlight; + + CacheEvictionScheduler({ + required CacheManager cacheManager, + required EvictionPolicy policy, + required Duration startupDelay, + required Duration interval, + required bool runOnStartup, + void Function(EvictionResult result)? onRunCompleted, + }) : _cacheManager = cacheManager, + _policy = policy, + _startupDelay = startupDelay, + _interval = interval, + _runOnStartup = runOnStartup, + _onRunCompleted = onRunCompleted; + + /// Starts startup and periodic timers. + void start() { + _startupTimer?.cancel(); + _intervalTimer?.cancel(); + + if (_runOnStartup) { + if (_startupDelay <= Duration.zero) { + unawaited(runNow(reason: 'startup')); + } else { + _startupTimer = Timer( + _startupDelay, + () => unawaited(runNow(reason: 'startup')), + ); + } + } + + if (_interval > Duration.zero) { + _intervalTimer = Timer.periodic( + _interval, + (_) => unawaited(runNow(reason: 'interval')), + ); + } + } + + /// Stops timers and waits for any in-flight run to finish. + Future stop() async { + _startupTimer?.cancel(); + _startupTimer = null; + _intervalTimer?.cancel(); + _intervalTimer = null; + + final inFlight = _runInFlight; + if (inFlight != null) { + await inFlight; + } + } + + /// Triggers an immediate run unless another run is already active. + Future runNow({String reason = 'manual'}) { + final inFlight = _runInFlight; + if (inFlight != null) { + Logger.log.t( + () => 'skip cache eviction run ($reason) because another run is active', + ); + return inFlight; + } + + final future = _run(reason: reason); + _runInFlight = future; + return future; + } + + Future _run({required String reason}) async { + try { + Logger.log.d(() => 'running cache eviction ($reason)'); + final result = await _cacheManager.evict(_policy); + _onRunCompleted?.call(result); + Logger.log.d( + () => + 'cache eviction finished ($reason): removed=${result.removedEvents}, ' + 'expired=${result.removedExpired}, deleted=${result.removedDeleted}, ' + 'superseded=${result.removedSuperseded}, capped=${result.removedByKindCap}, ' + 'deliveredDeliveries=${result.removedCompletedDeliveries}, ' + 'failedDeliveries=${result.removedTerminalFailedDeliveries}', + ); + } catch (e, st) { + Logger.log.e( + () => 'cache eviction failed ($reason): $e', + error: e, + stackTrace: st, + ); + } finally { + _runInFlight = null; + } + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart b/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart index 3f4cc79e7..a13e6589b 100644 --- a/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart +++ b/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart @@ -5,12 +5,21 @@ import '../../entities/nip_01_event.dart'; import '../../entities/request_state.dart'; import '../../repositories/cache_manager.dart'; +/// Cache-first helper for request resolution. +/// +/// This usecase is intentionally small: it asks the cache for currently visible +/// events matching unresolved filters and forwards them into the request +/// response stream before or alongside network results. class CacheRead { final CacheManager cacheManager; CacheRead(this.cacheManager); - /// find matching events in cache return them and remove/update unresolved filters + /// Finds matching visible events in cache and forwards them to + /// [outController]. + /// + /// `loadEvents()` already applies visibility rules, so callers do not need to + /// re-check replacement, expiration, or deletion here. Future resolveUnresolvedFilters({ required RequestState requestState, required StreamController outController, @@ -63,14 +72,10 @@ class CacheRead { } if (filter.ids != null) { - final foundIdEvents = []; - for (final id in filter.ids!) { - final foundId = await cacheManager.loadEvent(id); - if (foundId == null) { - continue; - } - foundIdEvents.add(foundId); - } + final foundIdEvents = await cacheManager.loadEvents( + ids: filter.ids!, + limit: filter.ids!.length, + ); filter.ids!.removeWhere((id) { return foundIdEvents.any((event) => event.id == id); diff --git a/packages/ndk/lib/domain_layer/usecases/cache_write/cache_write.dart b/packages/ndk/lib/domain_layer/usecases/cache_write/cache_write.dart index 096409e33..da530b807 100644 --- a/packages/ndk/lib/domain_layer/usecases/cache_write/cache_write.dart +++ b/packages/ndk/lib/domain_layer/usecases/cache_write/cache_write.dart @@ -4,27 +4,34 @@ import '../../../shared/logger/logger.dart'; import '../../entities/nip_01_event.dart'; import '../../repositories/cache_manager.dart'; -/// class to handle writes to cache/db with business logic +/// Writes network-delivered events into the cache. +/// +/// Network responses enter the same generic event store used by event reads, +/// replaceable convergence, deletion suppression, and convenience projections. class CacheWrite { final CacheManager cacheManager; CacheWrite(this.cacheManager); - /// saves network responses in db and then write to response stream if not already in db (useful to avoid duplicates) + /// Persists network responses when [writeToCache] is enabled. void saveNetworkResponse({ required bool writeToCache, required Stream inputStream, }) { - inputStream.listen((event) async { - Logger.log.t(() => "⛁ got event from network $event "); + inputStream.listen( + (event) async { + Logger.log.t(() => "⛁ got event from network $event "); - if (writeToCache) { - await cacheManager.saveEvent(event); - } - }, onDone: () { - //? cannot be implemented as stack insert when the stream closes, because it would screw up subscriptions. - }, onError: (error) { - Logger.log.e(() => "⛔ $error "); - }); + if (writeToCache) { + await cacheManager.saveEvent(event); + } + }, + onDone: () { + //? cannot be implemented as stack insert when the stream closes, because it would screw up subscriptions. + }, + onError: (error) { + Logger.log.e(() => "⛔ $error "); + }, + ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart index 7791b3b08..419d7ea02 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart @@ -50,10 +50,10 @@ class Cashu { required CacheManager cacheManager, required CashuKeyDerivation cashuKeyDerivation, CashuUserSeedphrase? cashuUserSeedphrase, - }) : _cashuRepo = cashuRepo, - _walletsRepo = walletsRepo, - _cacheManager = cacheManager, - _cashuKeyDerivation = cashuKeyDerivation { + }) : _cashuRepo = cashuRepo, + _walletsRepo = walletsRepo, + _cacheManager = cacheManager, + _cashuKeyDerivation = cashuKeyDerivation { _cashuKeysets = CashuKeysets( cashuRepo: _cashuRepo, cacheManager: _cacheManager, @@ -64,17 +64,17 @@ class Cashu { ); _cacheManagerCashu = CashuCacheDecorator(cacheManager: _cacheManager); - _cashuSeed = CashuSeed( - userSeedPhrase: cashuUserSeedphrase, - ); + _cashuSeed = CashuSeed(userSeedPhrase: cashuUserSeedphrase); _cashuExportImport = CashuStateExportImport( cacheManagerCashu: _cacheManagerCashu, walletsRepo: _walletsRepo, cashuSeed: _cashuSeed, ); 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()'); + Logger.log.w( + () => + 'Cashu initialized without user seed phrase, cashu features will not work \nSet the seed phrase using NdkConfig or Cashu.setCashuSeedPhrase()', + ); } } @@ -98,9 +98,7 @@ class Cashu { /// 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, - ); + _cashuSeed.setSeedPhrase(seedPhrase: userSeedPhrase.seedPhrase); } /// Get the cashu seed instance @@ -263,14 +261,18 @@ class Cashu { proofs: unspentProofs, mintUrl: mintUrl, ); - Logger.log.i(() => - 'Saved ${unspentProofs.length} unspent proofs to cache (filtered out ${newProofs.length - unspentProofs.length} spent proofs)'); + 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'); + 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'); @@ -279,8 +281,10 @@ class Cashu { proofs: newProofs, mintUrl: mintUrl, ); - Logger.log.w(() => - 'Saved ${newProofs.length} proofs without state check due to error'); + Logger.log.w( + () => + 'Saved ${newProofs.length} proofs without state check due to error', + ); // Update balance stream await _updateBalances(); @@ -320,21 +324,24 @@ class Cashu { final distinctKeysetIds = allKeysets.map((keyset) => keyset.id).toSet(); for (final keysetId in distinctKeysetIds) { - final mintUrl = - allKeysets.firstWhere((keyset) => keyset.id == keysetId).mintUrl; + final mintUrl = allKeysets + .firstWhere((keyset) => keyset.id == keysetId) + .mintUrl; if (!balances.containsKey(mintUrl)) { balances[mintUrl] = {}; } - final keysetProofs = - allProofs.where((proof) => proof.keysetId == keysetId).toList(); + final keysetProofs = allProofs + .where((proof) => proof.keysetId == keysetId) + .toList(); if (!returnZeroValues && keysetProofs.isEmpty) { continue; } - final unit = - allKeysets.firstWhere((keyset) => keyset.id == keysetId).unit; + final unit = allKeysets + .firstWhere((keyset) => keyset.id == keysetId) + .unit; final totalBalanceForKeyset = CashuTools.sumOfProofs( proofs: keysetProofs, ); @@ -345,18 +352,19 @@ class Cashu { } } final mintBalances = balances.entries - .map((entry) => CashuMintBalance( - mintUrl: entry.key, - balances: entry.value, - )) + .map( + (entry) => + CashuMintBalance(mintUrl: entry.key, balances: entry.value), + ) .toList(); return mintBalances; } Future _updateBalances() async { final balances = await getBalances(); - _balanceSubject ??= - BehaviorSubject>.seeded(balances); + _balanceSubject ??= BehaviorSubject>.seeded( + balances, + ); _balanceSubject!.add(balances); } @@ -379,11 +387,13 @@ class Cashu { if (_balanceSubject == null) { _balanceSubject = BehaviorSubject>.seeded([]); - getBalances().then((balances) { - _balanceSubject?.add(balances); - }).catchError((error) { - _balanceSubject?.addError(error); - }); + getBalances() + .then((balances) { + _balanceSubject?.add(balances); + }) + .catchError((error) { + _balanceSubject?.addError(error); + }); } return _balanceSubject!; @@ -394,17 +404,19 @@ class Cashu { 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'), - ); - }); + _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!; @@ -416,17 +428,19 @@ class Cashu { 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'), - ); - }); + _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!; @@ -439,15 +453,17 @@ class Cashu { _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'), - ); - }); + _getMintInfosDb() + .then((mintInfos) { + _knownMints.clear(); + _knownMints.addAll(mintInfos); + _knownMintsSubject?.add(_knownMints); + }) + .catchError((error) { + _knownMintsSubject?.addError( + Exception('Failed to load known mints: $error'), + ); + }); } return _knownMintsSubject!; @@ -456,9 +472,7 @@ class Cashu { Future> _getLatestTransactionsDb({ int limit = 50, }) async { - final transactions = await _walletsRepo.getTransactions( - limit: limit, - ); + final transactions = await _walletsRepo.getTransactions(limit: limit); // Filter to exclude draft and pending transactions (includes completed, failed, canceled) final fTransactions = transactions @@ -470,9 +484,7 @@ class Cashu { } Future> _getPendingTransactionsDb() async { - final transactions = await _walletsRepo.getTransactions( - limit: 20, - ); + final transactions = await _walletsRepo.getTransactions(limit: 20); // Filter to only include draft and pending transactions final pendingTransactions = transactions @@ -495,9 +507,7 @@ class Cashu { /// [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, - }) { + Future getMintInfoNetwork({required String mintUrl}) { return _cashuRepo.getMintInfo(mintUrl: mintUrl); } @@ -506,9 +516,7 @@ class Cashu { /// [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 { + Future addMintToKnownMints({required String mintUrl}) async { await preflightChecks(); final result = await _checkIfMintIsKnown(mintUrl); return !result; @@ -518,9 +526,7 @@ class Cashu { /// 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], - ); + final mintInfos = await _cacheManager.getMintInfos(mintUrls: [mintUrl]); if (mintInfos == null || mintInfos.isEmpty) { // fetch mint info from network @@ -554,7 +560,9 @@ class Cashu { final allProofs = await _cacheManagerCashu.getProofs(mintUrl: mintUrl); // Also delete associated proofs await _cacheManagerCashu.removeProofs( - mintUrl: mintUrl, proofs: allProofs); + mintUrl: mintUrl, + proofs: allProofs, + ); final transactionsToRemove = await _walletsRepo.getTransactions( walletType: WalletType.CASHU, @@ -713,9 +721,10 @@ class Cashu { blindedMessagesOutputs: blindedMessagesOutputs .map( (e) => CashuBlindedMessage( - amount: e.amount, - id: e.blindedMessage.id, - blindedMessage: e.blindedMessage.blindedMessage), + amount: e.amount, + id: e.blindedMessage.id, + blindedMessage: e.blindedMessage.blindedMessage, + ), ) .toList(), method: draftTransaction.method!, @@ -854,8 +863,10 @@ class Cashu { throw Exception('No keysets found for mint: $mintUrl'); } - final keysetsForUnit = - CashuTools.filterKeysetsByUnit(keysets: mintKeysets, unit: unit); + final keysetsForUnit = CashuTools.filterKeysetsByUnit( + keysets: mintKeysets, + unit: unit, + ); final int amountToSpend; @@ -868,12 +879,13 @@ class Cashu { late final ProofSelectionResult selectionResult; await _cacheManagerCashu.runInTransaction(() async { - final proofsUnfiltered = await _cacheManager.getProofs( - mintUrl: mintUrl, - ); + final proofsUnfiltered = await _cacheManager.getProofs(mintUrl: mintUrl); final proofs = CashuTools.filterProofsByUnit( - proofs: proofsUnfiltered, unit: unit, keysets: keysetsForUnit); + proofs: proofsUnfiltered, + unit: unit, + keysets: keysetsForUnit, + ); if (proofs.isEmpty) { throw Exception('No proofs found for mint: $mintUrl and unit: $unit'); @@ -896,8 +908,10 @@ class Cashu { ); }); - final activeKeyset = - CashuTools.filterKeysetsByUnitActive(keysets: mintKeysets, unit: unit); + final activeKeyset = CashuTools.filterKeysetsByUnitActive( + keysets: mintKeysets, + unit: unit, + ); /// outputs to send to mint final List myOutputs = []; @@ -906,34 +920,36 @@ class Cashu { 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, - ); + 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 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); + 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 + (a, b) => b.amount.compareTo(a.amount), + ); // sort outputs by amount desc // Remove draft transaction from pending if it exists _removePendingTransaction(draftRedeemTransaction); @@ -1006,8 +1022,9 @@ class Cashu { mintUrl: mintUrl, ); - final allSpent = - proofStates.every((state) => state.state == CashuProofState.spend); + final allSpent = proofStates.every( + (state) => state.state == CashuProofState.spend, + ); if (allSpent) { // Proofs were spent on mint side, mark them as spent locally @@ -1091,48 +1108,46 @@ class Cashu { late final ProofSelectionResult selectionResult; - await _cacheManagerCashu.runInTransaction( - () async { - // fetch proofs for the mint - final allProofs = await _cacheManager.getProofs( - mintUrl: mintUrl, - ); + 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'); - } + 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, - ); + // 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'); - } + 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'); + 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, - ); + // mark proofs as pending + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.pending, + ); - await _cacheManager.saveProofs( - proofs: selectionResult.selectedProofs, - mintUrl: mintUrl, - ); - }, - ); + await _cacheManager.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + }); final transactionId = "spend-${Helpers.getRandomString(5)}"; @@ -1150,15 +1165,19 @@ class Cashu { // add to pending transactions await _addAndSavePendingTransaction(pendingTransaction); - Logger.log.d(() => - 'Initiated spend for $amount $unit from mint $mintUrl, using ${selectionResult.selectedProofs.length} proofs'); + 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'); + Logger.log.d( + () => + 'Need to split ${selectionResult.splitAmount} $unit from ${selectionResult.totalSelected} total', + ); final SplitResult splitResult; try { @@ -1228,9 +1247,7 @@ class Cashu { await _updateBalances(); - checkSpendingState( - transaction: pendingTransaction, - ); + checkSpendingState(transaction: pendingTransaction); final token = proofsToToken( proofs: proofsToReturn, @@ -1244,10 +1261,7 @@ class Cashu { ); await _addAndSavePendingTransaction(pendingTransaction); - return CashuSpendingResult( - token: token, - transaction: pendingTransaction, - ); + return CashuSpendingResult(token: token, transaction: pendingTransaction); } /// todo: restore pending transaction from cache @@ -1271,7 +1285,8 @@ class Cashu { /// 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}'); + () => 'All proofs are spent for transaction ${transaction.id}', + ); final completedTransaction = transaction.copyWith( state: WalletTransactionState.completed, transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, @@ -1305,8 +1320,10 @@ class Cashu { await Future.delayed(CashuConfig.SPEND_CHECK_INTERVAL); } } catch (e) { - Logger.log.e(() => - 'Error checking spending state for transaction ${transaction.id}: $e'); + Logger.log.e( + () => + 'Error checking spending state for transaction ${transaction.id}: $e', + ); // Mark transaction as failed final failedTransaction = transaction.copyWith( state: WalletTransactionState.failed, @@ -1369,12 +1386,13 @@ class Cashu { List splittedAmounts = CashuTools.splitAmount(rcvSum); final blindedMessagesOutputs = await CashuBdhke.createBlindedMsgForAmounts( - keysetId: keyset.id, - amounts: splittedAmounts, - cacheManager: _cacheManagerCashu, - cashuSeed: _cashuSeed, - mintUrl: rcvToken.mintUrl, - cashuSeedSecretGenerator: _cashuKeyDerivation); + 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), @@ -1485,9 +1503,7 @@ class Cashu { await _walletsRepo.saveTransactions([transaction]); } - void _removePendingTransaction( - CashuWalletTransaction transaction, - ) { + void _removePendingTransaction(CashuWalletTransaction transaction) { _pendingTransactions.removeWhere((t) => t.id == transaction.id); _pendingTransactionsSubject?.add(_pendingTransactions.toList()); } diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart index 593c0c233..cc0822837 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart @@ -60,12 +60,14 @@ class CashuBdhke { blindedMessage: blindedMessageHex, ); - items.add(CashuBlindedMessageItem( - blindedMessage: blindedMessage, - secret: secret, - r: r, - amount: amount, - )); + items.add( + CashuBlindedMessageItem( + blindedMessage: blindedMessage, + secret: secret, + r: r, + amount: amount, + ), + ); } catch (e) { Logger.log.w( () => 'Error creating blinded message for amount $amount: $e', @@ -116,7 +118,8 @@ class CashuBdhke { if (mintSignatures.length != blindedMessages.length) { throw Exception( - 'Mismatched lengths: ${mintSignatures.length} signatures, ${blindedMessages.length} messages'); + 'Mismatched lengths: ${mintSignatures.length} signatures, ${blindedMessages.length} messages', + ); } final keysByAmount = {}; @@ -150,12 +153,14 @@ class CashuBdhke { throw Exception('Failed to unblind signature'); } - tokens.add(CashuProof( - secret: blindedMsg.secret, - amount: blindedMsg.amount, - unblindedSig: CashuTools.ecPointToHex(unblindedSig), - keysetId: mintPublicKeys.id, - )); + 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 index 56b95efe8..6c46099ad 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart @@ -4,22 +4,20 @@ import '../../../shared/helpers/mutex_simple.dart'; import '../../entities/cashu/cashu_keyset.dart'; import '../../entities/cashu/cashu_mint_info.dart'; import '../../entities/cashu/cashu_proof.dart'; +import '../../entities/cache_eviction.dart'; +import '../../entities/event_cache_records.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(); + CashuCacheDecorator({required CacheManager cacheManager, MutexSimple? mutex}) + : _delegate = cacheManager, + _mutex = mutex ?? MutexSimple(); @override - Future?> getMintInfos({ - List? mintUrls, - }) async { + Future?> getMintInfos({List? mintUrls}) async { return await _mutex.synchronized(() async { return await _delegate.getMintInfos(mintUrls: mintUrls); }); @@ -61,9 +59,7 @@ class CashuCacheDecorator implements CacheManager { } @override - Future> getKeysets({ - String? mintUrl, - }) { + Future> getKeysets({String? mintUrl}) { return _mutex.synchronized(() async { return await _delegate.getKeysets(mintUrl: mintUrl); }); @@ -77,18 +73,14 @@ class CashuCacheDecorator implements CacheManager { } @override - Future saveMintInfo({ - required CashuMintInfo mintInfo, - }) async { + Future saveMintInfo({required CashuMintInfo mintInfo}) async { await _mutex.synchronized(() async { await _delegate.saveMintInfo(mintInfo: mintInfo); }); } @override - Future removeMintInfo({ - required String mintUrl, - }) async { + Future removeMintInfo({required String mintUrl}) async { await _mutex.synchronized(() async { await _delegate.removeMintInfo(mintUrl: mintUrl); }); @@ -122,10 +114,93 @@ class CashuCacheDecorator implements CacheManager { }); } + @override + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ) async { + await _mutex.synchronized(() async { + await _delegate.saveDecryptedEventPayloadRecord(record); + }); + } + + @override + Future saveDecryptedEventPayloadRecords( + List records, + ) async { + await _mutex.synchronized(() async { + await _delegate.saveDecryptedEventPayloadRecords(records); + }); + } + + @override + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + return _mutex.synchronized(() async { + return _delegate.loadDecryptedEventPayloadRecord( + eventId: eventId, + viewerPubKey: viewerPubKey, + ); + }); + } + + @override + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }) async { + return _mutex.synchronized(() async { + return _delegate.loadDecryptedEventPayloadRecords( + eventId: eventId, + viewerPubKey: viewerPubKey, + status: status, + limit: limit, + ); + }); + } + + @override + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + await _mutex.synchronized(() async { + await _delegate.removeDecryptedEventPayloadRecord( + eventId: eventId, + viewerPubKey: viewerPubKey, + ); + }); + } + + @override + Future removeDecryptedEventPayloadRecords(String eventId) async { + await _mutex.synchronized(() async { + await _delegate.removeDecryptedEventPayloadRecords(eventId); + }); + } + + @override + Future removeAllDecryptedEventPayloadRecords() async { + await _mutex.synchronized(() async { + await _delegate.removeAllDecryptedEventPayloadRecords(); + }); + } + + @override + Future evict(EvictionPolicy policy) async { + return _mutex.synchronized(() async { + return _delegate.evict(policy); + }); + } + @override dynamic noSuchMethod(Invocation invocation) { throw UnimplementedError( - 'CashuCacheDecorator does not implement ${invocation.memberName}. Add an explicit delegate method.'); + 'CashuCacheDecorator does not implement ${invocation.memberName}. Add an explicit delegate method.', + ); } Future runInTransaction(Future Function() action) async { diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart index b8c7ca7a7..bbadb7ec2 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart @@ -40,9 +40,9 @@ class CashuStateExportImport { required CashuCacheDecorator cacheManagerCashu, required WalletsRepo walletsRepo, required CashuSeed cashuSeed, - }) : _cacheManagerCashu = cacheManagerCashu, - _walletsRepo = walletsRepo, - _cashuSeed = cashuSeed; + }) : _cacheManagerCashu = cacheManagerCashu, + _walletsRepo = walletsRepo, + _cashuSeed = cashuSeed; /// Export all cashu state as a JSON-serializable map. /// @@ -78,9 +78,11 @@ class CashuStateExportImport { state: state, ); for (final proof in proofs) { - proofsJson.add(proof.toJson() - ..['mintUrl'] = mintUrl - ..['state'] = state.value); + proofsJson.add( + proof.toJson() + ..['mintUrl'] = mintUrl + ..['state'] = state.value, + ); } } } @@ -113,8 +115,10 @@ class CashuStateExportImport { try { export['seedPhrase'] = _cashuSeed.getSeedPhrase().sentence; } catch (_) { - Logger.log.w(() => - 'Cashu export: no seed phrase set, exporting without it. The export will not be restorable on a new device.'); + Logger.log.w( + () => + 'Cashu export: no seed phrase set, exporting without it. The export will not be restorable on a new device.', + ); } } @@ -122,8 +126,9 @@ class CashuStateExportImport { final transactions = await _walletsRepo.getTransactions( walletType: WalletType.CASHU, ); - export['transactions'] = - transactions.map(WalletTransactionModel.toJson).toList(); + export['transactions'] = transactions + .map(WalletTransactionModel.toJson) + .toList(); } return export; @@ -164,12 +169,14 @@ class CashuStateExportImport { final type = json['type']; if (type != exportType) { throw ArgumentError( - 'Not a cashu state export: expected type "$exportType", got "$type"'); + 'Not a cashu state export: expected type "$exportType", got "$type"', + ); } final version = json['version']; if (version is! int || version > exportVersion) { throw ArgumentError( - 'Unsupported cashu state export version: $version (this build supports up to $exportVersion)'); + 'Unsupported cashu state export version: $version (this build supports up to $exportVersion)', + ); } // Phase 2: Parse all data into memory structures (no mutations yet) @@ -205,8 +212,9 @@ class CashuStateExportImport { amount: map['amount'] as int, secret: map['secret'] as String, unblindedSig: map['C'] as String, - state: - CashuProofState.fromValue(map['state'] as String? ?? 'UNSPENT'), + state: CashuProofState.fromValue( + map['state'] as String? ?? 'UNSPENT', + ), ), ); } @@ -224,7 +232,8 @@ class CashuStateExportImport { final transactionsJson = (json['transactions'] as List?) ?? const []; parsedTransactions = transactionsJson .map( - (t) => WalletTransactionModel.fromJson(t as Map)) + (t) => WalletTransactionModel.fromJson(t as Map), + ) .toList(); } @@ -260,10 +269,14 @@ class CashuStateExportImport { await _walletsRepo.saveTransactions(parsedTransactions); } - final totalRestoredProofs = - proofsByMint.values.fold(0, (sum, list) => sum + list.length); - Logger.log.i(() => - 'Cashu state export imported: $totalRestoredProofs proofs, ${parsedKeysets.length} keysets, ${parsedMintInfos.length} mint infos, ${parsedTransactions.length} transactions'); + final totalRestoredProofs = proofsByMint.values.fold( + 0, + (sum, list) => sum + list.length, + ); + Logger.log.i( + () => + 'Cashu state export imported: $totalRestoredProofs proofs, ${parsedKeysets.length} keysets, ${parsedMintInfos.length} mint infos, ${parsedTransactions.length} transactions', + ); return CashuStateImportResult( seedPhrase: restoredSeedPhrase, diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart index aaa98f0de..126b5ba1b 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart @@ -8,10 +8,7 @@ class CashuKeypair { final String privateKey; final String publicKey; - CashuKeypair({ - required this.privateKey, - required this.publicKey, - }); + CashuKeypair({required this.privateKey, required this.publicKey}); static CashuKeypair generateCashuKeyPair() { // 32-byte private key @@ -24,10 +21,7 @@ class CashuKeypair { final pubKey = pubKeyPoint.getEncoded(true); final pubKeyHex = hex.encode(pubKey); - return CashuKeypair( - privateKey: privKey, - publicKey: pubKeyHex, - ); + return CashuKeypair(privateKey: privKey, publicKey: pubKeyHex); } static ECPoint derivePublicKey(String privateKeyHex) { @@ -50,9 +44,6 @@ class CashuKeypair { } Map toJson() { - return { - 'privateKey': privateKey, - 'publicKey': publicKey, - }; + 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 index 00c1ba978..347e96d57 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keysets.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keysets.dart @@ -9,8 +9,8 @@ class CashuKeysets { CashuKeysets({ required CashuRepo cashuRepo, required CacheManager cacheManager, - }) : _cashuRepo = cashuRepo, - _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. \ @@ -26,9 +26,11 @@ class CashuKeysets { if (cachedKeysets != null && cachedKeysets.isNotEmpty) { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final isCacheStale = cachedKeysets.any((keyset) => - keyset.fetchedAt == null || - (now - keyset.fetchedAt!) >= validityDurationSeconds); + final isCacheStale = cachedKeysets.any( + (keyset) => + keyset.fetchedAt == null || + (now - keyset.fetchedAt!) >= validityDurationSeconds, + ); if (!isCacheStale) { return cachedKeysets; @@ -48,9 +50,7 @@ class CashuKeysets { required String mintUrl, }) async { final List mintKeys = []; - final keySets = await _cashuRepo.getKeysets( - mintUrl: mintUrl, - ); + final keySets = await _cashuRepo.getKeysets(mintUrl: mintUrl); for (final keySet in keySets) { final keys = await _cashuRepo.getKeys( 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 index 7eec1fa3b..4249c657d 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart @@ -35,10 +35,7 @@ class SplitResult { final List exactProofs; final List changeProofs; - SplitResult({ - required this.exactProofs, - required this.changeProofs, - }); + SplitResult({required this.exactProofs, required this.changeProofs}); } class CashuProofSelect { @@ -49,12 +46,14 @@ class CashuProofSelect { CashuProofSelect({ required CashuRepo cashuRepo, required CashuKeyDerivation cashuSeedSecretGenerator, - }) : _cashuRepo = cashuRepo, - _cashuSeedSecretGenerator = cashuSeedSecretGenerator; + }) : _cashuRepo = cashuRepo, + _cashuSeedSecretGenerator = cashuSeedSecretGenerator; /// Find keyset by ID from list static CahsuKeyset? _findKeysetById( - List keysets, String keysetId) { + List keysets, + String keysetId, + ) { try { return keysets.firstWhere((keyset) => keyset.id == keysetId); } catch (e) { @@ -63,10 +62,7 @@ class CashuProofSelect { } /// Calculate fees for a list of proofs across multiple keysets - static int calculateFees( - List proofs, - List keysets, - ) { + static int calculateFees(List proofs, List keysets) { if (proofs.isEmpty) return 0; int sumFees = 0; @@ -76,7 +72,8 @@ class CashuProofSelect { sumFees += keyset.inputFeePPK; } else { throw Exception( - 'Keyset not found for proof with keyset ID: ${proof.keysetId}'); + 'Keyset not found for proof with keyset ID: ${proof.keysetId}', + ); } } @@ -147,29 +144,28 @@ class CashuProofSelect { 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 - }); + 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 @@ -215,9 +211,7 @@ class CashuProofSelect { ); // sort to increase privacy - blindedMessagesOutputs.sort( - (a, b) => a.amount.compareTo(b.amount), - ); + blindedMessagesOutputs.sort((a, b) => a.amount.compareTo(b.amount)); final blindedSignatures = await _cashuRepo.swap( mintUrl: mint, @@ -277,11 +271,16 @@ class CashuProofSelect { // 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); + final exactMatch = _findExactMatchWithFees( + sortedProofs, + targetAmount, + keysets, + ); if (exactMatch.isNotEmpty) { - final feeData = - calculateFeesWithBreakdown(proofs: exactMatch, keysets: keysets); + final feeData = calculateFeesWithBreakdown( + proofs: exactMatch, + keysets: keysets, + ); return ProofSelectionResult( selectedProofs: exactMatch, totalSelected: exactMatch.fold(0, (sum, proof) => sum + proof.amount), @@ -345,11 +344,15 @@ class CashuProofSelect { // Now calculate exact fees with selected proofs final selectedProofs = selected.map((i) => sortedProofs[i]).toList(); - final feeData = - calculateFeesWithBreakdown(proofs: selectedProofs, keysets: keysets); + final feeData = calculateFeesWithBreakdown( + proofs: selectedProofs, + keysets: keysets, + ); final exactFees = feeData['totalFees']; - final exactTotal = - selectedProofs.fold(0, (sum, proof) => sum + proof.amount); + final exactTotal = selectedProofs.fold( + 0, + (sum, proof) => sum + proof.amount, + ); // Check if we need more proofs (fees were underestimated) if (exactTotal < targetAmount + exactFees) { @@ -363,15 +366,20 @@ class CashuProofSelect { } // Recalculate - final newFeeData = - calculateFeesWithBreakdown(proofs: selectedProofs, keysets: keysets); + final newFeeData = calculateFeesWithBreakdown( + proofs: selectedProofs, + keysets: keysets, + ); final newFees = newFeeData['totalFees']; - final newTotal = - selectedProofs.fold(0, (sum, proof) => sum + proof.amount); + 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'); + 'Insufficient funds: need $targetAmount + fees ($newFees), have $newTotal selected', + ); } final splitAmount = newTotal - targetAmount - newFees; @@ -412,8 +420,12 @@ class CashuProofSelect { } // Check combinations with fee consideration - return _findExactCombinationWithFees(proofs, targetAmount, keysets, - maxProofs: 5); + return _findExactCombinationWithFees( + proofs, + targetAmount, + keysets, + maxProofs: 5, + ); } /// Find exact combination accounting for fees across multiple keysets @@ -427,8 +439,12 @@ class CashuProofSelect { if (proofs.length > 15) return []; for (int len = 2; len <= maxProofs && len <= proofs.length; len++) { - final combination = - _findCombinationOfLengthWithFees(proofs, targetAmount, keysets, len); + final combination = _findCombinationOfLengthWithFees( + proofs, + targetAmount, + keysets, + len, + ); if (combination.isNotEmpty) return combination; } diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart index 5c7a5d205..550253e1e 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart @@ -56,8 +56,9 @@ class CashuRestore { int consecutiveEmptyBatches = 0; int lastUsedCounter = startCounter - 1; - Logger.log.i(() => - 'Starting restore for keyset $keysetId from counter $startCounter'); + Logger.log.i( + () => 'Starting restore for keyset $keysetId from counter $startCounter', + ); while (consecutiveEmptyBatches < gapLimit) { // Generate blinded messages for this batch @@ -94,21 +95,26 @@ class CashuRestore { blindedMessage: B_, ); - blindedMessageItems.add(CashuBlindedMessageItem( - blindedMessage: blindedMessage, - secret: secret, - r: rActual, - amount: 0, - )); + blindedMessageItems.add( + CashuBlindedMessageItem( + blindedMessage: blindedMessage, + secret: secret, + r: rActual, + amount: 0, + ), + ); } catch (e) { Logger.log.w( - () => 'Error creating blinded message for counter $counter: $e'); + () => 'Error creating blinded message for counter $counter: $e', + ); } } if (blindedMessageItems.isEmpty) { - Logger.log.w(() => - 'No valid blinded messages created for batch starting at $currentCounter'); + Logger.log.w( + () => + 'No valid blinded messages created for batch starting at $currentCounter', + ); consecutiveEmptyBatches++; currentCounter += batchSize; continue; @@ -116,8 +122,9 @@ class CashuRestore { // Call restore endpoint try { - final blindedMessages = - blindedMessageItems.map((item) => item.blindedMessage).toList(); + final blindedMessages = blindedMessageItems + .map((item) => item.blindedMessage) + .toList(); final (restoredOutputs, signatures) = await cashuRepo.restore( mintUrl: mintUrl, @@ -126,15 +133,19 @@ class CashuRestore { if (signatures.isEmpty) { // No signatures returned for this batch - Logger.log.d(() => - 'No signatures returned for batch starting at $currentCounter'); + 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'); + Logger.log.i( + () => + 'Found ${signatures.length} signatures in batch starting at $currentCounter', + ); // Unblind the signatures to get proofs final proofs = _unblindRestoreSignatures( @@ -150,8 +161,10 @@ class CashuRestore { lastUsedCounter = currentCounter + batchSize - 1; } } catch (e) { - Logger.log.e(() => - 'Error calling restore endpoint for batch starting at $currentCounter: $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++; } @@ -159,9 +172,12 @@ class CashuRestore { currentCounter += batchSize; } - Logger.log.i(() => 'Restore completed for keyset $keysetId. ' - 'Found ${allRestoredProofs.length} proofs. ' - 'Last used counter: $lastUsedCounter'); + Logger.log.i( + () => + 'Restore completed for keyset $keysetId. ' + 'Found ${allRestoredProofs.length} proofs. ' + 'Last used counter: $lastUsedCounter', + ); return CashuRestoreKeysetResult( keysetId: keysetId, @@ -208,15 +224,18 @@ class CashuRestore { // 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}'); + 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}'); + Logger.log.w( + () => 'No mint public key for amount ${signature.amount}', + ); continue; } @@ -229,8 +248,10 @@ class CashuRestore { ); if (unblindedSig == null) { - Logger.log.w(() => - 'Failed to unblind signature for amount ${signature.amount}'); + Logger.log.w( + () => + 'Failed to unblind signature for amount ${signature.amount}', + ); continue; } @@ -249,16 +270,19 @@ class CashuRestore { } } 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'); + 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}'); + Logger.log.w( + () => 'No mint public key for amount ${signature.amount}', + ); continue; } @@ -298,8 +322,10 @@ class CashuRestore { } if (!matched) { - Logger.log.w(() => - 'Could not find matching blinded message for signature with amount ${signature.amount}'); + Logger.log.w( + () => + 'Could not find matching blinded message for signature with amount ${signature.amount}', + ); } } } diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart index f84325b2b..2cab13ac1 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart @@ -19,9 +19,7 @@ class CashuSeed { Mnemonic? _userSeedPhrase; List _cachedSeed = []; - CashuSeed({ - CashuUserSeedphrase? userSeedPhrase, - }) { + CashuSeed({CashuUserSeedphrase? userSeedPhrase}) { if (userSeedPhrase != null) { setSeedPhrase( seedPhrase: userSeedPhrase.seedPhrase, 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 index 2e4f71d98..73ce086c2 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_token_encoder.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_token_encoder.dart @@ -8,9 +8,7 @@ import '../../entities/cashu/cashu_token.dart'; class CashuTokenEncoder { static final v4Prefix = 'cashuB'; - static String encodeTokenV4({ - required CashuToken token, - }) { + static String encodeTokenV4({required CashuToken token}) { final json = token.toV4Json(); final myCbor = CborValue(json); final base64String = base64.encode(cbor.encode(myCbor)); diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart index e2d2886e7..ef84ac26d 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart @@ -64,7 +64,7 @@ class CashuTools { static List splitAmount(int value) { return [ for (int i = 0; value > 0; i++, value >>= 1) - if (value & 1 == 1) 1 << i + if (value & 1 == 1) 1 << i, ]; } @@ -76,8 +76,10 @@ class CashuTools { const maxAttempt = 65536; final hashBytes = Uint8List.fromList(utf8.encode(hash)); - Uint8List msgToHash = Uint8List.fromList( - [...CashuConfig.DOMAIN_SEPARATOR_HashToCurve.codeUnits, ...hashBytes]); + Uint8List msgToHash = Uint8List.fromList([ + ...CashuConfig.DOMAIN_SEPARATOR_HashToCurve.codeUnits, + ...hashBytes, + ]); var digest = SHA256Digest(); Uint8List msgHash = digest.process(msgToHash); @@ -111,9 +113,7 @@ class CashuTools { static String ecPointToHex(ECPoint point, {bool compressed = true}) { return point .getEncoded(compressed) - .map( - (byte) => byte.toRadixString(16).padLeft(2, '0'), - ) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); } @@ -164,8 +164,9 @@ class CashuTools { required List keysets, required String unit, }) { - final keysetsFiltered = - keysets.where((keyset) => keyset.unit == unit).toList(); + final keysetsFiltered = keysets + .where((keyset) => keyset.unit == unit) + .toList(); if (keysetsFiltered.isEmpty) { throw Exception('No keysets found with unit: $unit'); diff --git a/packages/ndk/lib/domain_layer/usecases/connectivity/connectivity.dart b/packages/ndk/lib/domain_layer/usecases/connectivity/connectivity.dart index 90000c723..677e8b9ac 100644 --- a/packages/ndk/lib/domain_layer/usecases/connectivity/connectivity.dart +++ b/packages/ndk/lib/domain_layer/usecases/connectivity/connectivity.dart @@ -17,23 +17,23 @@ class Connectivy { /// forces all relays to reconnect \ /// use this for faster reconnects based on your application/os connectivity \ Future tryReconnect() async { - final relayConnectivities = - _relayManager.globalState.relays.values.toList(); + final relayConnectivities = _relayManager.globalState.relays.values + .toList(); for (final rConnectivity in relayConnectivities) { if (!rConnectivity.isConnected) { await _relayManager .reconnectRelay( - rConnectivity.url, - connectionSource: rConnectivity.relay.connectionSource, - force: true, - ) + rConnectivity.url, + connectionSource: rConnectivity.relay.connectionSource, + force: true, + ) .then((connected) { - _relayManager.updateRelayConnectivity(); - if (connected) { - _relayManager.reSubscribeInFlightSubscriptions(rConnectivity); - } - }); + _relayManager.updateRelayConnectivity(); + if (connected) { + _relayManager.reSubscribeInFlightSubscriptions(rConnectivity); + } + }); } } } diff --git a/packages/ndk/lib/domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart b/packages/ndk/lib/domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart new file mode 100644 index 000000000..e77cd30dd --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart @@ -0,0 +1,128 @@ +import '../../../shared/helpers/mutex_simple.dart'; +import '../../entities/event_cache_records.dart'; +import '../../entities/nip_01_event.dart'; +import '../../repositories/cache_manager.dart'; + +typedef EventPayloadDecryptor = Future Function(); +typedef DecryptedPayloadFailureClassifier = + DecryptedPayloadStatus? Function(Object error, StackTrace stackTrace); + +/// Read-through cache for decrypted payload sidecars. +/// +/// This usecase is the main app-facing abstraction for caching plaintext of +/// encrypted Nostr events. It intentionally does not rewrite [Nip01Event] +/// content. Instead it stores a viewer-specific sidecar record keyed by +/// `(eventId, viewerPubKey)`. +/// +/// Typical flow: +/// 1. caller asks for plaintext +/// 2. cache is checked first +/// 3. if missing, caller-provided decryptor is executed +/// 4. successful plaintext is stored for future reads +/// +/// A per-record mutex prevents the same event/viewer pair from being decrypted +/// multiple times concurrently. +class DecryptedEventPayloads { + final CacheManager _cacheManager; + final Map _recordMutexes = {}; + + DecryptedEventPayloads({required CacheManager cacheManager}) + : _cacheManager = cacheManager; + + /// Returns cached plaintext if a ready sidecar already exists. + Future loadCachedPlaintext({ + required String eventId, + required String viewerPubKey, + }) async { + final record = await _cacheManager.loadDecryptedEventPayloadRecord( + eventId: eventId, + viewerPubKey: viewerPubKey, + ); + if (record == null || + record.status != DecryptedPayloadStatus.ready || + record.plaintextContent == null) { + return null; + } + + return record.plaintextContent; + } + + /// Returns plaintext from cache or decrypts and persists it on demand. + /// + /// [scheme] describes the encryption family used by the event. + /// [decrypt] is only executed when no usable plaintext sidecar already + /// exists. + /// + /// If [classifyFailure] returns a status, the failure is also persisted so + /// callers and tests can distinguish transient from permanent failures. + Future loadOrDecrypt({ + required Nip01Event event, + required String viewerPubKey, + required DecryptedPayloadScheme scheme, + required EventPayloadDecryptor decrypt, + DecryptedPayloadFailureClassifier? classifyFailure, + }) async { + final recordKey = '${event.id}|$viewerPubKey'; + final mutex = _recordMutexes.putIfAbsent(recordKey, MutexSimple.new); + + return mutex.synchronized(() async { + final cached = await loadCachedPlaintext( + eventId: event.id, + viewerPubKey: viewerPubKey, + ); + if (cached != null) { + return cached; + } + + final existing = await _cacheManager.loadDecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: viewerPubKey, + ); + final now = _now(); + + try { + final plaintext = await decrypt(); + if (plaintext == null) { + return null; + } + + await _cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: viewerPubKey, + scheme: scheme, + status: DecryptedPayloadStatus.ready, + plaintextContent: plaintext, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + decryptedAt: now, + sourceEventPubKey: event.pubKey, + sourceEventKind: event.kind, + ), + ); + + return plaintext; + } catch (error, stackTrace) { + final status = classifyFailure?.call(error, stackTrace); + if (status != null) { + await _cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: viewerPubKey, + scheme: scheme, + status: status, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + failureReason: error.toString(), + sourceEventPubKey: event.pubKey, + sourceEventKind: event.kind, + ), + ); + } + rethrow; + } + }); + } + + int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; +} diff --git a/packages/ndk/lib/domain_layer/usecases/dms/dms.dart b/packages/ndk/lib/domain_layer/usecases/dms/dms.dart new file mode 100644 index 000000000..00133abba --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/dms/dms.dart @@ -0,0 +1,390 @@ +import '../../entities/filter.dart'; +import '../../entities/nip_17_conversation.dart'; +import '../../entities/nip_01_event.dart'; +import '../../entities/nip_17_message.dart'; +import '../../repositories/cache_manager.dart'; +import '../accounts/accounts.dart'; +import '../broadcast/broadcast.dart'; +import '../gift_wrap/gift_wrap.dart'; +import '../requests/requests.dart'; +import '../user_relay_lists/user_relay_lists.dart'; + +/// Direct-messages usecase built on NIP-17 gift-wrapped messages. +/// +/// This usecase provides app-facing helpers for: +/// - sending a DM to a peer +/// - loading the logged-in user's conversations +/// - loading one conversation with a specific peer +/// - parsing a wrapped event into a DM message model +/// +/// Conversation loading uses the logged-in user's DM relay list and can reuse +/// cached decrypted payload sidecars for fast repeated reads. +class Dms { + /// Message rumor kind used by this DM usecase. + static const int kMessageKind = 14; + + final Accounts _accounts; + final Requests _requests; + final Broadcast _broadcast; + final GiftWrap _giftWrap; + final UserRelayLists _userRelayLists; + final CacheManager _cacheManager; + + /// Creates the direct-messages usecase. + Dms({ + required Accounts accounts, + required Requests requests, + required Broadcast broadcast, + required GiftWrap giftWrap, + required UserRelayLists userRelayLists, + required CacheManager cacheManager, + }) : _accounts = accounts, + _requests = requests, + _broadcast = broadcast, + _giftWrap = giftWrap, + _userRelayLists = userRelayLists, + _cacheManager = cacheManager; + + /// Sends a direct message to [recipientPubKey]. + /// + /// NDK creates a message rumor, wraps it once for the recipient and once for + /// the sender, and broadcasts each wrapped copy to the corresponding DM + /// relays. + /// + /// Throws if either side has no published DM relay list. + Future sendMessage({ + required String recipientPubKey, + required String content, + List> additionalTags = const [], + }) async { + final senderPubKey = _requireLoggedPubKey(); + + final senderDmRelays = await _userRelayLists.getDmRelays(senderPubKey); + if (senderDmRelays == null || senderDmRelays.isEmpty) { + throw Exception( + 'Sender has no NIP-17 DM relays (kind 10050). Publish one first.', + ); + } + + final recipientDmRelays = await _userRelayLists.getDmRelays( + recipientPubKey, + forceRefresh: true, + ); + if (recipientDmRelays == null || recipientDmRelays.isEmpty) { + throw Exception('Recipient has no NIP-17 DM relays (kind 10050).'); + } + + final rumor = await _giftWrap.createRumor( + content: content, + kind: kMessageKind, + tags: [ + ['p', recipientPubKey], + ...additionalTags, + ], + ); + + final recipientWrap = await _giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: recipientPubKey, + ); + final senderWrap = await _giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: senderPubKey, + ); + + final recipientBroadcast = _broadcast.broadcast( + nostrEvent: recipientWrap, + specificRelays: recipientDmRelays, + ); + final senderBroadcast = _broadcast.broadcast( + nostrEvent: senderWrap, + specificRelays: senderDmRelays, + ); + + await Future.wait([ + recipientBroadcast.broadcastDoneFuture, + senderBroadcast.broadcastDoneFuture, + ]); + } + + /// Loads the full conversation with one peer. + /// + /// If [forceRefresh] is `false`, cached reads are allowed before or alongside + /// network refresh. If the peer has no messages, an empty list is returned. + Future> loadConversation({ + required String peerPubKey, + bool forceRefresh = false, + Duration timeout = const Duration(seconds: 5), + }) async { + final conversations = await loadConversations( + forceRefresh: forceRefresh, + timeout: timeout, + ); + for (final conversation in conversations) { + if (conversation.peerPubKey == peerPubKey) { + return conversation.messages; + } + } + return const []; + } + + /// Loads the conversation with one peer using cache only. + /// + /// This is useful for immediate UI rendering before a background refresh. + Future> loadConversationSnapshot({ + required String peerPubKey, + }) async { + final conversations = await loadConversationsSnapshot(); + for (final conversation in conversations) { + if (conversation.peerPubKey == peerPubKey) { + return conversation.messages; + } + } + return const []; + } + + /// Loads all conversations for the logged-in user. + /// + /// Conversations are grouped by peer pubkey and sorted by newest message + /// first. + Future> loadConversations({ + bool forceRefresh = false, + Duration timeout = const Duration(seconds: 5), + }) async { + final myPubKey = _requireLoggedPubKey(); + final wrappedEvents = await _loadWrappedEvents( + myPubKey: myPubKey, + forceRefresh: forceRefresh, + timeout: timeout, + ); + final messages = await _parseMessages( + wrappedEvents: wrappedEvents, + myPubKey: myPubKey, + cacheOnly: false, + ); + return _buildConversations(messages); + } + + /// Loads all conversations for the logged-in user using cache only. + Future> loadConversationsSnapshot() async { + final myPubKey = _requireLoggedPubKey(); + final wrappedEvents = await _loadWrappedEventsFromCache(myPubKey: myPubKey); + final messages = await _parseMessages( + wrappedEvents: wrappedEvents, + myPubKey: myPubKey, + cacheOnly: true, + ); + return _buildConversations(messages); + } + + Future> _loadWrappedEvents({ + required String myPubKey, + required bool forceRefresh, + required Duration timeout, + }) async { + final dmRelays = await _userRelayLists.getDmRelays( + myPubKey, + forceRefresh: forceRefresh, + ); + + if (dmRelays == null || dmRelays.isEmpty) { + throw Exception('Logged in user has no NIP-17 DM relays (kind 10050).'); + } + + final response = _requests.query( + name: 'dm-conversations', + explicitRelays: dmRelays, + cacheRead: !forceRefresh, + cacheWrite: true, + timeout: timeout, + filter: Filter(kinds: [GiftWrap.kGiftWrapEventkind], pTags: [myPubKey]), + ); + + return response.future; + } + + Future> _loadWrappedEventsFromCache({ + required String myPubKey, + }) { + return _cacheManager.loadEvents( + kinds: const [GiftWrap.kGiftWrapEventkind], + tags: { + 'p': [myPubKey], + }, + ); + } + + Future> _parseMessages({ + required List wrappedEvents, + required String myPubKey, + required bool cacheOnly, + }) async { + final byRumorId = {}; + final messages = await _mapConcurrent( + wrappedEvents, + _parseConcurrencyLimit, + (wrappedEvent) => _tryParseMessage( + wrappedEvent: wrappedEvent, + myPubKey: myPubKey, + cacheOnly: cacheOnly, + ), + ); + + for (final message in messages) { + if (message == null) { + continue; + } + final existing = byRumorId[message.id]; + if (existing == null || + existing.createdAt < message.createdAt || + (existing.createdAt == message.createdAt && + existing.wrappedEvent.createdAt < + message.wrappedEvent.createdAt)) { + byRumorId[message.id] = message; + } + } + + return byRumorId.values.toList(); + } + + List _buildConversations(List messages) { + final byPeer = >{}; + + for (final message in messages) { + byPeer.putIfAbsent(message.peerPubKey, () => []).add(message); + } + + final conversations = byPeer.entries.map((entry) { + final peerMessages = entry.value + ..sort((a, b) { + final createdAtCompare = a.createdAt.compareTo(b.createdAt); + if (createdAtCompare != 0) { + return createdAtCompare; + } + return a.id.compareTo(b.id); + }); + return Nip17Conversation( + peerPubKey: entry.key, + messages: List.unmodifiable(peerMessages), + ); + }).toList()..sort((a, b) => b.latestCreatedAt.compareTo(a.latestCreatedAt)); + + return conversations; + } + + /// Parses a single wrapped event into a DM message if possible. + /// + /// Returns `null` when the event is not a valid or decryptable NIP-17 message + /// for the logged-in user. + Future parseWrappedMessage({ + required Nip01Event wrappedEvent, + }) async { + final myPubKey = _requireLoggedPubKey(); + return _tryParseMessage( + wrappedEvent: wrappedEvent, + myPubKey: myPubKey, + cacheOnly: false, + ); + } + + Future _tryParseMessage({ + required Nip01Event wrappedEvent, + required String myPubKey, + required bool cacheOnly, + }) async { + try { + final cachedRumor = await _giftWrap.tryFromGiftWrapFromCache( + giftWrap: wrappedEvent, + ); + final rumor = cacheOnly + ? cachedRumor + : cachedRumor ?? await _giftWrap.fromGiftWrap(giftWrap: wrappedEvent); + if (rumor == null) { + return null; + } + if (rumor.kind != kMessageKind) { + return null; + } + + final resolvedPeer = _resolvePeerPubKey(rumor: rumor, myPubKey: myPubKey); + if (resolvedPeer == null) { + return null; + } + + return Nip17Message( + wrappedEvent: wrappedEvent, + rumor: rumor, + peerPubKey: resolvedPeer, + isOutgoing: rumor.pubKey == myPubKey, + ); + } catch (_) { + return null; + } + } + + String? _resolvePeerPubKey({ + required Nip01Event rumor, + required String myPubKey, + }) { + final orderedParticipants = rumor.tags + .where((tag) => tag.length >= 2 && tag[0] == 'p') + .map((tag) => tag[1]) + .toList(); + final participants = orderedParticipants.toSet(); + if (rumor.pubKey == myPubKey) { + for (final participant in orderedParticipants) { + if (participant != myPubKey) { + return participant; + } + } + return null; + } + + if (rumor.pubKey != myPubKey && participants.contains(myPubKey)) { + return rumor.pubKey; + } + + return null; + } + + String _requireLoggedPubKey() { + final pubKey = _accounts.getPublicKey(); + if (pubKey == null) { + throw Exception('NIP-17 requires a logged in account.'); + } + return pubKey; + } + + static const int _parseConcurrencyLimit = 8; + + Future> _mapConcurrent( + List items, + int concurrency, + Future Function(T item) mapper, + ) async { + if (items.isEmpty) { + return []; + } + + final results = List.filled(items.length, null); + var nextIndex = 0; + + Future worker() async { + while (true) { + final currentIndex = nextIndex; + if (currentIndex >= items.length) { + return; + } + nextIndex++; + results[currentIndex] = await mapper(items[currentIndex]); + } + } + + final workerCount = concurrency < items.length ? concurrency : items.length; + await Future.wait(List.generate(workerCount, (_) => worker())); + + return results.cast(); + } +} + +/// App-facing alias for the direct-messages usecase. diff --git a/packages/ndk/lib/domain_layer/usecases/fetched_ranges/fetched_ranges.dart b/packages/ndk/lib/domain_layer/usecases/fetched_ranges/fetched_ranges.dart index cec6d1163..36a89c46c 100644 --- a/packages/ndk/lib/domain_layer/usecases/fetched_ranges/fetched_ranges.dart +++ b/packages/ndk/lib/domain_layer/usecases/fetched_ranges/fetched_ranges.dart @@ -6,23 +6,24 @@ import '../../repositories/cache_manager.dart'; class FetchedRanges { final CacheManager _cacheManager; - FetchedRanges({ - required CacheManager cacheManager, - }) : _cacheManager = cacheManager; + FetchedRanges({required CacheManager cacheManager}) + : _cacheManager = cacheManager; /// Get fetched ranges for a filter across all relays Future> getForFilter(Filter filter) async { final filterHash = await FilterFingerprint.generateAsync(filter); - final records = - await _cacheManager.loadFilterFetchedRangeRecords(filterHash); + final records = await _cacheManager.loadFilterFetchedRangeRecords( + filterHash, + ); return _buildFetchedRangesMap(filter, records); } /// Get all fetched ranges for a relay (all filters) Future> getForRelay(String relayUrl) async { - final records = - await _cacheManager.loadFilterFetchedRangeRecordsByRelayUrl(relayUrl); + final records = await _cacheManager.loadFilterFetchedRangeRecordsByRelayUrl( + relayUrl, + ); // Group by filterHash final grouped = >{}; @@ -35,14 +36,17 @@ class FetchedRanges { // The filterHash is preserved but the filter details are not available final result = []; for (final entry in grouped.entries) { - final relayRecords = - entry.value.where((r) => r.relayUrl == relayUrl).toList(); + final relayRecords = entry.value + .where((r) => r.relayUrl == relayUrl) + .toList(); if (relayRecords.isNotEmpty) { - result.add(_buildRelayFetchedRanges( - relayUrl, - Filter(), // Empty filter - we only have the hash - relayRecords, - )); + result.add( + _buildRelayFetchedRanges( + relayUrl, + Filter(), // Empty filter - we only have the hash + relayRecords, + ), + ); } } @@ -88,11 +92,7 @@ class FetchedRanges { if (fetchedRanges == null || fetchedRanges.ranges.isEmpty) { // No cached ranges - entire range is a gap gaps = [ - FetchedRangesGap( - relayUrl: relayUrl, - since: since, - until: until, - ) + FetchedRangesGap(relayUrl: relayUrl, since: since, until: until), ]; } else { // Find gaps in existing ranges @@ -128,10 +128,7 @@ class FetchedRanges { // Convert to TimeRanges for merging final ranges = existingRecords - .map((r) => TimeRange( - since: r.rangeStart, - until: r.rangeEnd, - )) + .map((r) => TimeRange(since: r.rangeStart, until: r.rangeEnd)) .toList(); // Add the new range @@ -142,7 +139,9 @@ class FetchedRanges { // Delete old records for this filter/relay and save merged ones await _cacheManager.removeFilterFetchedRangeRecordsByFilterAndRelay( - filterHash, relayUrl); + filterHash, + relayUrl, + ); final newRecords = mergedRanges.map((range) { return FilterFetchedRangeRecord( @@ -190,8 +189,11 @@ class FetchedRanges { // Build RelayFetchedRanges for each relay final result = {}; for (final entry in grouped.entries) { - result[entry.key] = - _buildRelayFetchedRanges(entry.key, filter, entry.value); + result[entry.key] = _buildRelayFetchedRanges( + entry.key, + filter, + entry.value, + ); } return result; @@ -203,13 +205,11 @@ class FetchedRanges { Filter filter, List records, ) { - final ranges = records - .map((r) => TimeRange( - since: r.rangeStart, - until: r.rangeEnd, - )) - .toList() - ..sort((a, b) => a.since.compareTo(b.since)); + final ranges = + records + .map((r) => TimeRange(since: r.rangeStart, until: r.rangeEnd)) + .toList() + ..sort((a, b) => a.since.compareTo(b.since)); return RelayFetchedRanges( relayUrl: relayUrl, diff --git a/packages/ndk/lib/domain_layer/usecases/files/blossom.dart b/packages/ndk/lib/domain_layer/usecases/files/blossom.dart index 1fc889c83..14d4857f9 100644 --- a/packages/ndk/lib/domain_layer/usecases/files/blossom.dart +++ b/packages/ndk/lib/domain_layer/usecases/files/blossom.dart @@ -38,10 +38,10 @@ class Blossom { required BlossomRepository blossomRepository, required Accounts accounts, required LocalEventSignerFactory eventSignerFactory, - }) : _accounts = accounts, - _userServerList = blossomUserServerList, - _blossomImpl = blossomRepository, - _eventSignerFactory = eventSignerFactory; + }) : _accounts = accounts, + _userServerList = blossomUserServerList, + _blossomImpl = blossomRepository, + _eventSignerFactory = eventSignerFactory; /// Gets the signer to use for blossom operations /// Priority: customSigner > logged in account signer > temporary signer @@ -100,8 +100,9 @@ class Blossom { final signedAuthorization = await signer.sign(myAuthorization); - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [signer.getPublicKey()]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [signer.getPublicKey()], + ); if (serverUrls == null) { throw Exception("User has no server list"); @@ -187,8 +188,9 @@ class Blossom { final signedAuthorization = await signer.sign(myAuthorization); - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [signer.getPublicKey()]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [signer.getPublicKey()], + ); if (serverUrls == null) { throw Exception("User has no server list"); @@ -296,11 +298,13 @@ class Blossom { if (serverUrls == null) { if (pubkeyToFetchUserServerList == null) { throw Exception( - "pubkeyToFetchUserServerList is null and serverUrls is null"); + "pubkeyToFetchUserServerList is null and serverUrls is null", + ); } - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [pubkeyToFetchUserServerList]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [pubkeyToFetchUserServerList], + ); } if (serverUrls == null) { @@ -353,11 +357,13 @@ class Blossom { if (serverUrls == null) { if (pubkeyToFetchUserServerList == null) { throw Exception( - "pubkeyToFetchUserServerList is null and serverUrls is null"); + "pubkeyToFetchUserServerList is null and serverUrls is null", + ); } - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [pubkeyToFetchUserServerList]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [pubkeyToFetchUserServerList], + ); } if (serverUrls == null) { @@ -410,11 +416,13 @@ class Blossom { if (serverUrls == null) { if (pubkeyToFetchUserServerList == null) { throw Exception( - "pubkeyToFetchUserServerList is null and serverUrls is null"); + "pubkeyToFetchUserServerList is null and serverUrls is null", + ); } - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [pubkeyToFetchUserServerList]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [pubkeyToFetchUserServerList], + ); } if (serverUrls == null) { @@ -466,8 +474,9 @@ class Blossom { throw "pubkeyToFetchUserServerList is null and serverUrls is null"; } - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [pubkeyToFetchUserServerList]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [pubkeyToFetchUserServerList], + ); } if (serverUrls == null) { @@ -560,8 +569,9 @@ class Blossom { final signedAuthorization = await signer.sign(myAuthorization); /// fetch user server list from nostr - serverUrls ??= await _userServerList - .getUserServerList(pubkeys: [signer.getPublicKey()]); + serverUrls ??= await _userServerList.getUserServerList( + pubkeys: [signer.getPublicKey()], + ); if (serverUrls == null) { throw Exception("User has no server list"); @@ -574,9 +584,7 @@ class Blossom { } /// Directly downloads a blob from the url, without blossom - Future directDownload({ - required Uri url, - }) { + Future directDownload({required Uri url}) { return _blossomImpl.directDownload(url: url); } diff --git a/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart b/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart index ff2b99c6a..2c4adcfc6 100644 --- a/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart +++ b/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart @@ -16,9 +16,9 @@ class BlossomUserServerList { required Requests requests, required Broadcast broadcast, required Accounts accounts, - }) : _accounts = accounts, - _broadcast = broadcast, - _requests = requests; + }) : _accounts = accounts, + _broadcast = broadcast, + _requests = requests; /// Get user server list \ /// returns list of server urls \ @@ -29,10 +29,7 @@ class BlossomUserServerList { final rsp = _requests.query( timeout: Duration(seconds: 5), filters: [ - Filter( - authors: pubkeys, - kinds: [Blossom.kBlossomUserServerList], - ) + Filter(authors: pubkeys, kinds: [Blossom.kBlossomUserServerList]), ], ); diff --git a/packages/ndk/lib/domain_layer/usecases/files/files.dart b/packages/ndk/lib/domain_layer/usecases/files/files.dart index 119a53adb..931191186 100644 --- a/packages/ndk/lib/domain_layer/usecases/files/files.dart +++ b/packages/ndk/lib/domain_layer/usecases/files/files.dart @@ -59,10 +59,7 @@ class Files { required String sha256, List? serverUrls, }) { - return _blossom.deleteBlob( - sha256: sha256, - serverUrls: serverUrls, - ); + return _blossom.deleteBlob(sha256: sha256, serverUrls: serverUrls); } /// download a file from the server(s) \ @@ -86,9 +83,10 @@ class Files { // Try to download using blossom return await _blossom.getBlob( - sha256: sha256, - serverUrls: serverUrls, - pubkeyToFetchUserServerList: pubkey); + sha256: sha256, + serverUrls: serverUrls, + pubkeyToFetchUserServerList: pubkey, + ); } else { return await _blossom.directDownload(url: Uri.parse(url)); } diff --git a/packages/ndk/lib/domain_layer/usecases/follows/follows.dart b/packages/ndk/lib/domain_layer/usecases/follows/follows.dart index 993f62129..4f459a8cd 100644 --- a/packages/ndk/lib/domain_layer/usecases/follows/follows.dart +++ b/packages/ndk/lib/domain_layer/usecases/follows/follows.dart @@ -20,10 +20,10 @@ class Follows { required Broadcast broadcast, required CacheManager cacheManager, required Accounts accounts, - }) : _cacheManager = cacheManager, - _requests = requests, - _accounts = accounts, - _broadcast = broadcast; + }) : _cacheManager = cacheManager, + _requests = requests, + _accounts = accounts, + _broadcast = broadcast; void _checkSigner() { if (_accounts.cannotSign) { @@ -34,11 +34,15 @@ class Follows { /// contact list of a given pubkey, not intended to get followers Future getContactList( String pubKey, { + /// skips the cache bool forceRefresh = false, Duration idleTimeout = RequestDefaults.DEFAULT_QUERY_TIMEOUT, }) async { ContactList? contactList = await _cacheManager.loadContactList(pubKey); + if (contactList != null) { + contactList.loadedTimestamp = Helpers.now; + } if (contactList != null && !forceRefresh) { return contactList; @@ -46,12 +50,19 @@ class Follows { ContactList? loadedContactList; try { - await for (final event in _requests.query( - timeout: idleTimeout, - filters: [ - Filter(kinds: [ContactList.kKind], authors: [pubKey], limit: 1) - ], - ).stream) { + await for (final event + in _requests + .query( + timeout: idleTimeout, + filters: [ + Filter( + kinds: [ContactList.kKind], + authors: [pubKey], + limit: 1, + ), + ], + ) + .stream) { if (loadedContactList == null || loadedContactList.createdAt < event.createdAt) { loadedContactList = ContactList.fromEvent(event); @@ -66,7 +77,7 @@ class Follows { contactList.createdAt < loadedContactList.createdAt)) { loadedContactList.loadedTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await _cacheManager.saveContactList(loadedContactList); + await _cacheManager.saveEvent(loadedContactList.toEvent()); contactList = loadedContactList; } @@ -76,17 +87,17 @@ class Follows { // if cached contact list is older that now minus this duration that we should go refresh it, // otherwise we risk adding/removing contacts to a list that is out of date and thus loosing contacts other client has added/removed since. static const kRefreshContactListDuration = Duration(minutes: 10); - Future _ensureUpToDateContactListOrEmpty( - String pubkey, - ) async { + Future _ensureUpToDateContactListOrEmpty(String pubkey) async { ContactList? contactList = await _cacheManager.loadContactList(pubkey); - int sometimeAgo = DateTime.now() + if (contactList != null) { + contactList.loadedTimestamp = Helpers.now; + } + int sometimeAgo = + DateTime.now() .subtract(kRefreshContactListDuration) .millisecondsSinceEpoch ~/ 1000; - bool refresh = contactList == null || - contactList.loadedTimestamp == null || - contactList.loadedTimestamp! < sometimeAgo; + bool refresh = contactList == null || contactList.createdAt < sometimeAgo; if (refresh) { contactList = await getContactList(pubkey, forceRefresh: true); } @@ -100,20 +111,21 @@ class Follows { List Function(ContactList) collectionAccessor, ) async { _checkSigner(); - ContactList contactList = - await _ensureUpToDateContactListOrEmpty(_accounts.getPublicKey()!); + ContactList contactList = await _ensureUpToDateContactListOrEmpty( + _accounts.getPublicKey()!, + ); List collection = collectionAccessor(contactList); if (!collection.contains(toAdd)) { collection.add(toAdd); contactList.loadedTimestamp = Helpers.now; - contactList.createdAt = Helpers.now; + contactList.createdAt = _nextReplaceableTimestamp(contactList.createdAt); final bResult = _broadcast.broadcast( nostrEvent: contactList.toEvent(), specificRelays: customRelays, ); await bResult.broadcastDoneFuture; - await _cacheManager.saveContactList(contactList); + await _cacheManager.saveEvent(contactList.toEvent()); } return contactList; } @@ -123,14 +135,12 @@ class Follows { /// [createdAt] and [loadedTimestamp] are set to the current time Future broadcastSetContactList(ContactList contactList) async { contactList.loadedTimestamp = Helpers.now; - contactList.createdAt = Helpers.now; + contactList.createdAt = _nextReplaceableTimestamp(contactList.createdAt); - final bResult = _broadcast.broadcast( - nostrEvent: contactList.toEvent(), - ); + final bResult = _broadcast.broadcast(nostrEvent: contactList.toEvent()); await bResult.broadcastDoneFuture; - await _cacheManager.saveContactList(contactList); + await _cacheManager.saveEvent(contactList.toEvent()); return contactList; } @@ -140,7 +150,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastAddContactInCollection( - add, customRelays, (list) => list.contacts); + add, + customRelays, + (list) => list.contacts, + ); } /// broadcast adding of followed tag @@ -149,7 +162,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastAddContactInCollection( - add, customRelays, (list) => list.followedTags); + add, + customRelays, + (list) => list.followedTags, + ); } /// broadcast adding of followed community @@ -158,7 +174,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastAddContactInCollection( - toAdd, customRelays, (list) => list.followedCommunities); + toAdd, + customRelays, + (list) => list.followedCommunities, + ); } /// broadcast adding of followed event @@ -167,7 +186,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastAddContactInCollection( - toAdd, customRelays, (list) => list.followedEvents); + toAdd, + customRelays, + (list) => list.followedEvents, + ); } Future _broadcastRemoveContactInCollection( @@ -176,31 +198,43 @@ class Follows { List Function(ContactList) collectionAccessor, ) async { _checkSigner(); - ContactList? contactList = - await _ensureUpToDateContactListOrEmpty(_accounts.getPublicKey()!); + ContactList? contactList = await _ensureUpToDateContactListOrEmpty( + _accounts.getPublicKey()!, + ); List collection = collectionAccessor(contactList); if (collection.contains(toRemove)) { collection.remove(toRemove); contactList.loadedTimestamp = Helpers.now; - contactList.createdAt = Helpers.now; + contactList.createdAt = _nextReplaceableTimestamp(contactList.createdAt); final bResult = _broadcast.broadcast( nostrEvent: contactList.toEvent(), specificRelays: customRelays, ); await bResult.broadcastDoneFuture; - await _cacheManager.saveContactList(contactList); + await _cacheManager.saveEvent(contactList.toEvent()); } return contactList; } + int _nextReplaceableTimestamp(int currentCreatedAt) { + final now = Helpers.now; + if (now > currentCreatedAt) { + return now; + } + return currentCreatedAt + 1; + } + /// broadcast removal of contact Future broadcastRemoveContact( String toRemove, { Iterable? customRelays, }) async { return _broadcastRemoveContactInCollection( - toRemove, customRelays, (list) => list.contacts); + toRemove, + customRelays, + (list) => list.contacts, + ); } /// broadcast removal of followed tag @@ -209,7 +243,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastRemoveContactInCollection( - toRemove, customRelays, (list) => list.followedTags); + toRemove, + customRelays, + (list) => list.followedTags, + ); } /// broadcast removal of followed community @@ -218,7 +255,10 @@ class Follows { Iterable? customRelays, }) async { return _broadcastRemoveContactInCollection( - toRemove, customRelays, (list) => list.followedCommunities); + toRemove, + customRelays, + (list) => list.followedCommunities, + ); } /// broadcast removal of followed event @@ -227,6 +267,9 @@ class Follows { Iterable? customRelays, }) async { return _broadcastRemoveContactInCollection( - toRemove, customRelays, (list) => list.followedEvents); + toRemove, + customRelays, + (list) => list.followedEvents, + ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/gift_wrap/gift_wrap.dart b/packages/ndk/lib/domain_layer/usecases/gift_wrap/gift_wrap.dart index 846c593c6..e00d3c470 100644 --- a/packages/ndk/lib/domain_layer/usecases/gift_wrap/gift_wrap.dart +++ b/packages/ndk/lib/domain_layer/usecases/gift_wrap/gift_wrap.dart @@ -2,11 +2,13 @@ import 'dart:convert'; import 'dart:math'; import '../../../data_layer/models/nip_01_event_model.dart'; +import '../../entities/event_cache_records.dart'; import '../../entities/gift_wrap_unwrap_result.dart'; import '../../entities/nip_01_event.dart'; import '../../repositories/event_signer.dart'; import '../../repositories/event_verifier.dart'; import '../accounts/accounts.dart'; +import '../decrypted_event_payloads/decrypted_event_payloads.dart'; class GiftWrap { static const int kSealEventKind = 13; @@ -17,11 +19,13 @@ class GiftWrap { final Accounts accounts; final EventVerifier eventVerifier; final LocalEventSignerFactory eventSignerFactory; + final DecryptedEventPayloads decryptedEventPayloads; GiftWrap({ required this.accounts, required this.eventVerifier, required this.eventSignerFactory, + required this.decryptedEventPayloads, }); /// Returns the signer to use for signing operations. @@ -92,6 +96,52 @@ class GiftWrap { return rumor; } + /// Attempts to unwrap a gift wrap using only cached decrypted payloads. + /// + /// Returns `null` when either the wrapped seal payload or the sealed rumor + /// payload is not available in the decrypted payload sidecar cache. + Future tryFromGiftWrapFromCache({ + required Nip01Event giftWrap, + EventSigner? customSigner, + bool verifySignature = true, + }) async { + if (giftWrap.kind != kGiftWrapEventkind) { + throw Exception("Event is not a gift wrap (kind:1059)"); + } + + final signer = _getSigner(customSigner: customSigner); + final viewerPubKey = signer.getPublicKey(); + + final cachedSealJson = await decryptedEventPayloads.loadCachedPlaintext( + eventId: giftWrap.id, + viewerPubKey: viewerPubKey, + ); + if (cachedSealJson == null) { + return null; + } + + final Map sealJson = jsonDecode(cachedSealJson); + final sealEvent = Nip01EventModel.fromJson(sealJson); + + if (verifySignature) { + final isValid = await eventVerifier.verify(sealEvent); + if (!isValid) { + throw Exception("Seal event signature is invalid"); + } + } + + final cachedRumorJson = await decryptedEventPayloads.loadCachedPlaintext( + eventId: sealEvent.id, + viewerPubKey: viewerPubKey, + ); + if (cachedRumorJson == null) { + return null; + } + + final Map rumorJson = jsonDecode(cachedRumorJson); + return Nip01EventModel.fromJson(rumorJson); + } + /// Unwraps a gift-wrapped event with signature verification information /// /// This method returns a [GiftWrapUnwrapResult] containing the seal, rumor, @@ -214,10 +264,14 @@ class GiftWrap { final signer = _getSigner(customSigner: customSigner); - // Now decrypt the seal to get the rumor - final decryptedRumorJson = await signer.decryptNip44( - ciphertext: sealedEvent.content, - senderPubKey: sealedEvent.pubKey, + final decryptedRumorJson = await decryptedEventPayloads.loadOrDecrypt( + event: sealedEvent, + viewerPubKey: signer.getPublicKey(), + scheme: DecryptedPayloadScheme.nip44, + decrypt: () => signer.decryptNip44( + ciphertext: sealedEvent.content, + senderPubKey: sealedEvent.pubKey, + ), ); if (decryptedRumorJson == null) { @@ -259,7 +313,7 @@ class GiftWrap { } final tags = >[ - ['p', recipientPublicKey] + ['p', recipientPublicKey], ]; // Add any additional tags if provided @@ -267,7 +321,8 @@ class GiftWrap { tags.addAll(additionalTags); } - final giftWrapCreatedAt = createdAt ?? + final giftWrapCreatedAt = + createdAt ?? (randomizeCreatedAtBefore != null ? _randomCreatedAtBefore(randomizeCreatedAtBefore) : sealEvent.createdAt); @@ -295,9 +350,14 @@ class GiftWrap { }) async { final signer = _getSigner(customSigner: customSigner); - final decryptedEventJson = await signer.decryptNip44( - ciphertext: wrappedEvent.content, - senderPubKey: wrappedEvent.pubKey, + final decryptedEventJson = await decryptedEventPayloads.loadOrDecrypt( + event: wrappedEvent, + viewerPubKey: signer.getPublicKey(), + scheme: DecryptedPayloadScheme.giftWrap, + decrypt: () => signer.decryptNip44( + ciphertext: wrappedEvent.content, + senderPubKey: wrappedEvent.pubKey, + ), ); if (decryptedEventJson == null) { diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart index 3f0e6a510..b209de30d 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart @@ -53,9 +53,7 @@ class JitEngine with Logger implements NetworkEngine { /// the relay jit manager will try to find the right relay and use it /// if no relay is found the request will be blasted to all connected relays (on start seed Relays) @override - void handleRequest( - RequestState requestState, - ) async { + void handleRequest(RequestState requestState) async { await relayManagerLight.seedRelaysConnected; final ndkRequest = requestState.request; @@ -70,8 +68,9 @@ class JitEngine with Logger implements NetworkEngine { if (requestState.request.explicitRelays != null && requestState.request.explicitRelays!.isNotEmpty) { - final cleanedExplicitRelays = - cleanRelayUrls(requestState.request.explicitRelays!.toList()); + final cleanedExplicitRelays = cleanRelayUrls( + requestState.request.explicitRelays!.toList(), + ); RelayJitRequestSpecificStrategy.handleRequest( relayManager: relayManagerLight, requestState: requestState, @@ -123,8 +122,9 @@ class JitEngine with Logger implements NetworkEngine { } if (filter.search != null) { - Logger.log.w(() => - "search filter not implemented yet, using blast all strategy"); + Logger.log.w( + () => "search filter not implemented yet, using blast all strategy", + ); } // if (filter.ids != null) { @@ -149,7 +149,9 @@ class JitEngine with Logger implements NetworkEngine { ndkRequest.authenticateAs!.isNotEmpty) { for (final relayUrl in requestState.requests.keys) { relayManagerLight.authenticateIfNeeded( - relayUrl, ndkRequest.authenticateAs!); + relayUrl, + ndkRequest.authenticateAs!, + ); } } } @@ -186,6 +188,7 @@ class JitEngine with Logger implements NetworkEngine { await RelayJitBroadcastSpecificRelaysStrategy.broadcast( specificRelays: cleanedSpecificRelays, relayManager: relayManagerLight, + cacheManager: cache, eventToPublish: workingNostrEvent, connectedRelays: relayManagerLight.connectedRelays .whereType>() @@ -225,8 +228,12 @@ class JitEngine with Logger implements NetworkEngine { asyncStuff(); return NdkBroadcastResponse( publishEvent: nostrEvent, - broadcastDoneStream: broadcastState.stateUpdates - .map((state) => state.broadcasts.values.toList()), + broadcastDoneStream: broadcastState.stateUpdates.map( + (state) => state.broadcasts.values.toList(), + ), + broadcastDoneFuture: broadcastState.publishDoneFuture.then( + (state) => state.broadcasts.values.toList(), + ), ); } diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart index 627b4d46f..336e36a3a 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart @@ -18,7 +18,7 @@ class RelayJitBroadcastOtherReadStrategy { static Future broadcast({ required Nip01Event eventToPublish, required List> - connectedRelays, + connectedRelays, required CacheManager cacheManager, required RelayManager relayManager, required List pubkeysOfInbox, @@ -52,9 +52,7 @@ class RelayJitBroadcastOtherReadStrategy { final uniqueRelayUrls = myWriteRelayUrls.toSet().toList(); // function to send message to relay - void sendToRelay({ - required RelayConnectivity relay, - }) { + void sendToRelay({required RelayConnectivity relay}) { final myClientMsg = ClientMsg( ClientMsgType.kEvent, event: eventToPublish, @@ -102,8 +100,9 @@ class RelayJitBroadcastOtherReadStrategy { } try { - final relay = relayManager.connectedRelays - .firstWhere((element) => element.url == relayUrl); + final relay = relayManager.connectedRelays.firstWhere( + (element) => element.url == relayUrl, + ); sendToRelay(relay: relay); } catch (e) { relayManager.failBroadcast( diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart index ef5f6542c..55dd31c15 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart @@ -14,7 +14,7 @@ class RelayJitBroadcastOutboxStrategy { static Future broadcast({ required Nip01Event eventToPublish, required List> - connectedRelays, + connectedRelays, required CacheManager cacheManager, required RelayManager relayManager, required List bootstrapRelays, @@ -27,8 +27,10 @@ class RelayJitBroadcastOutboxStrategy { List writeRelaysUrls; if (nip65Data == null) { - Logger.log.w(() => - "broadcast - could not find nip65 data for ${eventToPublish.pubKey}, using DEFAULT_BOOTSTRAP_RELAYS for now. \nPlease ensure nip65Data exists to use outbox model => UserRelayLists usecase"); + Logger.log.w( + () => + "broadcast - could not find nip65 data for ${eventToPublish.pubKey}, using DEFAULT_BOOTSTRAP_RELAYS for now. \nPlease ensure nip65Data exists to use outbox model => UserRelayLists usecase", + ); writeRelaysUrls = bootstrapRelays; } else { @@ -44,9 +46,7 @@ class RelayJitBroadcastOutboxStrategy { final uniqueRelayUrls = writeRelaysUrls.toSet().toList(); // function to send message to relay - void sendToRelay({ - required RelayConnectivity relay, - }) { + void sendToRelay({required RelayConnectivity relay}) { final myClientMsg = ClientMsg( ClientMsgType.kEvent, event: eventToPublish, @@ -94,8 +94,9 @@ class RelayJitBroadcastOutboxStrategy { } try { - final relay = relayManager.connectedRelays - .firstWhere((element) => element.url == relayUrl); + final relay = relayManager.connectedRelays.firstWhere( + (element) => element.url == relayUrl, + ); sendToRelay(relay: relay); } catch (e) { relayManager.failBroadcast( diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart index 8afaae4a8..6b78ee6eb 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart @@ -1,4 +1,6 @@ import '../../../../shared/nips/nip01/client_msg.dart'; +import '../../../../shared/nips/nip01/event_kind_classification.dart'; +import '../../../repositories/cache_manager.dart'; import '../../../entities/connection_source.dart'; import '../../../entities/jit_engine_relay_connectivity_data.dart'; import '../../../entities/nip_01_event.dart'; @@ -10,8 +12,9 @@ class RelayJitBroadcastSpecificRelaysStrategy { /// [specificRelays] urls of relays you want to publish to static Future broadcast({ required Nip01Event eventToPublish, + required CacheManager cacheManager, required List> - connectedRelays, + connectedRelays, required RelayManager relayManager, required List specificRelays, }) async { @@ -19,9 +22,7 @@ class RelayJitBroadcastSpecificRelaysStrategy { final uniqueRelayUrls = specificRelays.toSet().toList(); // function to send message to relay - void sendToRelay({ - required RelayConnectivity relay, - }) { + void sendToRelay({required RelayConnectivity relay}) { final myClientMsg = ClientMsg( ClientMsgType.kEvent, event: eventToPublish, @@ -40,6 +41,17 @@ class RelayJitBroadcastSpecificRelaysStrategy { try { final isConnected = relayManager.isRelayConnected(relayUrl); if (isConnected) { + if (await _shouldSkipObsoleteReplaceableBroadcast( + cacheManager: cacheManager, + event: eventToPublish, + )) { + relayManager.failBroadcast( + eventToPublish.id, + relayUrl, + "obsolete replaceable event skipped", + ); + return; + } try { final relay = connectedRelays.firstWhere( (element) => element.url == relayUrl, @@ -55,10 +67,11 @@ class RelayJitBroadcastSpecificRelaysStrategy { return; } - final success = await relayManager.reconnectRelay( - relayUrl, + final success = (await relayManager.connectRelay( + dirtyUrl: relayUrl, connectionSource: ConnectionSource.broadcastSpecific, - ); + connectTimeout: 1, + )).first; if (!success) { relayManager.failBroadcast( eventToPublish.id, @@ -67,9 +80,21 @@ class RelayJitBroadcastSpecificRelaysStrategy { ); return; } + if (await _shouldSkipObsoleteReplaceableBroadcast( + cacheManager: cacheManager, + event: eventToPublish, + )) { + relayManager.failBroadcast( + eventToPublish.id, + relayUrl, + "obsolete replaceable event skipped", + ); + return; + } try { - final relay = relayManager.connectedRelays - .firstWhere((element) => element.url == relayUrl); + final relay = relayManager.connectedRelays.firstWhere( + (element) => element.url == relayUrl, + ); sendToRelay(relay: relay); } catch (e) { relayManager.failBroadcast( @@ -90,4 +115,32 @@ class RelayJitBroadcastSpecificRelaysStrategy { // Broadcast to all relays in parallel await Future.wait(uniqueRelayUrls.map(sendToUrl), eagerError: false); } + + static Future _shouldSkipObsoleteReplaceableBroadcast({ + required CacheManager cacheManager, + required Nip01Event event, + }) async { + if (!EventKindClassification.isReplaceableKind(event.kind)) { + return false; + } + + final dTag = event.getDtag(); + final visibleEvents = await cacheManager.loadEvents( + pubKeys: [event.pubKey], + kinds: [event.kind], + tags: + EventKindClassification.isAddressableKind(event.kind) && dTag != null + ? { + 'd': [dTag], + } + : null, + limit: 1, + ); + + if (visibleEvents.isEmpty) { + return false; + } + + return visibleEvents.single.id != event.id; + } } diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_blast_all_strategy.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_blast_all_strategy.dart index 26c281b47..07bd5598b 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_blast_all_strategy.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_blast_all_strategy.dart @@ -15,7 +15,7 @@ class RelayJitBlastAllStrategy { required RequestState requestState, required Filter filter, required List> - connectedRelays, + connectedRelays, required bool closeOnEOSE, required RelayManager relayManager, required List bootstrapRelays, diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart index 9ed074802..5e56e5e4e 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart @@ -41,7 +41,7 @@ class RelayJitPubkeyStrategy with Logger { // specific filter (only one for this request) required Filter filter, required List> - connectedRelays, + connectedRelays, required List bootstrapRelays, /// used to get the nip65 data if its necessary to look for not covered pubkeys @@ -54,15 +54,16 @@ class RelayJitPubkeyStrategy with Logger { }) { List combindedPubkeys = [ ...?filter.authors, - ...?filter.pTags + ...?filter.pTags, ]; // not perfect but probably fine, request got split earlier // init coveragePubkeys List coveragePubkeys = []; for (var pubkey in combindedPubkeys) { - coveragePubkeys - .add(CoveragePubkey(pubkey, desiredCoverage, desiredCoverage)); + coveragePubkeys.add( + CoveragePubkey(pubkey, desiredCoverage, desiredCoverage), + ); } // look for connected relays that cover the pubkey @@ -71,7 +72,10 @@ class RelayJitPubkeyStrategy with Logger { for (var coveragePubkey in coveragePubkeys) { if (JitEngine.doesRelayCoverPubkey( - connectedRelay, coveragePubkey.pubkey, direction)) { + connectedRelay, + coveragePubkey.pubkey, + direction, + )) { coveredPubkeysForRelay.add(coveragePubkey.pubkey); coveragePubkey.missingCoverage--; } @@ -125,8 +129,10 @@ class RelayJitPubkeyStrategy with Logger { return; } - Filter notFoundFilter = _splitFilter(filter, - coveragePubkeys.map((e) => e.pubkey).toList()); // split the filter + Filter notFoundFilter = _splitFilter( + filter, + coveragePubkeys.map((e) => e.pubkey).toList(), + ); // split the filter // send out not found request to all connected relays RelayJitBlastAllStrategy.handleRequest( requestState: requestState, @@ -148,7 +154,7 @@ class RelayJitPubkeyStrategy with Logger { required Filter filter, required List coveragePubkeys, required List> - connectedRelays, + connectedRelays, required CacheManager cacheManager, required int desiredCoverage, required ReadWriteMarker direction, @@ -159,9 +165,9 @@ class RelayJitPubkeyStrategy with Logger { // look in nip65 data for not covered pubkeys List nip65Data = await UserRelayLists.getUserRelayListCacheLatest( - pubkeys: coveragePubkeys.map((e) => e.pubkey).toList(), - cacheManager: cacheManager, - ); + pubkeys: coveragePubkeys.map((e) => e.pubkey).toList(), + cacheManager: cacheManager, + ); // by finding the best relays to connect and send out the request RelayRankingResult relayRanking = rankRelays( @@ -182,62 +188,72 @@ class RelayJitPubkeyStrategy with Logger { continue; } // check if the relayCandidate is already connected - bool alreadyConnected = connectedRelays - .any((element) => element.url == relayCandidate.relayUrl); + bool alreadyConnected = connectedRelays.any( + (element) => element.url == relayCandidate.relayUrl, + ); if (!alreadyConnected) { relayManger .connectRelay( - dirtyUrl: relayCandidate.relayUrl, - connectionSource: ConnectionSource.pubkeyStrategy) + dirtyUrl: relayCandidate.relayUrl, + connectionSource: ConnectionSource.pubkeyStrategy, + ) .then((success) { - if (success.first) { - final myRelayConnectivity = - globalState.relays[relayCandidate.relayUrl] - as RelayConnectivity; - // add assigned pubkeys - myRelayConnectivity.specificEngineData!.addPubkeysToAssignedPubkeys( - relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), - direction, - ); - - // send out the request - _sendRequestToSocket( - myRelayConnectivity, - requestState, - [ - _splitFilter( - filter, - relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), - ) - ], - globalState, - relayManger, - ); - } - - if (!success.first) { - Logger.log.w(() => - "Could not connect to relay: ${relayCandidate.relayUrl} - errorHandling"); - // _connectionErrorHandling( - // errorRelay: newRelay, - // requestState: requestState, - // filter: filter, - // connectedRelays: connectedRelays, - // cacheManager: cacheManager, - // desiredCoverage: desiredCoverage, - // direction: direction, - // ignoreRelays: ignoreRelays, - // closeOnEOSE: closeOnEOSE, - - // ); - } - }); + if (success.first) { + final myRelayConnectivity = + globalState.relays[relayCandidate.relayUrl] + as RelayConnectivity; + // add assigned pubkeys + myRelayConnectivity.specificEngineData! + .addPubkeysToAssignedPubkeys( + relayCandidate.coveredPubkeys + .map((e) => e.pubkey) + .toList(), + direction, + ); + + // send out the request + _sendRequestToSocket( + myRelayConnectivity, + requestState, + [ + _splitFilter( + filter, + relayCandidate.coveredPubkeys + .map((e) => e.pubkey) + .toList(), + ), + ], + globalState, + relayManger, + ); + } + + if (!success.first) { + Logger.log.w( + () => + "Could not connect to relay: ${relayCandidate.relayUrl} - errorHandling", + ); + // _connectionErrorHandling( + // errorRelay: newRelay, + // requestState: requestState, + // filter: filter, + // connectedRelays: connectedRelays, + // cacheManager: cacheManager, + // desiredCoverage: desiredCoverage, + // direction: direction, + // ignoreRelays: ignoreRelays, + // closeOnEOSE: closeOnEOSE, + + // ); + } + }); } if (alreadyConnected) { - final myRelayConnectivity = globalState.relays[relayCandidate.relayUrl] - as RelayConnectivity; + final myRelayConnectivity = + globalState.relays[relayCandidate.relayUrl] + as RelayConnectivity; myRelayConnectivity.specificEngineData!.addPubkeysToAssignedPubkeys( relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), @@ -248,8 +264,10 @@ class RelayJitPubkeyStrategy with Logger { myRelayConnectivity, requestState, [ - _splitFilter(filter, - relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList()) + _splitFilter( + filter, + relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), + ), ], globalState, relayManger, @@ -321,12 +339,9 @@ void _sendRequestToSocket( // send out the request relayManager.send( - connectedRelay, - ClientMsg( - ClientMsgType.kReq, - id: requestState.id, - filters: filters, - )); + connectedRelay, + ClientMsg(ClientMsgType.kReq, id: requestState.id, filters: filters), + ); } Filter _splitFilter(Filter filter, List pubkeysToInclude) { @@ -344,11 +359,7 @@ class CoveragePubkey { int desiredCoverage; int missingCoverage; - CoveragePubkey( - this.pubkey, - this.desiredCoverage, - this.missingCoverage, - ); + CoveragePubkey(this.pubkey, this.desiredCoverage, this.missingCoverage); @override operator ==(Object other) { diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart index 3e8410d06..d2cbf08b7 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart @@ -24,10 +24,12 @@ class RelayJitRequestSpecificStrategy { final isConnected = relayManager.isRelayConnected(sRelay); final tryingToConnect = relayManager.isRelayConnecting(sRelay); if (isConnected || tryingToConnect) continue; - connectFutures.add(relayManager.connectRelay( - dirtyUrl: sRelay, - connectionSource: ConnectionSource.explicit, - )); + connectFutures.add( + relayManager.connectRelay( + dirtyUrl: sRelay, + connectionSource: ConnectionSource.explicit, + ), + ); } await Future.wait(connectFutures); diff --git a/packages/ndk/lib/domain_layer/usecases/lists/lists.dart b/packages/ndk/lib/domain_layer/usecases/lists/lists.dart index 05dd43e71..e5a11dbd2 100644 --- a/packages/ndk/lib/domain_layer/usecases/lists/lists.dart +++ b/packages/ndk/lib/domain_layer/usecases/lists/lists.dart @@ -1,14 +1,16 @@ import 'package:rxdart/rxdart.dart'; +import 'dart:convert'; import '../../../shared/nips/nip01/helpers.dart'; +import '../../entities/event_cache_records.dart'; import '../../entities/filter.dart'; import '../../entities/nip_01_event.dart'; import '../../entities/nip_51_list.dart'; import '../../repositories/cache_manager.dart'; import '../../repositories/event_signer.dart'; - import '../accounts/accounts.dart'; import '../broadcast/broadcast.dart'; +import '../decrypted_event_payloads/decrypted_event_payloads.dart'; import '../requests/requests.dart'; /// Lists usecase for access to NIP-51 lists and sets. @@ -21,6 +23,7 @@ class Lists { final Broadcast _broadcast; final Accounts _accounts; final LocalEventSignerFactory _eventSignerFactory; + final DecryptedEventPayloads _decryptedEventPayloads; /// Creates a Lists usecase instance. Lists({ @@ -29,26 +32,89 @@ class Lists { required Broadcast broadcast, required Accounts accounts, required LocalEventSignerFactory eventSignerFactory, - }) : _cacheManager = cacheManager, - _requests = requests, - _broadcast = broadcast, - _accounts = accounts, - _eventSignerFactory = eventSignerFactory; + required DecryptedEventPayloads decryptedEventPayloads, + }) : _cacheManager = cacheManager, + _requests = requests, + _broadcast = broadcast, + _accounts = accounts, + _eventSignerFactory = eventSignerFactory, + _decryptedEventPayloads = decryptedEventPayloads; EventSigner? get _eventSigner { return _accounts.getLoggedAccount()?.signer; } + Future _loadPrivateTagsJson({ + required Nip01Event event, + required EventSigner signer, + }) async { + final isNip04 = event.content.contains('?iv='); + return _decryptedEventPayloads.loadOrDecrypt( + event: event, + viewerPubKey: signer.getPublicKey(), + scheme: isNip04 + ? DecryptedPayloadScheme.nip04 + : DecryptedPayloadScheme.nip44, + decrypt: () => isNip04 + // ignore: deprecated_member_use_from_same_package + ? signer.decrypt(event.content, signer.getPublicKey()) + : signer.decryptNip44( + ciphertext: event.content, + senderPubKey: signer.getPublicKey(), + ), + ); + } + + Future _applyPrivateTags({ + required Nip01Event event, + required EventSigner signer, + required Nip51List list, + }) async { + if (!Helpers.isNotBlank(event.content) || !signer.canSign()) { + return; + } + + try { + final json = await _loadPrivateTagsJson(event: event, signer: signer); + final tags = jsonDecode(json ?? '') as List; + list.parseTags(tags, private: true); + } catch (_) { + // Keep old behavior: fromEvent already logged decrypt/parse failures. + } + } + + Future _parseListEvent( + Nip01Event event, + EventSigner? signer, + ) async { + final list = await Nip51List.fromEvent(event, null); + if (signer != null) { + await _applyPrivateTags(event: event, signer: signer, list: list); + } + return list; + } + + Future _parseSetEvent( + Nip01Event event, + EventSigner? signer, + ) async { + final set = await Nip51Set.fromEvent(event, null); + if (set != null && signer != null) { + await _applyPrivateTags(event: event, signer: signer, list: set); + } + return set; + } + ///* lists */// Future _getCachedNip51List(int kind, EventSigner signer) async { - List? events = await _cacheManager - .loadEvents(pubKeys: [signer.getPublicKey()], kinds: [kind]); - events.sort( - (a, b) => b.createdAt.compareTo(a.createdAt), + List? events = await _cacheManager.loadEvents( + pubKeys: [signer.getPublicKey()], + kinds: [kind], ); + events.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return events.isNotEmpty - ? await Nip51List.fromEvent(events.first, signer) + ? await _parseListEvent(events.first, signer) : null; } @@ -71,19 +137,23 @@ class Lists { throw Exception("cannot get nip51 list without a signer"); } final signer = _eventSigner!; - Nip51List? list = - !forceRefresh ? await _getCachedNip51List(kind, signer) : null; + Nip51List? list = !forceRefresh + ? await _getCachedNip51List(kind, signer) + : null; if (list == null) { Nip51List? refreshedList; - await for (final event in _requests.query(filters: [ - Filter( - authors: [signer.getPublicKey()], - kinds: [kind], - ) - ], timeout: timeout).stream) { + await for (final event + in _requests + .query( + filters: [ + Filter(authors: [signer.getPublicKey()], kinds: [kind]), + ], + timeout: timeout, + ) + .stream) { if (refreshedList == null || refreshedList.createdAt <= event.createdAt) { - refreshedList = await Nip51List.fromEvent(event, signer); + refreshedList = await _parseListEvent(event, signer); // if (Helpers.isNotBlank(event.content)) { // Nip51List? decryptedList = await Nip51List.fromEvent(event, signer); // refreshedList = decryptedList; @@ -114,28 +184,32 @@ class Lists { bool forceRefresh = false, Duration timeout = const Duration(seconds: 5), }) async { - final signer = - _eventSignerFactory.create(privateKey: null, publicKey: publicKey); + final signer = _eventSignerFactory.create( + privateKey: null, + publicKey: publicKey, + ); - Nip51List? list = - !forceRefresh ? await _getCachedNip51List(kind, signer) : null; + Nip51List? list = !forceRefresh + ? await _getCachedNip51List(kind, signer) + : null; if (list != null) return list; - final events = await _requests.query(filters: [ - Filter( - authors: [publicKey], - kinds: [kind], - limit: 1, - ) - ], timeout: timeout).future; + final events = await _requests + .query( + filters: [ + Filter(authors: [publicKey], kinds: [kind], limit: 1), + ], + timeout: timeout, + ) + .future; if (events.isEmpty) return null; events.sort((a, b) => a.createdAt.compareTo(b.createdAt)); await _cacheManager.saveEvent(events.last); - return await Nip51List.fromEvent(events.last, signer); + return await _parseListEvent(events.last, signer); } /// Adds an element to a NIP-51 list. @@ -160,17 +234,16 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } - Nip51List? list = await getSingleNip51List( - kind, - forceRefresh: true, - ); + Nip51List? list = await getSingleNip51List(kind, forceRefresh: true); list ??= Nip51List( - kind: kind, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + kind: kind, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); list.addElement(tag, value, private); list.createdAt = Helpers.now; Nip01Event event = await list.toEvent(_eventSigner); @@ -182,8 +255,10 @@ class Lists { ); await broadcastResponse.broadcastDoneFuture; - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); for (var event in events) { _cacheManager.removeEvent(event.id); } @@ -211,18 +286,17 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } - Nip51List? list = await getSingleNip51List( - kind, - forceRefresh: true, - ); + Nip51List? list = await getSingleNip51List(kind, forceRefresh: true); if (list == null || list.elements.isEmpty) { list = Nip51List( - kind: kind, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + kind: kind, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); } if (list.elements.isNotEmpty) { list.removeElement(tag, value); @@ -236,8 +310,10 @@ class Lists { ); await broadcastResponse.broadcastDoneFuture; - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); for (var event in events) { _cacheManager.removeEvent(event.id); } @@ -254,19 +330,19 @@ class Lists { EventSigner signer, int kind, ) async { - List? events = await _cacheManager - .loadEvents(pubKeys: [signer.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [signer.getPublicKey()], + kinds: [kind], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; } return false; }).toList(); - events.sort( - (a, b) => b.createdAt.compareTo(a.createdAt), - ); + events.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return events.isNotEmpty - ? await Nip51Set.fromEvent(events.first, signer) + ? await _parseSetEvent(events.first, signer) : null; } @@ -281,10 +357,7 @@ class Lists { return _requests .query( filters: [ - Filter( - authors: [signer.getPublicKey()], - kinds: [kind], - ) + Filter(authors: [signer.getPublicKey()], kinds: [kind]), ], cacheRead: !forceRefresh, ) @@ -295,7 +368,7 @@ class Lists { final existingSet = relaySets[dtag]; if (existingSet == null || existingSet.createdAt < event.createdAt) { - final newSet = await Nip51Set.fromEvent(event, signer); + final newSet = await _parseSetEvent(event, signer); if (newSet != null) { await _cacheManager.saveEvent(event); relaySets[newSet.name] = newSet; @@ -336,22 +409,27 @@ class Lists { Nip51Set? relaySet = await _getCachedSetByName(name, signer, kind); if (relaySet == null || forceRefresh) { Nip51Set? newRelaySet; - await for (final event in _requests.query(filters: [ - Filter( - authors: [signer.getPublicKey()], - kinds: [kind], - tags: { - "#d": [name] - }, - ) - ], cacheRead: !forceRefresh).stream) { + await for (final event + in _requests + .query( + filters: [ + Filter( + authors: [signer.getPublicKey()], + kinds: [kind], + tags: { + "#d": [name], + }, + ), + ], + cacheRead: !forceRefresh, + ) + .stream) { if (newRelaySet == null || newRelaySet.createdAt < event.createdAt) { if (event.getDtag() != null && event.getDtag() == name) { - newRelaySet = await Nip51Set.fromEvent(event, signer); + newRelaySet = await _parseSetEvent(event, signer); await _cacheManager.saveEvent(event); } else if (Helpers.isNotBlank(event.content)) { - Nip51Set? decryptedRelaySet = - await Nip51Set.fromEvent(event, signer); + Nip51Set? decryptedRelaySet = await _parseSetEvent(event, signer); if (decryptedRelaySet != null && decryptedRelaySet.name == name) { newRelaySet = decryptedRelaySet; await _cacheManager.saveEvent(event); @@ -386,8 +464,10 @@ class Lists { } mySigner = _eventSigner!; } else { - mySigner = - _eventSignerFactory.create(privateKey: null, publicKey: publicKey); + mySigner = _eventSignerFactory.create( + privateKey: null, + publicKey: publicKey, + ); } return _getSets(kind, mySigner, forceRefresh: forceRefresh); @@ -414,14 +494,18 @@ class Lists { bool private = false, Iterable? specificRelays, }) async { - Nip51Set? set = - await getSetByName(name: name, kind: kind, forceRefresh: true); + Nip51Set? set = await getSetByName( + name: name, + kind: kind, + forceRefresh: true, + ); set ??= Nip51Set( - name: name, - pubKey: _eventSigner!.getPublicKey(), - kind: Nip51List.kRelaySet, - createdAt: Helpers.now, - elements: []); + name: name, + pubKey: _eventSigner!.getPublicKey(), + kind: Nip51List.kRelaySet, + createdAt: Helpers.now, + elements: [], + ); set.addElement(tag, value, private); set.createdAt = Helpers.now; Nip01Event event = await set.toEvent(_eventSigner); @@ -433,8 +517,10 @@ class Lists { ); await broadcastResponse.broadcastDoneFuture; - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; @@ -473,7 +559,8 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } Nip51Set? mySet = await getSetByName( name: name, @@ -482,11 +569,12 @@ class Lists { ); if ((mySet == null || mySet.allRelays.isEmpty)) { mySet = Nip51Set( - name: name, - kind: Nip51List.kRelaySet, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + name: name, + kind: Nip51List.kRelaySet, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); } mySet.removeElement(tag, value); @@ -501,7 +589,9 @@ class Lists { await broadcastResponse.broadcastDoneFuture; List? events = await _cacheManager.loadEvents( - pubKeys: [_eventSigner!.getPublicKey()], kinds: [Nip51List.kRelaySet]); + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [Nip51List.kRelaySet], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; @@ -541,8 +631,10 @@ class Lists { await broadcastResponse.broadcastDoneFuture; /// update cache, remove old set and set the new one - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == set.name) { return true; @@ -574,12 +666,15 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } /// remove all from cache - List? eventsInCache = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? eventsInCache = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); eventsInCache = eventsInCache.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; @@ -609,10 +704,7 @@ class Lists { String name, { bool forceRefresh = false, }) async { - return getSetByName( - name: name, - kind: Nip51List.kRelaySet, - ); + return getSetByName(name: name, kind: Nip51List.kRelaySet); } /// Use [getPublicSets] instead. @@ -625,20 +717,20 @@ class Lists { // Nip51Set? relaySet;// await getCachedNip51RelaySet(signer); // if (relaySet == null || forceRefresh) { Map newRelaySets = {}; - await for (final event in _requests.query( - filters: [ - Filter( - authors: [signer.getPublicKey()], - kinds: [kind], - ) - ], - cacheRead: !forceRefresh, - ).stream) { + await for (final event + in _requests + .query( + filters: [ + Filter(authors: [signer.getPublicKey()], kinds: [kind]), + ], + cacheRead: !forceRefresh, + ) + .stream) { if (event.getDtag() != null) { Nip51Set? newRelaySet = newRelaySets[event.getDtag()]; if (newRelaySet == null || newRelaySet.createdAt < event.createdAt) { if (event.getDtag() != null) { - newRelaySet = await Nip51Set.fromEvent(event, signer); + newRelaySet = await _parseSetEvent(event, signer); } if (newRelaySet != null) { await _cacheManager.saveEvent(event); @@ -659,8 +751,10 @@ class Lists { required String publicKey, bool forceRefresh = false, }) async { - final mySigner = - _eventSignerFactory.create(privateKey: null, publicKey: publicKey); + final mySigner = _eventSignerFactory.create( + privateKey: null, + publicKey: publicKey, + ); return getNip51RelaySets(kind, mySigner, forceRefresh: forceRefresh); } @@ -674,11 +768,12 @@ class Lists { }) async { Nip51Set? list = await getSingleNip51RelaySet(name, forceRefresh: true); list ??= Nip51Set( - name: name, - pubKey: _eventSigner!.getPublicKey(), - kind: Nip51List.kRelaySet, - createdAt: Helpers.now, - elements: []); + name: name, + pubKey: _eventSigner!.getPublicKey(), + kind: Nip51List.kRelaySet, + createdAt: Helpers.now, + elements: [], + ); list.addRelay(relayUrl, private); list.createdAt = Helpers.now; Nip01Event event = await list.toEvent(_eventSigner); @@ -692,7 +787,9 @@ class Lists { await broadcastResponse.broadcastDoneFuture; List? events = await _cacheManager.loadEvents( - pubKeys: [_eventSigner!.getPublicKey()], kinds: [Nip51List.kRelaySet]); + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [Nip51List.kRelaySet], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; @@ -718,21 +815,20 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } - Nip51Set? relaySet = await getSingleNip51RelaySet( - name, - forceRefresh: true, - ); + Nip51Set? relaySet = await getSingleNip51RelaySet(name, forceRefresh: true); if ((relaySet == null || relaySet.allRelays.isEmpty) && defaultRelaysIfEmpty != null && defaultRelaysIfEmpty.isNotEmpty) { relaySet = Nip51Set( - name: name, - kind: Nip51List.kRelaySet, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + name: name, + kind: Nip51List.kRelaySet, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); relaySet.privateRelays = defaultRelaysIfEmpty; } if (relaySet != null) { @@ -748,8 +844,9 @@ class Lists { await broadcastResponse.broadcastDoneFuture; List? events = await _cacheManager.loadEvents( - pubKeys: [_eventSigner!.getPublicKey()], - kinds: [Nip51List.kRelaySet]); + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [Nip51List.kRelaySet], + ); events = events.where((event) { if (event.getDtag() != null && event.getDtag() == name) { return true; @@ -774,14 +871,16 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } Nip51List? list = await getSingleNip51List(kind, forceRefresh: true); list ??= Nip51List( - kind: kind, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + kind: kind, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); list.addRelay(relayUrl, private); list.createdAt = Helpers.now; Nip01Event event = await list.toEvent(_eventSigner); @@ -794,8 +893,10 @@ class Lists { await broadcastResponse.broadcastDoneFuture; - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); for (var event in events) { _cacheManager.removeEvent(event.id); } @@ -813,20 +914,19 @@ class Lists { }) async { if (_eventSigner == null) { throw Exception( - "cannot broadcast private nip51 list without a signer that can sign"); + "cannot broadcast private nip51 list without a signer that can sign", + ); } - Nip51List? list = await getSingleNip51List( - kind, - forceRefresh: true, - ); + Nip51List? list = await getSingleNip51List(kind, forceRefresh: true); if ((list == null || list.allRelays.isEmpty) && defaultRelaysIfEmpty != null && defaultRelaysIfEmpty.isNotEmpty) { list = Nip51List( - kind: kind, - pubKey: _eventSigner!.getPublicKey(), - createdAt: Helpers.now, - elements: []); + kind: kind, + pubKey: _eventSigner!.getPublicKey(), + createdAt: Helpers.now, + elements: [], + ); list.privateRelays = defaultRelaysIfEmpty; } if (list != null && list.allRelays.isNotEmpty) { @@ -842,8 +942,10 @@ class Lists { await broadcastResponse.broadcastDoneFuture; - List? events = await _cacheManager - .loadEvents(pubKeys: [_eventSigner!.getPublicKey()], kinds: [kind]); + List? events = await _cacheManager.loadEvents( + pubKeys: [_eventSigner!.getPublicKey()], + kinds: [kind], + ); for (var event in events) { _cacheManager.removeEvent(event.id); } diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index fa3ea0624..a862f90a4 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -8,9 +8,7 @@ class Lnurl { final LnurlTransport _transport; /// - Lnurl({ - required LnurlTransport transport, - }) : _transport = transport; + Lnurl({required LnurlTransport transport}) : _transport = transport; /// transform a lud16 of format name@domain.com to https://domain.com/.well-known/lnurlp/name static String? getLud16LinkFromLud16(String lud16) { @@ -44,11 +42,12 @@ class Lnurl { } /// fetch invoice from callback - Future fetchInvoice( - {required LnurlResponse lnurlResponse, - required int amountSats, - ZapRequest? zapRequest, - String? comment}) async { + Future fetchInvoice({ + required LnurlResponse lnurlResponse, + required int amountSats, + ZapRequest? zapRequest, + String? comment, + }) async { var callback = lnurlResponse.callback!; if (callback.contains("?")) { callback += "&"; @@ -74,8 +73,9 @@ class Lnurl { zapRequest != null && zapRequest.sig != null && zapRequest.sig!.isNotEmpty) { - final zapRequstString = - Nip01EventModel.fromEntity(zapRequest).toJsonString(); + final zapRequstString = Nip01EventModel.fromEntity( + zapRequest, + ).toJsonString(); final eventStr = Uri.encodeQueryComponent(zapRequstString); callback += "&nostr=$eventStr"; } @@ -86,9 +86,10 @@ class Lnurl { var response = await _transport.fetchInvoice(callback); String invoice = response!["pr"]; return InvoiceResponse( - invoice: invoice, - amountSats: amountSats, - nostrPubkey: lnurlResponse.nostrPubkey); + invoice: invoice, + amountSats: amountSats, + nostrPubkey: lnurlResponse.nostrPubkey, + ); } catch (e) { Logger.log.d(() => e); } diff --git a/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart b/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart index cc9be8ce7..36320248c 100644 --- a/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart +++ b/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart @@ -26,10 +26,10 @@ class Metadatas { required CacheManager cacheManager, required Broadcast broadcast, required Accounts accounts, - }) : _cacheManager = cacheManager, - _requests = requests, - _accounts = accounts, - _broadcast = broadcast; + }) : _cacheManager = cacheManager, + _requests = requests, + _accounts = accounts, + _broadcast = broadcast; void _checkSigner() { if (!_accounts.canSign) { @@ -48,19 +48,27 @@ class Metadatas { bool forceRefresh = false, Duration idleTimeout = METADATA_IDLE_TIMEOUT, }) async { - Metadata? metadata = - !forceRefresh ? await _cacheManager.loadMetadata(pubKey) : null; + Metadata? metadata = !forceRefresh + ? await _cacheManager.loadMetadata(pubKey) + : null; if (metadata == null || forceRefresh) { Metadata? loadedMetadata; try { - await for (final event in _requests.query( - name: 'metadata', - cacheRead: !forceRefresh, - timeout: idleTimeout, - filters: [ - Filter(kinds: [Metadata.kKind], authors: [pubKey], limit: 1) - ], - ).stream) { + await for (final event + in _requests + .query( + name: 'metadata', + cacheRead: !forceRefresh, + timeout: idleTimeout, + filters: [ + Filter( + kinds: [Metadata.kKind], + authors: [pubKey], + limit: 1, + ), + ], + ) + .stream) { if (loadedMetadata == null || loadedMetadata.updatedAt == null || loadedMetadata.updatedAt! < event.createdAt) { @@ -74,10 +82,10 @@ class Metadatas { (metadata == null || loadedMetadata.updatedAt == null || metadata.updatedAt == null || - loadedMetadata.updatedAt! < metadata.updatedAt! || + loadedMetadata.updatedAt! > metadata.updatedAt! || forceRefresh)) { loadedMetadata.refreshedTimestamp = Helpers.now; - await _cacheManager.saveMetadata(loadedMetadata); + await _cacheManager.saveEvent(loadedMetadata.toEvent()); metadata = loadedMetadata; } } @@ -85,8 +93,11 @@ class Metadatas { } // TODO try to use generic query with cacheRead/Write mechanism - Future> loadMetadatas(List pubKeys, RelaySet? relaySet, - {Function(Metadata)? onLoad}) async { + Future> loadMetadatas( + List pubKeys, + RelaySet? relaySet, { + Function(Metadata)? onLoad, + }) async { List missingPubKeys = []; Map metadatas = {}; for (var pubKey in pubKeys) { @@ -100,24 +111,30 @@ class Metadatas { } if (missingPubKeys.isNotEmpty) { - Logger.log - .d(() => "loading missing user metadatas ${missingPubKeys.length}"); + Logger.log.d( + () => "loading missing user metadatas ${missingPubKeys.length}", + ); try { - await for (final event in (_requests.query( - name: "load-metadatas", - filters: [ - Filter(authors: missingPubKeys, kinds: [Metadata.kKind]) - ], - relaySet: relaySet)) - .stream - .timeout(const Duration(seconds: 5), onTimeout: (sink) { - Logger.log.w(() => "timeout metadatas.length:${metadatas.length}"); - })) { + await for (final event + in (_requests.query( + name: "load-metadatas", + filters: [ + Filter(authors: missingPubKeys, kinds: [Metadata.kKind]), + ], + relaySet: relaySet, + )).stream.timeout( + const Duration(seconds: 5), + onTimeout: (sink) { + Logger.log.w( + () => "timeout metadatas.length:${metadatas.length}", + ); + }, + )) { if (metadatas[event.pubKey] == null || metadatas[event.pubKey]!.updatedAt! < event.createdAt) { metadatas[event.pubKey] = Metadata.fromEvent(event); metadatas[event.pubKey]!.refreshedTimestamp = Helpers.now; - await _cacheManager.saveMetadata(metadatas[event.pubKey]!); + await _cacheManager.saveEvent(event); if (onLoad != null) { onLoad(metadatas[event.pubKey]!); } @@ -134,10 +151,18 @@ class Metadatas { Future _refreshMetadataEvent() async { _checkSigner(); Nip01Event? loaded; - await for (final event in _requests.query(filters: [ - Filter( - kinds: [Metadata.kKind], authors: [_signer.getPublicKey()], limit: 1) - ]).stream) { + await for (final event + in _requests + .query( + filters: [ + Filter( + kinds: [Metadata.kKind], + authors: [_signer.getPublicKey()], + limit: 1, + ), + ], + ) + .stream) { if (loaded == null || loaded.createdAt < event.createdAt) { loaded = event; } @@ -157,11 +182,12 @@ class Metadatas { Map map = json.decode(event.content); map.addAll(metadata.content); event = Nip01Event( - pubKey: event.pubKey, - kind: event.kind, - tags: event.tags, - content: json.encode(map), - createdAt: Helpers.now); + pubKey: event.pubKey, + kind: event.kind, + tags: event.tags, + content: json.encode(map), + createdAt: Helpers.now, + ); } else { event = metadata.toEvent(); } @@ -174,7 +200,7 @@ class Metadatas { metadata.updatedAt = Helpers.now; metadata.refreshedTimestamp = Helpers.now; - await _cacheManager.saveMetadata(metadata); + await _cacheManager.saveEvent(metadata.toEvent()); return metadata; } diff --git a/packages/ndk/lib/domain_layer/usecases/nip05/nip_05.dart b/packages/ndk/lib/domain_layer/usecases/nip05/nip_05.dart index 95845fef1..7f1842c61 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip05/nip_05.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip05/nip_05.dart @@ -19,8 +19,8 @@ class Nip05Usecase { Nip05Usecase({ required CacheManager database, required Nip05Repository nip05Repository, - }) : _database = database, - _nip05Repository = nip05Repository; + }) : _database = database, + _nip05Repository = nip05Repository; /// checks the nip05 object for validity /// it checks the cache first, if not found it fetches from the network @@ -51,8 +51,11 @@ class Nip05Usecase { } // Create a new request and add it to the in-flight map - final request = - _performCheck(nip05, pubkey, Nip05(pubKey: pubkey, nip05: nip05)); + final request = _performCheck( + nip05, + pubkey, + Nip05(pubKey: pubkey, nip05: nip05), + ); _inFlightRequests[nip05] = request; try { @@ -130,9 +133,7 @@ class Nip05Usecase { } on FormatException catch (e) { return Nip05ResolveInvalidResponse(e); } on TypeError catch (e) { - return Nip05ResolveInvalidResponse( - Exception(e.toString()), - ); + return Nip05ResolveInvalidResponse(Exception(e.toString())); } catch (e) { return Nip05ResolveNetworkError( e is Exception ? e : Exception(e.toString()), diff --git a/packages/ndk/lib/domain_layer/usecases/nip42/auth_event.dart b/packages/ndk/lib/domain_layer/usecases/nip42/auth_event.dart index 4e3ca523f..183b07aba 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip42/auth_event.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip42/auth_event.dart @@ -9,16 +9,8 @@ class AuthEvent extends Nip01Event { static const int KIND = 22242; /// Zap Request - AuthEvent._({ - required super.pubKey, - required super.tags, - required super.id, - }) : super( - kind: KIND, - content: '', - sig: null, - validSig: null, - ); + AuthEvent._({required super.pubKey, required super.tags, required super.id}) + : super(kind: KIND, content: '', sig: null, validSig: null); factory AuthEvent({ required String pubKey, @@ -32,10 +24,6 @@ class AuthEvent extends Nip01Event { content: '', ); - return AuthEvent._( - pubKey: pubKey, - tags: tags, - id: calculatedId, - ); + return AuthEvent._(pubKey: pubKey, tags: tags, id: calculatedId); } } diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart index 093c2390f..15d3fc557 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart @@ -67,10 +67,10 @@ class Nip77 { required RelayManager relayManager, required CacheManager cacheManager, }) : _internal = _Nip77Internal( - globalState: globalState, - relayManager: relayManager, - cacheManager: cacheManager, - ); + globalState: globalState, + relayManager: relayManager, + cacheManager: cacheManager, + ); /// Default timeout for reconciliation static const Duration defaultTimeout = Duration(seconds: 30); diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index c668e849b..0da587aa5 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -12,9 +12,9 @@ class _Nip77Internal { required GlobalState globalState, required RelayManager relayManager, required CacheManager cacheManager, - }) : _globalState = globalState, - _relayManager = relayManager, - _cacheManager = cacheManager; + }) : _globalState = globalState, + _relayManager = relayManager, + _cacheManager = cacheManager; Nip77Response reconcile({ required String relayUrl, @@ -79,7 +79,8 @@ class _Nip77Internal { } if (!connected) { state.completeWithError( - Exception('Failed to connect to relay: $cleanUrl')); + Exception('Failed to connect to relay: $cleanUrl'), + ); _globalState.inFlightNegotiations.remove(subscriptionId); return; } @@ -109,7 +110,9 @@ class _Nip77Internal { // Create initial message (hex encoded per NIP-77) final initialMessage = neg.NegentropyEncoder.createInitialMessage( - localItems, neg.NegentropyEncoder.idSize); + localItems, + neg.NegentropyEncoder.idSize, + ); final initialPayload = neg.NegentropyEncoder.bytesToHex(initialMessage); // Send NEG-OPEN (final guard before network action) @@ -118,11 +121,12 @@ class _Nip77Internal { 'NEG-OPEN', subscriptionId, filter.toMap(), - initialPayload + initialPayload, ]; - _relayManager.getRelayConnectivity(cleanUrl)?.relayTransport?.send( - jsonEncode(negOpen), - ); + _relayManager + .getRelayConnectivity(cleanUrl) + ?.relayTransport + ?.send(jsonEncode(negOpen)); Logger.log.d(() => 'NEG-OPEN sent to $cleanUrl: $subscriptionId'); } catch (e) { @@ -137,15 +141,11 @@ class _Nip77Internal { for (final id in ids) { final event = await _cacheManager.loadEvent(id); if (event != null) { - items.add(neg.NegentropyItem.fromHex( - timestamp: event.createdAt, - idHex: id, - )); + items.add( + neg.NegentropyItem.fromHex(timestamp: event.createdAt, idHex: id), + ); } else { - items.add(neg.NegentropyItem.fromHex( - timestamp: 0, - idHex: id, - )); + items.add(neg.NegentropyItem.fromHex(timestamp: 0, idHex: id)); } } @@ -165,10 +165,10 @@ class _Nip77Internal { ); return events - .map((e) => neg.NegentropyItem.fromHex( - timestamp: e.createdAt, - idHex: e.id, - )) + .map( + (e) => + neg.NegentropyItem.fromHex(timestamp: e.createdAt, idHex: e.id), + ) .toList(); } @@ -176,16 +176,19 @@ class _Nip77Internal { void processNegMsg(String subscriptionId, String relayUrl, String payload) { final state = _globalState.inFlightNegotiations[subscriptionId]; if (state == null) { - Logger.log - .w(() => 'Received NEG-MSG for unknown session: $subscriptionId'); + Logger.log.w( + () => 'Received NEG-MSG for unknown session: $subscriptionId', + ); return; } // Verify relay origin to avoid cross-relay session contamination final cleanUrl = cleanRelayUrl(relayUrl); if (cleanUrl == null || state.relayUrl != cleanUrl) { - Logger.log.w(() => - 'Received NEG-MSG from mismatched relay: expected ${state.relayUrl}, got $relayUrl'); + Logger.log.w( + () => + 'Received NEG-MSG from mismatched relay: expected ${state.relayUrl}, got $relayUrl', + ); return; } @@ -198,8 +201,10 @@ class _Nip77Internal { _sendNegClose(relayUrl, subscriptionId); state.complete(); _globalState.inFlightNegotiations.remove(subscriptionId); - Logger.log.d(() => - 'NEG reconciliation complete: need=${state.needIds.length}, have=${state.haveIds.length}'); + Logger.log.d( + () => + 'NEG reconciliation complete: need=${state.needIds.length}, have=${state.haveIds.length}', + ); } else { // Send response (hex encoded) final responsePayload = neg.NegentropyEncoder.bytesToHex(response); @@ -221,16 +226,19 @@ class _Nip77Internal { void processNegErr(String subscriptionId, String relayUrl, String errorMsg) { final state = _globalState.inFlightNegotiations[subscriptionId]; if (state == null) { - Logger.log - .w(() => 'Received NEG-ERR for unknown session: $subscriptionId'); + Logger.log.w( + () => 'Received NEG-ERR for unknown session: $subscriptionId', + ); return; } // Verify relay origin to avoid cross-relay session contamination final cleanUrl = cleanRelayUrl(relayUrl); if (cleanUrl == null || state.relayUrl != cleanUrl) { - Logger.log.w(() => - 'Received NEG-ERR from mismatched relay: expected ${state.relayUrl}, got $relayUrl'); + Logger.log.w( + () => + 'Received NEG-ERR from mismatched relay: expected ${state.relayUrl}, got $relayUrl', + ); return; } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart index 70e59d634..f6b94a140 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart @@ -27,18 +27,9 @@ enum ErrorCode { 'RESTRICTED', 'This public key is not allowed to do this operation.', ), - unauthorized( - 'UNAUTHORIZED', - 'This public key has no wallet connected.', - ), - internal( - 'INTERNAL', - 'An internal error.', - ), - other( - 'OTHER', - 'Other error.', - ); + unauthorized('UNAUTHORIZED', 'This public key has no wallet connected.'), + internal('INTERNAL', 'An internal error.'), + other('OTHER', 'Other error.'); final String value; final String message; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nostr_wallet_connect_uri.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nostr_wallet_connect_uri.dart index 0b67daf95..1e3f04c59 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nostr_wallet_connect_uri.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nostr_wallet_connect_uri.dart @@ -53,13 +53,12 @@ class NostrWalletConnectUri extends Equatable { required String relay, required String secret, String? lud16, - }) => - NostrWalletConnectUri( - walletPubkey: walletPubkey, - relays: [relay], - secret: secret, - lud16: lud16, - ); + }) => NostrWalletConnectUri( + walletPubkey: walletPubkey, + relays: [relay], + secret: secret, + lud16: lud16, + ); /// Legacy getter for backward compatibility @Deprecated('Use relays list instead') @@ -76,7 +75,8 @@ class NostrWalletConnectUri extends Equatable { secret == null || parsedUri.scheme != 'nostr+walletconnect') { throw Exception( - "Required fields (scheme, pubkey, secret) are missing or incorrect in the connection URI."); + "Required fields (scheme, pubkey, secret) are missing or incorrect in the connection URI.", + ); } // Parse relays - support both single relay and comma-separated relays @@ -175,9 +175,5 @@ class NostrWalletConnectUri extends Equatable { } @override - List get props => [ - walletPubkey, - relays, - secret, - ]; + List get props => [walletPubkey, relays, secret]; } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index 4fa5d01bc..721fdf004 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -50,9 +50,9 @@ class Nwc { required Requests requests, required Broadcast broadcast, required LocalEventSignerFactory eventSignerFactory, - }) : _requests = requests, - _broadcast = broadcast, - _eventSignerFactory = eventSignerFactory; + }) : _requests = requests, + _broadcast = broadcast, + _eventSignerFactory = eventSignerFactory; final Map> _inflighRequests = {}; final Map _inflighRequestTimers = {}; @@ -63,30 +63,35 @@ class Nwc { /// checking for 13194 event info, /// and optionally doing a `get_info` request (default false). /// It subscribes for notifications - Future connect(String uri, - {bool doGetInfoMethod = false, - bool useETagForEachRequest = false, - bool ignoreCapabilitiesCheck = false, - Function(String?)? onError, - Duration? timeout}) async { + Future connect( + String uri, { + bool doGetInfoMethod = false, + bool useETagForEachRequest = false, + bool ignoreCapabilitiesCheck = false, + Function(String?)? onError, + Duration? timeout, + }) async { var parsedUri = NostrWalletConnectUri.parseConnectionUri(uri); var relays = parsedUri.relays.map((r) => Uri.decodeFull(r)).toList(); - var filter = - Filter(kinds: [NwcKind.INFO.value], authors: [parsedUri.walletPubkey]); + var filter = Filter( + kinds: [NwcKind.INFO.value], + authors: [parsedUri.walletPubkey], + ); Completer completer = Completer(); List infoEvent = await _requests .query( - name: "nwc-info", - explicitRelays: relays, - filters: [filter], - timeout: timeout ?? Duration(seconds: 20 + relays.length * 5), - timeoutCallback: () { - onError?.call("timeout"); - }, - cacheRead: false, - cacheWrite: false) + name: "nwc-info", + explicitRelays: relays, + filters: [filter], + timeout: timeout ?? Duration(seconds: 20 + relays.length * 5), + timeoutCallback: () { + onError?.call("timeout"); + }, + cacheRead: false, + cacheWrite: false, + ) .future; if (infoEvent.isNotEmpty) { final event = infoEvent.first; @@ -100,8 +105,9 @@ class Nwc { connection.permissions = event.content.split(" ").toSet(); if (connection.permissions.length == 1) { - connection.permissions = - connection.permissions.first.split(",").toSet(); + connection.permissions = connection.permissions.first + .split(",") + .toSet(); } List versionTags = event.getTags('v'); @@ -131,16 +137,16 @@ class Nwc { completer.complete(connection); } else { onError?.call("not found"); - completer.complete(NwcConnection( - parsedUri, - eventSignerFactory: _eventSignerFactory, - )); + completer.complete( + NwcConnection(parsedUri, eventSignerFactory: _eventSignerFactory), + ); } return completer.future; } Future _subscribeToNotificationsAndResponses( - NwcConnection connection) async { + NwcConnection connection, + ) async { List kindsToSubscribe = [ connection.isLegacyNotifications() ? NwcKind.LEGACY_NOTIFICATION.value @@ -152,19 +158,20 @@ class Nwc { } connection.subscription = _requests.subscription( - name: - "nwc-sub-${connection.useETagForEachRequest ? "notifs-only" : ""}", - explicitRelays: - connection.uri.relays.map((r) => Uri.decodeFull(r)).toList(), - filters: [ - Filter( - kinds: kindsToSubscribe, - authors: [connection.uri.walletPubkey], - pTags: [connection.signer.getPublicKey()], - ) - ], - cacheRead: false, - cacheWrite: false); + name: "nwc-sub-${connection.useETagForEachRequest ? "notifs-only" : ""}", + explicitRelays: connection.uri.relays + .map((r) => Uri.decodeFull(r)) + .toList(), + filters: [ + Filter( + kinds: kindsToSubscribe, + authors: [connection.uri.walletPubkey], + pTags: [connection.signer.getPublicKey()], + ), + ], + cacheRead: false, + cacheWrite: false, + ); connection.listen((event) async { if (event.kind == NwcKind.LEGACY_NOTIFICATION.value) { await _onLegacyNotification(event, connection); @@ -180,7 +187,10 @@ class Nwc { Future _onResponse(Nip01Event event, NwcConnection connection) async { if (event.content != '') { var decrypted = Nip04.decrypt( - connection.uri.secret, connection.uri.walletPubkey, event.content); + connection.uri.secret, + connection.uri.walletPubkey, + event.content, + ); if (decrypted == '') { decrypted = await Nip44.decryptMessage( event.content, @@ -209,8 +219,9 @@ class Nwc { response = LookupInvoiceResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.CANCEL_HOLD_INVOICE.name || data['result_type'] == NwcMethod.SETTLE_HOLD_INVOICE.name) { - response = - NwcResponse(resultType: data['result_type']); // Generic response + response = NwcResponse( + resultType: data['result_type'], + ); // Generic response } } else { response = NwcResponse(resultType: data['result_type']); @@ -236,16 +247,23 @@ class Nwc { } Future _onLegacyNotification( - Nip01Event event, NwcConnection connection) async { + Nip01Event event, + NwcConnection connection, + ) async { if (event.content != "") { var decrypted = Nip04.decrypt( - connection.uri.secret, connection.uri.walletPubkey, event.content); + connection.uri.secret, + connection.uri.walletPubkey, + event.content, + ); Map data; data = json.decode(decrypted); if (data.containsKey("notification_type") && data['notification'] != null) { NwcNotification notification = NwcNotification.fromMap( - data["notification_type"], data['notification']); + data["notification_type"], + data['notification'], + ); connection.notificationStream.add(notification); } else if (data.containsKey("error")) { // TODO: Define what to do when data has an error @@ -254,7 +272,9 @@ class Nwc { } Future _onNotification( - Nip01Event event, NwcConnection connection) async { + Nip01Event event, + NwcConnection connection, + ) async { if (event.content != "") { final decrypted = await Nip44.decryptMessage( event.content, @@ -266,7 +286,9 @@ class Nwc { if (data.containsKey("notification_type") && data['notification'] != null) { NwcNotification notification = NwcNotification.fromMap( - data["notification_type"], data['notification']); + data["notification_type"], + data['notification'], + ); connection.notificationStream.add(notification); } else if (data.containsKey("error")) { // TODO: Define what to do when data has an error @@ -275,8 +297,10 @@ class Nwc { } Future _executeRequest( - NwcConnection connection, NwcRequest request, - {Duration? timeout}) async { + NwcConnection connection, + NwcRequest request, { + Duration? timeout, + }) async { if (!connection.ignoreCapabilitiesCheck && !connection.permissions.contains(request.method.name)) { throw Exception("${request.method.name} method not in permissions"); @@ -284,15 +308,19 @@ class Nwc { var json = request.toMap(); var content = jsonEncode(json); var encrypted = Nip04.encrypt( - connection.uri.secret, connection.uri.walletPubkey, content); + connection.uri.secret, + connection.uri.walletPubkey, + content, + ); Nip01Event event = Nip01Event( - pubKey: connection.signer.getPublicKey(), - kind: NwcKind.REQUEST.value, - tags: [ - ["p", connection.uri.walletPubkey] - ], - content: encrypted); + pubKey: connection.signer.getPublicKey(), + kind: NwcKind.REQUEST.value, + tags: [ + ["p", connection.uri.walletPubkey], + ], + content: encrypted, + ); Completer completer = Completer(); _inflighRequests[event.id] = completer; @@ -307,41 +335,48 @@ class Nwc { eTags: [event.id], // Tagged with the request event's ID ); dedicatedResponse = _requests.subscription( - name: "nwc-response-", - explicitRelays: - connection.uri.relays.map((r) => Uri.decodeFull(r)).toList(), - filters: [responseFilter], - cacheRead: false, - cacheWrite: false); - - dedicatedResponse.stream.listen((responseEvent) async { - await _onResponse(responseEvent, connection); - }, onError: (error) async { - if (!completer.isCompleted) { - completer.completeError( - "Error on temporary response subscription: $error"); - _inflighRequests.remove(event.id); - if (_inflighRequestTimers[event.id]?.isActive ?? false) { - _inflighRequestTimers[event.id]!.cancel(); + name: "nwc-response-", + explicitRelays: connection.uri.relays + .map((r) => Uri.decodeFull(r)) + .toList(), + filters: [responseFilter], + cacheRead: false, + cacheWrite: false, + ); + + dedicatedResponse.stream.listen( + (responseEvent) async { + await _onResponse(responseEvent, connection); + }, + onError: (error) async { + if (!completer.isCompleted) { + completer.completeError( + "Error on temporary response subscription: $error", + ); + _inflighRequests.remove(event.id); + if (_inflighRequestTimers[event.id]?.isActive ?? false) { + _inflighRequestTimers[event.id]!.cancel(); + } + _inflighRequestTimers.remove(event.id); } - _inflighRequestTimers.remove(event.id); - } - if (dedicatedResponse != null) { - await _requests.closeSubscription(dedicatedResponse.requestId); - } - }); + if (dedicatedResponse != null) { + await _requests.closeSubscription(dedicatedResponse.requestId); + } + }, + ); } final bResponse = _broadcast.broadcast( nostrEvent: event, - specificRelays: - connection.uri.relays.map((r) => Uri.decodeFull(r)).toList(), + specificRelays: connection.uri.relays + .map((r) => Uri.decodeFull(r)) + .toList(), customSigner: connection.signer, ); await bResponse.broadcastDoneFuture; - _inflighRequestTimers[event.id] = - Timer(timeout ?? Duration(seconds: 5), () async { + _inflighRequestTimers[event + .id] = Timer(timeout ?? Duration(seconds: 5), () async { if (!completer.isCompleted) { final error = "Timed out while executing NWC request ${request.method.name} with relay ${connection.uri.relays.map((r) => Uri.decodeFull(r)).toList()} and eventId ${event.id}"; // Added event.id to log @@ -364,7 +399,8 @@ class Nwc { return response; } throw Exception( - "error ${response.resultType} code: ${response.errorCode} ${response.errorMessage}"); + "error ${response.resultType} code: ${response.errorCode} ${response.errorMessage}", + ); } catch (e) { if (_inflighRequestTimers[event.id]?.isActive ?? false) { _inflighRequestTimers[event.id]!.cancel(); @@ -380,17 +416,27 @@ class Nwc { } /// Does a `get_info` request for returning node detailed info - Future getInfo(NwcConnection connection, - {Duration? timeout}) async { - return _executeRequest(connection, GetInfoRequest(), - timeout: timeout); + Future getInfo( + NwcConnection connection, { + Duration? timeout, + }) async { + return _executeRequest( + connection, + GetInfoRequest(), + timeout: timeout, + ); } /// Does a `get_balance` request - Future getBalance(NwcConnection connection, - {Duration? timeout}) async { - return _executeRequest(connection, GetBalanceRequest(), - timeout: timeout); + Future getBalance( + NwcConnection connection, { + Duration? timeout, + }) async { + return _executeRequest( + connection, + GetBalanceRequest(), + timeout: timeout, + ); } /// Does a `get_balance` request @@ -399,83 +445,113 @@ class Nwc { } /// Does a `make_invoice` request - Future makeInvoice(NwcConnection connection, - {required int amountSats, - String? description, - String? descriptionHash, - int? expiry}) async { + Future makeInvoice( + NwcConnection connection, { + required int amountSats, + String? description, + String? descriptionHash, + int? expiry, + }) async { return _executeRequest( - connection, - MakeInvoiceRequest( - amountMsat: amountSats * 1000, - description: description, - descriptionHash: descriptionHash, - expiry: expiry)); + connection, + MakeInvoiceRequest( + amountMsat: amountSats * 1000, + description: description, + descriptionHash: descriptionHash, + expiry: expiry, + ), + ); } /// Does a `make_hold_invoice` request - Future makeHoldInvoice(NwcConnection connection, - {required int amountSats, - String? description, - String? descriptionHash, - int? expiry, - required String paymentHash}) async { + Future makeHoldInvoice( + NwcConnection connection, { + required int amountSats, + String? description, + String? descriptionHash, + int? expiry, + required String paymentHash, + }) async { return _executeRequest( - connection, - MakeHoldInvoiceRequest( - amountMsat: amountSats * 1000, - description: description, - descriptionHash: descriptionHash, - expiry: expiry, - paymentHash: paymentHash)); + connection, + MakeHoldInvoiceRequest( + amountMsat: amountSats * 1000, + description: description, + descriptionHash: descriptionHash, + expiry: expiry, + paymentHash: paymentHash, + ), + ); } /// Does a `cancel_hold_invoice` request - Future cancelHoldInvoice(NwcConnection connection, - {required String paymentHash}) async { + Future cancelHoldInvoice( + NwcConnection connection, { + required String paymentHash, + }) async { return _executeRequest( - connection, CancelHoldInvoiceRequest(paymentHash: paymentHash)); + connection, + CancelHoldInvoiceRequest(paymentHash: paymentHash), + ); } /// Does a `settle_hold_invoice` request - Future settleHoldInvoice(NwcConnection connection, - {required String preimage}) async { + Future settleHoldInvoice( + NwcConnection connection, { + required String preimage, + }) async { return _executeRequest( - connection, SettleHoldInvoiceRequest(preimage: preimage)); + connection, + SettleHoldInvoiceRequest(preimage: preimage), + ); } /// Does a `pay_invoice` request - Future payInvoice(NwcConnection connection, - {required String invoice, Duration? timeout}) async { + Future payInvoice( + NwcConnection connection, { + required String invoice, + Duration? timeout, + }) async { return _executeRequest( - connection, PayInvoiceRequest(invoice: invoice), - timeout: timeout); + connection, + PayInvoiceRequest(invoice: invoice), + timeout: timeout, + ); } /// Does a `lookup_invoice` request - Future lookupInvoice(NwcConnection connection, - {String? paymentHash, String? invoice}) async { - return _executeRequest(connection, - LookupInvoiceRequest(paymentHash: paymentHash, invoice: invoice)); + Future lookupInvoice( + NwcConnection connection, { + String? paymentHash, + String? invoice, + }) async { + return _executeRequest( + connection, + LookupInvoiceRequest(paymentHash: paymentHash, invoice: invoice), + ); } /// Does a `list_transactions` request - Future listTransactions(NwcConnection connection, - {int? from, - int? until, - int? limit, - int? offset, - required bool unpaid, - TransactionType? type}) async { + Future listTransactions( + NwcConnection connection, { + int? from, + int? until, + int? limit, + int? offset, + required bool unpaid, + TransactionType? type, + }) async { return _executeRequest( - connection, - ListTransactionsRequest( - from: from, - until: until, - limit: limit, - offset: offset, - unpaid: unpaid, - type: type)); + connection, + ListTransactionsRequest( + from: from, + until: until, + limit: limit, + offset: offset, + unpaid: unpaid, + type: type, + ), + ); } /// Disconnects everything related to this connection, 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 11e21cf4b..4145660d6 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart @@ -22,19 +22,19 @@ class NwcConnection { StreamController notificationStream = StreamController.broadcast(); - Stream get paymentsReceivedStream => - notificationStream.stream - .where((notification) => notification.isPaymentReceived) - .asBroadcastStream(); + Stream get paymentsReceivedStream => notificationStream + .stream + .where((notification) => notification.isPaymentReceived) + .asBroadcastStream(); Stream get paymentsSentStream => notificationStream.stream .where((notification) => notification.isPaymentSent) .asBroadcastStream(); - Stream get holdInvoiceStateStream => - notificationStream.stream - .where((notification) => notification.isHoldInvoiceAccepted) - .asBroadcastStream(); + Stream get holdInvoiceStateStream => notificationStream + .stream + .where((notification) => notification.isHoldInvoiceAccepted) + .asBroadcastStream(); /// listen void listen(void Function(Nip01Event event)? onData) { @@ -42,7 +42,8 @@ class NwcConnection { _streamSubscription = subscription!.stream.listen(onData); } else { Logger.log.e( - () => "NwcConnection: Attempted to listen on a null subscription."); + () => "NwcConnection: Attempted to listen on a null subscription.", + ); } } 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 843341e2e..fe6a009a5 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart @@ -47,7 +47,9 @@ class NwcNotification { }); factory NwcNotification.fromMap( - String notificationType, Map map) { + String notificationType, + Map map, + ) { return NwcNotification( notificationType: notificationType, type: map['type'] as String, diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart index bee4c482c..171896a52 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart @@ -5,17 +5,14 @@ import 'nwc_request.dart'; class CancelHoldInvoiceRequest extends NwcRequest { final String paymentHash; - const CancelHoldInvoiceRequest({ - required this.paymentHash, - }) : super(method: NwcMethod.CANCEL_HOLD_INVOICE); + const CancelHoldInvoiceRequest({required this.paymentHash}) + : super(method: NwcMethod.CANCEL_HOLD_INVOICE); @override Map toMap() { return { ...super.toMap(), - 'params': { - 'payment_hash': paymentHash, - } + 'params': {'payment_hash': paymentHash}, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/list_transactions.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/list_transactions.dart index 54b96e800..5559e1f96 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/list_transactions.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/list_transactions.dart @@ -44,7 +44,7 @@ class ListTransactionsRequest extends NwcRequest { if (offset != null) 'offset': offset, 'unpaid': unpaid, if (type != null) 'type': type!.name, - } + }, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/lookup_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/lookup_invoice.dart index 507eccc89..f1d2964c2 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/lookup_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/lookup_invoice.dart @@ -6,10 +6,8 @@ class LookupInvoiceRequest extends NwcRequest { final String? paymentHash; final String? invoice; - const LookupInvoiceRequest({ - this.paymentHash, - this.invoice, - }) : super(method: NwcMethod.LOOKUP_INVOICE); + const LookupInvoiceRequest({this.paymentHash, this.invoice}) + : super(method: NwcMethod.LOOKUP_INVOICE); @override Map toMap() { @@ -18,7 +16,7 @@ class LookupInvoiceRequest extends NwcRequest { 'params': { if (paymentHash != null) 'payment_hash': paymentHash, if (invoice != null) 'invoice': invoice, - } + }, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/make_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/make_invoice.dart index 63c8e0937..04b733802 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/make_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/make_invoice.dart @@ -15,10 +15,10 @@ class MakeInvoiceRequest extends NwcRequest { this.descriptionHash, this.expiry, NwcMethod? method, // Add optional method parameter - }) : amountSat = amountMsat ~/ 1000, - super( - method: method ?? - NwcMethod.MAKE_INVOICE); // Use provided method or default + }) : amountSat = amountMsat ~/ 1000, + super( + method: method ?? NwcMethod.MAKE_INVOICE, + ); // Use provided method or default @override Map toMap() { @@ -29,7 +29,7 @@ class MakeInvoiceRequest extends NwcRequest { if (description != null) 'description': description, if (descriptionHash != null) 'description_hash': descriptionHash, if (expiry != null) 'expiry': expiry, - } + }, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_invoice.dart index 0cd719af3..dbd014af2 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_invoice.dart @@ -5,17 +5,14 @@ import 'nwc_request.dart'; class MultiPayInvoiceRequest extends NwcRequest { final List invoices; - const MultiPayInvoiceRequest({ - required this.invoices, - }) : super(method: NwcMethod.MULTI_PAY_INVOICE); + const MultiPayInvoiceRequest({required this.invoices}) + : super(method: NwcMethod.MULTI_PAY_INVOICE); @override Map toMap() { return { ...super.toMap(), - 'params': { - 'invoices': invoices.map((e) => e.toMap()).toList(), - } + 'params': {'invoices': invoices.map((e) => e.toMap()).toList()}, }; } } @@ -31,10 +28,7 @@ class MultiPayInvoiceRequestInvoicesElement { Map toMap() { return { - 'params': { - 'invoice': invoice, - 'amount': amountSat * 1000, - } + 'params': {'invoice': invoice, 'amount': amountSat * 1000}, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_keysend.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_keysend.dart index f58494d95..cecd18c2c 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_keysend.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/multi_pay_keysend.dart @@ -7,17 +7,14 @@ import 'nwc_request.dart'; class MultiPayKeysendRequest extends NwcRequest { final List keysends; - const MultiPayKeysendRequest({ - required this.keysends, - }) : super(method: NwcMethod.MULTI_PAY_KEYSEND); + const MultiPayKeysendRequest({required this.keysends}) + : super(method: NwcMethod.MULTI_PAY_KEYSEND); @override Map toMap() { return { ...super.toMap(), - 'params': { - 'keysends': keysends.map((e) => e.toMap()).toList(), - } + 'params': {'keysends': keysends.map((e) => e.toMap()).toList()}, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/nwc_request.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/nwc_request.dart index 72af95900..c89c22d51 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/nwc_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/nwc_request.dart @@ -5,9 +5,7 @@ class NwcRequest { final NwcMethod method; /// - const NwcRequest({ - required this.method, - }); + const NwcRequest({required this.method}); // /// // factory NwcRequest.fromEvent( @@ -108,8 +106,6 @@ class NwcRequest { // } Map toMap() { - return { - 'method': method.name, - }; + return {'method': method.name}; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_invoice.dart index ae323804e..88b9dc930 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_invoice.dart @@ -6,17 +6,14 @@ import 'nwc_request.dart'; class PayInvoiceRequest extends NwcRequest { final String invoice; - const PayInvoiceRequest({ - required this.invoice, - }) : super(method: NwcMethod.PAY_INVOICE); + const PayInvoiceRequest({required this.invoice}) + : super(method: NwcMethod.PAY_INVOICE); @override Map toMap() { return { ...super.toMap(), - 'params': { - 'invoice': invoice, - } + 'params': {'invoice': invoice}, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_keysend.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_keysend.dart index 9faa32b56..0add14442 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_keysend.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay_keysend.dart @@ -15,8 +15,8 @@ class PayKeysendRequest extends NwcRequest { required this.pubkey, this.preimage, this.tlvRecords, - }) : amountSat = amountMsat ~/ 1000, - super(method: NwcMethod.PAY_KEYSEND); + }) : amountSat = amountMsat ~/ 1000, + super(method: NwcMethod.PAY_KEYSEND); @override Map toMap() { @@ -27,7 +27,7 @@ class PayKeysendRequest extends NwcRequest { 'pubkey': pubkey, if (preimage != null) 'preimage': preimage, 'tlv_records': tlvRecords?.map((e) => e.toMap()).toList(), - } + }, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/settle_hold_invoice.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/settle_hold_invoice.dart index 21c0d2b2f..611719837 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/settle_hold_invoice.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/settle_hold_invoice.dart @@ -5,17 +5,14 @@ import 'nwc_request.dart'; class SettleHoldInvoiceRequest extends NwcRequest { final String preimage; - const SettleHoldInvoiceRequest({ - required this.preimage, - }) : super(method: NwcMethod.SETTLE_HOLD_INVOICE); + const SettleHoldInvoiceRequest({required this.preimage}) + : super(method: NwcMethod.SETTLE_HOLD_INVOICE); @override Map toMap() { return { ...super.toMap(), - 'params': { - 'preimage': preimage, - } + 'params': {'preimage': preimage}, }; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_budget_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_budget_response.dart index adb893b9b..163428d84 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_budget_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_budget_response.dart @@ -14,12 +14,13 @@ class GetBudgetResponse extends NwcResponse { int get totalBudgetSats => totalBudget ~/ 1000; - GetBudgetResponse( - {required super.resultType, - required this.usedBudget, - required this.totalBudget, - this.renewsAt, - required this.renewalPeriod}); + GetBudgetResponse({ + required super.resultType, + required this.usedBudget, + required this.totalBudget, + this.renewsAt, + required this.renewalPeriod, + }); factory GetBudgetResponse.deserialize(Map input) { if (!input.containsKey('result')) { @@ -34,7 +35,8 @@ class GetBudgetResponse extends NwcResponse { totalBudget: (result['total_budget'] as num?)?.toInt() ?? 0, renewsAt: result['renews_at'], renewalPeriod: BudgetRenewalPeriod.fromPlaintext( - result['renewal_period'] as String? ?? 'none'), + result['renewal_period'] as String? ?? 'none', + ), ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart index 9b17614a6..44d42056e 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart @@ -15,16 +15,17 @@ class GetInfoResponse extends NwcResponse { final List methods; final List notifications; - GetInfoResponse( - {required super.resultType, - required this.alias, - required this.color, - required this.pubkey, - required this.network, - required this.blockHeight, - required this.blockHash, - required this.methods, - required this.notifications}); + GetInfoResponse({ + required super.resultType, + required this.alias, + required this.color, + required this.pubkey, + required this.network, + required this.blockHeight, + required this.blockHash, + required this.methods, + required this.notifications, + }); factory GetInfoResponse.deserialize(Map input) { if (!input.containsKey('result')) { @@ -35,22 +36,24 @@ class GetInfoResponse extends NwcResponse { final methodsList = (result["methods"] as List?) ?? const []; final notificationsList = (result["notifications"] as List?) ?? const []; - List methods = - methodsList.map((method) => method.toString()).toList(); + List methods = methodsList + .map((method) => method.toString()) + .toList(); List notifications = notificationsList .map((notification) => notification.toString()) .toList(); return GetInfoResponse( - resultType: (input['result_type'] as String?) ?? '', - alias: (result['alias'] as String?) ?? '', - color: result['color'] as String?, - pubkey: result['pubkey'] as String?, - network: BitcoinNetwork.fromPlaintext(result['network'] as String?), - blockHeight: result['block_height'] as int?, - blockHash: result['block_hash'] as String?, - methods: methods, - notifications: notifications); + resultType: (input['result_type'] as String?) ?? '', + alias: (result['alias'] as String?) ?? '', + color: result['color'] as String?, + pubkey: result['pubkey'] as String?, + network: BitcoinNetwork.fromPlaintext(result['network'] as String?), + blockHeight: result['block_height'] as int?, + blockHash: result['block_hash'] as String?, + methods: methods, + notifications: notifications, + ); } } 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 5853dd468..05706b610 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 @@ -11,8 +11,10 @@ class ListTransactionsResponse extends NwcResponse { /// A list of transaction results. final List transactions; - ListTransactionsResponse( - {required this.transactions, required super.resultType}); + ListTransactionsResponse({ + required this.transactions, + required super.resultType, + }); factory ListTransactionsResponse.deserialize(Map input) { if (!input.containsKey('result')) { @@ -27,8 +29,9 @@ class ListTransactionsResponse extends NwcResponse { .toList(); return ListTransactionsResponse( - transactions: transactions, - resultType: NwcMethod.LIST_TRANSACTIONS.name); + transactions: transactions, + resultType: NwcMethod.LIST_TRANSACTIONS.name, + ); } } @@ -142,17 +145,17 @@ class TransactionResult extends Equatable { @override List get props => [ - type, - invoice, - description, - descriptionHash, - preimage, - paymentHash, - amount, - feesPaid, - createdAt, - expiresAt, - settledAt, - metadata, - ]; + type, + invoice, + description, + descriptionHash, + preimage, + paymentHash, + amount, + feesPaid, + createdAt, + expiresAt, + settledAt, + metadata, + ]; } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/make_invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/make_invoice_response.dart index 19c01c1db..9b312327b 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/make_invoice_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/make_invoice_response.dart @@ -70,14 +70,16 @@ class MakeInvoiceResponse extends NwcResponse { descriptionHash: result.containsKey('description_hash') ? result['description_hash'] as String : '', - preimage: - result.containsKey('preimage') ? result['preimage'] as String : '', + preimage: result.containsKey('preimage') + ? result['preimage'] as String + : '', paymentHash: result.containsKey('payment_hash') ? result['payment_hash'] as String : '', amountMsat: result['amount'] as int, - feesPaid: - result.containsKey('feeds_paid') ? result['fees_paid'] as int : 0, + feesPaid: result.containsKey('feeds_paid') + ? result['fees_paid'] as int + : 0, createdAt: result['created_at'] as int, expiresAt: result['expires_at'] as int?, settledAt: result['settled_at'] as int?, // optional diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/nwc_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/nwc_response.dart index e543d1d60..1ceda3be1 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/nwc_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/nwc_response.dart @@ -11,9 +11,7 @@ class NwcResponse { /// The type of the result. final String resultType; - NwcResponse({ - required this.resultType, - }); + NwcResponse({required this.resultType}); void deserializeError(Map input) { if (input.containsKey('error') && input['error'] != null) { diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_invoice_response.dart index 0ece91e78..48ea72072 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_invoice_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_invoice_response.dart @@ -10,8 +10,11 @@ class PayInvoiceResponse extends NwcResponse { /// The fees paid for the invoice (in MSATs). final int feesPaid; - PayInvoiceResponse( - {this.preimage, required super.resultType, required this.feesPaid}); + PayInvoiceResponse({ + this.preimage, + required super.resultType, + required this.feesPaid, + }); factory PayInvoiceResponse.deserialize(Map input) { if (!input.containsKey('result')) { @@ -23,8 +26,9 @@ class PayInvoiceResponse extends NwcResponse { return PayInvoiceResponse( preimage: result['preimage'] as String?, resultType: input['result_type'] as String, - feesPaid: - result.containsKey('fees_paid') ? result['fees_paid'] as int : 0, + feesPaid: result.containsKey('fees_paid') + ? result['fees_paid'] as int + : 0, ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/tlv_record.dart b/packages/ndk/lib/domain_layer/usecases/nwc/tlv_record.dart index 3c0d0645f..d89d3ff61 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/tlv_record.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/tlv_record.dart @@ -4,23 +4,14 @@ class TlvRecord extends Equatable { final int type; final String value; - const TlvRecord({ - required this.type, - required this.value, - }); + const TlvRecord({required this.type, required this.value}); factory TlvRecord.fromMap(Map map) { - return TlvRecord( - type: map['type'] as int, - value: map['value'] as String, - ); + return TlvRecord(type: map['type'] as int, value: map['value'] as String); } Map toMap() { - return { - 'type': type, - 'value': value, - }; + return {'type': type, 'value': value}; } @override diff --git a/packages/ndk/lib/domain_layer/usecases/proof_of_work/proof_of_work.dart b/packages/ndk/lib/domain_layer/usecases/proof_of_work/proof_of_work.dart index d6dc5724b..d57776716 100644 --- a/packages/ndk/lib/domain_layer/usecases/proof_of_work/proof_of_work.dart +++ b/packages/ndk/lib/domain_layer/usecases/proof_of_work/proof_of_work.dart @@ -8,8 +8,11 @@ class ProofOfWork { required int targetDifficulty, int? maxIterations, }) { - return Nip13.mineEvent(event, targetDifficulty, - maxIterations: maxIterations); + return Nip13.mineEvent( + event, + targetDifficulty, + maxIterations: maxIterations, + ); } /// Get the proof of work difficulty of this event diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index 86fc2f43d..d5853e0d1 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -51,16 +51,20 @@ class RelayManager { /// stores timers for pending AUTH callbacks to clean them up on timeout final Map _pendingAuthTimers = {}; + /// Tracks relay connect attempts that are still finishing setup so callers + /// can wait for "socket open + listener attached", not just raw socket open. + final Map> _connectReadyCompleters = {}; + /// timeout for AUTH callbacks (how long to wait for AUTH OK) final Duration authCallbackTimeout; /// Handler for NIP-77 NEG-MSG messages void Function(String subscriptionId, String relayUrl, String payload)? - onNegMsg; + onNegMsg; /// Handler for NIP-77 NEG-ERR messages void Function(String subscriptionId, String relayUrl, String errorMsg)? - onNegErr; + onNegErr; /// nostr transport factory, to create new transports (usually websocket) final NostrTransportFactory nostrTransportFactory; @@ -116,15 +120,16 @@ class RelayManager { if (bootstrapRelays.isEmpty) { bootstrapRelays = DEFAULT_BOOTSTRAP_RELAYS; } - await Future.wait(urls - .map( - (url) => connectRelay( - dirtyUrl: url, - connectionSource: ConnectionSource.seed, - ), - ) - .toList()) - .whenComplete(() { + await Future.wait( + urls + .map( + (url) => connectRelay( + dirtyUrl: url, + connectionSource: ConnectionSource.seed, + ), + ) + .toList(), + ).whenComplete(() { if (!_seedRelaysCompleter.isCompleted) { _seedRelaysCompleter.complete(); } @@ -148,6 +153,33 @@ class RelayManager { return relay != null && relay.connecting; } + Future _waitForTransportOpen( + NostrTransport transport, { + required int timeoutSeconds, + }) async { + final deadline = DateTime.now().add(Duration(seconds: timeoutSeconds)); + + while (DateTime.now().isBefore(deadline)) { + if (transport.isOpen()) { + return true; + } + + try { + await transport.ready.timeout(const Duration(milliseconds: 250)); + } catch (_) { + // keep polling isOpen() until the overall timeout expires + } + + if (transport.isOpen()) { + return true; + } + + await Future.delayed(const Duration(milliseconds: 50)); + } + + return transport.isOpen(); + } + /// Connects to a relay to the relay pool. /// Returns a tuple with the first element being a boolean indicating success \\ /// and the second element being a string with the error message if any. @@ -174,18 +206,28 @@ class RelayManager { if (isRelayConnecting(url)) { Logger.log.t(() => "relay is already connecting: $url"); + final inFlightConnect = _connectReadyCompleters[url]; + if (inFlightConnect != null) { + final connected = await inFlightConnect.future; + updateRelayConnectivity(); + return Tuple( + connected, + connected + ? "relay finished connecting" + : "relay failed while connecting", + ); + } updateRelayConnectivity(); - return Tuple(true, "relay is still connecting"); + return Tuple(false, "relay is still connecting"); } RelayConnectivity? relayConnectivity = globalState.relays[url]; + final connectCompleter = Completer(); + _connectReadyCompleters[url] = connectCompleter; try { if (relayConnectivity == null) { relayConnectivity = RelayConnectivity( - relay: Relay( - url: url, - connectionSource: connectionSource, - ), + relay: Relay(url: url, connectionSource: connectionSource), specificEngineData: engineAdditionalDataFactory?.call(), ); globalState.relays[url] = relayConnectivity; @@ -196,25 +238,43 @@ class RelayManager { /// TO BE REMOVED, ONCE WE FIND A WAY OF AVOIDING PROBLEM WHEN CONNECTING TO THIS if (url.startsWith("wss://brb.io")) { relayConnectivity.relay.failedToConnect(); + if (!connectCompleter.isCompleted) { + connectCompleter.complete(false); + } + if (identical(_connectReadyCompleters[url], connectCompleter)) { + _connectReadyCompleters.remove(url); + } updateRelayConnectivity(); return Tuple(false, "bad relay"); } Logger.log.i(() => "connecting to relay $dirtyUrl"); - relayConnectivity.relayTransport = - nostrTransportFactory(url, onReconnect: () { - reSubscribeInFlightSubscriptions(relayConnectivity!); - updateRelayConnectivity(); - }, onDisconnect: (code, error, reason) { - relayConnectivity!.stats.connectionErrors++; - updateRelayConnectivity(); - }); - await relayConnectivity.relayTransport!.ready.timeout( - Duration(seconds: connectTimeout), + relayConnectivity.relayTransport = nostrTransportFactory( + url, + onReconnect: () { + reSubscribeInFlightSubscriptions(relayConnectivity!); + updateRelayConnectivity(); + }, + onDisconnect: (code, error, reason) { + relayConnectivity!.stats.connectionErrors++; + updateRelayConnectivity(); + }, ); - + // Start listening immediately so we don't miss early frames such as + // relay AUTH challenges that may arrive before the transport reports + // itself fully open. _startListeningToSocket(relayConnectivity); + final opened = await _waitForTransportOpen( + relayConnectivity.relayTransport!, + timeoutSeconds: connectTimeout, + ); + if (!opened) { + throw TimeoutException( + "Future not completed", + Duration(seconds: connectTimeout), + ); + } Logger.log.i(() => "connected to relay: $url"); relayConnectivity.relay.succeededToConnect(); @@ -222,14 +282,26 @@ class RelayManager { getRelayInfo(url).then((info) { relayConnectivity!.relayInfo = info; }); + if (!connectCompleter.isCompleted) { + connectCompleter.complete(true); + } + if (identical(_connectReadyCompleters[url], connectCompleter)) { + _connectReadyCompleters.remove(url); + } updateRelayConnectivity(); return Tuple(true, ""); } catch (e) { Logger.log.e(() => "!! could not connect to $url -> $e"); - relayConnectivity!.relayTransport = null; + await relayConnectivity!.close(); } relayConnectivity.relay.failedToConnect(); relayConnectivity.stats.connectionErrors++; + if (!connectCompleter.isCompleted) { + connectCompleter.complete(false); + } + if (identical(_connectReadyCompleters[url], connectCompleter)) { + _connectReadyCompleters.remove(url); + } updateRelayConnectivity(); return Tuple(false, "could not connect to $url"); } @@ -240,15 +312,27 @@ class RelayManager { required ConnectionSource connectionSource, bool force = false, }) async { + final inFlightConnect = _connectReadyCompleters[url]; + if (inFlightConnect != null) { + final connected = await inFlightConnect.future; + updateRelayConnectivity(); + return connected; + } + RelayConnectivity? relayConnectivity = globalState.relays[url]; if (relayConnectivity != null && relayConnectivity.relayTransport != null) { - await relayConnectivity.relayTransport!.ready - .timeout(Duration(seconds: DEFAULT_WEB_SOCKET_CONNECT_TIMEOUT)) - .onError( - (error, stackTrace) { - Logger.log.e(() => "error connecting to relay $url: $error"); - }, - ); + try { + final opened = await _waitForTransportOpen( + relayConnectivity.relayTransport!, + timeoutSeconds: DEFAULT_WEB_SOCKET_CONNECT_TIMEOUT, + ); + if (opened && relayConnectivity.relayTransport!.isOpen()) { + updateRelayConnectivity(); + return true; + } + } catch (error) { + Logger.log.e(() => "error connecting to relay $url: $error"); + } } if (relayConnectivity == null || relayConnectivity.relayTransport == null || @@ -266,8 +350,7 @@ class RelayManager { if (!(await connectRelay( dirtyUrl: url, connectionSource: connectionSource, - )) - .first) { + )).first) { // could not connect return false; } @@ -282,52 +365,127 @@ class RelayManager { return true; } + /// Closes and clears only transport-scoped state for a relay, while keeping + /// the relay entry and relay-scoped metadata in memory. + Future resetTransport(String url) async { + final connectivity = globalState.relays[url]; + if (connectivity != null) { + Logger.log.d(() => "Resetting transport for $url..."); + connectivity.relay.failedToConnect(); + _lastChallengePerRelay.remove(url); + await connectivity.close(); + updateRelayConnectivity(); + } + } + /// Reconnects all given relays Future reconnectRelays(Iterable urls) async { final startTime = DateTime.now(); Logger.log.d(() => "connecting ${urls.length} relays in parallel"); - List connected = await Future.wait(urls.map((url) => reconnectRelay( - url, - connectionSource: ConnectionSource.explicit, - force: true))); + List connected = await Future.wait( + urls.map( + (url) => reconnectRelay( + url, + connectionSource: ConnectionSource.explicit, + force: true, + ), + ), + ); final endTime = DateTime.now(); final duration = endTime.difference(startTime); - Logger.log.d(() => - "CONNECTED ${connected.where((element) => element).length} , ${connected.where((element) => !element).length} FAILED, took ${duration.inMilliseconds} ms"); + Logger.log.d( + () => + "CONNECTED ${connected.where((element) => element).length} , ${connected.where((element) => !element).length} FAILED, took ${duration.inMilliseconds} ms", + ); } void reSubscribeInFlightSubscriptions(RelayConnectivity relayConnectivity) { + final transport = relayConnectivity.relayTransport; + if (transport == null || !transport.isOpen()) { + return; + } + globalState.inFlightRequests.forEach((key, state) { state.requests.values .where((req) => req.url == relayConnectivity.url) .forEach((req) { - if (!state.request.closeOnEOSE) { - List list = ["REQ", state.id]; - list.addAll(req.filters.map((filter) => filter.toMap())); - - relayConnectivity.stats.activeRequests++; - _sendRaw(relayConnectivity, jsonEncode(list)); - } - }); + if (!state.request.closeOnEOSE) { + List list = ["REQ", state.id]; + list.addAll(req.filters.map((filter) => filter.toMap())); + + relayConnectivity.stats.activeRequests++; + _sendRaw(relayConnectivity, transport, jsonEncode(list)); + } + }); }); } - void _sendRaw(RelayConnectivity relayConnectivity, dynamic data) { - relayConnectivity.relayTransport!.send(data); + void _sendRaw( + RelayConnectivity relayConnectivity, + NostrTransport transport, + dynamic data, + ) { + if (!identical(relayConnectivity.relayTransport, transport) || + !transport.isOpen()) { + Logger.log.t( + () => + "skip send to ${relayConnectivity.url}: transport changed or closed", + ); + return; + } + transport.send(data); Logger.log.d(() => "send message to ${relayConnectivity.url}: $data"); } - /// sends a [ClientMsg] to relay transport sink, throw an error if relay not connected - void send(RelayConnectivity relayConnectivity, ClientMsg msg) async { - if (relayConnectivity.relayTransport == null) { - throw Exception("relay not connected"); + /// Sends a [ClientMsg] and surfaces transport churn/closed-socket failures to + /// the caller instead of silently dropping the write. + Future sendOrThrow( + RelayConnectivity relayConnectivity, + ClientMsg msg, + ) async { + NostrTransport? transport = relayConnectivity.relayTransport; + if (transport == null) { + throw StateError("relay not connected: ${relayConnectivity.url}"); } - /// wait until rdy - await relayConnectivity.relayTransport!.ready; + await transport.ready; + + if (!identical(relayConnectivity.relayTransport, transport) || + !transport.isOpen()) { + transport = relayConnectivity.relayTransport; + if (transport == null) { + throw StateError( + "transport changed while waiting for ${relayConnectivity.url}", + ); + } + + await transport.ready; + + if (!identical(relayConnectivity.relayTransport, transport) || + !transport.isOpen()) { + throw StateError( + "transport changed while waiting for ${relayConnectivity.url}", + ); + } + } final String encodedMsg = jsonEncode(msg.toJson()); - _sendRaw(relayConnectivity, encodedMsg); + if (!identical(relayConnectivity.relayTransport, transport) || + !transport.isOpen()) { + throw StateError( + "transport closed before send: ${relayConnectivity.url}", + ); + } + _sendRaw(relayConnectivity, transport, encodedMsg); + } + + /// sends a [ClientMsg] to relay transport sink, throw an error if relay not connected + void send(RelayConnectivity relayConnectivity, ClientMsg msg) { + unawaited( + sendOrThrow(relayConnectivity, msg).catchError((Object error) { + Logger.log.t(() => "skip send to ${relayConnectivity.url}: $error"); + }), + ); } /// use this to register your request against a relay, \ @@ -340,14 +498,12 @@ class RelayManager { // new tracking if (globalState.inFlightRequests[reqId]!.requests[relayUrl] == null) { globalState.inFlightRequests[reqId]!.requests[relayUrl] = - RelayRequestState( - relayUrl, - filters, - ); + RelayRequestState(relayUrl, filters); } else { // do not overwrite and add new filters - globalState.inFlightRequests[reqId]!.requests[relayUrl]!.filters - .addAll(filters); + globalState.inFlightRequests[reqId]!.requests[relayUrl]!.filters.addAll( + filters, + ); } } @@ -359,8 +515,10 @@ class RelayManager { }) { final broadcastState = globalState.inFlightBroadcasts[eventToPublish.id]; if (broadcastState == null) { - Logger.log.w(() => - "registerRelayBroadcast: no broadcast state for ${eventToPublish.id}"); + Logger.log.w( + () => + "registerRelayBroadcast: no broadcast state for ${eventToPublish.id}", + ); return; } @@ -374,84 +532,109 @@ class RelayManager { ); } else { // do not overwrite - Logger.log.w(() => - "registerRelayBroadcast: relay broadcast already registered for ${eventToPublish.id} $relayUrl, skipping"); + Logger.log.w( + () => + "registerRelayBroadcast: relay broadcast already registered for ${eventToPublish.id} $relayUrl, skipping", + ); } } /// use this to signal a failed broadcast void failBroadcast(String nostrEventId, String relay, String msg) { - if (globalState.inFlightBroadcasts.containsKey(nostrEventId)) { - globalState.inFlightBroadcasts[nostrEventId]?.networkController.add( - RelayBroadcastResponse( - relayUrl: relay, - okReceived: false, - broadcastSuccessful: false, - msg: msg, - ), + final broadcastState = globalState.inFlightBroadcasts[nostrEventId]; + if (broadcastState == null) { + return; + } + if (broadcastState.networkController.isClosed) { + Logger.log.w( + () => + "Ignoring late failed broadcast for $nostrEventId on $relay because the broadcast controller is already closed", ); + return; } + + broadcastState.networkController.add( + RelayBroadcastResponse( + relayUrl: relay, + okReceived: false, + broadcastSuccessful: false, + msg: msg, + ), + ); } void _startListeningToSocket(RelayConnectivity relayConnectivity) { - relayConnectivity.listen((message) { - _handleIncomingMessage( - message, - relayConnectivity, - ); - }, onError: (error) async { - Logger.log.e(() => "onError ${relayConnectivity.url} on listen $error"); - relayConnectivity.stats.connectionErrors++; - try { - await relayConnectivity.close(); - } catch (e) { - Logger.log.w(() => "Error closing relay ${relayConnectivity.url}: $e"); - } - updateRelayConnectivity(); - }, onDone: () async { - Logger.log.t(() => - "onDone ${relayConnectivity.url} on listen (close: ${relayConnectivity.relayTransport?.closeCode()} ${relayConnectivity.relayTransport?.closeReason()})"); + relayConnectivity.listen( + (message) { + _handleIncomingMessage(message, relayConnectivity); + }, + onError: (error) async { + Logger.log.e(() => "onError ${relayConnectivity.url} on listen $error"); + relayConnectivity.stats.connectionErrors++; + try { + await relayConnectivity.close(); + } catch (e) { + Logger.log.w( + () => "Error closing relay ${relayConnectivity.url}: $e", + ); + } + updateRelayConnectivity(); + }, + onDone: () async { + Logger.log.t( + () => + "onDone ${relayConnectivity.url} on listen (close: ${relayConnectivity.relayTransport?.closeCode()} ${relayConnectivity.relayTransport?.closeReason()})", + ); - try { - await relayConnectivity.close(); - } catch (e) { - Logger.log.w(() => "Error closing relay ${relayConnectivity.url}: $e"); - } - updateRelayConnectivity(); - // reconnect on close - if (allowReconnectRelays && - globalState.relays[relayConnectivity.url] != null && - globalState.relays[relayConnectivity.url]!.relayTransport != null) { - Logger.log.i(() => "closed ${relayConnectivity.url}. Reconnecting"); - reconnectRelay(relayConnectivity.url, - connectionSource: relayConnectivity.relay.connectionSource) - .then((connected) { - updateRelayConnectivity(); - if (connected) { - reSubscribeInFlightSubscriptions(relayConnectivity); - } - }); - } - }); + try { + await relayConnectivity.close(); + } catch (e) { + Logger.log.w( + () => "Error closing relay ${relayConnectivity.url}: $e", + ); + } + updateRelayConnectivity(); + // Reconnect on close. close() above nulls relayTransport, so the only + // condition is that the relay is still tracked: deliberate closes cancel + // the stream subscription first and never reach this handler. + if (allowReconnectRelays && + globalState.relays[relayConnectivity.url] != null) { + Logger.log.i(() => "closed ${relayConnectivity.url}. Reconnecting"); + reconnectRelay( + relayConnectivity.url, + connectionSource: relayConnectivity.relay.connectionSource, + ).then((connected) { + updateRelayConnectivity(); + if (connected) { + reSubscribeInFlightSubscriptions(relayConnectivity); + } + }); + } + }, + ); } - // tracking to process in order - Completer? _lastMessageCompleter; + // Track processing order per relay so EVENT/EOSE/AUTH/CLOSED ordering stays + // correct without introducing cross-relay head-of-line blocking. + final Map> _lastMessageCompleters = {}; Future _handleIncomingMessage( - dynamic message, RelayConnectivity relayConnectivity) async { - final previousMessage = _lastMessageCompleter; + dynamic message, + RelayConnectivity relayConnectivity, + ) async { + final relayUrl = relayConnectivity.url; + final previousMessage = _lastMessageCompleters[relayUrl]; final myCompleter = Completer(); - _lastMessageCompleter = myCompleter; + _lastMessageCompleters[relayUrl] = myCompleter; NostrMessageRaw nostrMsg; try { nostrMsg = await IsolateManager.instance .runInEncodingIsolate( - decodeNostrMsg, - message, - ); + decodeNostrMsg, + message, + ); } catch (e) { // Isolates not available on web nostrMsg = decodeNostrMsg(message); @@ -460,18 +643,29 @@ class RelayManager { if (previousMessage != null) { await previousMessage.future; } - - myCompleter.complete(); - - _processDecodedMessage(nostrMsg, relayConnectivity, message); + try { + await _processDecodedMessage(nostrMsg, relayConnectivity, message); + } finally { + if (!myCompleter.isCompleted) { + myCompleter.complete(); + } + if (identical(_lastMessageCompleters[relayUrl], myCompleter)) { + _lastMessageCompleters.remove(relayUrl); + } + } } - void _processDecodedMessage(NostrMessageRaw nostrMsg, - RelayConnectivity relayConnectivity, dynamic message) { + Future _processDecodedMessage( + NostrMessageRaw nostrMsg, + RelayConnectivity relayConnectivity, + dynamic message, + ) { if (nostrMsg.type == NostrMessageRawType.unknown) { - Logger.log.w(() => - "Received non NostrMessageRaw message from ${relayConnectivity.url}: $nostrMsg"); - return; + Logger.log.w( + () => + "Received non NostrMessageRaw message from ${relayConnectivity.url}: $nostrMsg", + ); + return Future.value(); } if (nostrMsg.type == NostrMessageRawType.ok) { @@ -492,7 +686,7 @@ class RelayManager { Logger.log.e(() => "AUTH failed for $eventId: $msg"); _pendingAuthCallbacks.remove(eventId); } - return; + return Future.value(); } //nip 20 used to notify clients if an EVENT was successful @@ -502,12 +696,14 @@ class RelayManager { // Check if this is auth-required for a broadcast - don't mark as done, will retry if (msg != null && msg.startsWith("auth-required")) { _handleBroadcastAuthRequired(eventId, relayConnectivity); - return; // Don't add to network controller yet, wait for retry result + return Future.value(); // Don't add to network controller yet, wait for retry result } } if (globalState.inFlightBroadcasts[eventId] != null && !globalState - .inFlightBroadcasts[eventId]!.networkController.isClosed) { + .inFlightBroadcasts[eventId]! + .networkController + .isClosed) { globalState.inFlightBroadcasts[eventId]?.networkController.add( RelayBroadcastResponse( relayUrl: relayConnectivity.url, @@ -517,10 +713,12 @@ class RelayManager { ), ); } else { - Logger.log.w(() => - "Received OK for broadcast $eventId but the network controller is already closed"); + Logger.log.w( + () => + "Received OK for broadcast $eventId but the network controller is already closed", + ); } - return; + return Future.value(); } if (nostrMsg.type == NostrMessageRawType.notice) { final eventJson = nostrMsg.otherData; @@ -531,7 +729,8 @@ class RelayManager { // Check if this is a negentropy-related error // Look for various patterns relays might use to reject NEG commands final noticeLower = noticeMsg.toLowerCase(); - final isNegentropyError = noticeLower.contains('negentropy') || + final isNegentropyError = + noticeLower.contains('negentropy') || noticeLower.contains('neg-') || noticeLower.contains('unsupported') || noticeLower.contains('unknown command') || @@ -545,7 +744,8 @@ class RelayManager { for (final entry in globalState.inFlightNegotiations.entries) { if (entry.value.relayUrl == relayUrl) { entry.value.completeWithError( - Exception('Relay does not support NIP-77: $noticeMsg')); + Exception('Relay does not support NIP-77: $noticeMsg'), + ); toRemove.add(entry.key); } } @@ -555,7 +755,10 @@ class RelayManager { } } else if (nostrMsg.type == NostrMessageRawType.event) { _handleIncomingEvent( - nostrMsg, relayConnectivity, message.toString().codeUnits.length); + nostrMsg, + relayConnectivity, + message.toString().codeUnits.length, + ); // Logger.log.t(()=>"EVENT from ${relayConnectivity.url}: $eventJson"); } else if (nostrMsg.type == NostrMessageRawType.eose) { final eventJson = nostrMsg.otherData; @@ -563,8 +766,10 @@ class RelayManager { _handleEOSE(eventJson, relayConnectivity); } else if (nostrMsg.type == NostrMessageRawType.closed) { final eventJson = nostrMsg.otherData; - Logger.log.w(() => - " CLOSED subscription url: ${relayConnectivity.url} id: ${eventJson[1]} msg: ${eventJson.length > 2 ? eventJson[2] : ''}"); + Logger.log.w( + () => + " CLOSED subscription url: ${relayConnectivity.url} id: ${eventJson[1]} msg: ${eventJson.length > 2 ? eventJson[2] : ''}", + ); _handleClosed(eventJson, relayConnectivity); } if (nostrMsg.type == NostrMessageRawType.auth) { @@ -572,28 +777,31 @@ class RelayManager { // nip 42 used to send authentication challenges // NIP-42 allows multiple AUTH events for different pubkeys on the same connection final challenge = eventJson[1]; - Logger.log - .d(() => "AUTH challenge from ${relayConnectivity.url}: $challenge"); + Logger.log.d( + () => "AUTH challenge from ${relayConnectivity.url}: $challenge", + ); // Store challenge for late authentication (multiple accounts on same connection) _lastChallengePerRelay[relayConnectivity.url] = challenge; // If not eager auth, don't authenticate now - wait for auth-required if (!eagerAuth) { - return; + return Future.value(); } if (_accounts == null) { - Logger.log - .w(() => "Received an AUTH challenge but no accounts configured"); - return; + Logger.log.w( + () => "Received an AUTH challenge but no accounts configured", + ); + return Future.value(); } // Collect accounts from active requests on this relay final accountsToAuth = {}; for (final state in globalState.inFlightRequests.values) { - final hasRequestOnThisRelay = - state.requests.keys.contains(relayConnectivity.url); + final hasRequestOnThisRelay = state.requests.keys.contains( + relayConnectivity.url, + ); if (hasRequestOnThisRelay && state.request.authenticateAs != null) { accountsToAuth.addAll(state.request.authenticateAs!); } @@ -606,12 +814,13 @@ class RelayManager { if (accountsToAuth.isEmpty) { Logger.log.w( - () => "Received an AUTH challenge but no accounts to authenticate"); - return; + () => "Received an AUTH challenge but no accounts to authenticate", + ); + return Future.value(); } _authenticateAccounts(relayConnectivity, challenge, accountsToAuth); - return; + return Future.value(); } if (nostrMsg.type == NostrMessageRawType.negMsg) { final msgData = nostrMsg.otherData; @@ -620,7 +829,7 @@ class RelayManager { final payload = msgData[2] as String; onNegMsg!(subscriptionId, relayConnectivity.url, payload); } - return; + return Future.value(); } if (nostrMsg.type == NostrMessageRawType.negErr) { final msgData = nostrMsg.otherData; @@ -629,8 +838,9 @@ class RelayManager { final errorMsg = msgData[2] as String; onNegErr!(subscriptionId, relayConnectivity.url, errorMsg); } - return; + return Future.value(); } + return Future.value(); // // if (eventJson[0] == 'COUNT') { // log("COUNT: ${eventJson[1]}"); @@ -656,19 +866,26 @@ class RelayManager { int authCount = 0; for (final account in accounts) { if (account.signer.canSign()) { - final auth = AuthEvent(pubKey: account.pubkey, tags: [ - ["relay", relayConnectivity.url], - ["challenge", challenge] - ]); + final auth = AuthEvent( + pubKey: account.pubkey, + tags: [ + ["relay", relayConnectivity.url], + ["challenge", challenge], + ], + ); account.signer.sign(auth).then((signedAuth) { // Resume timeout for requests after signing completes for (final state in requestsOnRelay) { state.resumeTimeout(); } - send(relayConnectivity, - ClientMsg(ClientMsgType.kAuth, event: signedAuth)); - Logger.log.d(() => - "AUTH sent for ${account.pubkey.substring(0, 8)} to ${relayConnectivity.url}"); + send( + relayConnectivity, + ClientMsg(ClientMsgType.kAuth, event: signedAuth), + ); + Logger.log.d( + () => + "AUTH sent for ${account.pubkey.substring(0, 8)} to ${relayConnectivity.url}", + ); }); authCount++; } @@ -688,8 +905,9 @@ class RelayManager { void authenticateIfNeeded(String relayUrl, List accounts) { final challenge = _lastChallengePerRelay[relayUrl]; if (challenge == null) { - Logger.log - .t(() => "No stored challenge for $relayUrl, skipping late auth"); + Logger.log.t( + () => "No stored challenge for $relayUrl, skipping late auth", + ); return; } @@ -699,19 +917,25 @@ 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()); } - void _handleIncomingEvent(NostrMessageRaw nostrMsgRaw, - RelayConnectivity connectivity, int messageSize) { + void _handleIncomingEvent( + NostrMessageRaw nostrMsgRaw, + RelayConnectivity connectivity, + int messageSize, + ) { final requestId = nostrMsgRaw.requestId!; final event = nostrMsgRaw.nip01Event!; if (globalState.inFlightRequests[requestId] == null) { - Logger.log.w(() => - "RECEIVED EVENT from ${connectivity.url} for id $requestId, not in globalState inFlightRequests. Likely data after EOSE on a query"); + Logger.log.w( + () => + "RECEIVED EVENT from ${connectivity.url} for id $requestId, not in globalState inFlightRequests. Likely data after EOSE on a query", + ); return; } @@ -725,13 +949,16 @@ class RelayManager { return; } - final eventWithSources = - event.copyWith(sources: [...event.sources, connectivity.url]); + final eventWithSources = event.copyWith( + sources: [...event.sources, connectivity.url], + ); if (state.networkController.isClosed) { // this might happen because relays even after we send a CLOSE subscription.id, they'll still send more events - Logger.log.t(() => - "tried to add event to an already closed STREAM ${state.request.id} ${state.request.filters}"); + Logger.log.t( + () => + "tried to add event to an already closed STREAM ${state.request.id} ${state.request.filters}", + ); } else { state.networkController.add(eventWithSources); } @@ -740,12 +967,16 @@ class RelayManager { /// handles EOSE messages void _handleEOSE( - List eventJson, RelayConnectivity relayConnectivity) { + List eventJson, + RelayConnectivity relayConnectivity, + ) { String id = eventJson[1]; RequestState? state = globalState.inFlightRequests[id]; if (state != null && state.request.closeOnEOSE) { - Logger.log.t(() => - "⛁ received EOSE from ${relayConnectivity.url} for REQ id $id, remaining requests from :${state.requests.keys} kind:${state.requests.values.first.filters.first.kinds}"); + Logger.log.t( + () => + "⛁ received EOSE from ${relayConnectivity.url} for REQ id $id, remaining requests from :${state.requests.keys} kind:${state.requests.values.first.filters.first.kinds}", + ); RelayRequestState? request = state.requests[relayConnectivity.url]; if (request != null) { request.receivedEOSE = true; @@ -762,7 +993,9 @@ class RelayManager { /// handles CLOSED messages void _handleClosed( - List eventJson, RelayConnectivity relayConnectivity) { + List eventJson, + RelayConnectivity relayConnectivity, + ) { String id = eventJson[1]; String? message = eventJson.length > 2 ? eventJson[2] : null; @@ -774,8 +1007,10 @@ class RelayManager { RequestState? state = globalState.inFlightRequests[id]; if (state != null) { - Logger.log.t(() => - "⛁ received CLOSE from ${relayConnectivity.url} for REQ id $id, remaining requests from :${state.requests.keys} kind:${state.requests.values.first.filters.first.kinds}"); + Logger.log.t( + () => + "⛁ received CLOSE from ${relayConnectivity.url} for REQ id $id, remaining requests from :${state.requests.keys} kind:${state.requests.values.first.filters.first.kinds}", + ); RelayRequestState? request = state.requests[relayConnectivity.url]; if (request != null) { request.receivedClosed = true; @@ -789,25 +1024,32 @@ class RelayManager { /// Handles CLOSED auth-required by authenticating and re-sending the REQ void _handleClosedAuthRequired( - String reqId, RelayConnectivity relayConnectivity) { + String reqId, + RelayConnectivity relayConnectivity, + ) { final state = globalState.inFlightRequests[reqId]; if (state == null) { - Logger.log - .w(() => "Received CLOSED auth-required for unknown request $reqId"); + Logger.log.w( + () => "Received CLOSED auth-required for unknown request $reqId", + ); return; } final request = state.requests[relayConnectivity.url]; if (request == null) { - Logger.log.w(() => - "Received CLOSED auth-required but no request state for ${relayConnectivity.url}"); + Logger.log.w( + () => + "Received CLOSED auth-required but no request state for ${relayConnectivity.url}", + ); return; } final challenge = _lastChallengePerRelay[relayConnectivity.url]; if (challenge == null) { - Logger.log.w(() => - "Received CLOSED auth-required but no challenge stored for ${relayConnectivity.url}"); + Logger.log.w( + () => + "Received CLOSED auth-required but no challenge stored for ${relayConnectivity.url}", + ); // Mark this relay as closed since we can't authenticate without a challenge request.receivedClosed = true; _checkNetworkClose(state, relayConnectivity); @@ -824,20 +1066,25 @@ class RelayManager { } // Filter to accounts that can sign - final signableAccounts = - accountsToAuth.where((a) => a.signer.canSign()).toList(); + final signableAccounts = accountsToAuth + .where((a) => a.signer.canSign()) + .toList(); if (signableAccounts.isEmpty) { - Logger.log.w(() => - "Received CLOSED auth-required but no account can sign for ${relayConnectivity.url}"); + Logger.log.w( + () => + "Received CLOSED auth-required but no account can sign for ${relayConnectivity.url}", + ); // Mark this relay as closed and check if we can complete the request request.receivedClosed = true; _checkNetworkClose(state, relayConnectivity); return; } - Logger.log.d(() => - "AUTH required for REQ $reqId on ${relayConnectivity.url}, authenticating ${signableAccounts.length} account(s)..."); + Logger.log.d( + () => + "AUTH required for REQ $reqId on ${relayConnectivity.url}, authenticating ${signableAccounts.length} account(s)...", + ); // Pause timeout during AUTH signing state.pauseTimeout(); @@ -847,10 +1094,13 @@ class RelayManager { int pendingSignCount = signableAccounts.length; for (final account in signableAccounts) { - final auth = AuthEvent(pubKey: account.pubkey, tags: [ - ["relay", relayConnectivity.url], - ["challenge", challenge] - ]); + final auth = AuthEvent( + pubKey: account.pubkey, + tags: [ + ["relay", relayConnectivity.url], + ["challenge", challenge], + ], + ); account.signer.sign(auth).then((signedAuth) { // Resume timeout after all signings complete @@ -863,51 +1113,69 @@ class RelayManager { _pendingAuthCallbacks[signedAuth.id] = () { pendingAuthCount--; if (pendingAuthCount == 0) { - Logger.log.d(() => - "All AUTH OK received, re-sending REQ $reqId to ${relayConnectivity.url}"); + Logger.log.d( + () => + "All AUTH OK received, re-sending REQ $reqId to ${relayConnectivity.url}", + ); List list = ["REQ", reqId]; list.addAll(request.filters.map((filter) => filter.toMap())); - _sendRaw(relayConnectivity, jsonEncode(list)); + final transport = relayConnectivity.relayTransport; + if (transport != null && transport.isOpen()) { + _sendRaw(relayConnectivity, transport, jsonEncode(list)); + } } }; // Start timeout timer to clean up orphaned callbacks _pendingAuthTimers[signedAuth.id] = Timer(authCallbackTimeout, () { - Logger.log.w(() => - "AUTH callback timeout for ${signedAuth.id} on ${relayConnectivity.url}"); + Logger.log.w( + () => + "AUTH callback timeout for ${signedAuth.id} on ${relayConnectivity.url}", + ); _pendingAuthCallbacks.remove(signedAuth.id); _pendingAuthTimers.remove(signedAuth.id); }); - send(relayConnectivity, - ClientMsg(ClientMsgType.kAuth, event: signedAuth)); - Logger.log.d(() => - "AUTH sent for ${account.pubkey.substring(0, 8)} to ${relayConnectivity.url}"); + send( + relayConnectivity, + ClientMsg(ClientMsgType.kAuth, event: signedAuth), + ); + Logger.log.d( + () => + "AUTH sent for ${account.pubkey.substring(0, 8)} to ${relayConnectivity.url}", + ); }); } } /// Handles OK auth-required for broadcasts by authenticating and re-sending the EVENT void _handleBroadcastAuthRequired( - String eventId, RelayConnectivity relayConnectivity) { + String eventId, + RelayConnectivity relayConnectivity, + ) { final challenge = _lastChallengePerRelay[relayConnectivity.url]; if (challenge == null) { - Logger.log.w(() => - "Received OK auth-required but no challenge stored for ${relayConnectivity.url}"); + Logger.log.w( + () => + "Received OK auth-required but no challenge stored for ${relayConnectivity.url}", + ); return; } final broadcastState = globalState.inFlightBroadcasts[eventId]; if (broadcastState == null) { - Logger.log - .w(() => "Received OK auth-required for unknown broadcast $eventId"); + Logger.log.w( + () => "Received OK auth-required for unknown broadcast $eventId", + ); return; } final eventToResend = broadcastState.event; if (eventToResend == null) { - Logger.log.w(() => - "Received OK auth-required but no event stored for broadcast $eventId"); + Logger.log.w( + () => + "Received OK auth-required but no event stored for broadcast $eventId", + ); return; } @@ -923,48 +1191,67 @@ class RelayManager { } if (account == null || !account.signer.canSign()) { - Logger.log.w(() => - "Received OK auth-required but no account can sign for ${relayConnectivity.url}"); + Logger.log.w( + () => + "Received OK auth-required but no account can sign for ${relayConnectivity.url}", + ); return; } - Logger.log.d(() => - "AUTH required for EVENT $eventId on ${relayConnectivity.url}, authenticating..."); + Logger.log.d( + () => + "AUTH required for EVENT $eventId on ${relayConnectivity.url}, authenticating...", + ); // Create AUTH event - final auth = AuthEvent(pubKey: account.pubkey, tags: [ - ["relay", relayConnectivity.url], - ["challenge", challenge] - ]); + final auth = AuthEvent( + pubKey: account.pubkey, + tags: [ + ["relay", relayConnectivity.url], + ["challenge", challenge], + ], + ); // Sign and send AUTH, then re-send EVENT on OK account.signer.sign(auth).then((signedAuth) { // Store callback to re-send EVENT after AUTH OK _pendingAuthCallbacks[signedAuth.id] = () { - Logger.log.d(() => - "AUTH OK received, re-sending EVENT $eventId to ${relayConnectivity.url}"); + Logger.log.d( + () => + "AUTH OK received, re-sending EVENT $eventId to ${relayConnectivity.url}", + ); // Re-send the EVENT - send(relayConnectivity, - ClientMsg(ClientMsgType.kEvent, event: eventToResend)); + send( + relayConnectivity, + ClientMsg(ClientMsgType.kEvent, event: eventToResend), + ); }; // Start timeout timer to clean up orphaned callbacks _pendingAuthTimers[signedAuth.id] = Timer(authCallbackTimeout, () { - Logger.log.w(() => - "AUTH callback timeout for ${signedAuth.id} on ${relayConnectivity.url}"); + Logger.log.w( + () => + "AUTH callback timeout for ${signedAuth.id} on ${relayConnectivity.url}", + ); _pendingAuthCallbacks.remove(signedAuth.id); _pendingAuthTimers.remove(signedAuth.id); }); send( - relayConnectivity, ClientMsg(ClientMsgType.kAuth, event: signedAuth)); - Logger.log.d(() => - "AUTH sent for ${account!.pubkey.substring(0, 8)} to ${relayConnectivity.url}, waiting for OK..."); + relayConnectivity, + ClientMsg(ClientMsgType.kAuth, event: signedAuth), + ); + Logger.log.d( + () => + "AUTH sent for ${account!.pubkey.substring(0, 8)} to ${relayConnectivity.url}, waiting for OK...", + ); }); } void _checkNetworkClose( - RequestState state, RelayConnectivity relayConnectivity) { + RequestState state, + RelayConnectivity relayConnectivity, + ) { /// received everything, close the network controller if (state.didAllRequestsFinish) { state.networkController.close(); @@ -1029,8 +1316,10 @@ class RelayManager { // kindsMap[kind] = kindCount; namesMap[state.request.name] = nameCount; }); - Logger.log.d(() => - "------------ IN FLIGHT REQUESTS: ${globalState.inFlightRequests.length} || $namesMap"); + Logger.log.d( + () => + "------------ IN FLIGHT REQUESTS: ${globalState.inFlightRequests.length} || $namesMap", + ); } /// Closes this url transport and removes diff --git a/packages/ndk/lib/domain_layer/usecases/relay_sets/relay_sets.dart b/packages/ndk/lib/domain_layer/usecases/relay_sets/relay_sets.dart index 5ca738f34..076a6a307 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets/relay_sets.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets/relay_sets.dart @@ -23,26 +23,28 @@ class RelaySets { required RelayManager relayManager, required UserRelayLists userRelayLists, required Set blockedRelays, - }) : _userRelayLists = userRelayLists, - _relayManager = relayManager, - _cacheManager = cacheManager, - _blockedRelays = blockedRelays; + }) : _userRelayLists = userRelayLists, + _relayManager = relayManager, + _cacheManager = cacheManager, + _blockedRelays = blockedRelays; /// relay -> list of pubKey mappings - Future calculateRelaySet( - {required String name, - required String ownerPubKey, - required List pubKeys, - required RelayDirection direction, - required int relayMinCountPerPubKey, - Function(String, int, int)? onProgress}) async { + Future calculateRelaySet({ + required String name, + required String ownerPubKey, + required List pubKeys, + required RelayDirection direction, + required int relayMinCountPerPubKey, + Function(String, int, int)? onProgress, + }) async { RelaySet byScore = await _relaysByPopularity( - name: name, - ownerPubKey: ownerPubKey, - pubKeys: pubKeys, - direction: direction, - relayMinCountPerPubKey: relayMinCountPerPubKey, - onProgress: onProgress); + name: name, + ownerPubKey: ownerPubKey, + pubKeys: pubKeys, + direction: direction, + relayMinCountPerPubKey: relayMinCountPerPubKey, + onProgress: onProgress, + ); /// try by score if (byScore.relaysMap.isNotEmpty) { @@ -51,20 +53,25 @@ class RelaySets { /// if everything fails just return a map of all currently registered connected relays for each pubKeys return RelaySet( - name: name, - pubKey: ownerPubKey, - relayMinCountPerPubkey: relayMinCountPerPubKey, - direction: direction, - relaysMap: _allConnectedRelays(pubKeys), - notCoveredPubkeys: []); + name: name, + pubKey: ownerPubKey, + relayMinCountPerPubkey: relayMinCountPerPubKey, + direction: direction, + relaysMap: _allConnectedRelays(pubKeys), + notCoveredPubkeys: [], + ); } Map> _allConnectedRelays(List pubKeys) { Map> map = {}; for (final relay in _relayManager.connectedRelays) { map[relay.url] = pubKeys - .map((pubKey) => PubkeyMapping( - pubKey: pubKey, rwMarker: ReadWriteMarker.readWrite)) + .map( + (pubKey) => PubkeyMapping( + pubKey: pubKey, + rwMarker: ReadWriteMarker.readWrite, + ), + ) .toList(); } return map; @@ -77,15 +84,18 @@ class RelaySets { /// - check if relay is connected or can connect /// - for each pubKey mapped for given relay check if you already have minimum amount of relay coverage (use auxiliary map to remember this) /// - if not add this relay to list of best relays - Future _relaysByPopularity( - {required String name, - required String ownerPubKey, - required List pubKeys, - required RelayDirection direction, - required int relayMinCountPerPubKey, - Function(String stepName, int count, int total)? onProgress}) async { - await _userRelayLists.loadMissingRelayListsFromNip65OrNip02(pubKeys, - onProgress: onProgress); + Future _relaysByPopularity({ + required String name, + required String ownerPubKey, + required List pubKeys, + required RelayDirection direction, + required int relayMinCountPerPubKey, + Function(String stepName, int count, int total)? onProgress, + }) async { + await _userRelayLists.loadMissingRelayListsFromNip65OrNip02( + pubKeys, + onProgress: onProgress, + ); Map> pubKeysByRelayUrl = await _buildPubKeysMapFromRelayLists(pubKeys, direction); @@ -93,8 +103,11 @@ class RelaySets { Map> bestRelays = {}; if (onProgress != null) { Logger.log.d(() => "Calculating best relays..."); - onProgress.call("Calculating best relays", - minimumRelaysCoverageByPubkey.length, pubKeysByRelayUrl.length); + onProgress.call( + "Calculating best relays", + minimumRelaysCoverageByPubkey.length, + pubKeysByRelayUrl.length, + ); } Map notCoveredPubkeys = {}; for (var pubKey in pubKeys) { @@ -104,17 +117,18 @@ class RelaySets { if (_blockedRelays.contains(cleanRelayUrl(url))) { continue; } - if (!pubKeysByRelayUrl[url]!.any((pubKey) => - minimumRelaysCoverageByPubkey[pubKey.pubKey] == null || - minimumRelaysCoverageByPubkey[pubKey.pubKey]!.length < - relayMinCountPerPubKey)) { + if (!pubKeysByRelayUrl[url]!.any( + (pubKey) => + minimumRelaysCoverageByPubkey[pubKey.pubKey] == null || + minimumRelaysCoverageByPubkey[pubKey.pubKey]!.length < + relayMinCountPerPubKey, + )) { continue; } bool connectable = (await _relayManager.connectRelay( dirtyUrl: url, connectionSource: ConnectionSource.connectionProbe, - )) - .first; + )).first; Logger.log.d(() => "tried to reconnect to $url = $connectable"); if (!connectable) { continue; @@ -144,81 +158,96 @@ class RelaySets { // print( // "Calculating best relays minimumRelaysCoverageByPubkey.length:${minimumRelaysCoverageByPubkey // .length} pubKeysByRelayUrl.length: ${pubKeys.length}"); - onProgress.call("Calculating best relays", - minimumRelaysCoverageByPubkey.length, pubKeys.length); + onProgress.call( + "Calculating best relays", + minimumRelaysCoverageByPubkey.length, + pubKeys.length, + ); } } notCoveredPubkeys.removeWhere((key, value) => value <= 0); return RelaySet( - name: name, - pubKey: ownerPubKey, - relayMinCountPerPubkey: relayMinCountPerPubKey, - direction: direction, - relaysMap: bestRelays, - notCoveredPubkeys: notCoveredPubkeys.entries - .map( - (entry) => NotCoveredPubKey(entry.key, entry.value), - ) - .toList()); + name: name, + pubKey: ownerPubKey, + relayMinCountPerPubkey: relayMinCountPerPubKey, + direction: direction, + relaysMap: bestRelays, + notCoveredPubkeys: notCoveredPubkeys.entries + .map((entry) => NotCoveredPubKey(entry.key, entry.value)) + .toList(), + ); } Future>> _buildPubKeysMapFromRelayLists( - List pubKeys, RelayDirection direction) async { + List pubKeys, + RelayDirection direction, + ) async { Map> pubKeysByRelayUrl = {}; int foundCount = 0; for (String pubKey in pubKeys) { - UserRelayList? userRelayList = - await _cacheManager.loadUserRelayList(pubKey); + UserRelayList? userRelayList = await _cacheManager.loadUserRelayList( + pubKey, + ); if (userRelayList != null) { if (userRelayList.relays.isNotEmpty) { foundCount++; } for (var entry in userRelayList.relays.entries) { _handleRelayUrlForPubKey( - pubKey, direction, entry.key, entry.value, pubKeysByRelayUrl); + pubKey, + direction, + entry.key, + entry.value, + pubKeysByRelayUrl, + ); } } else { int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await _cacheManager.saveUserRelayList(UserRelayList( + await _cacheManager.saveUserRelayList( + UserRelayList( pubKey: pubKey, relays: {}, createdAt: now, - refreshedTimestamp: now)); + refreshedTimestamp: now, + ), + ); } } - Logger.log.d(() => - "Have lists of relays for $foundCount/${pubKeys.length} pubKeys ${foundCount < pubKeys.length ? "(missing ${pubKeys.length - foundCount})" : ""}"); + Logger.log.d( + () => + "Have lists of relays for $foundCount/${pubKeys.length} pubKeys ${foundCount < pubKeys.length ? "(missing ${pubKeys.length - foundCount})" : ""}", + ); /// sort by pubKeys count for each relay descending - List>> sortedEntries = - pubKeysByRelayUrl.entries.toList() - - /// todo: use more stuff to improve sorting - ..sort((a, b) { - int rr = b.value.length.compareTo(a.value.length); - if (rr == 0) { - // if amount of pubKeys is equal check for webSocket connected, and prioritize connected - bool aC = _relayManager.isRelayConnected(a.key); - bool bC = _relayManager.isRelayConnected(b.key); - if (aC != bC) { - return aC ? -1 : 1; - } - return 0; - } - return rr; - }); + List>> + sortedEntries = pubKeysByRelayUrl.entries.toList() + /// todo: use more stuff to improve sorting + ..sort((a, b) { + int rr = b.value.length.compareTo(a.value.length); + if (rr == 0) { + // if amount of pubKeys is equal check for webSocket connected, and prioritize connected + bool aC = _relayManager.isRelayConnected(a.key); + bool bC = _relayManager.isRelayConnected(b.key); + if (aC != bC) { + return aC ? -1 : 1; + } + return 0; + } + return rr; + }); return Map>.fromEntries(sortedEntries); } void _handleRelayUrlForPubKey( - String pubKey, - RelayDirection direction, - String url, - ReadWriteMarker marker, - Map> pubKeysByRelayUrl) { + String pubKey, + RelayDirection direction, + String url, + ReadWriteMarker marker, + Map> pubKeysByRelayUrl, + ) { String? cleanUrl = cleanRelayUrl(url); if (cleanUrl != null) { if (direction.matchesMarker(marker)) { @@ -226,8 +255,9 @@ class RelaySets { if (set == null) { pubKeysByRelayUrl[cleanUrl] = {}; } - pubKeysByRelayUrl[cleanUrl]! - .add(PubkeyMapping(pubKey: pubKey, rwMarker: marker)); + pubKeysByRelayUrl[cleanUrl]!.add( + PubkeyMapping(pubKey: pubKey, rwMarker: marker), + ); } } } diff --git a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart index 458a3fa00..1684b0a85 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart @@ -6,6 +6,7 @@ import 'dart:math'; import '../../config/bootstrap_relays.dart'; import '../../config/broadcast_defaults.dart'; import '../../shared/logger/logger.dart'; +import '../../shared/nips/nip01/event_kind_classification.dart'; import '../../shared/nips/nip01/client_msg.dart'; import '../../shared/nips/nip01/helpers.dart'; import '../../shared/helpers/relay_helper.dart'; @@ -43,9 +44,9 @@ class RelaySetsEngine implements NetworkEngine { required CacheManager cacheManager, required List? bootstrapRelays, GlobalState? globalState, - }) : _cacheManager = cacheManager, - _relayManager = relayManager, - _bootstrapRelays = bootstrapRelays ?? DEFAULT_BOOTSTRAP_RELAYS { + }) : _cacheManager = cacheManager, + _relayManager = relayManager, + _bootstrapRelays = bootstrapRelays ?? DEFAULT_BOOTSTRAP_RELAYS { _globalState = globalState ?? GlobalState(); } @@ -53,37 +54,41 @@ class RelaySetsEngine implements NetworkEngine { Future doRelayRequest(String id, RelayRequestState request) async { if (_globalState.blockedRelays.contains(request.url)) { - Logger.log.w(() => - "COULD NOT SEND REQUEST TO ${request.url} since relay is blocked"); + Logger.log.w( + () => "COULD NOT SEND REQUEST TO ${request.url} since relay is blocked", + ); return false; } - final connected = await _relayManager.reconnectRelay(request.url, - connectionSource: - ConnectionSource.explicit // TODO improve this connection source - ); + final connected = await _relayManager.reconnectRelay( + request.url, + connectionSource: + ConnectionSource.explicit, // TODO improve this connection source + force: false, + ); if (connected) { RelayConnectivity? relay = _globalState.relays[request.url]; if (relay != null) { relay.stats.activeRequests++; try { - _relayManager.send( - relay, - ClientMsg( - ClientMsgType.kReq, - id: id, - filters: request.filters, - )); + await _relayManager.sendOrThrow( + relay, + ClientMsg(ClientMsgType.kReq, id: id, filters: request.filters), + ); } catch (e) { - Logger.log - .e(() => "COULD NOT SEND REQUEST TO ${request.url}:", error: e); + Logger.log.e( + () => "COULD NOT SEND REQUEST TO ${request.url}:", + error: e, + ); return false; } } return true; } else { - Logger.log.e(() => - "COULD NOT SEND REQUEST TO ${request.url} since socket seems to be not open"); + Logger.log.e( + () => + "COULD NOT SEND REQUEST TO ${request.url} since socket seems to be not open", + ); return false; } } @@ -101,36 +106,85 @@ class RelaySetsEngine implements NetworkEngine { eventToPublish: nostrEvent, relayUrl: relayUrl, ); - bool connected = false; + + var connected = _relayManager.isRelayConnected(relayUrl); Object? error; - try { - connected = await _relayManager.reconnectRelay(relayUrl, - connectionSource: ConnectionSource.broadcastSpecific); - } catch (e) { - Logger.log.w( - () => "Error during reconnectRelay for $relayUrl in doRelayBroadcast", - error: e); - error = e; + if (!connected) { + try { + final result = await _relayManager.connectRelay( + dirtyUrl: relayUrl, + connectionSource: ConnectionSource.broadcastSpecific, + connectTimeout: 1, + ); + connected = result.first; + } catch (e) { + Logger.log.w( + () => "Error during quick connect for $relayUrl in doRelayBroadcast", + error: e, + ); + error = e; + } } if (connected) { + if (await _shouldSkipObsoleteReplaceableBroadcast(nostrEvent)) { + Logger.log.d( + () => + 'skip obsolete specific-relay broadcast ${nostrEvent.id} for $relayUrl', + ); + _relayManager.failBroadcast( + nostrEvent.id, + relayUrl, + 'obsolete replaceable event skipped', + ); + return; + } + final relayConnectivity = _relayManager.getRelayConnectivity(relayUrl); if (relayConnectivity != null) { - _relayManager.send( - relayConnectivity, - ClientMsg( - ClientMsgType.kEvent, - event: nostrEvent, - )); + await _relayManager.sendOrThrow( + relayConnectivity, + ClientMsg(ClientMsgType.kEvent, event: nostrEvent), + ); return; } } _relayManager.failBroadcast( - nostrEvent.id, relayUrl, "Could not connect to relay $relayUrl $error"); + nostrEvent.id, + relayUrl, + "Could not connect to relay $relayUrl $error", + ); } - Future doNostrRequestWithRelaySet(RequestState state, - {bool splitRequestsByPubKeyMappings = true}) async { + Future _shouldSkipObsoleteReplaceableBroadcast(Nip01Event event) async { + if (!EventKindClassification.isReplaceableKind(event.kind)) { + return false; + } + + final dTag = event.getDtag(); + final visibleEvents = await _cacheManager.loadEvents( + pubKeys: [event.pubKey], + kinds: [event.kind], + tags: + EventKindClassification.isAddressableKind(event.kind) && dTag != null + ? { + 'd': [dTag], + } + : null, + limit: 1, + ); + + if (visibleEvents.isEmpty) { + return false; + } + + return visibleEvents.single.id != event.id; + } + + Future doNostrRequestWithRelaySet( + RequestState state, { + bool splitRequestsByPubKeyMappings = true, + }) async { if (state.unresolvedFilters.isEmpty || state.request.relaySet == null) { return; } @@ -139,11 +193,13 @@ class RelaySetsEngine implements NetworkEngine { if (splitRequestsByPubKeyMappings) { relaySet.splitIntoRequests(filter, state); print( - "request for ${filter.authors != null ? filter.authors!.length : 0} authors with kinds: ${filter.kinds} made requests to ${state.requests.length} relays"); + "request for ${filter.authors != null ? filter.authors!.length : 0} authors with kinds: ${filter.kinds} made requests to ${state.requests.length} relays", + ); if (state.requests.isEmpty && relaySet.fallbackToBootstrapRelays) { print( - "making fallback requests to ${_bootstrapRelays.length} bootstrap relays for ${filter.authors != null ? filter.authors!.length : 0} authors with kinds: ${filter.kinds}"); + "making fallback requests to ${_bootstrapRelays.length} bootstrap relays for ${filter.authors != null ? filter.authors!.length : 0} authors with kinds: ${filter.kinds}", + ); for (final url in _bootstrapRelays) { state.addRequest(url, RelaySet.sliceFilterAuthors(filter)); } @@ -161,7 +217,9 @@ class RelaySetsEngine implements NetworkEngine { state.request.authenticateAs!.isNotEmpty) { for (final relayUrl in state.requests.keys) { _relayManager.authenticateIfNeeded( - relayUrl, state.request.authenticateAs!); + relayUrl, + state.request.authenticateAs!, + ); } } @@ -172,7 +230,10 @@ class RelaySetsEngine implements NetworkEngine { @override Future handleRequest(RequestState state) async { - await _relayManager.seedRelaysConnected; + if (state.request.explicitRelays == null || + state.request.explicitRelays!.isEmpty) { + await _relayManager.seedRelaysConnected; + } if (state.request.relaySet != null) { return await doNostrRequestWithRelaySet(state); @@ -203,7 +264,9 @@ class RelaySetsEngine implements NetworkEngine { state.request.authenticateAs!.isNotEmpty) { for (final relayUrl in state.requests.keys) { _relayManager.authenticateIfNeeded( - relayUrl, state.request.authenticateAs!); + relayUrl, + state.request.authenticateAs!, + ); } } @@ -228,18 +291,16 @@ class RelaySetsEngine implements NetworkEngine { bool closeOnEOSE = true, }) async { String id = Helpers.getRandomString(10); - RequestState state = RequestState(closeOnEOSE - ? NdkRequest.query( - id, - name: name, - filters: [filter], - timeoutDuration: timeout, - ) - : NdkRequest.subscription( - id, - name: name, - filters: [], - )); + RequestState state = RequestState( + closeOnEOSE + ? NdkRequest.query( + id, + name: name, + filters: [filter], + timeoutDuration: timeout, + ) + : NdkRequest.subscription(id, name: name, filters: []), + ); for (var url in urls) { state.addRequest(url, RelaySet.sliceFilterAuthors(filter)); @@ -289,9 +350,13 @@ class RelaySetsEngine implements NetworkEngine { // ===================================================================================== if (specificRelays != null) { if (specificRelays.isNotEmpty) { - await Future.wait(specificRelays.map((relayUrl) => - // broadcast async - doRelayBroadcast(relayUrl, workingEvent))); + await Future.wait( + specificRelays.map( + (relayUrl) => + // broadcast async + doRelayBroadcast(relayUrl, workingEvent), + ), + ); } } else { // ===================================================================================== @@ -303,20 +368,25 @@ class RelaySetsEngine implements NetworkEngine { cacheManager: _cacheManager, )); // make a copy of the keys since connectRelay may mutate the underlying map - List writeRelaysUrls = - _relayManager.globalState.relays.keys.toList(); + List writeRelaysUrls = _relayManager.globalState.relays.keys + .toList(); if (nip65List.isNotEmpty) { writeRelaysUrls = nip65List.first.relays.entries .where((element) => element.value.isWrite) .map((e) => e.key) .toList(); } else { - Logger.log.w(() => - "could not find user relay list from nip65, using default bootstrap relays"); + Logger.log.w( + () => + "could not find user relay list from nip65, using default bootstrap relays", + ); } - await Future.wait(writeRelaysUrls - .map((relayUrl) => doRelayBroadcast(relayUrl, workingEvent))); + await Future.wait( + writeRelaysUrls.map( + (relayUrl) => doRelayBroadcast(relayUrl, workingEvent), + ), + ); // ===================================================================================== // other inbox @@ -339,14 +409,19 @@ class RelaySetsEngine implements NetworkEngine { // cut list of at a certain threshold final maxList = completeList.sublist( 0, - min(completeList.length, - BroadcastDefaults.MAX_INBOX_RELAYS_TO_BROADCAST), + min( + completeList.length, + BroadcastDefaults.MAX_INBOX_RELAYS_TO_BROADCAST, + ), ); myWriteRelayUrlsOthers.addAll(maxList); } - await Future.wait(myWriteRelayUrlsOthers - .map((relayUrl) => doRelayBroadcast(relayUrl, workingEvent))); + await Future.wait( + myWriteRelayUrlsOthers.map( + (relayUrl) => doRelayBroadcast(relayUrl, workingEvent), + ), + ); } } broadcastState.closeIfNoRelays(); @@ -356,8 +431,12 @@ class RelaySetsEngine implements NetworkEngine { return NdkBroadcastResponse( publishEvent: nostrEvent, - broadcastDoneStream: broadcastState.stateUpdates - .map((state) => state.broadcasts.values.toList()), + broadcastDoneStream: broadcastState.stateUpdates.map( + (state) => state.broadcasts.values.toList(), + ), + broadcastDoneFuture: broadcastState.publishDoneFuture.then( + (state) => state.broadcasts.values.toList(), + ), ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/requests/requests.dart b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart index c09156200..e10360196 100644 --- a/packages/ndk/lib/domain_layer/usecases/requests/requests.dart +++ b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart @@ -4,6 +4,7 @@ import 'package:rxdart/rxdart.dart'; import '../../../config/request_defaults.dart'; import '../../../shared/logger/logger.dart'; +import '../../../shared/nips/nip01/event_kind_classification.dart'; import '../../../shared/nips/nip01/helpers.dart'; import '../../entities/account.dart'; import '../../entities/event_filter.dart'; @@ -60,14 +61,64 @@ class Requests { required EventVerifier eventVerifier, required List eventOutFilters, required Duration defaultQueryTimeout, - }) : _engine = networkEngine, - _relayManager = relayManager, - _cacheWrite = cacheWrite, - _cacheRead = cacheRead, - _globalState = globalState, - _eventVerifier = eventVerifier, - _eventOutFilters = eventOutFilters, - _defaultQueryTimeout = defaultQueryTimeout; + }) : _engine = networkEngine, + _relayManager = relayManager, + _cacheWrite = cacheWrite, + _cacheRead = cacheRead, + _globalState = globalState, + _eventVerifier = eventVerifier, + _eventOutFilters = eventOutFilters, + _defaultQueryTimeout = defaultQueryTimeout; + + Stream _prepareNetworkStream( + Stream verifiedNetworkStream, { + required bool writeToCache, + }) { + if (!writeToCache) { + return verifiedNetworkStream; + } + + return verifiedNetworkStream + .flatMap( + (event) => + Stream.fromFuture(_persistAndFilterVisibleNetworkEvent(event)), + ) + .whereType() + .shareReplay(maxSize: 1); + } + + Future _persistAndFilterVisibleNetworkEvent( + Nip01Event event, + ) async { + // Ephemeral events (NIP-01 kinds 20000-29999) are non-persistent by + // definition. They must not be written to cache — relays don't store them + // either. Inbound events flow through to the subscriber but are not + // persisted, preventing unbounded cache growth. Locally-created ephemeral + // events (broadcast / pending-delivery) bypass this path and remain cached + // for local-first delivery and retry. + if (EventKindClassification.isEphemeralKind(event.kind)) { + return event; + } + + await _cacheWrite.cacheManager.saveEvent(event); + if (event.sources.isNotEmpty) { + await _cacheWrite.cacheManager.addEventSources( + eventId: event.id, + relayUrls: event.sources.toSet(), + ); + } + + if (event.kind == 5) { + return event; + } + + final visible = await _cacheWrite.cacheManager.loadEvents( + ids: [event.id], + limit: 1, + ); + + return visible.any((candidate) => candidate.id == event.id) ? event : null; + } /// Set the fetched ranges tracker for automatic range recording set fetchedRanges(FetchedRanges? fetchedRanges) => @@ -93,7 +144,8 @@ class Requests { NdkResponse query({ Filter? filter, @Deprecated( - 'Use filter instead. Multiple filters support will be removed in a future version.') + 'Use filter instead. Multiple filters support will be removed in a future version.', + ) List? filters, String name = '', RelaySet? relaySet, @@ -129,21 +181,23 @@ class Requests { ); } - return requestNostrEvent(NdkRequest.query( - '$name-${Helpers.getRandomString(10)}', - name: name, - filters: effectiveFilters.map((e) => e.clone()).toList(), - relaySet: relaySet, - cacheRead: cacheRead, - cacheWrite: cacheWrite, - timeoutDuration: timeout, - timeoutCallbackUserFacing: timeoutCallbackUserFacing, - timeoutCallback: timeoutCallback, - explicitRelays: explicitRelays, - desiredCoverage: - desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, - authenticateAs: authenticateAs, - )); + return requestNostrEvent( + NdkRequest.query( + '$name-${Helpers.getRandomString(10)}', + name: name, + filters: effectiveFilters.map((e) => e.clone()).toList(), + relaySet: relaySet, + cacheRead: cacheRead, + cacheWrite: cacheWrite, + timeoutDuration: timeout, + timeoutCallbackUserFacing: timeoutCallbackUserFacing, + timeoutCallback: timeoutCallback, + explicitRelays: explicitRelays, + desiredCoverage: + desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, + authenticateAs: authenticateAs, + ), + ); } /// Creates a low-level Nostr subscription @@ -163,7 +217,8 @@ class Requests { NdkResponse subscription({ Filter? filter, @Deprecated( - 'Use filter instead. Multiple filters support will be removed in a future version.') + 'Use filter instead. Multiple filters support will be removed in a future version.', + ) List? filters, String name = '', String? id, @@ -178,18 +233,20 @@ class Requests { throw ArgumentError('Either filter or filters must be provided'); } final effectiveFilters = filter != null ? [filter] : filters!; - return requestNostrEvent(NdkRequest.subscription( - id ?? "$name-${Helpers.getRandomString(10)}", - name: name, - filters: effectiveFilters.map((e) => e.clone()).toList(), - relaySet: relaySet, - cacheRead: cacheRead, - cacheWrite: cacheWrite, - explicitRelays: explicitRelays, - desiredCoverage: - desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, - authenticateAs: authenticateAs, - )); + return requestNostrEvent( + NdkRequest.subscription( + id ?? "$name-${Helpers.getRandomString(10)}", + name: name, + filters: effectiveFilters.map((e) => e.clone()).toList(), + relaySet: relaySet, + cacheRead: cacheRead, + cacheWrite: cacheWrite, + explicitRelays: explicitRelays, + desiredCoverage: + desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, + authenticateAs: authenticateAs, + ), + ); } /// Closes a Nostr network subscription @@ -197,8 +254,10 @@ class Requests { final relayUrls = _globalState.inFlightRequests[subId]?.requests.keys; if (relayUrls == null) { - Logger.log.w(() => - "no relay urls found for subscription $subId, cannot close :: debug: $debugLabel"); + Logger.log.w( + () => + "no relay urls found for subscription $subId, cannot close :: debug: $debugLabel", + ); return; } Iterable relays = _relayManager.connectedRelays @@ -213,7 +272,8 @@ class Requests { if (state == null) { Logger.log.w( - () => "no request state found for subscription $subId, cannot close"); + () => "no request state found for subscription $subId, cannot close", + ); return; } @@ -223,8 +283,11 @@ class Requests { /// Close all subscriptions Future closeAllSubscription() async { - await Future.wait(_globalState.inFlightRequests.values - .map((state) => closeSubscription(state.id))); + await Future.wait( + _globalState.inFlightRequests.values.map( + (state) => closeSubscription(state.id), + ), + ); } /// Performs a low-level Nostr event request @@ -260,18 +323,14 @@ class Requests { eventVerifier: _eventVerifier, )(); - /// register cache new responses - _cacheWrite.saveNetworkResponse( + final preparedNetworkStream = _prepareNetworkStream( + verifiedNetworkStream, writeToCache: request.cacheWrite, - inputStream: verifiedNetworkStream, ); // register listener StreamResponseCleaner( - inputStreams: [ - verifiedNetworkStream, - state.cacheController.stream, - ], + inputStreams: [preparedNetworkStream, state.cacheController.stream], trackingSet: state.returnedIds, outController: state.controller, eventOutFilters: _eventOutFilters, @@ -353,21 +412,23 @@ class Requests { final since = filter.since; // First request to discover relays and get initial events - final initialResponse = requestNostrEvent(NdkRequest.query( - '$name-page-initial-${Helpers.getRandomString(5)}', - name: name, - filters: [filter.clone()], - relaySet: relaySet, - cacheRead: cacheRead, - cacheWrite: cacheWrite, - timeoutDuration: timeout, - timeoutCallbackUserFacing: timeoutCallbackUserFacing, - timeoutCallback: timeoutCallback, - explicitRelays: explicitRelays, - desiredCoverage: - desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, - authenticateAs: authenticateAs, - )); + final initialResponse = requestNostrEvent( + NdkRequest.query( + '$name-page-initial-${Helpers.getRandomString(5)}', + name: name, + filters: [filter.clone()], + relaySet: relaySet, + cacheRead: cacheRead, + cacheWrite: cacheWrite, + timeoutDuration: timeout, + timeoutCallbackUserFacing: timeoutCallbackUserFacing, + timeoutCallback: timeoutCallback, + explicitRelays: explicitRelays, + desiredCoverage: + desiredCoverage ?? RequestDefaults.DEFAULT_BEST_RELAYS_MIN_COUNT, + authenticateAs: authenticateAs, + ), + ); final initialEvents = await initialResponse.future; @@ -427,20 +488,22 @@ class Requests { final pageFilter = filter.clone(); pageFilter.until = state.currentUntil; - final response = requestNostrEvent(NdkRequest.query( - '$name-page-${Helpers.getRandomString(5)}', - name: name, - filters: [pageFilter], - relaySet: relaySet, - cacheRead: false, // Don't read from cache for subsequent pages - cacheWrite: cacheWrite, - timeoutDuration: timeout, - timeoutCallbackUserFacing: timeoutCallbackUserFacing, - timeoutCallback: timeoutCallback, - explicitRelays: [relay], - desiredCoverage: 1, - authenticateAs: authenticateAs, - )); + final response = requestNostrEvent( + NdkRequest.query( + '$name-page-${Helpers.getRandomString(5)}', + name: name, + filters: [pageFilter], + relaySet: relaySet, + cacheRead: false, // Don't read from cache for subsequent pages + cacheWrite: cacheWrite, + timeoutDuration: timeout, + timeoutCallbackUserFacing: timeoutCallbackUserFacing, + timeoutCallback: timeoutCallback, + explicitRelays: [relay], + desiredCoverage: 1, + authenticateAs: authenticateAs, + ), + ); return MapEntry(relay, await response.future); }); diff --git a/packages/ndk/lib/domain_layer/usecases/requests/verify_event_stream.dart b/packages/ndk/lib/domain_layer/usecases/requests/verify_event_stream.dart index 42a9022a0..883d89648 100644 --- a/packages/ndk/lib/domain_layer/usecases/requests/verify_event_stream.dart +++ b/packages/ndk/lib/domain_layer/usecases/requests/verify_event_stream.dart @@ -29,8 +29,9 @@ class VerifyEventStream { final valid = await eventVerifier.verify(data); if (!valid) { - Logger.log - .w(() => 'WARNING: Event with id ${data.id} has invalid signature'); + Logger.log.w( + () => 'WARNING: Event with id ${data.id} has invalid signature', + ); return null; } diff --git a/packages/ndk/lib/domain_layer/usecases/search/search.dart b/packages/ndk/lib/domain_layer/usecases/search/search.dart index 2b85f7ecc..0f40673eb 100644 --- a/packages/ndk/lib/domain_layer/usecases/search/search.dart +++ b/packages/ndk/lib/domain_layer/usecases/search/search.dart @@ -8,11 +8,9 @@ class Search { final CacheManager _cacheManager; final Requests _requests; - Search({ - required CacheManager cacheManager, - required Requests requests, - }) : _cacheManager = cacheManager, - _requests = requests; + Search({required CacheManager cacheManager, required Requests requests}) + : _cacheManager = cacheManager, + _requests = requests; /// Search for metadata \ /// [query] can be pubkey, name, nip05 diff --git a/packages/ndk/lib/domain_layer/usecases/stream_response_cleaner/stream_response_cleaner.dart b/packages/ndk/lib/domain_layer/usecases/stream_response_cleaner/stream_response_cleaner.dart index 7a296f9eb..e89813bdc 100644 --- a/packages/ndk/lib/domain_layer/usecases/stream_response_cleaner/stream_response_cleaner.dart +++ b/packages/ndk/lib/domain_layer/usecases/stream_response_cleaner/stream_response_cleaner.dart @@ -24,10 +24,10 @@ class StreamResponseCleaner { required List> inputStreams, required StreamController outController, required List eventOutFilters, - }) : _trackingSet = trackingSet, - _outController = outController, - _inputStreams = inputStreams, - _eventOutFilters = eventOutFilters; + }) : _trackingSet = trackingSet, + _outController = outController, + _inputStreams = inputStreams, + _eventOutFilters = eventOutFilters; void call() { for (final stream in _inputStreams) { @@ -36,31 +36,35 @@ class StreamResponseCleaner { } void _addStreamListener(Stream stream) { - stream.listen((event) { - // check if event id is in the set - if (_trackingSet.contains(event.id)) { - return; - } + stream.listen( + (event) { + // check if event id is in the set + if (_trackingSet.contains(event.id)) { + return; + } - if (_outController.isClosed) { - return; - } + if (_outController.isClosed) { + return; + } - _trackingSet.add(event.id); + _trackingSet.add(event.id); - // check against filters - for (final filter in _eventOutFilters) { - if (!filter.filter(event)) { - return; + // check against filters + for (final filter in _eventOutFilters) { + if (!filter.filter(event)) { + return; + } } - } - _outController.add(event); - Logger.log.t(() => "added event ${event.content}"); - }, onDone: () async { - _canClose(); - }, onError: (error) { - Logger.log.e(() => "⛔ $error "); - }); + _outController.add(event); + Logger.log.t(() => "added event ${event.content}"); + }, + onDone: () async { + _canClose(); + }, + onError: (error) { + Logger.log.e(() => "⛔ $error "); + }, + ); } /// used to wait on all streams diff --git a/packages/ndk/lib/domain_layer/usecases/ta/trusted_assertions.dart b/packages/ndk/lib/domain_layer/usecases/ta/trusted_assertions.dart index c00fc48bf..e060b9ff2 100644 --- a/packages/ndk/lib/domain_layer/usecases/ta/trusted_assertions.dart +++ b/packages/ndk/lib/domain_layer/usecases/ta/trusted_assertions.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'dart:async'; import '../../entities/filter.dart'; @@ -15,8 +16,8 @@ class TrustedAssertions { TrustedAssertions({ required Requests requests, required List defaultProviders, - }) : _requests = requests, - _defaultProviders = defaultProviders; + }) : _requests = requests, + _defaultProviders = defaultProviders; /// Filter providers by kind and optionally by metrics List _filterProviders( @@ -77,19 +78,20 @@ class TrustedAssertions { final providerPubkeys = relayProviders.map((p) => p.pubkey).toList(); try { - await for (final event in _requests - .query( - filter: Filter( - kinds: [Nip85Kind.user], - authors: providerPubkeys, - dTags: [pubkey], - limit: providerPubkeys.length, - ), - explicitRelays: [relay], - cacheRead: true, - cacheWrite: true, - ) - .stream) { + await for (final event + in _requests + .query( + filter: Filter( + kinds: [Nip85Kind.user], + authors: providerPubkeys, + dTags: [pubkey], + limit: providerPubkeys.length, + ), + explicitRelays: [relay], + cacheRead: true, + cacheWrite: true, + ) + .stream) { final parsed = _parseUserMetricsEvent(event, metrics); if (parsed == null) continue; @@ -330,19 +332,20 @@ class TrustedAssertions { final providerPubkeys = relayProviders.map((p) => p.pubkey).toList(); try { - await for (final event in _requests - .query( - filter: Filter( - kinds: [Nip85Kind.event], - authors: providerPubkeys, - dTags: [eventId], - limit: providerPubkeys.length, - ), - explicitRelays: [relay], - cacheRead: true, - cacheWrite: true, - ) - .stream) { + await for (final event + in _requests + .query( + filter: Filter( + kinds: [Nip85Kind.event], + authors: providerPubkeys, + dTags: [eventId], + limit: providerPubkeys.length, + ), + explicitRelays: [relay], + cacheRead: true, + cacheWrite: true, + ) + .stream) { final parsed = _parseEventMetricsEvent(event, metrics); if (parsed == null) continue; @@ -560,19 +563,20 @@ class TrustedAssertions { final providerPubkeys = relayProviders.map((p) => p.pubkey).toList(); try { - await for (final event in _requests - .query( - filter: Filter( - kinds: [Nip85Kind.addressable], - authors: providerPubkeys, - dTags: [eventAddress], - limit: providerPubkeys.length, - ), - explicitRelays: [relay], - cacheRead: true, - cacheWrite: true, - ) - .stream) { + await for (final event + in _requests + .query( + filter: Filter( + kinds: [Nip85Kind.addressable], + authors: providerPubkeys, + dTags: [eventAddress], + limit: providerPubkeys.length, + ), + explicitRelays: [relay], + cacheRead: true, + cacheWrite: true, + ) + .stream) { final parsed = _parseAddressableMetricsEvent(event, metrics); if (parsed != null && parsed.createdAt > latestCreatedAt) { result = parsed; @@ -627,15 +631,12 @@ class TrustedAssertions { subscriptionIds.add(response.requestId); - response.stream.listen( - (event) { - final parsed = _parseAddressableMetricsEvent(event, metrics); - if (parsed != null) { - controller.add(parsed); - } - }, - onError: (e) {}, - ); + response.stream.listen((event) { + final parsed = _parseAddressableMetricsEvent(event, metrics); + if (parsed != null) { + controller.add(parsed); + } + }, onError: (e) {}); } controller.onCancel = () async { @@ -721,19 +722,20 @@ class TrustedAssertions { final providerPubkeys = relayProviders.map((p) => p.pubkey).toList(); try { - await for (final event in _requests - .query( - filter: Filter( - kinds: [Nip85Kind.externalId], - authors: providerPubkeys, - dTags: [identifier], - limit: providerPubkeys.length, - ), - explicitRelays: [relay], - cacheRead: true, - cacheWrite: true, - ) - .stream) { + await for (final event + in _requests + .query( + filter: Filter( + kinds: [Nip85Kind.externalId], + authors: providerPubkeys, + dTags: [identifier], + limit: providerPubkeys.length, + ), + explicitRelays: [relay], + cacheRead: true, + cacheWrite: true, + ) + .stream) { final parsed = _parseExternalIdMetricsEvent(event, metrics); if (parsed != null && parsed.createdAt > latestCreatedAt) { result = parsed; @@ -788,15 +790,12 @@ class TrustedAssertions { subscriptionIds.add(response.requestId); - response.stream.listen( - (event) { - final parsed = _parseExternalIdMetricsEvent(event, metrics); - if (parsed != null) { - controller.add(parsed); - } - }, - onError: (e) {}, - ); + response.stream.listen((event) { + final parsed = _parseExternalIdMetricsEvent(event, metrics); + if (parsed != null) { + controller.add(parsed); + } + }, onError: (e) {}); } controller.onCancel = () async { diff --git a/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart b/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart index 1a08966f2..146b526aa 100644 --- a/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart +++ b/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart @@ -31,10 +31,10 @@ class UserRelayLists { required CacheManager cacheManager, required Broadcast broadcast, required Accounts accounts, - }) : _cacheManager = cacheManager, - _requests = requests, - _broadcast = broadcast, - _accounts = accounts; + }) : _cacheManager = cacheManager, + _requests = requests, + _broadcast = broadcast, + _accounts = accounts; EventSigner get _signer { if (_accounts.isNotLoggedIn) { @@ -52,8 +52,9 @@ class UserRelayLists { }) async { List missingPubKeys = []; for (var pubKey in pubKeys) { - UserRelayList? userRelayList = - await _cacheManager.loadUserRelayList(pubKey); + UserRelayList? userRelayList = await _cacheManager.loadUserRelayList( + pubKey, + ); if (userRelayList == null || forceRefresh) { // TODO check if not too old (time passed since last refreshed timestamp) missingPubKeys.add(pubKey); @@ -65,35 +66,42 @@ class UserRelayLists { Set found = {}; if (missingPubKeys.isNotEmpty) { - Logger.log - .d(() => "loading missing relay lists ${missingPubKeys.length}"); + Logger.log.d( + () => "loading missing relay lists ${missingPubKeys.length}", + ); if (onProgress != null) { onProgress.call( - "Loading missing relay lists", 0, missingPubKeys.length); + "Loading missing relay lists", + 0, + missingPubKeys.length, + ); } try { await for (final event in (_requests.query( -// timeout: missingPubKeys.length > 1 ? 10 : 3, - name: "user-relay-lists", - filters: [ - Filter( - authors: missingPubKeys, - kinds: [Nip65.kKind, ContactList.kKind]) - ])).stream) { + // timeout: missingPubKeys.length > 1 ? 10 : 3, + name: "user-relay-lists", + filters: [ + Filter( + authors: missingPubKeys, + kinds: [Nip65.kKind, ContactList.kKind], + ), + ], + )).stream) { switch (event.kind) { case Nip65.kKind: Nip65 nip65 = Nip65.fromEvent(event); - if (nip65.relays.isNotEmpty) { - UserRelayList fromNip65 = UserRelayList.fromNip65(nip65); - if (fromNip65s[event.pubKey] == null || - fromNip65s[event.pubKey]!.createdAt < event.createdAt) { - fromNip65s[event.pubKey] = fromNip65; - } - if (onProgress != null) { - found.add(event.pubKey); - onProgress.call("Loading missing relay lists", found.length, - missingPubKeys.length); - } + UserRelayList fromNip65 = UserRelayList.fromNip65(nip65); + if (fromNip65s[event.pubKey] == null || + fromNip65s[event.pubKey]!.createdAt < event.createdAt) { + fromNip65s[event.pubKey] = fromNip65; + } + if (onProgress != null) { + found.add(event.pubKey); + onProgress.call( + "Loading missing relay lists", + found.length, + missingPubKeys.length, + ); } case ContactList.kKind: ContactList contactList = ContactList.fromEvent(event); @@ -107,8 +115,11 @@ class UserRelayLists { } if (onProgress != null) { found.add(event.pubKey); - onProgress.call("Loading missing relay lists", found.length, - missingPubKeys.length); + onProgress.call( + "Loading missing relay lists", + found.length, + missingPubKeys.length, + ); } } } @@ -116,30 +127,30 @@ class UserRelayLists { } catch (e) { Logger.log.e(() => e); } - Set relayLists = Set.of(fromNip65s.values); - // Only add kind3 contents relays if there is no Nip65 for given pubKey. - // This is because kind3 contents relay should be deprecated, and if we have a nip65 list should be considered more up-to-date. - for (MapEntry entry in fromNip02Contacts.entries) { - if (!fromNip65s.containsKey(entry.key)) { - relayLists.add(entry.value); + final eventsToSave = []; + eventsToSave.addAll( + fromNip65s.values.map((relayList) => relayList.toNip65().toEvent()), + ); + for (final contactList in contactLists) { + final existingEvent = await _loadCachedRelaySourceEvent( + pubKey: contactList.pubKey, + kind: ContactList.kKind, + ); + if (existingEvent == null || + existingEvent.createdAt < contactList.createdAt) { + eventsToSave.add(contactList.toEvent()); } } - await _cacheManager.saveUserRelayLists(relayLists.toList()); - - // also save to cache any fresher contact list - List contactListsSave = []; - for (ContactList contactList in contactLists) { - ContactList? existing = - await _cacheManager.loadContactList(contactList.pubKey); - if (existing == null || existing.createdAt < contactList.createdAt) { - contactListsSave.add(contactList); - } + if (eventsToSave.isNotEmpty) { + await _cacheManager.saveEvents(eventsToSave); } - await _cacheManager.saveContactLists(contactListsSave); if (onProgress != null) { onProgress.call( - "Loading missing relay lists", found.length, missingPubKeys.length); + "Loading missing relay lists", + found.length, + missingPubKeys.length, + ); } } Logger.log.d(() => "Loaded ${found.length} relay lists "); @@ -150,14 +161,14 @@ class UserRelayLists { String pubKey, { bool forceRefresh = false, }) async { - UserRelayList? userRelayList = - await _cacheManager.loadUserRelayList(pubKey); + UserRelayList? userRelayList = await _cacheManager.loadUserRelayList( + pubKey, + ); if (userRelayList == null || forceRefresh) { - await loadMissingRelayListsFromNip65OrNip02( - [pubKey], - forceRefresh: forceRefresh, - ); + await loadMissingRelayListsFromNip65OrNip02([ + pubKey, + ], forceRefresh: forceRefresh); userRelayList = await _cacheManager.loadUserRelayList(pubKey); } return userRelayList; @@ -187,16 +198,14 @@ class UserRelayLists { } } - final events = await _requests.query( - name: "dm-relays", - filters: [ - Filter( - authors: [pubKey], - kinds: [Nip51List.kDmRelays], - limit: 1, - ), - ], - ).future; + final events = await _requests + .query( + name: "dm-relays", + filters: [ + Filter(authors: [pubKey], kinds: [Nip51List.kDmRelays], limit: 1), + ], + ) + .future; if (events.isEmpty) return null; @@ -222,12 +231,18 @@ class UserRelayLists { /// if cached user relay list is older that now minus this duration that we should go refresh it, /// otherwise we risk adding/removing relays to a list that is out of date and thus loosing relays other client has added/removed since. - int sometimeAgo = DateTime.now() + int sometimeAgo = + DateTime.now() .subtract(REFRESH_USER_RELAY_DURATION) .millisecondsSinceEpoch ~/ 1000; + final latestSourceEvent = await _loadCachedUserRelaySourceEvent( + _signer.getPublicKey(), + ); bool refresh = - userRelayList == null || userRelayList.refreshedTimestamp < sometimeAgo; + userRelayList == null || + latestSourceEvent == null || + latestSourceEvent.createdAt < sometimeAgo; if (refresh) { userRelayList = await getSingleUserRelayList( @@ -250,14 +265,19 @@ class UserRelayLists { if (userRelayList == null) { int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; userRelayList = UserRelayList( - pubKey: _signer.getPublicKey(), - relays: { - for (String url in broadcastRelays) url: ReadWriteMarker.readWrite - }, - createdAt: now, - refreshedTimestamp: now); + pubKey: _signer.getPublicKey(), + relays: { + for (String url in broadcastRelays) url: ReadWriteMarker.readWrite, + }, + createdAt: now, + refreshedTimestamp: now, + ); } userRelayList.relays[relayUrl] = marker; + userRelayList.createdAt = _nextReplaceableTimestamp( + userRelayList.createdAt, + ); + userRelayList.refreshedTimestamp = Helpers.now; final broadcastResponse = _broadcast.broadcast( nostrEvent: userRelayList.toNip65().toEvent(), @@ -266,7 +286,7 @@ class UserRelayLists { await Future.wait([ broadcastResponse.broadcastDoneFuture, Future.delayed(Duration(seconds: 1)), - _cacheManager.saveUserRelayList(userRelayList) + _cacheManager.saveEvent(userRelayList.toNip65().toEvent()), ]); return userRelayList; } @@ -281,10 +301,10 @@ class UserRelayLists { double? considerDonePercent, Duration? timeout, }) async { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // set created at and refreshed timestamp - newUserRelayList.refreshedTimestamp = now; - newUserRelayList.createdAt = now; + newUserRelayList.createdAt = _nextReplaceableTimestamp( + newUserRelayList.createdAt, + ); + newUserRelayList.refreshedTimestamp = Helpers.now; final broadcastResponse = _broadcast.broadcast( nostrEvent: newUserRelayList.toNip65().toEvent(), @@ -295,7 +315,7 @@ class UserRelayLists { await broadcastResponse.broadcastDoneFuture; - await _cacheManager.saveUserRelayList(newUserRelayList); + await _cacheManager.saveEvent(newUserRelayList.toNip65().toEvent()); return newUserRelayList; } @@ -313,6 +333,9 @@ class UserRelayLists { } if (userRelayList.relays.keys.contains(relayUrl)) { userRelayList.relays.remove(relayUrl); + userRelayList.createdAt = _nextReplaceableTimestamp( + userRelayList.createdAt, + ); userRelayList.refreshedTimestamp = Helpers.now; final broadcastResponse = _broadcast.broadcast( @@ -322,7 +345,7 @@ class UserRelayLists { await Future.wait([ broadcastResponse.broadcastDoneFuture, Future.delayed(Duration(seconds: 1)), - _cacheManager.saveUserRelayList(userRelayList) + _cacheManager.saveEvent(userRelayList.toNip65().toEvent()), ]); } return userRelayList; @@ -353,20 +376,67 @@ class UserRelayLists { } if (url != null) { userRelayList.relays[url] = marker; + userRelayList.createdAt = _nextReplaceableTimestamp( + userRelayList.createdAt, + ); userRelayList.refreshedTimestamp = Helpers.now; final broadcastResponse = _broadcast.broadcast( - nostrEvent: userRelayList.toNip65().toEvent(), - specificRelays: broadcastRelays); + nostrEvent: userRelayList.toNip65().toEvent(), + specificRelays: broadcastRelays, + ); await broadcastResponse.broadcastDoneFuture; await Future.delayed(Duration(seconds: 1)); - await _cacheManager.saveUserRelayList(userRelayList); + await _cacheManager.saveEvent(userRelayList.toNip65().toEvent()); } return userRelayList; } + Future _loadCachedRelaySourceEvent({ + required String pubKey, + required int kind, + }) async { + final events = await _cacheManager.loadEvents( + pubKeys: [pubKey], + kinds: [kind], + limit: 1, + ); + if (events.isEmpty) return null; + return events.first; + } + + Future _loadCachedUserRelaySourceEvent(String pubKey) async { + final events = await _cacheManager.loadEvents( + pubKeys: [pubKey], + kinds: [Nip65.kKind, ContactList.kKind], + limit: 10, + ); + + Nip01Event? latestNip65; + Nip01Event? latestKind3WithRelayContent; + for (final event in events) { + if (event.kind == Nip65.kKind) { + latestNip65 ??= event; + } else if (event.kind == ContactList.kKind && + event.content.isNotEmpty && + ContactList.relaysFromContent(event).isNotEmpty) { + latestKind3WithRelayContent ??= event; + } + } + + return latestNip65 ?? latestKind3WithRelayContent; + } + + int _nextReplaceableTimestamp(int currentCreatedAt) { + final now = Helpers.now; + if (now > currentCreatedAt) { + return now; + } + return currentCreatedAt + 1; + } + /// reads the latest nip65 data from cache \ /// [pubkeys] pubkeys you want nip65 data for \ /// [cacheManger] the cache manager you want to use @@ -376,9 +446,7 @@ class UserRelayLists { }) async { // get the data from cache final List events = await Future.wait( - pubkeys.map( - (pubkey) => cacheManager.loadUserRelayList(pubkey), - ), + pubkeys.map((pubkey) => cacheManager.loadUserRelayList(pubkey)), ).then((results) => results.whereType().toList()); List nip65Data = _filterLatest(events); @@ -408,8 +476,9 @@ class UserRelayLists { final List cleanData = []; for (final data in uncleanData) { - final alreadyIn = - cleanData.where((element) => element.pubKey == data.pubKey); + final alreadyIn = cleanData.where( + (element) => element.pubKey == data.pubKey, + ); if (alreadyIn.isNotEmpty) { final existing = alreadyIn.first; diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index 07842209a..c501cf692 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -35,22 +35,22 @@ class Wallets { BehaviorSubject>(); final BehaviorSubject> - _combinedPendingTransactionsSubject = + _combinedPendingTransactionsSubject = BehaviorSubject>(); final BehaviorSubject> - _combinedRecentTransactionsSubject = + _combinedRecentTransactionsSubject = BehaviorSubject>(); /// individual wallet streams - created on demand final Map>> - _walletBalanceStreams = {}; + _walletBalanceStreams = {}; final Map>> - _walletPendingTransactionStreams = {}; + _walletPendingTransactionStreams = {}; final Map>> - _walletRecentTransactionStreams = {}; + _walletRecentTransactionStreams = {}; /// stream subscriptions for cleanup final Map> _subscriptions = {}; @@ -61,8 +61,8 @@ class Wallets { required List providers, required WalletsRepo repository, this.latestTransactionCount = 10, - }) : _providers = {for (final p in providers) p.type: p}, - _repository = repository { + }) : _providers = {for (final p in providers) p.type: p}, + _repository = repository { _initializationFuture = _initialize(); } @@ -168,15 +168,16 @@ class Wallets { } // Listen to discovered wallets from all providers - _walletsUsecaseSubscription = Rx.merge( - _providers.values.map((p) => p.discoveredWallets), - ).listen((wallets) { - for (final wallet in wallets) { - if (!_wallets.any((w) => w.id == wallet.id)) { - addWallet(wallet); - } - } - }); + _walletsUsecaseSubscription = + Rx.merge(_providers.values.map((p) => p.discoveredWallets)).listen(( + wallets, + ) { + for (final wallet in wallets) { + if (!_wallets.any((w) => w.id == wallet.id)) { + addWallet(wallet); + } + } + }); if (_isDisposed) { return; @@ -187,8 +188,9 @@ class Wallets { void _updateCombinedStreams() { // combine all wallet balances - final allBalances = - _walletsBalances.values.expand((balances) => balances).toList(); + final allBalances = _walletsBalances.values + .expand((balances) => balances) + .toList(); if (!_combinedBalancesSubject.isClosed) { _combinedBalancesSubject.add(allBalances); } @@ -380,13 +382,18 @@ class Wallets { final provider = _providers[wallet.type]; if (provider != null) { subscriptions.add( - provider.getBalances(wallet).listen((balances) { - _walletsBalances[id] = balances; - _walletBalanceStreams[id]?.add(balances); - _updateCombinedStreams(); - }, onError: (error) { - _walletBalanceStreams[id]?.add([]); - }), + provider + .getBalances(wallet) + .listen( + (balances) { + _walletsBalances[id] = balances; + _walletBalanceStreams[id]?.add(balances); + _updateCombinedStreams(); + }, + onError: (error) { + _walletBalanceStreams[id]?.add([]); + }, + ), ); } } @@ -411,15 +418,21 @@ class Wallets { final provider = _providers[wallet.type]; if (provider != null) { subscriptions.add( - provider.getRecentTransactions(wallet).listen((transactions) { - transactions = - transactions.where((tx) => tx.state.isDone).toList(); - _walletsRecentTransactions[id] = transactions; - _walletRecentTransactionStreams[id]?.add(transactions); - _updateCombinedStreams(); - }, onError: (error) { - _walletRecentTransactionStreams[id]?.add([]); - }), + provider + .getRecentTransactions(wallet) + .listen( + (transactions) { + transactions = transactions + .where((tx) => tx.state.isDone) + .toList(); + _walletsRecentTransactions[id] = transactions; + _walletRecentTransactionStreams[id]?.add(transactions); + _updateCombinedStreams(); + }, + onError: (error) { + _walletRecentTransactionStreams[id]?.add([]); + }, + ), ); } } @@ -444,15 +457,21 @@ class Wallets { final provider = _providers[wallet.type]; if (provider != null) { subscriptions.add( - provider.getPendingTransactions(wallet).listen((transactions) { - transactions = - transactions.where((tx) => tx.state.isPending).toList(); - _walletsPendingTransactions[id] = transactions; - _walletPendingTransactionStreams[id]?.add(transactions); - _updateCombinedStreams(); - }, onError: (error) { - _walletPendingTransactionStreams[id]?.add([]); - }), + provider + .getPendingTransactions(wallet) + .listen( + (transactions) { + transactions = transactions + .where((tx) => tx.state.isPending) + .toList(); + _walletsPendingTransactions[id] = transactions; + _walletPendingTransactionStreams[id]?.add(transactions); + _updateCombinedStreams(); + }, + onError: (error) { + _walletPendingTransactionStreams[id]?.add([]); + }, + ), ); } } @@ -481,7 +500,8 @@ class Wallets { } Stream> getPendingTransactionsStream( - String walletId) { + String walletId, + ) { _initPendingTransactionStream(walletId); return _walletPendingTransactionStreams[walletId]!.stream; } @@ -492,8 +512,9 @@ class Wallets { if (balances == null) { return 0; } - final balance = - balances.firstWhereOrNull((balance) => balance.unit == unit); + final balance = balances.firstWhereOrNull( + (balance) => balance.unit == unit, + ); return balance?.amount ?? 0; } @@ -530,8 +551,11 @@ class Wallets { } /// Send payment - Future send( - {String? walletId, required String invoice, Duration? timeout}) async { + Future send({ + String? walletId, + required String invoice, + Duration? timeout, + }) async { await _initializationFuture; walletId ??= _repository.getDefaultWalletIdForSending(); if (walletId == null) { @@ -562,8 +586,9 @@ class Wallets { } Future _getWalletForOperation(String walletId) async { - final inMemory = - _wallets.firstWhereOrNull((wallet) => wallet.id == walletId); + final inMemory = _wallets.firstWhereOrNull( + (wallet) => wallet.id == walletId, + ); if (inMemory != null) { return inMemory; } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart index 35fd4d430..0855b0234 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart @@ -5,6 +5,9 @@ class InvoiceResponse { String? nostrPubkey; /// . - InvoiceResponse( - {required this.invoice, this.nostrPubkey, required this.amountSats}); + InvoiceResponse({ + required this.invoice, + this.nostrPubkey, + required this.amountSats, + }); } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart index 0ac0b98f3..a3b845f90 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -111,5 +111,6 @@ class ZapReceipt { String toString() { return 'ZapReceipt(paidAt: $paidAt, pubKey: $pubKey, bolt11: $bolt11, preimage: $preimage, amount: $amountSats, recipient: $recipient, eventId: $eventId, comment: $comment, sender: $sender, anon: $anon)'; } + // coverage:ignore-end } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index 922d979c2..6e7683f40 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -9,17 +9,16 @@ class ZapRequest extends Nip01Event { /// [event] the nip01 event \ /// returns the zap request \ /// kind is set to [kZapRequestKind] - ZapRequest.nip01Event({ - required Nip01Event event, - }) : super( - pubKey: event.pubKey, - tags: event.tags, - content: event.content, - id: event.id, - sig: event.sig, - kind: kZapRequestKind, - validSig: event.validSig, - ); + ZapRequest.nip01Event({required Nip01Event event}) + : super( + pubKey: event.pubKey, + tags: event.tags, + content: event.content, + id: event.id, + sig: event.sig, + kind: kZapRequestKind, + validSig: event.validSig, + ); /// Zap Request ZapRequest._({ @@ -28,10 +27,7 @@ class ZapRequest extends Nip01Event { required super.content, required super.id, super.sig, - }) : super( - kind: kZapRequestKind, - validSig: null, - ); + }) : super(kind: kZapRequestKind, validSig: null); /// creates a zap request \ /// [pubKey] the pubkey of the zap requester \ diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 06391c886..408d5662d 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -21,20 +21,18 @@ class Zaps { final Lnurl _lnurl; /// . - Zaps({ - required Requests requests, - required Nwc nwc, - required Lnurl lnurl, - }) : _requests = requests, - _nwc = nwc, - _lnurl = lnurl; + Zaps({required Requests requests, required Nwc nwc, required Lnurl lnurl}) + : _requests = requests, + _nwc = nwc, + _lnurl = lnurl; /// creates an invoice with an optional zap request encoded if signer, pubKey & relays are non empty - Future fetchInvoice( - {required String lud16Link, - required int amountSats, - ZapRequest? zapRequest, - String? comment}) async { + Future fetchInvoice({ + required String lud16Link, + required int amountSats, + ZapRequest? zapRequest, + String? comment, + }) async { final lnurlResponse = await _lnurl.getLnurlResponse(lud16Link); if (lnurlResponse == null) { return null; @@ -42,9 +40,10 @@ class Zaps { try { return _lnurl.fetchInvoice( - lnurlResponse: lnurlResponse, - amountSats: amountSats, - zapRequest: zapRequest); + lnurlResponse: lnurlResponse, + amountSats: amountSats, + zapRequest: zapRequest, + ); } catch (e) { Logger.log.d(() => e); return null; @@ -82,7 +81,10 @@ class Zaps { tags.add(["poll_option", pollOption]); } final zapRequestEvent = ZapRequest( - pubKey: signer.getPublicKey(), tags: tags, content: comment ?? ''); + pubKey: signer.getPublicKey(), + tags: tags, + content: comment ?? '', + ); final signedEvent = await signer.sign(zapRequestEvent); final sigendZapRequest = ZapRequest.nip01Event(event: signedEvent); @@ -113,13 +115,14 @@ class Zaps { relays != null && relays.isNotEmpty) { zapRequest = await createZapRequest( - amountSats: amountSats, - signer: signer, - pubKey: pubKey, - comment: comment, - relays: relays, - eventId: eventId, - addressableId: addressableId); + amountSats: amountSats, + signer: signer, + pubKey: pubKey, + comment: comment, + relays: relays, + eventId: eventId, + addressableId: addressableId, + ); } final invoice = await fetchInvoice( lud16Link: lud16Link!, @@ -131,8 +134,11 @@ class Zaps { return ZapResponse(error: "couldn't get invoice from $lnurl"); } try { - final payResponse = await _nwc.payInvoice(nwcConnection, - invoice: invoice.invoice, timeout: Duration(seconds: 10)); + final payResponse = await _nwc.payInvoice( + nwcConnection, + invoice: invoice.invoice, + timeout: Duration(seconds: 10), + ); if (payResponse.preimage != null && payResponse.preimage!.isNotEmpty && payResponse.errorCode == null) { @@ -142,29 +148,35 @@ class Zaps { invoice.nostrPubkey != null && invoice.nostrPubkey!.isNotEmpty) { // if it's a zap, try to find the zap receipt - zapResponse.receiptResponse = - _requests.subscription(explicitRelays: relays, filters: [ - eventId != null - ? Filter( - kinds: [ZapReceipt.kKind], - eTags: [eventId], - pTags: [pubKey!]) - : Filter(kinds: [ZapReceipt.kKind], pTags: [pubKey!]) - ]); + zapResponse.receiptResponse = _requests.subscription( + explicitRelays: relays, + filters: [ + eventId != null + ? Filter( + kinds: [ZapReceipt.kKind], + eTags: [eventId], + pTags: [pubKey!], + ) + : Filter(kinds: [ZapReceipt.kKind], pTags: [pubKey!]), + ], + ); // TODO make timeout waiting for receipt parameterizable somehow StreamSubscription? streamSubscription; final timeout = Timer(Duration(seconds: 30), () { - _requests - .closeSubscription(zapResponse.zapReceiptResponse!.requestId); + _requests.closeSubscription( + zapResponse.zapReceiptResponse!.requestId, + ); if (streamSubscription != null) { streamSubscription.cancel(); } Logger.log.w( - () => "timed out waiting for zap receipt for invoice $invoice"); + () => "timed out waiting for zap receipt for invoice $invoice", + ); }); - streamSubscription = - zapResponse.zapReceiptResponse!.stream.listen((event) { + streamSubscription = zapResponse.zapReceiptResponse!.stream.listen(( + event, + ) { String? bolt11 = event.getFirstTag("bolt11"); String? preimage = event.getFirstTag("preimage"); if (bolt11 != null && bolt11 == invoice.invoice || @@ -172,14 +184,17 @@ class Zaps { ZapReceipt receipt = ZapReceipt.fromEvent(event); Logger.log.d(() => "Zap Receipt: $receipt"); if (receipt.isValid( - nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { + nostrPubKey: invoice.nostrPubkey!, + recipientLnurl: lnurl, + )) { zapResponse.emitReceipt(receipt); } else { Logger.log.w(() => "Zap Receipt invalid: $receipt"); } timeout.cancel(); - _requests - .closeSubscription(zapResponse.zapReceiptResponse!.requestId); + _requests.closeSubscription( + zapResponse.zapReceiptResponse!.requestId, + ); if (streamSubscription != null) { streamSubscription.cancel(); } @@ -197,19 +212,23 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - Stream fetchZappedReceipts( - {required String pubKey, - String? eventId, - String? addressableId, - Duration? timeout}) { - NdkResponse? response = - _requests.query(timeout: timeout ?? Duration(seconds: 10), filters: [ - Filter( + Stream fetchZappedReceipts({ + required String pubKey, + String? eventId, + String? addressableId, + Duration? timeout, + }) { + NdkResponse? response = _requests.query( + timeout: timeout ?? Duration(seconds: 10), + filters: [ + Filter( kinds: [ZapReceipt.kKind], eTags: eventId != null ? [eventId] : null, aTags: addressableId != null ? [addressableId] : null, - pTags: [pubKey]) - ]); + pTags: [pubKey], + ), + ], + ); // TODO how to check validity of zap receipts without nostrPubKey and recipientLnurl???? return response.stream.map((event) => ZapReceipt.fromEvent(event)); // List events = await response.future; @@ -217,15 +236,21 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - NdkResponse subscribeToZapReceipts( - {required String pubKey, String? eventId, String? addressableId}) { - NdkResponse? response = _requests.subscription(filters: [ - Filter( + NdkResponse subscribeToZapReceipts({ + required String pubKey, + String? eventId, + String? addressableId, + }) { + NdkResponse? response = _requests.subscription( + filters: [ + Filter( kinds: [ZapReceipt.kKind], eTags: eventId != null ? [eventId] : null, aTags: addressableId != null ? [addressableId] : null, - pTags: [pubKey]) - ]); + pTags: [pubKey], + ), + ], + ); return response; } } diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 8bddaf429..b3e8a7015 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -6,9 +6,11 @@ library; export 'domain_layer/entities/broadcast_response.dart'; export 'domain_layer/entities/broadcast_state.dart'; +export 'domain_layer/entities/cache_eviction.dart'; export 'domain_layer/entities/connection_source.dart'; export 'domain_layer/entities/contact_list.dart'; export 'domain_layer/entities/event_filter.dart'; +export 'domain_layer/entities/event_cache_records.dart'; export 'domain_layer/entities/filter.dart'; export 'domain_layer/entities/global_state.dart'; export 'domain_layer/entities/metadata.dart'; @@ -35,6 +37,8 @@ export 'domain_layer/entities/blob_upload_progress.dart'; export 'domain_layer/entities/file_hash_progress.dart'; export 'domain_layer/entities/account.dart'; export 'domain_layer/entities/gift_wrap_unwrap_result.dart'; +export 'domain_layer/entities/nip_17_message.dart'; +export 'domain_layer/entities/nip_17_conversation.dart'; export 'domain_layer/entities/nip_85.dart'; /// Cashu entities diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 50457cf3f..fd573528a 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -45,6 +45,10 @@ export 'domain_layer/entities/blossom_blobs.dart'; export 'domain_layer/entities/blossom_strategies.dart'; export 'domain_layer/entities/blob_upload_progress.dart'; export 'domain_layer/entities/file_hash_progress.dart'; +export 'domain_layer/entities/cache_eviction.dart'; +export 'domain_layer/entities/event_cache_records.dart'; +export 'domain_layer/entities/event_delivery_inspection.dart'; +export 'domain_layer/entities/nip_17_conversation.dart'; export ''; export 'domain_layer/entities/account.dart'; @@ -95,6 +99,9 @@ export 'domain_layer/usecases/accounts/accounts.dart'; export 'domain_layer/usecases/files/blossom_user_server_list.dart'; export 'domain_layer/usecases/search/search.dart'; export 'domain_layer/usecases/gift_wrap/gift_wrap.dart'; +export 'domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart'; +export 'domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart'; +export 'domain_layer/usecases/dms/dms.dart'; export 'domain_layer/usecases/cashu/cashu.dart'; export 'domain_layer/usecases/cashu/cashu_seed.dart'; export 'domain_layer/usecases/cashu/cashu_export_import.dart'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 6e58baa2a..2bd9614cc 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:http/http.dart' as http; import '../data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; @@ -11,7 +13,9 @@ 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 '../domain_layer/entities/global_state.dart'; +import '../domain_layer/entities/connection_source.dart'; import '../domain_layer/entities/jit_engine_relay_connectivity_data.dart'; +import '../domain_layer/entities/relay_connectivity.dart'; import '../domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; @@ -22,14 +26,17 @@ import '../domain_layer/repositories/nip_05_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/broadcast/broadcast_sender.dart'; +import '../domain_layer/usecases/broadcast/pending_broadcast_delivery.dart'; import '../domain_layer/usecases/bunkers/bunkers.dart'; -import '../domain_layer/usecases/proof_of_work/proof_of_work.dart'; +import '../domain_layer/usecases/cache_eviction/cache_eviction_scheduler.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/decrypted_event_payloads/decrypted_event_payloads.dart'; import '../domain_layer/usecases/engines/network_engine.dart'; +import '../domain_layer/usecases/fetched_ranges/fetched_ranges.dart'; import '../domain_layer/usecases/files/blossom.dart'; import '../domain_layer/usecases/files/blossom_user_server_list.dart'; import '../domain_layer/usecases/files/files.dart'; @@ -40,8 +47,10 @@ import '../domain_layer/usecases/lists/lists.dart'; import '../domain_layer/usecases/lnurl/lnurl.dart'; import '../domain_layer/usecases/metadatas/metadatas.dart'; import '../domain_layer/usecases/nip05/nip_05.dart'; +import '../domain_layer/usecases/dms/dms.dart'; import '../domain_layer/usecases/nip77/nip77.dart'; import '../domain_layer/usecases/nwc/nwc.dart'; +import '../domain_layer/usecases/proof_of_work/proof_of_work.dart'; import '../domain_layer/usecases/relay_manager.dart'; import '../domain_layer/usecases/relay_sets/relay_sets.dart'; import '../domain_layer/usecases/relay_sets_engine.dart'; @@ -85,6 +94,7 @@ class Initialization { late Lists lists; late RelaySets relaySets; late Broadcast broadcast; + late PendingBroadcastDelivery pendingBroadcastDelivery; late Nwc nwc; late Zaps zaps; late Lnurl lnurl; @@ -93,12 +103,18 @@ class Initialization { late BlossomUserServerList blossomUserServerList; late Search search; late GiftWrap giftWrap; + late Dms dms; late Connectivy connectivity; + late DecryptedEventPayloads decryptedEventPayloads; late Cashu cashu; late Wallets wallets; late FetchedRanges fetchedRanges; + CacheEvictionScheduler? cacheEvictionScheduler; late ProofOfWork proofOfWork; late TrustedAssertions trustedAssertions; + StreamSubscription>? + _relayConnectivitySubscription; + final Map _relayOpenStates = {}; late Nip05Usecase nip05; late Nip77 nip77; @@ -110,8 +126,8 @@ class Initialization { Initialization({ required NdkConfig ndkConfig, required GlobalState globalState, - }) : _globalState = globalState, - _ndkConfig = ndkConfig { + }) : _globalState = globalState, + _ndkConfig = ndkConfig { // Configure global WebSocket User-Agent on dart:io platforms configureDefaultUserAgent(ndkConfig.userAgent); @@ -157,21 +173,23 @@ class Initialization { } /// repositories - final Nip05Repository nip05repository = - Nip05HttpRepositoryImpl(httpDS: _httpRequestDS); + final Nip05Repository nip05repository = Nip05HttpRepositoryImpl( + httpDS: _httpRequestDS, + ); final BlossomRepository blossomRepository = BlossomRepositoryImpl( client: _httpRequestDS, fileIO: createFileIO(), ); - final CashuRepo cashuRepo = CashuRepoImpl( - client: _httpRequestDS, - ); + final CashuRepo cashuRepo = CashuRepoImpl(client: _httpRequestDS); /// use cases cacheWrite = CacheWrite(_ndkConfig.cache); cacheRead = CacheRead(_ndkConfig.cache); + decryptedEventPayloads = DecryptedEventPayloads( + cacheManager: _ndkConfig.cache, + ); requests = Requests( defaultQueryTimeout: _ndkConfig.defaultQueryTimeout, @@ -184,7 +202,7 @@ class Initialization { eventOutFilters: _ndkConfig.eventOutFilters, ); - broadcast = Broadcast( + final broadcastSender = BroadcastSender( globalState: _globalState, networkEngine: engine, cacheManager: _ndkConfig.cache, @@ -193,6 +211,29 @@ class Initialization { timeout: _ndkConfig.defaultBroadcastTimeout, saveToCache: _ndkConfig.defaultBroadcastSaveToCache, ); + pendingBroadcastDelivery = PendingBroadcastDelivery( + cacheManager: _ndkConfig.cache, + broadcastSender: broadcastSender, + accounts: accounts, + ); + broadcast = Broadcast( + broadcastSender: broadcastSender, + accounts: accounts, + cacheManager: _ndkConfig.cache, + pendingDelivery: pendingBroadcastDelivery, + ); + _relayConnectivitySubscription = relayManager.relayConnectivityChanges + .listen(_handleRelayConnectivityUpdate); + pendingBroadcastDelivery.startPeriodicRetry( + connectedRelayUrls: () => + relayManager.connectedRelays.map((relay) => relay.url), + reconnectRelay: (relayUrl) => relayManager.reconnectRelay( + relayUrl, + connectionSource: ConnectionSource.explicit, + force: true, + ), + retryInterval: _ndkConfig.pendingDeliveryRetryInterval, + ); // Initialize nwc and cashu before walletsOperationsRepo since they are dependencies nwc = Nwc( @@ -255,6 +296,7 @@ class Initialization { broadcast: broadcast, accounts: accounts, eventSignerFactory: _ndkConfig.eventSignerFactory, + decryptedEventPayloads: decryptedEventPayloads, ); relaySets = RelaySets( @@ -269,19 +311,16 @@ class Initialization { nip05Repository: nip05repository, ); - final LnurlTransport lnurlTransport = - LnurlTransportHttpImpl(_httpRequestDS); + final LnurlTransport lnurlTransport = LnurlTransportHttpImpl( + _httpRequestDS, + ); lnurl = Lnurl(transport: lnurlTransport); // Create LNURL wallet provider after lnurl is initialized final lnurlProvider = LnurlWalletProvider(lnurl); - zaps = Zaps( - requests: requests, - nwc: nwc, - lnurl: lnurl, - ); + zaps = Zaps(requests: requests, nwc: nwc, lnurl: lnurl); blossomUserServerList = BlossomUserServerList( requests: requests, @@ -298,14 +337,9 @@ class Initialization { files = Files(blossom: blossom); - search = Search( - cacheManager: _ndkConfig.cache, - requests: requests, - ); + search = Search(cacheManager: _ndkConfig.cache, requests: requests); - fetchedRanges = FetchedRanges( - cacheManager: _ndkConfig.cache, - ); + fetchedRanges = FetchedRanges(cacheManager: _ndkConfig.cache); // Connect fetchedRanges to requests for automatic range recording (if enabled) if (_ndkConfig.fetchedRangesEnabled) { @@ -316,6 +350,15 @@ class Initialization { accounts: accounts, eventVerifier: _ndkConfig.eventVerifier, eventSignerFactory: _ndkConfig.eventSignerFactory, + decryptedEventPayloads: decryptedEventPayloads, + ); + dms = Dms( + accounts: accounts, + requests: requests, + broadcast: broadcast, + giftWrap: giftWrap, + userRelayLists: userRelayLists, + cacheManager: _ndkConfig.cache, ); connectivity = Connectivy(relayManager); @@ -341,6 +384,16 @@ class Initialization { defaultProviders: _ndkConfig.defaultTrustedProviders, ); + if (_ndkConfig.cacheEvictionEnabled) { + cacheEvictionScheduler = CacheEvictionScheduler( + cacheManager: _ndkConfig.cache, + policy: _ndkConfig.cacheEvictionPolicy, + startupDelay: _ndkConfig.cacheEvictionStartupDelay, + interval: _ndkConfig.cacheEvictionInterval, + runOnStartup: _ndkConfig.runCacheEvictionOnStartup, + )..start(); + } + /// set the user configured log level Logger.setLogLevel(_ndkConfig.logLevel); } @@ -349,4 +402,35 @@ class Initialization { void closeAllNip77Negotiations() { nip77.closeAll(); } + + Future dispose() async { + await cacheEvictionScheduler?.stop(); + await pendingBroadcastDelivery.stop(); + await _relayConnectivitySubscription?.cancel(); + } + + void _handleRelayConnectivityUpdate(Map relays) { + for (final entry in relays.entries) { + final relayUrl = entry.key; + final isOpen = entry.value.relayTransport?.isOpen() ?? false; + final wasOpen = _relayOpenStates[relayUrl] ?? false; + _relayOpenStates[relayUrl] = isOpen; + + if (isOpen && !wasOpen) { + unawaited( + pendingBroadcastDelivery.retryInteractiveSigningForTransportRelay( + relayUrl, + ), + ); + unawaited(pendingBroadcastDelivery.flushForRelay(relayUrl)); + } + } + + final removedUrls = _relayOpenStates.keys + .where((relayUrl) => !relays.containsKey(relayUrl)) + .toList(); + for (final relayUrl in removedUrls) { + _relayOpenStates.remove(relayUrl); + } + } } diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 35f65d212..f62b6d5ed 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -7,7 +7,9 @@ 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/cache_eviction/cache_eviction_scheduler.dart'; import '../domain_layer/usecases/connectivity/connectivity.dart'; +import '../domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart'; import '../domain_layer/usecases/fetched_ranges/fetched_ranges.dart'; import '../domain_layer/usecases/files/blossom.dart'; import '../domain_layer/usecases/files/blossom_user_server_list.dart'; @@ -17,6 +19,7 @@ import '../domain_layer/usecases/gift_wrap/gift_wrap.dart'; import '../domain_layer/usecases/lists/lists.dart'; import '../domain_layer/usecases/metadatas/metadatas.dart'; import '../domain_layer/usecases/nip05/nip_05.dart'; +import '../domain_layer/usecases/dms/dms.dart'; import '../domain_layer/usecases/nip77/nip77.dart'; import '../domain_layer/usecases/nwc/nwc.dart'; import '../domain_layer/usecases/proof_of_work/proof_of_work.dart'; @@ -47,29 +50,29 @@ class Ndk { /// Creates a new instance of [Ndk] with the given [config] Ndk(this.config) - : _initialization = Initialization( - ndkConfig: config, - globalState: _globalState, - ); + : _initialization = Initialization( + ndkConfig: config, + globalState: _globalState, + ); /// Creates a new instance of [Ndk] with default configuration Ndk.defaultConfig() - : this( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: Bip340EventVerifier(), - ), - ); + : this( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: Bip340EventVerifier(), + ), + ); /// Creates a new instance of [Ndk] with default configuration and empty bootstrap relays Ndk.emptyBootstrapRelaysConfig() - : this( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: Bip340EventVerifier(), - bootstrapRelays: [], - ), - ); + : this( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: Bip340EventVerifier(), + bootstrapRelays: [], + ), + ); /// Provides access to low-level Nostr requests. /// @@ -135,12 +138,28 @@ class Ndk { /// low level usecase, recommended for advanced users GiftWrap get giftWrap => _initialization.giftWrap; + /// Direct messages usecase. + /// + /// App-facing alias for NIP-17 private direct messages. + Dms get dms => _initialization.dms; + /// Use case for managing relay connectivity \ /// get notified about relay connectivity changes \ /// and update NDK about your application connectivity \ /// for faster reconnects Connectivy get connectivity => _initialization.connectivity; + /// Background cache eviction scheduler, when enabled in config. + CacheEvictionScheduler? get cacheEvictionScheduler => + _initialization.cacheEvictionScheduler; + + /// Read-through cache for decrypted event payloads. + /// + /// Lets callers cache plaintext sidecars keyed by `(eventId, viewerPubKey)` + /// so remote signers do not need to decrypt the same event repeatedly. + DecryptedEventPayloads get decryptedEventPayloads => + _initialization.decryptedEventPayloads; + ProofOfWork get proofOfWork => _initialization.proofOfWork; /// Nostr Wallet connect @@ -189,6 +208,7 @@ class Ndk { /// Close all transports on relay manager Future destroy() async { final allFutures = [ + _initialization.dispose(), Future(() => _initialization.closeAllNip77Negotiations()), nwc.disconnectAll(), _initialization.requests.closeAllSubscription(), diff --git a/packages/ndk/lib/presentation_layer/ndk_config.dart b/packages/ndk/lib/presentation_layer/ndk_config.dart index 4bcdb9212..526cd6c7c 100644 --- a/packages/ndk/lib/presentation_layer/ndk_config.dart +++ b/packages/ndk/lib/presentation_layer/ndk_config.dart @@ -5,6 +5,7 @@ import '../config/logger_defaults.dart'; import '../config/request_defaults.dart'; import '../data_layer/repositories/signers/bip340_event_signer.dart'; import '../domain_layer/entities/cashu/cashu_user_seedphrase.dart'; +import '../domain_layer/entities/cache_eviction.dart'; import '../domain_layer/entities/event_filter.dart'; import '../domain_layer/entities/nip_85.dart'; import '../domain_layer/repositories/cache_manager.dart'; @@ -86,9 +87,27 @@ class NdkConfig { /// Defaults to 30 seconds. Duration authCallbackTimeout; + /// Interval for retrying pending broadcast deliveries while relays remain connected. + Duration pendingDeliveryRetryInterval; + /// Default trusted providers for NIP-85 trusted assertions. List defaultTrustedProviders; + /// Whether background cache eviction scheduling is enabled. + bool cacheEvictionEnabled; + + /// Cache eviction policy used by the background scheduler. + EvictionPolicy cacheEvictionPolicy; + + /// Delay before the first scheduled cache eviction run after startup. + Duration cacheEvictionStartupDelay; + + /// Interval between background cache eviction runs. + Duration cacheEvictionInterval; + + /// Whether to run cache eviction once on startup before periodic runs. + bool runCacheEvictionOnStartup; + /// Creates a new instance of [NdkConfig]. /// /// [eventVerifier] The verifier used to validate Nostr events. \ @@ -121,7 +140,13 @@ class NdkConfig { this.fetchedRangesEnabled = false, this.eagerAuth = false, this.authCallbackTimeout = RequestDefaults.DEFAULT_AUTH_CALLBACK_TIMEOUT, + this.pendingDeliveryRetryInterval = const Duration(seconds: 15), this.defaultTrustedProviders = DEFAULT_NIP85_PROVIDERS, + this.cacheEvictionEnabled = false, + this.cacheEvictionPolicy = const EvictionPolicy(), + this.cacheEvictionStartupDelay = const Duration(minutes: 1), + this.cacheEvictionInterval = const Duration(hours: 1), + this.runCacheEvictionOnStartup = true, }); } @@ -133,5 +158,5 @@ enum NdkEngine { /// Uses Just-In-Time (JIT) mode for network operations. // ignore: constant_identifier_names - JIT + JIT, } diff --git a/packages/ndk/lib/shared/bloom_filter/bloom_filter.dart b/packages/ndk/lib/shared/bloom_filter/bloom_filter.dart index 3480367a6..910727cc6 100644 --- a/packages/ndk/lib/shared/bloom_filter/bloom_filter.dart +++ b/packages/ndk/lib/shared/bloom_filter/bloom_filter.dart @@ -15,7 +15,8 @@ class BloomFilter { }) { if (falsePositiveProbability <= 0 || falsePositiveProbability >= 1) { throw ArgumentError( - "False positive probability must be in range (0, 1)."); + "False positive probability must be in range (0, 1).", + ); } if (numItems <= 0) { throw ArgumentError("Number of items must be positive."); diff --git a/packages/ndk/lib/shared/bloom_filter/bloom_filter_prehash.dart b/packages/ndk/lib/shared/bloom_filter/bloom_filter_prehash.dart index d5fc249e0..3b2be80a2 100644 --- a/packages/ndk/lib/shared/bloom_filter/bloom_filter_prehash.dart +++ b/packages/ndk/lib/shared/bloom_filter/bloom_filter_prehash.dart @@ -16,7 +16,8 @@ class BloomFilterPrehash { }) { if (falsePositiveProbability <= 0 || falsePositiveProbability >= 1) { throw ArgumentError( - "False positive probability must be in range (0, 1)."); + "False positive probability must be in range (0, 1).", + ); } if (numItems <= 0) { throw ArgumentError("Number of items must be positive."); diff --git a/packages/ndk/lib/shared/decode_nostr_msg/decode_nostr_msg.dart b/packages/ndk/lib/shared/decode_nostr_msg/decode_nostr_msg.dart index 3f2c0dbd7..19345da93 100644 --- a/packages/ndk/lib/shared/decode_nostr_msg/decode_nostr_msg.dart +++ b/packages/ndk/lib/shared/decode_nostr_msg/decode_nostr_msg.dart @@ -15,7 +15,9 @@ NostrMessageRaw decodeNostrMsg(String msgJsonStr) { switch (msgTypeStr) { case 'NOTICE': return NostrMessageRaw( - type: NostrMessageRawType.notice, otherData: decoded); + type: NostrMessageRawType.notice, + otherData: decoded, + ); case 'EVENT': if (decoded.length < 3) { return NostrMessageRaw(type: NostrMessageRawType.unknown); @@ -47,29 +49,41 @@ NostrMessageRaw decodeNostrMsg(String msgJsonStr) { ); case 'OK': return NostrMessageRaw( - type: NostrMessageRawType.ok, otherData: decoded); + type: NostrMessageRawType.ok, + otherData: decoded, + ); case 'CLOSED': return NostrMessageRaw( - type: NostrMessageRawType.closed, otherData: decoded); + type: NostrMessageRawType.closed, + otherData: decoded, + ); case 'AUTH': return NostrMessageRaw( - type: NostrMessageRawType.auth, otherData: decoded); + type: NostrMessageRawType.auth, + otherData: decoded, + ); case 'NEG-MSG': return NostrMessageRaw( - type: NostrMessageRawType.negMsg, - requestId: decoded.length > 1 ? decoded[1] : null, - otherData: decoded); + type: NostrMessageRawType.negMsg, + requestId: decoded.length > 1 ? decoded[1] : null, + otherData: decoded, + ); case 'NEG-ERR': return NostrMessageRaw( - type: NostrMessageRawType.negErr, - requestId: decoded.length > 1 ? decoded[1] : null, - otherData: decoded); + type: NostrMessageRawType.negErr, + requestId: decoded.length > 1 ? decoded[1] : null, + otherData: decoded, + ); default: return NostrMessageRaw( - type: NostrMessageRawType.unknown, otherData: decoded); + type: NostrMessageRawType.unknown, + otherData: decoded, + ); } } catch (e) { return NostrMessageRaw( - type: NostrMessageRawType.unknown, otherData: msgJsonStr); + type: NostrMessageRawType.unknown, + otherData: msgJsonStr, + ); } } diff --git a/packages/ndk/lib/shared/event_filters/nip51_mute_event_filter.dart b/packages/ndk/lib/shared/event_filters/nip51_mute_event_filter.dart index f9b62b9d1..4fa9aa5c1 100644 --- a/packages/ndk/lib/shared/event_filters/nip51_mute_event_filter.dart +++ b/packages/ndk/lib/shared/event_filters/nip51_mute_event_filter.dart @@ -16,8 +16,9 @@ class Nip51MuteEventFilter extends EventFilter { _mutedTags = muteList.hashtags .map((element) => element.value.trim().toLowerCase()) .toList(); - List words = - muteList.words.map((e) => e.value.toLowerCase()).toList(); + List words = muteList.words + .map((e) => e.value.toLowerCase()) + .toList(); List> treeWords = List.generate(words.length, (index) { var word = words[index]; return word.codeUnits; @@ -97,9 +98,7 @@ class TrieNode { Map children = {}; bool done; - TrieNode({ - this.done = false, - }); + TrieNode({this.done = false}); void insertWord(List word, List skips) { var current = this; diff --git a/packages/ndk/lib/shared/helpers/relay_helper.dart b/packages/ndk/lib/shared/helpers/relay_helper.dart index c5fc02381..7e1384fb7 100644 --- a/packages/ndk/lib/shared/helpers/relay_helper.dart +++ b/packages/ndk/lib/shared/helpers/relay_helper.dart @@ -1,11 +1,14 @@ import 'package:ndk/shared/helpers/url_normalizer.dart'; final RegExp relayUrlRegex = RegExp( - r'^(wss?:\/\/)([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*|[0-9]{1,3}(?:\.[0-9]{1,3}){3}):?([0-9]{1,5})?(\/[^\s]*)?$'); + r'^(wss?:\/\/)([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*|[0-9]{1,3}(?:\.[0-9]{1,3}){3}):?([0-9]{1,5})?(\/[^\s]*)?$', +); /// Matches extra slashes after protocol (e.g., wss:/// or wss:////) -final RegExp _extraSlashesRegex = - RegExp(r'^(wss?:)\/{3,}', caseSensitive: false); +final RegExp _extraSlashesRegex = RegExp( + r'^(wss?:)\/{3,}', + caseSensitive: false, +); /// Normalizes a relay URL according to RFC 3986. /// @@ -31,7 +34,9 @@ String? cleanRelayUrl(String adr) { // Remove extra slashes after protocol (e.g., wss:/// -> wss://) adr = adr.replaceFirstMapped( - _extraSlashesRegex, (match) => '${match.group(1)}//'); + _extraSlashesRegex, + (match) => '${match.group(1)}//', + ); // Parse using Dart's Uri class for RFC 3986 compliance Uri uri; diff --git a/packages/ndk/lib/shared/isolates/isolate_manager_io.dart b/packages/ndk/lib/shared/isolates/isolate_manager_io.dart index 219854f84..f468b8d04 100644 --- a/packages/ndk/lib/shared/isolates/isolate_manager_io.dart +++ b/packages/ndk/lib/shared/isolates/isolate_manager_io.dart @@ -10,19 +10,23 @@ const int kMinIsolatePoolSize = 1; const int kNumberOfProcessorsFactor = 4; final int encodingIsolatePoolSize = math.max( - kMinIsolatePoolSize, - math.min(Platform.numberOfProcessors ~/ kNumberOfProcessorsFactor, - kMaxIsolatePoolSize)); + kMinIsolatePoolSize, + math.min( + Platform.numberOfProcessors ~/ kNumberOfProcessorsFactor, + kMaxIsolatePoolSize, + ), +); final int computeIsolatePoolSize = math.max( - kMinIsolatePoolSize, - math.min(Platform.numberOfProcessors ~/ kNumberOfProcessorsFactor, - kMaxIsolatePoolSize)); - -typedef StreamComputeTask = FutureOr Function( - Q argument, - void Function(P progress) emit, + kMinIsolatePoolSize, + math.min( + Platform.numberOfProcessors ~/ kNumberOfProcessorsFactor, + kMaxIsolatePoolSize, + ), ); +typedef StreamComputeTask = + FutureOr Function(Q argument, void Function(P progress) emit); + class IsolateConfig { Isolate isolate; SendPort sendPort; @@ -74,16 +78,20 @@ class IsolateManager { Future _initialize() async { try { - Logger.log.d(() => - "Initializing encoding isolate pool size = $encodingIsolatePoolSize"); + Logger.log.d( + () => + "Initializing encoding isolate pool size = $encodingIsolatePoolSize", + ); // Initialize encoding isolate pool for (int i = 0; i < encodingIsolatePoolSize; i++) { final config = await _createIsolate(); _encodePool.add(config); } - Logger.log.d(() => - "Initializing compute isolate pool size = $encodingIsolatePoolSize"); + Logger.log.d( + () => + "Initializing compute isolate pool size = $encodingIsolatePoolSize", + ); // Initialize compute isolate pool for (int i = 0; i < computeIsolatePoolSize; i++) { final config = await _createIsolate(); @@ -133,19 +141,13 @@ class IsolateManager { Future get ready => _readyCompleter.future; /// dedicated for decoding/encoding json - Future runInEncodingIsolate( - R Function(Q) task, - Q argument, - ) async { + Future runInEncodingIsolate(R Function(Q) task, Q argument) async { await ready; return _runTask(task, argument, _encodePool); } /// dedicated for compute operations (like crypto, hashing, etc) - Future runInComputeIsolate( - R Function(Q) task, - Q argument, - ) async { + Future runInComputeIsolate(R Function(Q) task, Q argument) async { await ready; return _runTask(task, argument, _computePool); } diff --git a/packages/ndk/lib/shared/isolates/isolate_manager_stub.dart b/packages/ndk/lib/shared/isolates/isolate_manager_stub.dart index 910cce1ee..590d05c18 100644 --- a/packages/ndk/lib/shared/isolates/isolate_manager_stub.dart +++ b/packages/ndk/lib/shared/isolates/isolate_manager_stub.dart @@ -1,9 +1,7 @@ import 'dart:async'; -typedef StreamComputeTask = FutureOr Function( - Q argument, - void Function(P progress) emit, -); +typedef StreamComputeTask = + FutureOr Function(Q argument, void Function(P progress) emit); /// Web implementation of IsolateManager that runs tasks on the main thread. /// Isolates are not supported on web platforms, so this implementation @@ -25,19 +23,13 @@ class IsolateManager { Future get ready => _readyCompleter.future; /// On web, runs the task synchronously on the main thread - Future runInEncodingIsolate( - R Function(Q) task, - Q argument, - ) async { + Future runInEncodingIsolate(R Function(Q) task, Q argument) async { await ready; return task(argument); } /// On web, runs the task synchronously on the main thread - Future runInComputeIsolate( - R Function(Q) task, - Q argument, - ) async { + Future runInComputeIsolate(R Function(Q) task, Q argument) async { await ready; return task(argument); } diff --git a/packages/ndk/lib/shared/logger/logger.dart b/packages/ndk/lib/shared/logger/logger.dart index 83d88e799..870737844 100644 --- a/packages/ndk/lib/shared/logger/logger.dart +++ b/packages/ndk/lib/shared/logger/logger.dart @@ -52,4 +52,5 @@ class LogLevels { /// [LogLevel] off - log nothing LogLevel get off => LogLevel.off; } + // coverage:ignore-end diff --git a/packages/ndk/lib/shared/logger/ndk_logger.dart b/packages/ndk/lib/shared/logger/ndk_logger.dart index 3386ae50f..b53f15aaa 100644 --- a/packages/ndk/lib/shared/logger/ndk_logger.dart +++ b/packages/ndk/lib/shared/logger/ndk_logger.dart @@ -16,10 +16,8 @@ class NdkLogger { final List _outputs; /// Constructor - NdkLogger({ - required this.level, - List? outputs, - }) : _outputs = outputs ?? []; + NdkLogger({required this.level, List? outputs}) + : _outputs = outputs ?? []; /// Add an output to the logger void addOutput(LogOutput output) { diff --git a/packages/ndk/lib/shared/nips/nip01/client_msg.dart b/packages/ndk/lib/shared/nips/nip01/client_msg.dart index a7adca6f2..f1bb9127a 100644 --- a/packages/ndk/lib/shared/nips/nip01/client_msg.dart +++ b/packages/ndk/lib/shared/nips/nip01/client_msg.dart @@ -16,12 +16,7 @@ class ClientMsg { List? filters; Nip01Event? event; - ClientMsg( - this.type, { - this.id, - this.event, - this.filters, - }) { + ClientMsg(this.type, {this.id, this.event, this.filters}) { // verify based on type if (type == ClientMsgType.kEvent) { if (event == null) { diff --git a/packages/ndk/lib/shared/nips/nip01/event_eviction_planner.dart b/packages/ndk/lib/shared/nips/nip01/event_eviction_planner.dart new file mode 100644 index 000000000..9f4149c2f --- /dev/null +++ b/packages/ndk/lib/shared/nips/nip01/event_eviction_planner.dart @@ -0,0 +1,440 @@ +import '../../../domain_layer/entities/cache_eviction.dart'; +import '../../../domain_layer/entities/event_cache_records.dart'; +import '../../../domain_layer/entities/nip_01_event.dart'; +import 'event_kind_classification.dart'; + +/// Result of planning one eviction pass before the backend removes any rows. +class EventEvictionPlan { + final Set eventIdsToRemove; + final int removedExpired; + final int removedDeleted; + final int removedSuperseded; + final int removedDeliveredEphemeral; + final int removedByKindCap; + final int keptDueToDeliveryState; + final int keptProtected; + + const EventEvictionPlan({ + required this.eventIdsToRemove, + required this.removedExpired, + required this.removedDeleted, + required this.removedSuperseded, + required this.removedDeliveredEphemeral, + required this.removedByKindCap, + required this.keptDueToDeliveryState, + required this.keptProtected, + }); + + EvictionResult toResult() { + return EvictionResult( + removedEvents: eventIdsToRemove.length, + removedExpired: removedExpired, + removedDeleted: removedDeleted, + removedSuperseded: removedSuperseded, + removedDeliveredEphemeral: removedDeliveredEphemeral, + removedByKindCap: removedByKindCap, + keptDueToDeliveryState: keptDueToDeliveryState, + keptProtected: keptProtected, + ); + } +} + +/// Result of planning a standalone delivery-record sweep. +/// +/// This is independent of event eviction: it targets [EventDeliveryRecord]s +/// (and their [RelayDeliveryTarget]s) that are no longer useful, regardless of +/// whether their parent event is still cached. +class DeliverySweepPlan { + /// Event ids whose delivery record + relay delivery targets should be removed. + final Set deliveryEventIdsToRemove; + final int removedCompletedDeliveries; + final int removedTerminalFailedDeliveries; + + const DeliverySweepPlan({ + required this.deliveryEventIdsToRemove, + required this.removedCompletedDeliveries, + required this.removedTerminalFailedDeliveries, + }); + + static const empty = DeliverySweepPlan( + deliveryEventIdsToRemove: {}, + removedCompletedDeliveries: 0, + removedTerminalFailedDeliveries: 0, + ); + + bool get isEmpty => deliveryEventIdsToRemove.isEmpty; +} + +/// Pure planner for cache eviction decisions. +/// +/// This class is backend-agnostic: it receives raw events plus a small amount +/// of external state (`lockedEventIds`) and decides what should be removed. +/// +/// Important semantics: +/// - structural cleanup runs before kind caps +/// - "visible" means: +/// - not expired +/// - not tombstoned by author delete +/// - latest replaceable/addressable winner for its coordinate +/// - protection only prevents cap-based eviction, not structural cleanup +class EventEvictionPlanner { + static EventEvictionPlan planFromStateRecords({ + required List stateRecords, + required Set lockedEventIds, + required Set deliveredEventIds, + required EvictionPolicy policy, + int? now, + }) { + final currentTime = now ?? Nip01Event.secondsSinceEpoch(); + final eventIdsToRemove = {}; + var removedExpired = 0; + var removedDeleted = 0; + var removedSuperseded = 0; + var removedDeliveredEphemeral = 0; + var removedByKindCap = 0; + var keptDueToDeliveryState = 0; + var keptProtected = 0; + final eligibleByKind = >{}; + + for (final record in stateRecords) { + if (lockedEventIds.contains(record.eventId)) { + keptDueToDeliveryState++; + continue; + } + + if (policy.sweepExpired && record.isExpiredAt(currentTime)) { + eventIdsToRemove.add(record.eventId); + removedExpired++; + continue; + } + + if (policy.sweepDeleted && record.isDeleted) { + eventIdsToRemove.add(record.eventId); + removedDeleted++; + continue; + } + + if (policy.sweepSuperseded && + EventKindClassification.isReplaceableKind(record.kind) && + !record.isCurrent) { + eventIdsToRemove.add(record.eventId); + removedSuperseded++; + continue; + } + + if (policy.sweepDeliveredEphemeral && + EventKindClassification.isEphemeralKind(record.kind) && + deliveredEventIds.contains(record.eventId)) { + eventIdsToRemove.add(record.eventId); + removedDeliveredEphemeral++; + continue; + } + + if (record.isCurrent) { + if (_isCapProtectedRecord(record, policy)) { + keptProtected++; + continue; + } + eligibleByKind + .putIfAbsent(record.kind, () => []) + .add(record); + } + } + + for (final entry in policy.kindCaps.entries) { + final cap = entry.value; + final recordsForKind = eligibleByKind[entry.key]; + if (recordsForKind == null || cap < 0) continue; + + recordsForKind.sort(_compareNewestRecordFirst); + final removable = cap >= recordsForKind.length + ? const [] + : recordsForKind.skip(cap); + for (final record in removable) { + if (eventIdsToRemove.add(record.eventId)) { + removedByKindCap++; + } + } + } + + return EventEvictionPlan( + eventIdsToRemove: eventIdsToRemove, + removedExpired: removedExpired, + removedDeleted: removedDeleted, + removedSuperseded: removedSuperseded, + removedDeliveredEphemeral: removedDeliveredEphemeral, + removedByKindCap: removedByKindCap, + keptDueToDeliveryState: keptDueToDeliveryState, + keptProtected: keptProtected, + ); + } + + /// Plans removal of standalone delivery records that have outlived their + /// usefulness: delivered records past their retention, and (when enabled) + /// terminally failed records past theirs. + /// + /// Delivered records are keyed off `completedAt` (falling back to + /// `updatedAt`); failed records off `updatedAt`. + static DeliverySweepPlan planDeliverySweep({ + required List deliveryRecords, + required EvictionPolicy policy, + int? now, + }) { + if (!policy.sweepCompletedDeliveries && + !policy.sweepTerminalFailedDeliveries) { + return DeliverySweepPlan.empty; + } + + final currentTime = now ?? Nip01Event.secondsSinceEpoch(); + final toRemove = {}; + var removedCompleted = 0; + var removedFailed = 0; + + for (final record in deliveryRecords) { + if (policy.sweepCompletedDeliveries && + record.status == EventDeliveryStatus.delivered) { + final completedAt = record.completedAt ?? record.updatedAt; + if (currentTime - completedAt >= + policy.completedDeliveryRetention.inSeconds) { + if (toRemove.add(record.eventId)) removedCompleted++; + continue; + } + } + + if (policy.sweepTerminalFailedDeliveries && + record.status == EventDeliveryStatus.failed) { + if (currentTime - record.updatedAt >= + policy.terminalFailedDeliveryRetention.inSeconds) { + if (toRemove.add(record.eventId)) removedFailed++; + continue; + } + } + } + + return DeliverySweepPlan( + deliveryEventIdsToRemove: toRemove, + removedCompletedDeliveries: removedCompleted, + removedTerminalFailedDeliveries: removedFailed, + ); + } + + static EventEvictionPlan plan({ + required List rawEvents, + required Set lockedEventIds, + required Set deliveredEventIds, + required EvictionPolicy policy, + int? now, + }) { + final currentTime = now ?? Nip01Event.secondsSinceEpoch(); + final deletionEvents = rawEvents + .where((event) => event.kind == 5) + .toList(growable: false); + final visibleIds = _visibleIds(rawEvents, deletionEvents, currentTime); + + final eventIdsToRemove = {}; + var removedExpired = 0; + var removedDeleted = 0; + var removedSuperseded = 0; + var removedDeliveredEphemeral = 0; + var removedByKindCap = 0; + var keptDueToDeliveryState = 0; + var keptProtected = 0; + final eligibleByKind = >{}; + + for (final event in rawEvents) { + // Delivery-locked events are part of pending background delivery and + // should not disappear while that state exists. + if (lockedEventIds.contains(event.id)) { + keptDueToDeliveryState++; + continue; + } + + if (policy.sweepExpired && _isExpired(event, currentTime)) { + eventIdsToRemove.add(event.id); + removedExpired++; + continue; + } + + if (policy.sweepDeleted && _isDeletedByAuthor(event, deletionEvents)) { + eventIdsToRemove.add(event.id); + removedDeleted++; + continue; + } + + if (policy.sweepSuperseded && + EventKindClassification.isReplaceableKind(event.kind) && + !visibleIds.contains(event.id)) { + eventIdsToRemove.add(event.id); + removedSuperseded++; + continue; + } + + if (policy.sweepDeliveredEphemeral && + EventKindClassification.isEphemeralKind(event.kind) && + deliveredEventIds.contains(event.id)) { + eventIdsToRemove.add(event.id); + removedDeliveredEphemeral++; + continue; + } + + if (visibleIds.contains(event.id)) { + if (_isCapProtected(event, policy)) { + keptProtected++; + continue; + } + eligibleByKind.putIfAbsent(event.kind, () => []).add(event); + } + } + + for (final entry in policy.kindCaps.entries) { + final cap = entry.value; + final eventsForKind = eligibleByKind[entry.key]; + if (eventsForKind == null || cap < 0) continue; + + eventsForKind.sort(_compareNewestFirst); + final removable = cap >= eventsForKind.length + ? const [] + : eventsForKind.skip(cap); + for (final event in removable) { + if (eventIdsToRemove.add(event.id)) { + removedByKindCap++; + } + } + } + + return EventEvictionPlan( + eventIdsToRemove: eventIdsToRemove, + removedExpired: removedExpired, + removedDeleted: removedDeleted, + removedSuperseded: removedSuperseded, + removedDeliveredEphemeral: removedDeliveredEphemeral, + removedByKindCap: removedByKindCap, + keptDueToDeliveryState: keptDueToDeliveryState, + keptProtected: keptProtected, + ); + } + + static bool _isCapProtected(Nip01Event event, EvictionPolicy policy) { + if (policy.protectedEventIds.contains(event.id)) return true; + if (policy.protectedPubKeys.contains(event.pubKey)) return true; + if (policy.protectedKinds.contains(event.kind)) return true; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey != null && + policy.protectedCoordinates.contains(coordinateKey)) { + return true; + } + + return false; + } + + static bool _isCapProtectedRecord( + EventCacheStateRecord record, + EvictionPolicy policy, + ) { + if (policy.protectedEventIds.contains(record.eventId)) return true; + if (policy.protectedPubKeys.contains(record.pubKey)) return true; + if (policy.protectedKinds.contains(record.kind)) return true; + if (record.coordinateKey != null && + policy.protectedCoordinates.contains(record.coordinateKey)) { + return true; + } + return false; + } + + static Set _visibleIds( + List events, + List deletionEvents, + int now, + ) { + // Visibility is computed independently from the backend so every cache + // implementation applies the same local-first rules. + final visible = {}; + final replaceableWinners = {}; + + for (final event in events) { + if (_isExpired(event, now)) continue; + if (_isDeletedByAuthor(event, deletionEvents)) continue; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey == null) { + visible.add(event.id); + continue; + } + + final current = replaceableWinners[coordinateKey]; + if (current == null || _isMoreRecentReplaceable(event, current)) { + replaceableWinners[coordinateKey] = event; + } + } + + visible.addAll(replaceableWinners.values.map((event) => event.id)); + return visible; + } + + static bool _isDeletedByAuthor( + Nip01Event target, + List deletionEvents, + ) { + if (target.kind == 5) return false; + + // Addressable/replaceable events are deleted by coordinate (`a` tag), so a + // later version published after the deletion stays visible (NIP-09 only + // deletes coordinate matches with created_at <= the deletion). + final coordinate = _coordinateKey(target); + + for (final event in deletionEvents) { + if (event.pubKey != target.pubKey) continue; + if (event.getTags('e').contains(target.id.toLowerCase())) { + return true; + } + if (coordinate != null && + event.createdAt >= target.createdAt && + event.getTags('a').contains(coordinate)) { + return true; + } + } + + return false; + } + + static bool _isExpired(Nip01Event event, int now) { + final expirationValue = event.getFirstTag('expiration'); + if (expirationValue == null) return false; + final expiration = int.tryParse(expirationValue); + if (expiration == null) return false; + return expiration <= now; + } + + static String? _coordinateKey(Nip01Event event) { + if (!EventKindClassification.isReplaceableKind(event.kind)) return null; + final dTag = event.getDtag() ?? ''; + return '${event.kind}:${event.pubKey}:$dTag'; + } + + static bool _isMoreRecentReplaceable( + Nip01Event candidate, + Nip01Event current, + ) { + return _compareNewestFirst(candidate, current) < 0; + } + + static int _compareNewestFirst(Nip01Event a, Nip01Event b) { + if (a.createdAt != b.createdAt) { + return b.createdAt.compareTo(a.createdAt); + } + + return a.id.compareTo(b.id); + } + + static int _compareNewestRecordFirst( + EventCacheStateRecord a, + EventCacheStateRecord b, + ) { + if (a.createdAt != b.createdAt) { + return b.createdAt.compareTo(a.createdAt); + } + + return a.eventId.compareTo(b.eventId); + } +} diff --git a/packages/ndk/lib/shared/nips/nip01/event_kind_classification.dart b/packages/ndk/lib/shared/nips/nip01/event_kind_classification.dart new file mode 100644 index 000000000..82a876a74 --- /dev/null +++ b/packages/ndk/lib/shared/nips/nip01/event_kind_classification.dart @@ -0,0 +1,29 @@ +import '../../../domain_layer/entities/contact_list.dart'; +import '../../../domain_layer/entities/metadata.dart'; +import '../nip28/channel_metadata.dart'; + +class EventKindClassification { + static bool isEphemeralKind(int kind) => kind >= 20000 && kind <= 29999; + + static bool isReplaceableKind(int kind) { + if (kind == Metadata.kKind || + kind == ContactList.kKind || + kind == ChannelMetadata.kKind) { + return true; + } + + return isRegularReplaceableKind(kind) || isAddressableKind(kind); + } + + static bool isRegularReplaceableKind(int kind) { + return kind >= 10000 && kind <= 19999; + } + + static bool isAddressableKind(int kind) { + return isParameterizedReplaceableKind(kind); + } + + static bool isParameterizedReplaceableKind(int kind) { + return kind >= 30000 && kind <= 39999; + } +} diff --git a/packages/ndk/lib/shared/nips/nip01/helpers.dart b/packages/ndk/lib/shared/nips/nip01/helpers.dart index 441208627..b7574dc08 100644 --- a/packages/ndk/lib/shared/nips/nip01/helpers.dart +++ b/packages/ndk/lib/shared/nips/nip01/helpers.dart @@ -15,8 +15,12 @@ class Helpers { /// return a random string of given length static String getRandomString(int length) { - return String.fromCharCodes(Iterable.generate( - length, (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length)))); + return String.fromCharCodes( + Iterable.generate( + length, + (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length)), + ), + ); } /// return a secure random string of given length @@ -91,7 +95,11 @@ class Helpers { /// If pad is true, and there are remaining bits after the conversion, then the remaining bits are left-shifted and added to the result /// [return] - the converted data static List _convertBits( - List data, int fromBits, int toBits, bool pad) { + List data, + int fromBits, + int toBits, + bool pad, + ) { int acc = 0; int bits = 0; List result = []; diff --git a/packages/ndk/lib/shared/nips/nip04/nip04.dart b/packages/ndk/lib/shared/nips/nip04/nip04.dart index be8f1aaa8..a67a289f5 100644 --- a/packages/ndk/lib/shared/nips/nip04/nip04.dart +++ b/packages/ndk/lib/shared/nips/nip04/nip04.dart @@ -29,23 +29,32 @@ class Nip04 { } static String encryptWithAgreement( - String message, ECDHBasicAgreement agreement, String publicKey) { + String message, + ECDHBasicAgreement agreement, + String publicKey, + ) { var pubKey = getPubKey(publicKey); var agreementD0 = agreement.calculateAgreement(pubKey); var encryptKey = agreementD0.toRadixString(16).padLeft(64, "0"); final random = Random.secure(); - var ivData = - Uint8List.fromList(List.generate(16, (i) => random.nextInt(256))); + var ivData = Uint8List.fromList( + List.generate(16, (i) => random.nextInt(256)), + ); // var iv = "UeAMaJl5Hj6IZcot7zLfmQ=="; // var ivData = base64.decode(iv); - final cipherCbc = - PaddedBlockCipherImpl(PKCS7Padding(), CBCBlockCipher(AESEngine())); + final cipherCbc = PaddedBlockCipherImpl( + PKCS7Padding(), + CBCBlockCipher(AESEngine()), + ); final paramsCbc = PaddedBlockCipherParameters( - ParametersWithIV( - KeyParameter(Uint8List.fromList(hex.decode(encryptKey))), ivData), - null); + ParametersWithIV( + KeyParameter(Uint8List.fromList(hex.decode(encryptKey))), + ivData, + ), + null, + ); cipherCbc.init(true, paramsCbc); // print(cipherCbc.algorithmName); @@ -56,7 +65,10 @@ class Nip04 { } static String decryptWithAgreement( - String message, ECDHBasicAgreement agreement, String publicKey) { + String message, + ECDHBasicAgreement agreement, + String publicKey, + ) { var strs = message.split("?iv="); if (strs.length != 2) { return ""; @@ -74,12 +86,17 @@ class Nip04 { // mode: AESMode.cbc)); // return encrypter.decrypt(Encrypted.from64(message), iv: IV.fromBase64(iv)); - final cipherCbc = - PaddedBlockCipherImpl(PKCS7Padding(), CBCBlockCipher(AESEngine())); + final cipherCbc = PaddedBlockCipherImpl( + PKCS7Padding(), + CBCBlockCipher(AESEngine()), + ); final paramsCbc = PaddedBlockCipherParameters( - ParametersWithIV( - KeyParameter(Uint8List.fromList(hex.decode(encryptKey))), ivData), - null); + ParametersWithIV( + KeyParameter(Uint8List.fromList(hex.decode(encryptKey))), + ivData, + ), + null, + ); cipherCbc.init(false, paramsCbc); var result = cipherCbc.process(base64.decode(message)); @@ -89,8 +106,10 @@ class Nip04 { static ECPublicKey getPubKey(String pk) { // BigInt x = BigInt.parse(pk, radix: 16); - BigInt x = - BigInt.parse(hex.encode(hex.decode(pk.padLeft(64, '0'))), radix: 16); + BigInt x = BigInt.parse( + hex.encode(hex.decode(pk.padLeft(64, '0'))), + radix: 16, + ); BigInt? y; try { y = liftX(x); @@ -102,8 +121,9 @@ class Nip04 { } static var curveP = BigInt.parse( - 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', - radix: 16); + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', + radix: 16, + ); // helper methods: // liftX returns Y for this X diff --git a/packages/ndk/lib/shared/nips/nip13/nip13.dart b/packages/ndk/lib/shared/nips/nip13/nip13.dart index 7246ba69d..96be20882 100644 --- a/packages/ndk/lib/shared/nips/nip13/nip13.dart +++ b/packages/ndk/lib/shared/nips/nip13/nip13.dart @@ -46,13 +46,13 @@ class Nip13 { }) async { return await IsolateManager.instance .runInComputeIsolate<_MiningParams, Nip01Event>( - _mineEventInIsolate, - _MiningParams( - event: event, - targetDifficulty: targetDifficulty, - maxIterations: maxIterations ?? 1000000, - ), - ); + _mineEventInIsolate, + _MiningParams( + event: event, + targetDifficulty: targetDifficulty, + maxIterations: maxIterations ?? 1000000, + ), + ); } static Nip01Event _mineEventInIsolate(_MiningParams params) { @@ -67,8 +67,11 @@ class Nip13 { nonce = random.nextInt(0x100000000); final updatedTags = List>.from(tags); - updatedTags - .add(['nonce', nonce.toString(), params.targetDifficulty.toString()]); + updatedTags.add([ + 'nonce', + nonce.toString(), + params.targetDifficulty.toString(), + ]); final minedEvent = Nip01Event( pubKey: params.event.pubKey, @@ -86,7 +89,8 @@ class Nip13 { } throw Exception( - 'Failed to mine event with difficulty ${params.targetDifficulty} after ${params.maxIterations} iterations'); + 'Failed to mine event with difficulty ${params.targetDifficulty} after ${params.maxIterations} iterations', + ); } static int? getTargetDifficultyFromEvent(Nip01Event event) { diff --git a/packages/ndk/lib/shared/nips/nip19/nip19.dart b/packages/ndk/lib/shared/nips/nip19/nip19.dart index b52220248..6074a6236 100644 --- a/packages/ndk/lib/shared/nips/nip19/nip19.dart +++ b/packages/ndk/lib/shared/nips/nip19/nip19.dart @@ -19,8 +19,9 @@ class Nip19 { static const int kNoteIdLength = 63; static RegExp nip19regex = RegExp( - r'@?(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)', - caseSensitive: false); + r'@?(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)', + caseSensitive: false, + ); // ============================================================================ // Validation methods @@ -126,14 +127,8 @@ class Nip19 { /// Encode nprofile (profile reference) /// [pubkey] - 32-byte hex public key (required) /// [relays] - optional list of relay URLs where the profile may be found - static String encodeNprofile({ - required String pubkey, - List? relays, - }) { - return Nip19Encoder.encodeNprofile( - pubkey: pubkey, - relays: relays, - ); + static String encodeNprofile({required String pubkey, List? relays}) { + return Nip19Encoder.encodeNprofile(pubkey: pubkey, relays: relays); } // ============================================================================ diff --git a/packages/ndk/lib/shared/nips/nip19/nip19_decoder.dart b/packages/ndk/lib/shared/nips/nip19/nip19_decoder.dart index 4beb2d0d5..450a5a5b8 100644 --- a/packages/ndk/lib/shared/nips/nip19/nip19_decoder.dart +++ b/packages/ndk/lib/shared/nips/nip19/nip19_decoder.dart @@ -47,7 +47,8 @@ class Nip19Decoder { if (bech32Result.hrp != Hrps.kNprofile) { throw ArgumentError( - "Invalid HRP: expected '${Hrps.kNprofile}', got '${bech32Result.hrp}'"); + "Invalid HRP: expected '${Hrps.kNprofile}', got '${bech32Result.hrp}'", + ); } var data = Nip19Utils.convertBits(bech32Result.data, 5, 8, false); @@ -81,10 +82,7 @@ class Nip19Decoder { throw ArgumentError('Missing required pubkey field (type 0)'); } - return Nprofile( - pubkey: pubkey, - relays: relays.isEmpty ? null : relays, - ); + return Nprofile(pubkey: pubkey, relays: relays.isEmpty ? null : relays); } /// Decode nevent and return Nevent object @@ -94,7 +92,8 @@ class Nip19Decoder { if (bech32Result.hrp != Hrps.kNevent) { throw ArgumentError( - "Invalid HRP: expected '${Hrps.kNevent}', got '${bech32Result.hrp}'"); + "Invalid HRP: expected '${Hrps.kNevent}', got '${bech32Result.hrp}'", + ); } var data = Nip19Utils.convertBits(bech32Result.data, 5, 8, false); @@ -126,7 +125,8 @@ class Nip19Decoder { break; case 3: // kind if (t.value.length == 4) { - kind = (t.value[0] << 24) | + kind = + (t.value[0] << 24) | (t.value[1] << 16) | (t.value[2] << 8) | t.value[3]; @@ -158,7 +158,8 @@ class Nip19Decoder { if (bech32Result.hrp != Hrps.kNaddr) { throw ArgumentError( - "Invalid HRP: expected '${Hrps.kNaddr}', got '${bech32Result.hrp}'"); + "Invalid HRP: expected '${Hrps.kNaddr}', got '${bech32Result.hrp}'", + ); } var data = Nip19Utils.convertBits(bech32Result.data, 5, 8, false); @@ -192,7 +193,8 @@ class Nip19Decoder { break; case 3: // kind if (t.value.length == 4) { - kind = (t.value[0] << 24) | + kind = + (t.value[0] << 24) | (t.value[1] << 16) | (t.value[2] << 8) | t.value[3]; diff --git a/packages/ndk/lib/shared/nips/nip19/nip19_encoder.dart b/packages/ndk/lib/shared/nips/nip19/nip19_encoder.dart index 284374bd6..5fce46fe8 100644 --- a/packages/ndk/lib/shared/nips/nip19/nip19_encoder.dart +++ b/packages/ndk/lib/shared/nips/nip19/nip19_encoder.dart @@ -51,7 +51,8 @@ class Nip19Encoder { final relayBytes = utf8.encode(relay); if (relayBytes.length > 255) { throw ArgumentError( - 'Relay URL too long: ${relay.length} bytes (max 255)'); + 'Relay URL too long: ${relay.length} bytes (max 255)', + ); } tlvData.add(1); // type tlvData.add(relayBytes.length); // length @@ -64,7 +65,8 @@ class Nip19Encoder { final authorBytes = hex.decode(author); if (authorBytes.length != 32) { throw ArgumentError( - 'Author pubkey must be 32 bytes (64 hex characters)'); + 'Author pubkey must be 32 bytes (64 hex characters)', + ); } tlvData.add(2); // type tlvData.add(32); // length @@ -117,7 +119,8 @@ class Nip19Encoder { final relayBytes = utf8.encode(relay); if (relayBytes.length > 255) { throw ArgumentError( - 'Relay URL too long: ${relay.length} bytes (max 255)'); + 'Relay URL too long: ${relay.length} bytes (max 255)', + ); } tlvData.add(1); // type tlvData.add(relayBytes.length); // length @@ -156,10 +159,7 @@ class Nip19Encoder { /// Encode nprofile (profile reference) /// [pubkey] - 32-byte hex public key (required) /// [relays] - optional list of relay URLs where the profile may be found - static String encodeNprofile({ - required String pubkey, - List? relays, - }) { + static String encodeNprofile({required String pubkey, List? relays}) { final tlvData = []; // Type 0: pubkey (special) - 32 bytes @@ -177,7 +177,8 @@ class Nip19Encoder { final relayBytes = utf8.encode(relay); if (relayBytes.length > 255) { throw ArgumentError( - 'Relay URL too long: ${relay.length} bytes (max 255)'); + 'Relay URL too long: ${relay.length} bytes (max 255)', + ); } tlvData.add(1); // type tlvData.add(relayBytes.length); // length diff --git a/packages/ndk/lib/shared/nips/nip28/channel_metadata.dart b/packages/ndk/lib/shared/nips/nip28/channel_metadata.dart new file mode 100644 index 000000000..c681c62e1 --- /dev/null +++ b/packages/ndk/lib/shared/nips/nip28/channel_metadata.dart @@ -0,0 +1,3 @@ +class ChannelMetadata { + static const int kKind = 41; +} diff --git a/packages/ndk/lib/shared/nips/nip44/nip44.dart b/packages/ndk/lib/shared/nips/nip44/nip44.dart index 3a1cd57e7..d35f0b430 100644 --- a/packages/ndk/lib/shared/nips/nip44/nip44.dart +++ b/packages/ndk/lib/shared/nips/nip44/nip44.dart @@ -16,7 +16,8 @@ class Nip44 { Uint8List? customConversationKey, }) async { // Step 1: Compute Shared Secret - final sharedSecret = customConversationKey ?? + final sharedSecret = + customConversationKey ?? computeSharedSecret(senderPrivateKey, recipientPublicKey); // Step 2: Derive Conversation Key @@ -36,8 +37,11 @@ class Nip44 { final paddedPlaintext = pad(utf8.encode(plaintext)); // Step 6: Encrypt - final ciphertext = - await encryptChaCha20(chachaKey, chachaNonce, paddedPlaintext); + final ciphertext = await encryptChaCha20( + chachaKey, + chachaNonce, + paddedPlaintext, + ); // Step 7: Calculate MAC final mac = calculateMac(hmacKey, nonce, ciphertext); @@ -53,7 +57,8 @@ class Nip44 { Uint8List? customConversationKey, }) async { // Step 1: Compute Shared Secret - final sharedSecret = customConversationKey ?? + final sharedSecret = + customConversationKey ?? computeSharedSecret(recipientPrivateKey, senderPublicKey); // Step 2: Derive Conversation Key @@ -76,8 +81,11 @@ class Nip44 { verifyMac(hmacKey, nonce, ciphertext, mac); // Step 6: Decrypt - final paddedPlaintext = - await decryptChaCha20(chachaKey, chachaNonce, ciphertext); + final paddedPlaintext = await decryptChaCha20( + chachaKey, + chachaNonce, + ciphertext, + ); // Step 7: Unpad Plaintext final plaintextBytes = unpad(paddedPlaintext); @@ -86,7 +94,9 @@ class Nip44 { } static Uint8List computeSharedSecret( - String privateKeyHex, String publicKeyHex) { + String privateKeyHex, + String publicKeyHex, + ) { final ec = getS256(); final privateKey = PrivateKey.fromHex(ec, privateKeyHex); final publicKey = PublicKey.fromHex(ec, checkPublicKey(publicKeyHex)); diff --git a/packages/ndk/lib/shared/nips/nip44/utils.dart b/packages/ndk/lib/shared/nips/nip44/utils.dart index 0db611756..994d7b351 100644 --- a/packages/ndk/lib/shared/nips/nip44/utils.dart +++ b/packages/ndk/lib/shared/nips/nip44/utils.dart @@ -8,11 +8,14 @@ import 'package:cryptography/cryptography.dart'; Uint8List secureRandomBytes(int length) { final random = Random.secure(); return Uint8List.fromList( - List.generate(length, (_) => random.nextInt(256))); + List.generate(length, (_) => random.nextInt(256)), + ); } Map deriveMessageKeys( - Uint8List conversationKey, Uint8List nonce) { + Uint8List conversationKey, + Uint8List nonce, +) { if (conversationKey.length != 32) { throw FormatException('Invalid conversation key length'); } @@ -20,11 +23,7 @@ Map deriveMessageKeys( throw FormatException('Invalid nonce length'); } - final hkdfOutput = hkdfExpand( - prk: conversationKey, - info: nonce, - length: 76, - ); + final hkdfOutput = hkdfExpand(prk: conversationKey, info: nonce, length: 76); return { 'chachaKey': hkdfOutput.sublist(0, 32), @@ -63,7 +62,10 @@ int calcPaddedLen(int unpaddedLen) { } Future encryptChaCha20( - Uint8List key, Uint8List nonce, Uint8List data) async { + Uint8List key, + Uint8List nonce, + Uint8List data, +) async { final algorithm = Chacha20(macAlgorithm: MacAlgorithm.empty); final skey = SecretKey(key); final secretBox = await algorithm.encrypt( @@ -76,19 +78,15 @@ Future encryptChaCha20( } Future decryptChaCha20( - Uint8List key, Uint8List nonce, Uint8List ciphertext) async { + Uint8List key, + Uint8List nonce, + Uint8List ciphertext, +) async { final algorithm = Chacha20(macAlgorithm: MacAlgorithm.empty); final skey = SecretKey(key); - final secretBox = SecretBox( - ciphertext, - nonce: nonce, - mac: Mac.empty, - ); + final secretBox = SecretBox(ciphertext, nonce: nonce, mac: Mac.empty); - final plaintext = await algorithm.decrypt( - secretBox, - secretKey: skey, - ); + final plaintext = await algorithm.decrypt(secretBox, secretKey: skey); return Uint8List.fromList(plaintext); } @@ -121,11 +119,7 @@ Uint8List hkdfExpand({ for (var i = 1; i <= n; i++) { var hmacSha256 = crypto.Hmac(crypto.sha256, prk); - var data = [ - ...previous, - ...info, - i, - ]; + var data = [...previous, ...info, i]; previous = hmacSha256.convert(data).bytes; okm.addAll(previous); } @@ -165,15 +159,15 @@ Map parsePayload(String payload) { Uint8List mac = data.sublist(data.length - 32); Uint8List ciphertext = data.sublist(33, data.length - 32); - return { - 'nonce': nonce, - 'ciphertext': ciphertext, - 'mac': mac, - }; + return {'nonce': nonce, 'ciphertext': ciphertext, 'mac': mac}; } void verifyMac( - Uint8List hmacKey, Uint8List nonce, Uint8List ciphertext, Uint8List mac) { + Uint8List hmacKey, + Uint8List nonce, + Uint8List ciphertext, + Uint8List mac, +) { Uint8List calculatedMac = calculateMac(hmacKey, nonce, ciphertext); if (!const ListEquality().equals(calculatedMac, mac)) { throw Exception('Invalid MAC'); diff --git a/packages/ndk/lib/shared/nips/nip65/relay_ranking.dart b/packages/ndk/lib/shared/nips/nip65/relay_ranking.dart index cd5c7d39c..ea36749de 100644 --- a/packages/ndk/lib/shared/nips/nip65/relay_ranking.dart +++ b/packages/ndk/lib/shared/nips/nip65/relay_ranking.dart @@ -83,24 +83,27 @@ RelayRankingResult rankRelays({ for (final pubkey in bestCoveredPubkeys) { if ((remainingCoverage[pubkey] ?? 0) > 0) { coveredPubkeyObjects.add( - CoveragePubkey(pubkey, 1, 0) // This relay covers this pubkey once - ); + CoveragePubkey(pubkey, 1, 0), // This relay covers this pubkey once + ); remainingCoverage[pubkey] = remainingCoverage[pubkey]! - 1; } } // check if this relay is already selected, if so update it - int existingIndex = - selectedRelays.indexWhere((r) => r.relayUrl == bestRelay); + int existingIndex = selectedRelays.indexWhere( + (r) => r.relayUrl == bestRelay, + ); if (existingIndex != -1) { selectedRelays[existingIndex].score += bestScore; selectedRelays[existingIndex].coveredPubkeys.addAll(coveredPubkeyObjects); } else { - selectedRelays.add(RelayRanking( - relayUrl: bestRelay, - score: bestScore, - coveredPubkeys: coveredPubkeyObjects, - )); + selectedRelays.add( + RelayRanking( + relayUrl: bestRelay, + score: bestScore, + coveredPubkeys: coveredPubkeyObjects, + ), + ); } // remove this relay from future consideration for this iteration @@ -112,8 +115,9 @@ RelayRankingResult rankRelays({ for (final cp in searchingPubkeys) { int remaining = remainingCoverage[cp.pubkey] ?? 0; if (remaining > 0) { - notCoveredPubkeys - .add(CoveragePubkey(cp.pubkey, cp.desiredCoverage, remaining)); + notCoveredPubkeys.add( + CoveragePubkey(cp.pubkey, cp.desiredCoverage, remaining), + ); } } @@ -130,10 +134,7 @@ class RelayRankingResult { final List ranking; final List notCoveredPubkeys; - RelayRankingResult({ - required this.ranking, - required this.notCoveredPubkeys, - }); + RelayRankingResult({required this.ranking, required this.notCoveredPubkeys}); } class RelayRanking { diff --git a/packages/ndk/lib/shared/nips/nip77/negentropy.dart b/packages/ndk/lib/shared/nips/nip77/negentropy.dart index 90178db6a..03437a9c6 100644 --- a/packages/ndk/lib/shared/nips/nip77/negentropy.dart +++ b/packages/ndk/lib/shared/nips/nip77/negentropy.dart @@ -45,8 +45,10 @@ class NegentropyEncoder { } /// Decodes a varint from bytes, returns (value, bytesConsumed) - static (int value, int bytesConsumed) decodeVarint(Uint8List data, - [int offset = 0]) { + static (int value, int bytesConsumed) decodeVarint( + Uint8List data, [ + int offset = 0, + ]) { if (offset >= data.length) { throw ArgumentError('Not enough data to decode varint'); } @@ -72,7 +74,8 @@ class NegentropyEncoder { final lastByte = data[offset + bytesConsumed - 1]; if ((lastByte & 0x80) != 0) { throw ArgumentError( - 'Truncated varint: data ends with continuation bit set'); + 'Truncated varint: data ends with continuation bit set', + ); } return (value, bytesConsumed); @@ -114,12 +117,14 @@ class NegentropyEncoder { /// Decodes a bound from bytes static (int timestamp, Uint8List idPrefix, int bytesConsumed) decodeBound( - Uint8List data, - [int offset = 0]) { + Uint8List data, [ + int offset = 0, + ]) { final (timestamp, tsBytes) = decodeVarint(data, offset); final prefixLength = data[offset + tsBytes]; - final idPrefix = Uint8List.fromList(data.sublist( - offset + tsBytes + 1, offset + tsBytes + 1 + prefixLength)); + final idPrefix = Uint8List.fromList( + data.sublist(offset + tsBytes + 1, offset + tsBytes + 1 + prefixLength), + ); return (timestamp, idPrefix, tsBytes + 1 + prefixLength); } @@ -142,7 +147,9 @@ class NegentropyEncoder { /// Creates an initial client message (NEG-OPEN query payload) static Uint8List createInitialMessage( - List items, int idSize) { + List items, + int idSize, + ) { items.sort((a, b) { final tsCmp = a.timestamp.compareTo(b.timestamp); if (tsCmp != 0) return tsCmp; @@ -169,7 +176,7 @@ class NegentropyEncoder { /// Reconciles received message and creates response /// Returns (response bytes, need IDs, have IDs) static (Uint8List response, List needIds, List haveIds) - reconcile(Uint8List message, List items) { + reconcile(Uint8List message, List items) { items.sort((a, b) { final tsCmp = a.timestamp.compareTo(b.timestamp); if (tsCmp != 0) return tsCmp; @@ -228,7 +235,8 @@ class NegentropyEncoder { throw ArgumentError('Not enough data for fingerprint'); } final theirFingerprint = Uint8List.fromList( - message.sublist(offset, offset + fingerprintSize)); + message.sublist(offset, offset + fingerprintSize), + ); offset += fingerprintSize; // Calculate our fingerprint for this range @@ -253,15 +261,19 @@ class NegentropyEncoder { // First half output.add(encodeBound(midItem.timestamp, midItem.id)); output.addByte(modeFingerprint); - final firstHalfIds = - rangeItems.sublist(0, mid).map((i) => i.id).toList(); + final firstHalfIds = rangeItems + .sublist(0, mid) + .map((i) => i.id) + .toList(); output.add(calculateFingerprint(firstHalfIds)); // Second half output.add(encodeBound(timestamp, idPrefix)); output.addByte(modeFingerprint); - final secondHalfIds = - rangeItems.sublist(mid).map((i) => i.id).toList(); + final secondHalfIds = rangeItems + .sublist(mid) + .map((i) => i.id) + .toList(); output.add(calculateFingerprint(secondHalfIds)); } } @@ -278,7 +290,8 @@ class NegentropyEncoder { throw ArgumentError('Not enough data for ID'); } theirIds.add( - Uint8List.fromList(message.sublist(offset, offset + idSize))); + Uint8List.fromList(message.sublist(offset, offset + idSize)), + ); offset += idSize; } @@ -361,8 +374,10 @@ class NegentropyItem { NegentropyItem({required this.timestamp, required this.id}); - factory NegentropyItem.fromHex( - {required int timestamp, required String idHex}) { + factory NegentropyItem.fromHex({ + required int timestamp, + required String idHex, + }) { return NegentropyItem( timestamp: timestamp, id: NegentropyEncoder.hexToBytes(idHex), diff --git a/packages/ndk/lib/src/cli/accounts/accounts_cli_command.dart b/packages/ndk/lib/src/cli/accounts/accounts_cli_command.dart new file mode 100644 index 000000000..13ea9cc17 --- /dev/null +++ b/packages/ndk/lib/src/cli/accounts/accounts_cli_command.dart @@ -0,0 +1,382 @@ +import 'dart:io'; + +import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/ndk.dart'; + +import '../cli_accounts_store.dart'; +import '../cli_command.dart'; + +/// `ndk accounts` - manage local identities. +/// +/// Identities are persisted in plaintext at [CliAccountsStore.defaultPath]. +/// See [CliAccountRecord] for the security trade-off. +class AccountsCliCommand implements CliCommand { + @override + String get name => 'accounts'; + + @override + String get description => + 'Manage local identities (login, logout, list, switch, whoami)'; + + @override + String get usage => _help; + + static const String _help = '''ndk accounts [args] +Sub-commands: + login nsec Login with private key + login npub Read-only login (public key) + login bunker Connect to a NIP-46 bunker + login generate [name] Generate a fresh keypair and login + logout [pubkey] Logout current (or specific) account + list List persisted accounts + switch Set the active account + whoami Show the active account +Options: + -h, --help Show this help +Environment: + NDK_ACCOUNTS_FILE Override store path (default ~/.ndk/accounts.json)'''; + + @override + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln(_help); + return 0; + } + + try { + final sub = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (sub) { + case 'login': + return await _handleLogin(rest, ndk, accountsStore); + case 'logout': + return await _handleLogout(rest, ndk, accountsStore); + case 'list': + return _handleList(accountsStore); + case 'switch': + return await _handleSwitch(rest, ndk, accountsStore); + case 'whoami': + return _handleWhoami(ndk, accountsStore); + default: + stderr.writeln('Unknown accounts sub-command: "$sub"'); + stdout.writeln(_help); + return 2; + } + } catch (e) { + stderr.writeln('Accounts command failed: $e'); + return 1; + } + } + + // ---- login --------------------------------------------------------------- + + Future _handleLogin( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln( + 'Usage: ndk accounts login ...', + ); + return 0; + } + final kind = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (kind) { + case 'nsec': + return await _loginNsec(rest, ndk, store); + case 'npub': + return await _loginNpub(rest, ndk, store); + case 'bunker': + return await _loginBunker(rest, ndk, store); + case 'generate': + return await _loginGenerate(rest, ndk, store); + default: + stderr.writeln('Unknown login kind: "$kind"'); + return 2; + } + } + + Future _loginNsec( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + if (args.isEmpty) { + stderr.writeln('Usage: ndk accounts login nsec '); + return 2; + } + final privkey = _resolvePrivateKey(args.first); + if (privkey == null) { + stderr.writeln('Invalid private key: ${args.first}'); + return 2; + } + final pubkey = _derivePubkey(privkey); + if (pubkey == null) { + stderr.writeln('Failed to derive pubkey from private key'); + return 1; + } + _doLogin( + ndk, + pubkey, + () => ndk.accounts.loginPrivateKey(pubkey: pubkey, privkey: privkey), + ); + store.upsert( + CliAccountRecord( + pubkey: pubkey, + type: CliAccountType.privateKey, + privkey: privkey, + ), + setDefault: true, + ); + return await _persistAndAnnounce(store, pubkey, 'private-key'); + } + + Future _loginNpub( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + if (args.isEmpty) { + stderr.writeln('Usage: ndk accounts login npub '); + return 2; + } + final pubkey = _resolvePubkey(args.first); + if (pubkey == null) { + stderr.writeln('Invalid public key: ${args.first}'); + return 2; + } + _doLogin(ndk, pubkey, () => ndk.accounts.loginPublicKey(pubkey: pubkey)); + store.upsert( + CliAccountRecord(pubkey: pubkey, type: CliAccountType.publicKey), + setDefault: true, + ); + return await _persistAndAnnounce(store, pubkey, 'read-only'); + } + + Future _loginBunker( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + if (args.isEmpty) { + stderr.writeln('Usage: ndk accounts login bunker '); + return 2; + } + final bunkerUrl = args.first; + stdout.writeln( + 'Connecting to bunker... (auth URL will be printed if the bunker requires it)', + ); + final connection = await ndk.accounts.loginWithBunkerUrl( + bunkerUrl: bunkerUrl, + bunkers: ndk.bunkers, + authCallback: (authUrl) { + stdout.writeln('Bunker auth required. Open this URL in your signer:'); + stdout.writeln(' $authUrl'); + }, + ); + if (connection == null) { + stderr.writeln('Bunker connection failed for: $bunkerUrl'); + return 1; + } + final pubkey = ndk.accounts.getPublicKey(); + if (pubkey == null) { + stderr.writeln('Bunker connected but no pubkey returned'); + return 1; + } + store.upsert( + CliAccountRecord( + pubkey: pubkey, + type: CliAccountType.bunker, + bunker: connection.toJson(), + ), + setDefault: true, + ); + return await _persistAndAnnounce(store, pubkey, 'bunker'); + } + + Future _loginGenerate( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + final ( + privkey, + pubkey, + ) = (ndk.accounts.eventSignerFactory as Bip340EventSignerFactory) + .generateKeyPair(); + ndk.accounts.loginPrivateKey(pubkey: pubkey, privkey: privkey); + store.upsert( + CliAccountRecord( + pubkey: pubkey, + type: CliAccountType.privateKey, + privkey: privkey, + ), + setDefault: true, + ); + stdout.writeln('Generated new keypair:'); + stdout.writeln(' npub: ${Nip19.encodePubKey(pubkey)}'); + stdout.writeln(' nsec: ${Nip19.encodePrivateKey(privkey)}'); + stdout.writeln( + ' (nsec has been stored in plaintext at ${CliAccountsStore.defaultPath()})', + ); + return await _persistAndAnnounce(store, pubkey, 'private-key (generated)'); + } + + /// Login, but if the account already exists in the live `Accounts` map + /// (e.g. restored at startup), just switch to it. + void _doLogin(Ndk ndk, String pubkey, void Function() login) { + if (ndk.accounts.hasAccount(pubkey)) { + ndk.accounts.switchAccount(pubkey: pubkey); + } else { + login(); + } + } + // ---- logout -------------------------------------------------------------- + + Future _handleLogout( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + final target = args.isNotEmpty ? _resolvePubkey(args.first) : null; + final pubkey = target ?? ndk.accounts.getPublicKey(); + if (pubkey == null) { + stderr.writeln('No active account to logout.'); + return 1; + } + if (ndk.accounts.hasAccount(pubkey)) { + ndk.accounts.removeAccount(pubkey: pubkey); + } + store.remove(pubkey); + await store.save(); + stdout.writeln('Logged out and removed: ${Nip19.encodePubKey(pubkey)}'); + return 0; + } + + // ---- list / switch / whoami --------------------------------------------- + + int _handleList(CliAccountsStore store) { + if (store.records.isEmpty) { + stdout.writeln( + 'No persisted accounts. Use "ndk accounts login ..." to add one.', + ); + return 0; + } + stdout.writeln('Accounts (${store.records.length}):'); + for (final r in store.records) { + final npub = Nip19.encodePubKey(r.pubkey); + final flag = r.pubkey == store.defaultPubkey ? ' [active]' : ''; + stdout.writeln(' $npub (${r.type.name})$flag'); + stdout.writeln(' hex: ${r.pubkey}'); + if (r.type == CliAccountType.bunker && r.bunker != null) { + final relays = r.bunker!['relays']; + stdout.writeln(' bunker relays: $relays'); + } + } + return 0; + } + + Future _handleSwitch( + List args, + Ndk ndk, + CliAccountsStore store, + ) async { + if (args.isEmpty) { + stderr.writeln('Usage: ndk accounts switch '); + return 2; + } + final pubkey = _resolvePubkey(args.first); + if (pubkey == null) { + stderr.writeln('Invalid pubkey: ${args.first}'); + return 2; + } + if (store.find(pubkey) == null) { + stderr.writeln('Unknown account: ${Nip19.encodePubKey(pubkey)}'); + return 1; + } + store.setDefault(pubkey); + if (ndk.accounts.hasAccount(pubkey)) { + ndk.accounts.switchAccount(pubkey: pubkey); + } + return await _persistAndAnnounce(store, pubkey, 'switched'); + } + + int _handleWhoami(Ndk ndk, CliAccountsStore store) { + final pubkey = ndk.accounts.getPublicKey(); + if (pubkey == null) { + stdout.writeln('No active account.'); + return 0; + } + final account = ndk.accounts.getLoggedAccount(); + final record = store.find(pubkey); + stdout.writeln('Active account:'); + stdout.writeln(' npub: ${Nip19.encodePubKey(pubkey)}'); + stdout.writeln(' hex: $pubkey'); + stdout.writeln( + ' type: ${record?.type.name ?? account?.type.name ?? 'unknown'}', + ); + stdout.writeln(' canSign: ${ndk.accounts.canSign}'); + return 0; + } + + // ---- helpers ------------------------------------------------------------- + + Future _persistAndAnnounce( + CliAccountsStore store, + String pubkey, + String label, + ) async { + try { + await store.save(); + } catch (e) { + stderr.writeln('warning: failed to persist accounts store: $e'); + } + stdout.writeln('Active: ${Nip19.encodePubKey(pubkey)} ($label)'); + return 0; + } + + String? _resolvePrivateKey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isPrivateKey(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + String? _resolvePubkey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isPubkey(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + String? _derivePubkey(String privkey) { + try { + return (Bip340EventSignerFactory().derivePublicKey(privkey)); + } catch (_) { + return null; + } + } + + bool _isHelp(String value) { + return value == 'help' || value == '--help' || value == '-h'; + } +} diff --git a/packages/ndk/lib/src/cli/blossom/blossom_cli_command.dart b/packages/ndk/lib/src/cli/blossom/blossom_cli_command.dart new file mode 100644 index 000000000..0f482bc00 --- /dev/null +++ b/packages/ndk/lib/src/cli/blossom/blossom_cli_command.dart @@ -0,0 +1,533 @@ +import 'dart:io'; + +import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/ndk.dart'; + +import '../cli_accounts_store.dart'; +import '../cli_command.dart'; + +/// `ndk blossom` - low-level Blossom (BUD-*) operations + user server list. +/// +/// Sub-commands: +/// upload, download, delete, list, mirror, check, servers +class BlossomCliCommand implements CliCommand { + @override + String get name => 'blossom'; + + @override + String get description => + 'Blossom ops (upload, download, delete, list, mirror, check, servers)'; + + @override + String get usage => _help; + + static const String _help = '''ndk blossom [args] +Sub-commands: + upload [options] Upload a local file + download [options] Download a blob to disk + delete [options] Delete a blob + list [pubkey] [options] List blobs for a pubkey + mirror --server [--server url ...] Mirror an existing blob + check [options] Check blob existence + servers list [pubkey] Get a user's server list + servers publish [url ...] Publish your server list +Options: + --server (repeatable) Blossom server(s) + --pubkey Server-list owner + --content-type Override mime type (upload) + --media Server-side media optimisation (upload) + --auth Use signed GET (download/check/list) + --since list: only blobs after this date + --until list: only blobs before this date + -h, --help Show this help'''; + + @override + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln(_help); + return 0; + } + try { + final sub = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (sub) { + case 'upload': + return await _handleUpload(rest, ndk); + case 'download': + return await _handleDownload(rest, ndk); + case 'delete': + return await _handleDelete(rest, ndk); + case 'list': + return await _handleList(rest, ndk); + case 'mirror': + return await _handleMirror(rest, ndk); + case 'check': + return await _handleCheck(rest, ndk); + case 'servers': + return await _handleServers(rest, ndk); + default: + stderr.writeln('Unknown blossom sub-command: "$sub"'); + stdout.writeln(_help); + return 2; + } + } catch (e) { + stderr.writeln('Blossom command failed: $e'); + return 1; + } + } + + // ---- upload ------------------------------------------------------------- + + Future _handleUpload(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final filePath = parsed.positional[0]; + if (!await File(filePath).exists()) { + stderr.writeln('File not found: $filePath'); + return 2; + } + + stdout.writeln('Uploading $filePath ...'); + final stream = ndk.blossom.uploadBlobFromFile( + filePath: filePath, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + contentType: parsed.contentType, + serverMediaOptimisation: parsed.media, + ); + + List? finalResults; + await for (final progress in stream) { + final phase = progress.phase.name; + final pct = progress.percentage.toStringAsFixed(1); + stdout.writeln( + ' [$phase] ${progress.currentServer.isEmpty ? "-" : progress.currentServer} ' + '$pct% ' + '${progress.mirrorsTotal > 0 ? "(${progress.mirrorsCompleted}/${progress.mirrorsTotal} mirrors)" : ""}' + .trimRight(), + ); + if (progress.completedUploads.isNotEmpty) { + finalResults = progress.completedUploads; + } + } + if (finalResults == null || finalResults.isEmpty) { + stderr.writeln('Upload produced no results.'); + return 1; + } + stdout.writeln('Upload done:'); + _printUploadResults(finalResults); + return finalResults.every((r) => r.success) ? 0 : 1; + } + + // ---- download ----------------------------------------------------------- + + Future _handleDownload(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 2); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final sha = _requireSha256(parsed.positional[0]); + if (sha == null) return 2; + final outPath = parsed.positional[1]; + + stdout.writeln('Downloading $sha -> $outPath ...'); + await ndk.blossom.downloadBlobToFile( + sha256: sha, + outputPath: outPath, + useAuth: parsed.auth, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + pubkeyToFetchUserServerList: parsed.pubkey, + ); + final size = await File(outPath).length(); + stdout.writeln('Done: $outPath (${_humanSize(size)}).'); + return 0; + } + + // ---- delete ------------------------------------------------------------- + + Future _handleDelete(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final sha = _requireSha256(parsed.positional[0]); + if (sha == null) return 2; + + stdout.writeln('Deleting $sha ...'); + final results = await ndk.blossom.deleteBlob( + sha256: sha, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + ); + for (final r in results) { + final status = r.success ? 'OK' : 'FAILED'; + stdout.writeln( + ' ${r.serverUrl}: $status' + '${r.error == null ? "" : " (${r.error})"}', + ); + } + return results.every((r) => r.success) ? 0 : 1; + } + + // ---- list --------------------------------------------------------------- + + Future _handleList(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 0); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final pubkey = + parsed.pubkey ?? + (parsed.positional.isNotEmpty + ? _resolvePubkey(parsed.positional[0]) + : null) ?? + ndk.accounts.getPublicKey(); + if (pubkey == null) { + stderr.writeln('A pubkey is required (positional, --pubkey, or login).'); + return 2; + } + + stdout.writeln('Listing blobs for ${Nip19.encodePubKey(pubkey)} ...'); + final blobs = await ndk.blossom.listBlobs( + pubkey: pubkey, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + useAuth: parsed.auth, + since: parsed.since, + until: parsed.until, + ); + if (blobs.isEmpty) { + stdout.writeln('(no blobs)'); + return 0; + } + for (final b in blobs) { + stdout.writeln( + ' sha256=${b.sha256} ' + 'size=${_humanSize(b.size ?? 0)} ' + 'type=${b.type ?? "?"} ' + 'uploaded=${b.uploaded.toIso8601String()}', + ); + stdout.writeln(' url=${b.url}'); + } + stdout.writeln('Total: ${blobs.length} blob(s).'); + return 0; + } + + // ---- mirror ------------------------------------------------------------- + + Future _handleMirror(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + if (parsed.servers.isEmpty) { + stderr.writeln('mirror requires at least one --server target.'); + return 2; + } + final source = Uri.parse(parsed.positional[0]); + stdout.writeln( + 'Mirroring $source -> ${parsed.servers.length} server(s) ...', + ); + final results = await ndk.blossom.mirrorToServers( + blossomUrl: source, + targetServerUrls: parsed.servers, + ); + _printUploadResults(results); + return results.every((r) => r.success) ? 0 : 1; + } + + // ---- check -------------------------------------------------------------- + + Future _handleCheck(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final sha = _requireSha256(parsed.positional[0]); + if (sha == null) return 2; + + stdout.writeln('Checking $sha ...'); + final url = await ndk.blossom.checkBlob( + sha256: sha, + useAuth: parsed.auth, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + pubkeyToFetchUserServerList: parsed.pubkey, + ); + stdout.writeln('Alive URL: $url'); + return 0; + } + + // ---- servers ------------------------------------------------------------ + + Future _handleServers(List args, Ndk ndk) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln(_serversHelp); + return 0; + } + final sub = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (sub) { + case 'list': + return await _handleServersList(rest, ndk); + case 'publish': + return await _handleServersPublish(rest, ndk); + default: + stderr.writeln('Unknown blossom servers sub-command: "$sub"'); + stdout.writeln(_serversHelp); + return 2; + } + } + + Future _handleServersList(List args, Ndk ndk) async { + String? pubkey; + if (args.isNotEmpty && !args.first.startsWith('-')) { + pubkey = _resolvePubkey(args.first); + if (pubkey == null) { + stderr.writeln('Invalid pubkey: ${args.first}'); + return 2; + } + } + pubkey ??= ndk.accounts.getPublicKey(); + if (pubkey == null) { + stderr.writeln('A pubkey is required (positional or login).'); + return 2; + } + + stdout.writeln( + 'Fetching blossom server list for ' + '${Nip19.encodePubKey(pubkey)} ...', + ); + final servers = await ndk.blossomUserServerList.getUserServerList( + pubkeys: [pubkey], + ); + if (servers == null || servers.isEmpty) { + stdout.writeln('(no server list published)'); + return 0; + } + for (var i = 0; i < servers.length; i++) { + stdout.writeln(' ${i + 1}. ${servers[i]}'); + } + return 0; + } + + Future _handleServersPublish(List args, Ndk ndk) async { + final servers = args.where((a) => !a.startsWith('-')).toList(); + if (servers.isEmpty) { + stderr.writeln('Usage: ndk blossom servers publish [url ...]'); + return 2; + } + if (ndk.accounts.isNotLoggedIn) { + stderr.writeln('Login required: use "ndk accounts login ..." first.'); + return 2; + } + stdout.writeln('Publishing server list (${servers.length} server(s)) ...'); + final responses = await ndk.blossomUserServerList.publishUserServerList( + serverUrlsOrdered: servers, + ); + var ok = 0; + for (final r in responses) { + final status = r.broadcastSuccessful ? 'OK' : 'FAILED'; + stdout.writeln(' ${r.relayUrl}: $status'); + if (r.broadcastSuccessful) ok++; + } + stdout.writeln('Done: $ok/${responses.length} relay(s) accepted.'); + return ok == 0 ? 1 : 0; + } + + static const String _serversHelp = + '''ndk blossom servers [args] + list [pubkey] Show the (kind 10063) server list for a pubkey + publish [url ...] Publish your own server list (login required)'''; + + // ---- helpers ------------------------------------------------------------ + + void _printUploadResults(List results) { + for (final r in results) { + if (r.success && r.descriptor != null) { + final d = r.descriptor!; + stdout.writeln( + ' ${r.serverUrl}: OK ' + 'sha256=${d.sha256} ' + 'size=${_humanSize(d.size ?? 0)} ' + 'type=${d.type ?? "?"}', + ); + } else { + stdout.writeln( + ' ${r.serverUrl}: FAILED ' + '${r.error == null ? "" : "(${r.error})"}', + ); + } + } + } + + String? _requireSha256(String raw) { + final t = raw.toLowerCase().trim(); + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(t)) { + stderr.writeln('Invalid sha256: $raw'); + return null; + } + return t; + } + + String _humanSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KiB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MiB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GiB'; + } + + DateTime? _parseDateTime(String value) { + final asInt = int.tryParse(value); + if (asInt != null) { + return DateTime.fromMillisecondsSinceEpoch(asInt * 1000, isUtc: true); + } + try { + return DateTime.parse(value).toUtc(); + } on FormatException { + return null; + } + } + + String? _resolvePubkey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isPubkey(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + bool _isHelp(String value) => + value == 'help' || value == '--help' || value == '-h'; + + _BlossomArgs _parseArgs(List args, {required int requirePositional}) { + final result = _BlossomArgs(); + var i = 0; + String? takeValue() { + if (i + 1 >= args.length) return null; + final v = args[i + 1]; + i += 2; + return v; + } + + while (i < args.length) { + final raw = args[i]; + String flag; + String? inline; + final eq = raw.indexOf('='); + if (eq > 0 && raw.startsWith('-')) { + flag = raw.substring(0, eq); + inline = raw.substring(eq + 1); + } else { + flag = raw; + inline = null; + } + String? nextOrInline() { + if (inline != null) { + i += 1; + return inline; + } + return takeValue(); + } + + if (flag == '--server') { + final v = nextOrInline(); + if (v == null) return _BlossomArgs(error: 'Missing value for --server'); + result.servers.add(v); + continue; + } + if (flag == '--pubkey') { + final v = nextOrInline(); + if (v == null) return _BlossomArgs(error: 'Missing value for --pubkey'); + result.pubkey = _resolvePubkey(v) ?? v; + continue; + } + if (flag == '--content-type') { + final v = nextOrInline(); + if (v == null) { + return _BlossomArgs(error: 'Missing value for --content-type'); + } + result.contentType = v; + continue; + } + if (flag == '--media') { + result.media = true; + i += 1; + continue; + } + if (flag == '--auth') { + result.auth = true; + i += 1; + continue; + } + if (flag == '--since') { + final v = nextOrInline(); + if (v == null) return _BlossomArgs(error: 'Missing value for --since'); + final dt = _parseDateTime(v); + if (dt == null) { + return _BlossomArgs(error: 'Invalid --since "$v"'); + } + result.since = dt; + continue; + } + if (flag == '--until') { + final v = nextOrInline(); + if (v == null) return _BlossomArgs(error: 'Missing value for --until'); + final dt = _parseDateTime(v); + if (dt == null) { + return _BlossomArgs(error: 'Invalid --until "$v"'); + } + result.until = dt; + continue; + } + if (flag == '-h' || flag == '--help') { + i += 1; + continue; + } + if (flag.startsWith('-')) { + return _BlossomArgs(error: 'Unknown option: $flag'); + } + result.positional.add(raw); + i += 1; + } + + if (result.positional.length < requirePositional) { + return _BlossomArgs( + error: + 'Expected $requirePositional positional argument(s), ' + 'got ${result.positional.length}.', + ); + } + return result; + } +} + +class _BlossomArgs { + final List positional = []; + final List servers = []; + String? pubkey; + String? contentType; + bool media = false; + bool auth = false; + DateTime? since; + DateTime? until; + String? error; + + _BlossomArgs({this.error}); +} diff --git a/packages/ndk/lib/src/cli/broadcast/broadcast_cli_command.dart b/packages/ndk/lib/src/cli/broadcast/broadcast_cli_command.dart new file mode 100644 index 000000000..22d5b6e9a --- /dev/null +++ b/packages/ndk/lib/src/cli/broadcast/broadcast_cli_command.dart @@ -0,0 +1,414 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/helpers/relay_helper.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart' as nip19; + +import '../cli_accounts_store.dart'; +import '../cli_command.dart'; + +/// `ndk broadcast` - publish a signed event to relays. +/// +/// Accepts pre-signed events (sent as-is) or unsigned events that are signed +/// with either `--privkey` or the active account from `ndk accounts login`. +class BroadcastCliCommand implements CliCommand { + @override + String get name => 'broadcast'; + + @override + String get description => 'Publish events to relays'; + + @override + String get usage => _help; + + static const String _help = '''ndk broadcast [options] [relay ...] +Event source (one required): + --event Inline event JSON + --file Read event JSON from file + --stdin Read event JSON from stdin + --kind --content Build a text-note (kind 1) inline + --kind --content --pubkey + Build an unsigned event with explicit pubkey +Signing: + --privkey Sign with this key (overrides active account) + (none) Use the active account from "ndk accounts login" +Output control: + --relay (repeatable) Target relays (also accepted positionally) + --timeout Per-event timeout (default 10) + --consider-done <0..1> Fraction of OKs to wait for (default 0.5) + --no-cache Don't save event to local cache + -h, --help Show this help'''; + + @override + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + stdout.writeln(_help); + return 0; + } + + final parsed = await _parseArgs(args); + if (parsed.error != null) { + stderr.writeln(parsed.error); + stderr.writeln('Usage: ndk broadcast [options] [relay ...]'); + return 2; + } + + // Build the event + Nip01Event event; + try { + event = await _buildEvent(parsed, ndk); + } catch (e) { + stderr.writeln('Failed to build event: $e'); + return 2; + } + + // Resolve signing strategy + final eventSigner = _resolveSigner(ndk, parsed, event); + if (eventSigner == null) { + stderr.writeln( + 'Event is unsigned and no signer available. Pass --privkey or run "ndk accounts login".', + ); + return 2; + } + + if (parsed.relays.isEmpty) { + stderr.writeln('At least one relay URL is required.'); + return 2; + } + + if (event.pubKey.isEmpty) { + stderr.writeln('Event is missing a pubkey.'); + return 2; + } + + stdout.writeln( + 'Broadcasting event ${event.id} to ${parsed.relays.length} relay(s)...', + ); + final response = ndk.broadcast.broadcast( + nostrEvent: event, + specificRelays: parsed.relays, + customSigner: eventSigner.customSigner, + timeout: Duration(seconds: parsed.timeoutSeconds), + considerDonePercent: parsed.considerDonePercent, + saveToCache: parsed.saveToCache, + ); + + final results = await response.broadcastDoneFuture; + var okCount = 0; + for (final r in results) { + final status = r.broadcastSuccessful + ? 'OK' + : (r.okReceived ? 'REJECTED' : 'NO-RESPONSE'); + stdout.writeln( + ' ${r.relayUrl}: $status ${r.msg.isEmpty ? "" : '(${r.msg})'}', + ); + if (r.broadcastSuccessful) okCount++; + } + stdout.writeln( + 'Done: $okCount/${results.length} relay(s) accepted the event.', + ); + return okCount == 0 ? 1 : 0; + } + + Future _buildEvent(_BroadcastArgs parsed, Ndk ndk) async { + if (parsed.eventJson != null) { + final decoded = jsonDecode(parsed.eventJson!); + if (decoded is! Map) { + throw ArgumentError( + 'Event JSON must decode to an object, got ${decoded.runtimeType}', + ); + } + return Nip01EventModel.fromJson(decoded.cast()); + } + + if (parsed.kind != null) { + // Prefer explicit --pubkey, else fall back to active account. + var pubKey = parsed.pubkey; + pubKey ??= ndk.accounts.getPublicKey(); + pubKey ??= (parsed.privkey != null) + ? _buildSigner(parsed.privkey!)?.publicKey + : null; + if (pubKey == null || pubKey.isEmpty) { + throw ArgumentError( + 'Inline build requires --pubkey, an active account, or --privkey.', + ); + } + return Nip01Event( + pubKey: pubKey, + kind: parsed.kind!, + tags: parsed.tags, + content: parsed.content ?? '', + ); + } + + throw ArgumentError( + 'No event source. Use --event, --file, --stdin, or --kind + --content.', + ); + } + + _SignerResolution? _resolveSigner( + Ndk ndk, + _BroadcastArgs parsed, + Nip01Event event, + ) { + // Pre-signed event: no signer needed at all. + if (event.sig != null && event.sig!.isNotEmpty) { + return _SignerResolution.none(); + } + + // Explicit private key + if (parsed.privkey != null) { + final signer = _buildSigner(parsed.privkey!); + if (signer == null) return null; + return _SignerResolution.custom(signer); + } + + // Use the active account. + if (ndk.accounts.canSign) { + return _SignerResolution.useActiveAccount(); + } + + return null; + } + + Bip340EventSigner? _buildSigner(String value) { + final hex = _resolvePrivateKey(value); + if (hex == null) return null; + return Bip340EventSigner( + privateKey: hex, + publicKey: Bip340EventSignerFactory().derivePublicKey(hex), + ); + } + + String? _resolvePrivateKey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (nip19.Nip19.isPrivateKey(t)) { + try { + return nip19.Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + // ---- arg parsing --------------------------------------------------------- + + Future<_BroadcastArgs> _parseArgs(List args) async { + final result = _BroadcastArgs(); + int i = 0; + String? takeValue() { + if (i + 1 >= args.length) return null; + final v = args[i + 1]; + i += 2; + return v; + } + + while (i < args.length) { + final raw = args[i]; + + String flag; + String? inline; + final eq = raw.indexOf('='); + if (eq > 0 && raw.startsWith('-')) { + flag = raw.substring(0, eq); + inline = raw.substring(eq + 1); + } else { + flag = raw; + inline = null; + } + + String? nextOrInline() { + if (inline != null) { + i += 1; + return inline; + } + return takeValue(); + } + + if (flag == '--event') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --event'); + } + result.eventJson = v; + continue; + } + if (flag == '--file') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --file'); + } + try { + result.eventJson = File(v).readAsStringSync(); + } catch (e) { + return _BroadcastArgs(error: 'Failed to read $v: $e'); + } + continue; + } + if (flag == '--stdin') { + if (!stdin.hasTerminal) { + // piped stdin: read everything + final body = await stdin.transform(utf8.decoder).join(); + result.eventJson = body; + } else { + // interactive: read a single line + final line = stdin.readLineSync(encoding: utf8) ?? ''; + result.eventJson = line; + } + i += 1; + continue; + } + if (flag == '--kind') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --kind'); + } + final k = int.tryParse(v); + if (k == null) { + return _BroadcastArgs(error: 'Invalid --kind "$v"'); + } + result.kind = k; + continue; + } + if (flag == '--content') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --content'); + } + result.content = v; + continue; + } + if (flag == '--pubkey') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --pubkey'); + } + result.pubkey = _resolvePubkey(v) ?? v; + continue; + } + if (flag == '--privkey') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --privkey'); + } + result.privkey = v; + continue; + } + if (flag == '--relay') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --relay'); + } + final cleaned = cleanRelayUrl(v) ?? cleanRelayUrl('wss://$v'); + if (cleaned == null) { + return _BroadcastArgs(error: 'Invalid relay URL: $v'); + } + result.relays.add(cleaned); + continue; + } + if (flag == '--timeout') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --timeout'); + } + final n = int.tryParse(v); + if (n == null || n <= 0) { + return _BroadcastArgs(error: 'Invalid --timeout "$v"'); + } + result.timeoutSeconds = n; + continue; + } + if (flag == '--consider-done') { + final v = nextOrInline(); + if (v == null) { + return _BroadcastArgs(error: 'Missing value for --consider-done'); + } + final d = double.tryParse(v); + if (d == null || d <= 0 || d > 1) { + return _BroadcastArgs( + error: 'Invalid --consider-done "$v" (must be 0 < x <= 1)', + ); + } + result.considerDonePercent = d; + continue; + } + if (flag == '--no-cache') { + result.saveToCache = false; + i += 1; + continue; + } + if (flag == '-h' || flag == '--help') { + // already handled by caller + i += 1; + continue; + } + if (flag.startsWith('-')) { + return _BroadcastArgs(error: 'Unknown option: $flag'); + } + // positional relay + final cleaned = cleanRelayUrl(raw) ?? cleanRelayUrl('wss://$raw'); + if (cleaned == null) { + return _BroadcastArgs(error: 'Invalid relay URL: $raw'); + } + result.relays.add(cleaned); + i += 1; + } + return result; + } + + String? _resolvePubkey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (nip19.Nip19.isPubkey(t)) { + try { + return nip19.Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } +} + +class _BroadcastArgs { + String? eventJson; + int? kind; + String? content; + String? pubkey; + List> tags = const []; + String? privkey; + final List relays = []; + int timeoutSeconds = 10; + double considerDonePercent = 0.5; + bool saveToCache = true; + String? error; + + _BroadcastArgs({this.error}); +} + +class _SignerResolution { + /// Pass this signer as `customSigner`. + final Bip340EventSigner? customSigner; + + /// True when the broadcast should rely on the active account (no customSigner). + final bool useActiveAccount; + + const _SignerResolution._({this.customSigner, this.useActiveAccount = false}); + + factory _SignerResolution.custom(Bip340EventSigner signer) => + _SignerResolution._(customSigner: signer); + + factory _SignerResolution.useActiveAccount() => + const _SignerResolution._(useActiveAccount: true); + + factory _SignerResolution.none() => const _SignerResolution._(); +} diff --git a/packages/ndk/lib/src/cli/cli_accounts_store.dart b/packages/ndk/lib/src/cli/cli_accounts_store.dart new file mode 100644 index 000000000..f6025487a --- /dev/null +++ b/packages/ndk/lib/src/cli/cli_accounts_store.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ndk/ndk.dart'; +import 'package:path/path.dart' as p; + +/// Persisted CLI account record. +/// +/// Private keys and bunker connection secrets are stored in PLAINTEXT at +/// [CliAccountsStore.defaultPath] (`~/.ndk/accounts.json`, file mode 0600). +/// This is acceptable for a local dev/CLI tool but you should not commit or +/// share this file. +class CliAccountRecord { + final String pubkey; + final CliAccountType type; + + /// hex private key. Only set for [CliAccountType.privateKey]. + final String? privkey; + + /// Serialized [BunkerConnection]. Only set for [CliAccountType.bunker]. + final Map? bunker; + + CliAccountRecord({ + required this.pubkey, + required this.type, + this.privkey, + this.bunker, + }); + + bool get canSign => + type == CliAccountType.privateKey || type == CliAccountType.bunker; + + Map toJson() => { + 'pubkey': pubkey, + 'type': type.name, + if (privkey != null) 'privkey': privkey, + if (bunker != null) 'bunker': bunker, + }; + + factory CliAccountRecord.fromJson(Map json) { + final type = CliAccountType.values.firstWhere( + (t) => t.name == json['type'], + orElse: () => CliAccountType.publicKey, + ); + return CliAccountRecord( + pubkey: json['pubkey'] as String, + type: type, + privkey: json['privkey'] as String?, + bunker: json['bunker'] as Map?, + ); + } +} + +enum CliAccountType { privateKey, publicKey, bunker } + +/// On-disk store of CLI accounts. Single-process safe; not concurrency-locked. +class CliAccountsStore { + static const String _defaultDirName = '.ndk'; + static const String _defaultFileName = 'accounts.json'; + + final File _file; + final List _records = []; + String? defaultPubkey; + bool _warnedOnCreate = false; + + CliAccountsStore._(this._file); + + /// Default path: `~/.ndk/accounts.json`. Override with the + /// `NDK_ACCOUNTS_FILE` environment variable. + static String defaultPath() { + final fromEnv = Platform.environment['NDK_ACCOUNTS_FILE']; + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + final home = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + return p.join(home, _defaultDirName, _defaultFileName); + } + + /// Load the store from [path] (defaults to [defaultPath]). + /// Missing file is treated as empty store (not an error). + static Future load({String? path}) async { + final file = File(path ?? defaultPath()); + final store = CliAccountsStore._(file); + if (await file.exists()) { + try { + final raw = await file.readAsString(); + final data = jsonDecode(raw) as Map; + final list = data['accounts'] as List? ?? const []; + for (final item in list) { + store._records.add( + CliAccountRecord.fromJson(item as Map), + ); + } + store.defaultPubkey = data['defaultPubkey'] as String?; + } catch (e) { + stderr.writeln('warning: failed to parse ${file.path}: $e'); + } + } + return store; + } + + List get records => List.unmodifiable(_records); + + CliAccountRecord? get defaultRecord { + if (defaultPubkey == null) return null; + for (final r in _records) { + if (r.pubkey == defaultPubkey) return r; + } + return null; + } + + CliAccountRecord? find(String pubkey) { + for (final r in _records) { + if (r.pubkey == pubkey) return r; + } + return null; + } + + void upsert(CliAccountRecord record, {bool setDefault = true}) { + final idx = _records.indexWhere((r) => r.pubkey == record.pubkey); + if (idx >= 0) { + _records[idx] = record; + } else { + _records.add(record); + } + if (setDefault || defaultPubkey == null) { + defaultPubkey = record.pubkey; + } + } + + void remove(String pubkey) { + _records.removeWhere((r) => r.pubkey == pubkey); + if (defaultPubkey == pubkey) { + defaultPubkey = _records.isEmpty ? null : _records.first.pubkey; + } + } + + void setDefault(String pubkey) { + if (find(pubkey) == null) { + throw ArgumentError('Unknown pubkey: $pubkey'); + } + defaultPubkey = pubkey; + } + + Future save() async { + final dir = _file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + } + final alreadyExisted = await _file.exists(); + final data = { + 'defaultPubkey': defaultPubkey, + 'accounts': _records.map((r) => r.toJson()).toList(), + }; + await _file.writeAsString(jsonEncode(data)); + if (!alreadyExisted) { + await _file.parent.stat(); + } + try { + // best-effort chmod 0600 on POSIX + if (!Platform.isWindows) { + final result = await Process.run('chmod', ['600', _file.path]); + if (result.exitCode != 0 && !_warnedOnCreate) { + _warnedOnCreate = true; + } + } + } catch (_) { + // ignore chmod failures + } + if (!alreadyExisted) { + stderr.writeln( + 'warning: wrote plaintext signing material to ${_file.path} (mode 600).', + ); + stderr.writeln(' Do not commit or share this file.'); + } + } +} + +/// Bridges [CliAccountsStore] -> [Accounts] usecase at startup. +/// +/// Returns the list of pubkeys that were restored. The first record becomes the +/// logged-in (active) account, mirroring [CliAccountsStore.defaultPubkey]. +Future> restoreAccountsIntoNdk({ + required Ndk ndk, + required CliAccountsStore store, +}) async { + final restored = []; + var first = true; + for (final record in store.records) { + try { + switch (record.type) { + case CliAccountType.privateKey: + if (record.privkey != null) { + ndk.accounts.loginPrivateKey( + pubkey: record.pubkey, + privkey: record.privkey!, + ); + } + break; + case CliAccountType.publicKey: + ndk.accounts.loginPublicKey(pubkey: record.pubkey); + break; + case CliAccountType.bunker: + if (record.bunker != null) { + final connection = BunkerConnection.fromJson(record.bunker!); + await ndk.accounts.loginWithBunkerConnection( + connection: connection, + bunkers: ndk.bunkers, + ); + } + break; + } + restored.add(record.pubkey); + if (first) { + first = false; + } + } catch (e) { + stderr.writeln('warning: failed to restore account ${record.pubkey}: $e'); + } + } + final wanted = store.defaultPubkey; + if (wanted != null && restored.contains(wanted)) { + try { + ndk.accounts.switchAccount(pubkey: wanted); + } catch (_) { + // ignore + } + } + return restored; +} diff --git a/packages/ndk/lib/src/cli/cli_command.dart b/packages/ndk/lib/src/cli/cli_command.dart index f4da313a3..99c79fad3 100644 --- a/packages/ndk/lib/src/cli/cli_command.dart +++ b/packages/ndk/lib/src/cli/cli_command.dart @@ -1,10 +1,17 @@ import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; +import 'cli_accounts_store.dart'; + abstract class CliCommand { String get name; String get description; String get usage; - Future run(List args, Ndk ndk, WalletsRepo walletsRepo); + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ); } diff --git a/packages/ndk/lib/src/cli/files/files_cli_command.dart b/packages/ndk/lib/src/cli/files/files_cli_command.dart new file mode 100644 index 000000000..52dee58d6 --- /dev/null +++ b/packages/ndk/lib/src/cli/files/files_cli_command.dart @@ -0,0 +1,320 @@ +import 'dart:io'; + +import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/ndk.dart'; + +import '../cli_accounts_store.dart'; +import '../cli_command.dart'; + +/// `ndk files` - high-level file management (Blossom-backed, transparent +/// mirror of [Ndk.files]). +/// +/// Sub-commands: `upload`, `download`, `delete`, `check`. +class FilesCliCommand implements CliCommand { + @override + String get name => 'files'; + + @override + String get description => 'Manage files (upload, download, delete, check)'; + + @override + String get usage => _help; + + static const String _help = '''ndk files [args] +Sub-commands: + upload [options] Upload a local file + download [options] Download to disk + delete [options] Delete a blob + check [options] Check if URL resolves +Options: + --server (repeatable) Blossom server(s) + --pubkey Server-list owner + --content-type Override mime type (upload) + --media Server-side media optimisation (upload) + -h, --help Show this help'''; + + @override + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln(_help); + return 0; + } + try { + final sub = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (sub) { + case 'upload': + return await _handleUpload(rest, ndk); + case 'download': + return await _handleDownload(rest, ndk); + case 'delete': + return await _handleDelete(rest, ndk); + case 'check': + return await _handleCheck(rest, ndk); + default: + stderr.writeln('Unknown files sub-command: "$sub"'); + stdout.writeln(_help); + return 2; + } + } catch (e) { + stderr.writeln('Files command failed: $e'); + return 1; + } + } + + // ---- upload ------------------------------------------------------------- + + Future _handleUpload(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final filePath = parsed.positional[0]; + if (!await File(filePath).exists()) { + stderr.writeln('File not found: $filePath'); + return 2; + } + + stdout.writeln('Uploading $filePath ...'); + final stream = ndk.files.uploadFromFile( + filePath: filePath, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + contentType: parsed.contentType, + serverMediaOptimisation: parsed.media, + ); + + List? finalResults; + await for (final progress in stream) { + final phase = progress.phase.name; + final pct = progress.percentage.toStringAsFixed(1); + stdout.writeln( + ' [$phase] ${progress.currentServer.isEmpty ? "-" : progress.currentServer} ' + '$pct% ' + '${progress.mirrorsTotal > 0 ? "(${progress.mirrorsCompleted}/${progress.mirrorsTotal} mirrors)" : ""}' + .trimRight(), + ); + if (progress.completedUploads.isNotEmpty) { + finalResults = progress.completedUploads; + } + } + + if (finalResults == null || finalResults.isEmpty) { + stderr.writeln('Upload produced no results.'); + return 1; + } + stdout.writeln('Upload done:'); + _printUploadResults(finalResults); + return finalResults.every((r) => r.success) ? 0 : 1; + } + + // ---- download ----------------------------------------------------------- + + Future _handleDownload(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 2); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final url = parsed.positional[0]; + final outPath = parsed.positional[1]; + + stdout.writeln('Downloading $url -> $outPath ...'); + await ndk.files.downloadToFile( + url: url, + outputPath: outPath, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + pubkey: parsed.pubkey, + ); + final size = await File(outPath).length(); + stdout.writeln('Done: $outPath (${_humanSize(size)}).'); + return 0; + } + + // ---- delete ------------------------------------------------------------- + + Future _handleDelete(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final sha = parsed.positional[0].toLowerCase(); + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(sha)) { + stderr.writeln('Invalid sha256: $sha'); + return 2; + } + + stdout.writeln('Deleting $sha ...'); + final results = await ndk.files.delete( + sha256: sha, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + ); + for (final r in results) { + final status = r.success ? 'OK' : 'FAILED'; + stdout.writeln( + ' ${r.serverUrl}: $status' + '${r.error == null ? "" : " (${r.error})"}', + ); + } + return results.every((r) => r.success) ? 0 : 1; + } + + // ---- check -------------------------------------------------------------- + + Future _handleCheck(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final url = parsed.positional[0]; + + stdout.writeln('Checking $url ...'); + final alive = await ndk.files.checkUrl( + url: url, + serverUrls: parsed.servers.isEmpty ? null : parsed.servers, + pubkey: parsed.pubkey, + ); + stdout.writeln('Alive URL: $alive'); + return 0; + } + + // ---- helpers ------------------------------------------------------------ + + void _printUploadResults(List results) { + for (final r in results) { + if (r.success && r.descriptor != null) { + final d = r.descriptor!; + stdout.writeln( + ' ${r.serverUrl}: OK ' + 'sha256=${d.sha256} ' + 'size=${_humanSize(d.size ?? 0)} ' + 'type=${d.type ?? "?"}', + ); + } else { + stdout.writeln( + ' ${r.serverUrl}: FAILED ' + '${r.error == null ? "" : "(${r.error})"}', + ); + } + } + } + + String _humanSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KiB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MiB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GiB'; + } + + String? _resolvePubkey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isPubkey(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + bool _isHelp(String value) => + value == 'help' || value == '--help' || value == '-h'; + + _FilesArgs _parseArgs(List args, {required int requirePositional}) { + final result = _FilesArgs(); + var i = 0; + String? takeValue() { + if (i + 1 >= args.length) return null; + final v = args[i + 1]; + i += 2; + return v; + } + + while (i < args.length) { + final raw = args[i]; + String flag; + String? inline; + final eq = raw.indexOf('='); + if (eq > 0 && raw.startsWith('-')) { + flag = raw.substring(0, eq); + inline = raw.substring(eq + 1); + } else { + flag = raw; + inline = null; + } + String? nextOrInline() { + if (inline != null) { + i += 1; + return inline; + } + return takeValue(); + } + + if (flag == '--server') { + final v = nextOrInline(); + if (v == null) return _FilesArgs(error: 'Missing value for --server'); + result.servers.add(v); + continue; + } + if (flag == '--pubkey') { + final v = nextOrInline(); + if (v == null) return _FilesArgs(error: 'Missing value for --pubkey'); + result.pubkey = _resolvePubkey(v) ?? v; + continue; + } + if (flag == '--content-type') { + final v = nextOrInline(); + if (v == null) { + return _FilesArgs(error: 'Missing value for --content-type'); + } + result.contentType = v; + continue; + } + if (flag == '--media') { + result.media = true; + i += 1; + continue; + } + if (flag == '-h' || flag == '--help') { + i += 1; + continue; + } + if (flag.startsWith('-')) { + return _FilesArgs(error: 'Unknown option: $flag'); + } + result.positional.add(raw); + i += 1; + } + + if (result.positional.length < requirePositional) { + return _FilesArgs( + error: + 'Expected $requirePositional positional argument(s), ' + 'got ${result.positional.length}.', + ); + } + return result; + } +} + +class _FilesArgs { + final List positional = []; + final List servers = []; + String? pubkey; + String? contentType; + bool media = false; + String? error; + + _FilesArgs({this.error}); +} diff --git a/packages/ndk/lib/src/cli/ndk_cli_app.dart b/packages/ndk/lib/src/cli/ndk_cli_app.dart index 7c46867f8..f83d34a0e 100644 --- a/packages/ndk/lib/src/cli/ndk_cli_app.dart +++ b/packages/ndk/lib/src/cli/ndk_cli_app.dart @@ -4,6 +4,7 @@ import 'package:ndk/data_layer/repositories/wallets/sembast_wallets_repo.dart'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; +import 'cli_accounts_store.dart'; import 'cli_command.dart'; class NdkCliApp { @@ -51,12 +52,28 @@ class NdkCliApp { } final walletsRepo = await _createWalletsRepo(); - final ndk = _createNdk(walletsRepo, globalOptions.logLevel); + final cache = await _createCache(); + final ndk = _createNdk(walletsRepo, cache, globalOptions.logLevel); try { + final accountsStore = await CliAccountsStore.load(); + if (accountsStore.records.isNotEmpty) { + await restoreAccountsIntoNdk(ndk: ndk, store: accountsStore); + } return await command.run( - globalOptions.commandArgs.sublist(1), ndk, walletsRepo); + globalOptions.commandArgs.sublist(1), + ndk, + walletsRepo, + accountsStore, + ); } finally { - await ndk.destroy(); + // Swallow cleanup errors so a failing exit code reflects the actual + // command result, not post-run teardown noise (cashu wallet disposal + // can throw when no seed is configured). + try { + await ndk.destroy(); + } catch (e) { + // ignore + } } } @@ -94,16 +111,27 @@ class NdkCliApp { } Future _createWalletsRepo() { - return SembastWalletsRepo.create( - filename: 'wallets_db.db', + return SembastWalletsRepo.create(filename: 'wallets_db.db'); + } + + Future _createCache() { + // Persist proofs/keysets/mint infos next to wallets_db.db so cashu + // operations work across CLI invocations. + return SembastCacheManager.create( + databasePath: '.', + databaseName: 'ndk_cache', ); } - Ndk _createNdk(WalletsRepo walletsRepo, LogLevel logLevel) { + Ndk _createNdk( + WalletsRepo walletsRepo, + SembastCacheManager cache, + LogLevel logLevel, + ) { Logger.setLogLevel(logLevel); return Ndk( NdkConfig( - cache: MemCacheManager(), + cache: cache, walletsRepo: walletsRepo, eventVerifier: _CliEventVerifier(), bootstrapRelays: const [], @@ -147,11 +175,13 @@ class NdkCliApp { for (final value in remaining) { if (value == '--version' || value == '-V') { return _GlobalCliOptions( - error: '--version must be provided before the command name.'); + error: '--version must be provided before the command name.', + ); } if (value == '-v' || value == '-vv' || value == '-vvv') { return _GlobalCliOptions( - error: '$value must be provided before the command name.'); + error: '$value must be provided before the command name.', + ); } } diff --git a/packages/ndk/lib/src/cli/req_cli_command.dart b/packages/ndk/lib/src/cli/req_cli_command.dart index 48c20e65a..1cc00012d 100644 --- a/packages/ndk/lib/src/cli/req_cli_command.dart +++ b/packages/ndk/lib/src/cli/req_cli_command.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -5,6 +6,7 @@ import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/helpers/relay_helper.dart'; +import 'cli_accounts_store.dart'; import 'cli_command.dart'; class ReqCliCommand implements CliCommand { @@ -16,10 +18,32 @@ class ReqCliCommand implements CliCommand { @override String get usage => - 'ndk req [-k ] [-l ] [-t ] [relay2 ...]'; + 'ndk req [options] [relay2 ...]\n' + 'Options:\n' + ' -k, --kind Event kind (repeatable)\n' + ' -a, --author Author pubkey (repeatable)\n' + ' -i, --id Event id (repeatable)\n' + ' -e, --e #e tag value (repeatable)\n' + ' -p, --p #p tag value (repeatable)\n' + ' -d, --d #d tag value (repeatable)\n' + ' -T, --hashtag #t tag value (repeatable)\n' + ' --tag Arbitrary single-char tag, e.g. --tag r=wss://x (repeatable)\n' + ' --search NIP-50 search\n' + ' --since created_at >= (e.g. 1h, 2d, 2024-01-01)\n' + ' --until created_at <=\n' + ' -l, --limit Max events to emit (default 10)\n' + ' --timeout Query timeout (default 12)\n' + ' --stream Live subscription: keep receiving until Ctrl+C\n' + ' -o, --output Output mode (default json)\n' + ' -h, --help Show this help'; @override - Future run(List args, Ndk ndk, WalletsRepo walletsRepo) async { + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { if (_isHelp(args)) { _printUsage(); return 0; @@ -28,14 +52,31 @@ class ReqCliCommand implements CliCommand { final parseResult = _parseArgs(args); if (parseResult.error != null) { stderr.writeln(parseResult.error); - stderr.writeln('Usage: $usage'); + stderr.writeln('Usage: ndk req [options] [relay ...]'); return 2; } final filter = Filter( - kinds: parseResult.kind != null ? [parseResult.kind!] : null, + kinds: parseResult.kinds.isEmpty ? null : parseResult.kinds, + ids: parseResult.ids.isEmpty ? null : parseResult.ids, + authors: parseResult.authors.isEmpty ? null : parseResult.authors, + search: parseResult.search, + eTags: parseResult.eTags.isEmpty ? null : parseResult.eTags, + pTags: parseResult.pTags.isEmpty ? null : parseResult.pTags, + dTags: parseResult.dTags.isEmpty ? null : parseResult.dTags, + tTags: parseResult.tTags.isEmpty ? null : parseResult.tTags, + since: parseResult.since, + until: parseResult.until, limit: parseResult.limit, ); + if (parseResult.extraTags.isNotEmpty) { + filter.tags ??= {}; + filter.tags!.addAll(parseResult.extraTags); + } + + if (parseResult.stream) { + return _runStream(parseResult, filter, ndk); + } final response = ndk.requests.query( filter: filter, @@ -48,7 +89,7 @@ class ReqCliCommand implements CliCommand { var emitted = 0; try { await for (final event in response.stream) { - stdout.writeln(jsonEncode(_eventToJson(event))); + _emitEvent(event, parseResult); emitted++; if (emitted >= parseResult.limit) { break; @@ -64,96 +105,383 @@ class ReqCliCommand implements CliCommand { return 0; } + Future _runStream( + _ReqArgsParseResult parseResult, + Filter filter, + Ndk ndk, + ) async { + stderr.writeln('Streaming (Ctrl+C to stop)...'); + + final response = ndk.requests.subscription( + filter: filter, + explicitRelays: parseResult.relays, + cacheRead: false, + cacheWrite: false, + ); + + var received = 0; + final done = Completer(); + + final sigintSub = ProcessSignal.sigint.watch().listen((_) { + stderr.writeln('\nStopping...'); + if (!done.isCompleted) { + done.complete(); + } + }); + + final eventSub = response.stream.listen( + (event) { + _emitEvent(event, parseResult); + received++; + }, + onDone: () { + if (!done.isCompleted) { + done.complete(); + } + }, + onError: (e) { + stderr.writeln('Subscription error: $e'); + if (!done.isCompleted) { + done.complete(); + } + }, + ); + + try { + await done.future; + } finally { + await eventSub.cancel(); + await sigintSub.cancel(); + await ndk.requests.closeSubscription( + response.requestId, + debugLabel: 'req CLI stream done', + ); + } + + stderr.writeln('Received $received event(s).'); + return 0; + } + + void _emitEvent(Nip01Event event, _ReqArgsParseResult parseResult) { + if (parseResult.output == _OutputMode.summary) { + stdout.writeln(_eventSummary(event)); + } else { + stdout.writeln(jsonEncode(_eventToJson(event))); + } + } + bool _isHelp(List args) { return args.contains('--help') || args.contains('-h'); } void _printUsage() { stdout.writeln(description); - stdout.writeln('Usage: $usage'); - stdout.writeln('Options:'); - stdout.writeln(' -k, --kind Filter by event kind'); - stdout.writeln(' -l, --limit Limit number of events'); - stdout.writeln(' -t, --timeout Query timeout in seconds'); - stdout.writeln(' -h, --help Show this help'); + stdout.writeln(usage); } _ReqArgsParseResult _parseArgs(List args) { - int? kind; - int limit = 10; - int timeoutSeconds = 12; - final relays = []; - + final result = _ReqArgsParseResult(); int i = 0; while (i < args.length) { - final arg = args[i]; + final raw = args[i]; + + // split "--flag=value" form + String flag; + String? inlineValue; + final eq = raw.indexOf('='); + if (eq > 0 && raw.startsWith('-')) { + flag = raw.substring(0, eq); + inlineValue = raw.substring(eq + 1); + } else { + flag = raw; + inlineValue = null; + } - if (arg == '-k' || arg == '--kind') { - i++; - if (i >= args.length) { - return _ReqArgsParseResult(error: 'Missing value for $arg'); + String? takeValue() { + if (inlineValue != null) { + i += 1; + return inlineValue; } - final parsedKind = int.tryParse(args[i]); - if (parsedKind == null) { - return _ReqArgsParseResult( - error: 'Invalid kind value "${args[i]}"', - ); + if (i + 1 >= args.length) { + return null; } - kind = parsedKind; - } else if (arg == '-l' || arg == '--limit') { - i++; - if (i >= args.length) { - return _ReqArgsParseResult(error: 'Missing value for $arg'); + final v = args[i + 1]; + i += 2; + return v; + } + + if (flag == '-k' || flag == '--kind') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final kind = int.tryParse(v); + if (kind == null) { + return _ReqArgsParseResult(error: 'Invalid kind value "$v"'); + } + result.kinds.add(kind); + continue; + } + + if (flag == '-a' || flag == '--author') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final hex = _resolvePubkey(v); + if (hex == null) { + return _ReqArgsParseResult(error: 'Invalid author value "$v"'); + } + result.authors.add(hex); + continue; + } + + if (flag == '-i' || flag == '--id') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + result.ids.add(_resolveEventId(v)); + continue; + } + + if (flag == '-e' || flag == '--e') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + result.eTags.add(_resolveEventId(v)); + continue; + } + + if (flag == '-p' || flag == '--p') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final hex = _resolvePubkey(v); + if (hex == null) { + return _ReqArgsParseResult(error: 'Invalid p-tag value "$v"'); + } + result.pTags.add(hex); + continue; + } + + if (flag == '-d' || flag == '--d') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + result.dTags.add(v); + continue; + } + + if (flag == '-T' || flag == '--hashtag') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); } - final parsedLimit = int.tryParse(args[i]); - if (parsedLimit == null || parsedLimit <= 0) { + result.tTags.add(v); + continue; + } + + if (flag == '--tag') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final parsed = _parseTagAssignment(v); + if (parsed == null) { return _ReqArgsParseResult( - error: 'Invalid limit value "${args[i]}"', + error: 'Invalid --tag "$v" (expected key=value)', ); } - limit = parsedLimit; - } else if (arg == '-t' || arg == '--timeout') { - i++; - if (i >= args.length) { - return _ReqArgsParseResult(error: 'Missing value for $arg'); - } - final parsedTimeout = int.tryParse(args[i]); - if (parsedTimeout == null || parsedTimeout <= 0) { + final key = parsed.key; + if (key.length != 1) { return _ReqArgsParseResult( - error: 'Invalid timeout value "${args[i]}"', + error: '--tag key must be a single char (got "$key")', ); } - timeoutSeconds = parsedTimeout; - } else if (arg.startsWith('-')) { - return _ReqArgsParseResult(error: 'Unknown option: $arg'); - } else { - final cleanedRelay = _parseRelay(arg); - if (cleanedRelay == null) { - return _ReqArgsParseResult(error: 'Invalid relay URL: $arg'); + final mapKey = '#$key'; + result.extraTags + .putIfAbsent(mapKey, () => []) + .add(parsed.value); + continue; + } + + if (flag == '--search') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + result.search = v; + continue; + } + + if (flag == '--since') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final ts = _parseTime(v, upperBound: false); + if (ts == null) { + return _ReqArgsParseResult(error: 'Invalid --since value "$v"'); + } + result.since = ts; + continue; + } + + if (flag == '--until') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final ts = _parseTime(v, upperBound: true); + if (ts == null) { + return _ReqArgsParseResult(error: 'Invalid --until value "$v"'); + } + result.until = ts; + continue; + } + + if (flag == '-l' || flag == '--limit') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final n = int.tryParse(v); + if (n == null || n <= 0) { + return _ReqArgsParseResult(error: 'Invalid limit value "$v"'); + } + result.limit = n; + continue; + } + + if (flag == '--timeout' || flag == '-t') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + final n = int.tryParse(v); + if (n == null || n <= 0) { + return _ReqArgsParseResult(error: 'Invalid timeout value "$v"'); + } + result.timeoutSeconds = n; + continue; + } + + if (flag == '--stream') { + result.stream = true; + i += 1; + continue; + } + + if (flag == '-o' || flag == '--output') { + final v = takeValue(); + if (v == null) { + return _ReqArgsParseResult(error: 'Missing value for $flag'); + } + switch (v) { + case 'json': + result.output = _OutputMode.json; + break; + case 'summary': + result.output = _OutputMode.summary; + break; + default: + return _ReqArgsParseResult( + error: 'Invalid --output "$v" (json|summary)', + ); } - relays.add(cleanedRelay); + continue; } + if (flag.startsWith('-') && flag != '-') { + return _ReqArgsParseResult(error: 'Unknown option: $flag'); + } + + final cleanedRelay = _parseRelay(raw); + if (cleanedRelay == null) { + return _ReqArgsParseResult(error: 'Invalid relay URL: $raw'); + } + result.relays.add(cleanedRelay); i++; } - if (relays.isEmpty) { + if (result.relays.isEmpty) { return _ReqArgsParseResult(error: 'At least one relay URL is required.'); } - return _ReqArgsParseResult( - kind: kind, - limit: limit, - timeoutSeconds: timeoutSeconds, - relays: relays, - ); + return result; + } + + String? _resolvePubkey(String value) { + final trimmed = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(trimmed)) { + return trimmed.toLowerCase(); + } + if (Nip19.isPubkey(trimmed)) { + try { + return Nip19.decode(trimmed); + } catch (_) { + return null; + } + } + return null; + } + + String _resolveEventId(String value) { + final trimmed = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(trimmed)) { + return trimmed.toLowerCase(); + } + if (Nip19.isNoteId(trimmed)) { + try { + return Nip19.decode(trimmed); + } catch (_) { + return trimmed; + } + } + return trimmed; + } + + MapEntry? _parseTagAssignment(String raw) { + final eq = raw.indexOf('='); + if (eq <= 0 || eq == raw.length - 1) return null; + return MapEntry(raw.substring(0, eq), raw.substring(eq + 1)); + } + + /// Parses a time arg. Accepted forms: + /// - unix seconds (e.g. 1700000000) + /// - ISO 8601 (e.g. 2024-01-01, 2024-01-01T12:00:00Z) + /// - duration: digits + unit (s|m|h|d|w). For --since this means "now - dur", + /// for --until this means "now + dur" (rarely useful). + int? _parseTime(String value, {required bool upperBound}) { + final trimmed = value.trim(); + final asInt = int.tryParse(trimmed); + if (asInt != null) return asInt; + + final durMatch = RegExp(r'^(\d+)\s*([smhdw])$').firstMatch(trimmed); + if (durMatch != null) { + final amount = int.parse(durMatch.group(1)!); + final unit = durMatch.group(2)!; + final mult = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800}[unit]!; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final delta = amount * mult; + return upperBound ? now + delta : now - delta; + } + + try { + final parsed = DateTime.parse(trimmed); + return parsed.toUtc().millisecondsSinceEpoch ~/ 1000; + } on FormatException { + return null; + } } String? _parseRelay(String value) { final direct = cleanRelayUrl(value); - if (direct != null) { - return direct; - } + if (direct != null) return direct; return cleanRelayUrl('wss://$value'); } @@ -168,20 +496,42 @@ class ReqCliCommand implements CliCommand { 'sig': event.sig, }; } + + String _eventSummary(Nip01Event event) { + final created = DateTime.fromMillisecondsSinceEpoch( + event.createdAt * 1000, + isUtc: true, + ); + final preview = event.content.length > 80 + ? '${event.content.substring(0, 77)}...' + : event.content; + final oneLine = preview.replaceAll('\n', ' '); + return 'kind=${event.kind} ${created.toIso8601String()} ' + 'id=${event.id} author=${event.pubKey.substring(0, 12)}... ' + '"$oneLine"'; + } } +enum _OutputMode { json, summary } + class _ReqArgsParseResult { - final int? kind; - final int limit; - final int timeoutSeconds; - final List relays; - final String? error; - - _ReqArgsParseResult({ - this.kind, - this.limit = 10, - this.timeoutSeconds = 12, - this.relays = const [], - this.error, - }); + final List kinds = []; + final List ids = []; + final List authors = []; + final List eTags = []; + final List pTags = []; + final List dTags = []; + final List tTags = []; + final Map> extraTags = {}; + String? search; + int? since; + int? until; + int limit = 10; + int timeoutSeconds = 12; + bool stream = false; + _OutputMode output = _OutputMode.json; + final List relays = []; + String? error; + + _ReqArgsParseResult({this.error}); } diff --git a/packages/ndk/lib/src/cli/wallets/wallets_cli_command.dart b/packages/ndk/lib/src/cli/wallets/wallets_cli_command.dart index dbb226002..8cf0410fc 100644 --- a/packages/ndk/lib/src/cli/wallets/wallets_cli_command.dart +++ b/packages/ndk/lib/src/cli/wallets/wallets_cli_command.dart @@ -1,10 +1,10 @@ import 'dart:io'; -import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; +import '../cli_accounts_store.dart'; import '../cli_command.dart'; class WalletsCliCommand implements CliCommand { @@ -20,7 +20,12 @@ class WalletsCliCommand implements CliCommand { 'wallets [args]'; @override - Future run(List args, Ndk ndk, WalletsRepo walletsRepo) async { + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { if (args.isEmpty || _isHelp(args.first)) { _printHelp(); return 0; @@ -66,6 +71,36 @@ class WalletsCliCommand implements CliCommand { return 0; } + if (subCommand == 'set-default') { + await _handleSetDefault(subArgs, walletsRepo, walletsUsecase); + return 0; + } + + if (subCommand == 'melt') { + await _handleMelt(subArgs, walletsRepo, walletsUsecase, ndk); + return 0; + } + + if (subCommand == 'mint') { + await _handleMint(subArgs, walletsRepo, walletsUsecase, ndk); + return 0; + } + + if (subCommand == 'swap-receive') { + await _handleSwapReceive(subArgs, ndk); + return 0; + } + + if (subCommand == 'swap-spend') { + await _handleSwapSpend(subArgs, walletsRepo, walletsUsecase, ndk); + return 0; + } + + if (subCommand == 'pay-stats') { + await _handlePayStats(subArgs, walletsRepo, walletsUsecase, ndk); + return 0; + } + stderr.writeln('Unknown wallets sub-command: "$subCommand"'); _printHelp(stderr); return 2; @@ -120,8 +155,9 @@ class WalletsCliCommand implements CliCommand { final wallets = await walletsRepo.getWallets(); final defaultName = 'NWC ${wallets.length + 1}'; - final walletName = - args.length > 2 ? args.sublist(2).join(' ') : defaultName; + final walletName = args.length > 2 + ? args.sublist(2).join(' ') + : defaultName; final wallet = walletsUsecase.createWallet( id: _buildWalletId(), @@ -133,8 +169,9 @@ class WalletsCliCommand implements CliCommand { await walletsUsecase.addWallet(wallet); - stdout - .writeln('Added wallet: id=${wallet.id} name=${wallet.name} type=nwc'); + stdout.writeln( + 'Added wallet: id=${wallet.id} name=${wallet.name} type=nwc', + ); final updatedWallets = await walletsRepo.getWallets(); _printWallets(updatedWallets, walletsUsecase); } @@ -153,8 +190,9 @@ class WalletsCliCommand implements CliCommand { final mintInfo = await ndk.cashu.getMintInfoNetwork(mintUrl: mintUrl); final wallets = await walletsRepo.getWallets(); final defaultName = 'Cashu ${wallets.length + 1}'; - final walletName = - args.length > 2 ? args.sublist(2).join(' ') : defaultName; + final walletName = args.length > 2 + ? args.sublist(2).join(' ') + : defaultName; final supportedUnits = mintInfo.supportedUnits.isEmpty ? {'sat'} : mintInfo.supportedUnits; @@ -164,10 +202,7 @@ class WalletsCliCommand implements CliCommand { name: walletName, type: WalletType.CASHU, supportedUnits: supportedUnits, - metadata: { - 'mintUrl': mintUrl, - 'mintInfo': mintInfo.toJson(), - }, + metadata: {'mintUrl': mintUrl, 'mintInfo': mintInfo.toJson()}, ); await walletsUsecase.addWallet(wallet); @@ -203,10 +238,7 @@ class WalletsCliCommand implements CliCommand { _printWallets(updatedWallets, walletsUsecase); } - Future _handleReceive( - List args, - Wallets walletsUsecase, - ) async { + Future _handleReceive(List args, Wallets walletsUsecase) async { if (args.isEmpty || args.length > 2) { stderr.writeln('Usage: ndk wallets receive [walletId]'); throw ArgumentError('Invalid arguments for wallets receive'); @@ -227,10 +259,7 @@ class WalletsCliCommand implements CliCommand { stdout.writeln(invoice); } - Future _handleSend( - List args, - Wallets walletsUsecase, - ) async { + Future _handleSend(List args, Wallets walletsUsecase) async { if (args.isEmpty || args.length > 2) { stderr.writeln('Usage: ndk wallets send [walletId]'); throw ArgumentError('Invalid arguments for wallets send'); @@ -346,8 +375,9 @@ class WalletsCliCommand implements CliCommand { stdout.writeln('- remaining: $remainingSats sats'); stdout.writeln('- renewal period: ${budget.renewalPeriod.plaintext}'); if (budget.renewsAt != null) { - final renewsAt = - DateTime.fromMillisecondsSinceEpoch(budget.renewsAt! * 1000); + final renewsAt = DateTime.fromMillisecondsSinceEpoch( + budget.renewsAt! * 1000, + ); stdout.writeln('- renews at: ${renewsAt.toIso8601String()}'); } } finally { @@ -355,6 +385,403 @@ class WalletsCliCommand implements CliCommand { } } + Future _handleSetDefault( + List args, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + ) async { + if (args.isEmpty || args.length > 2) { + stderr.writeln( + 'Usage: ndk wallets set-default ' + '[receive|send|both] (default: both)', + ); + throw ArgumentError('Invalid arguments for wallets set-default'); + } + final walletId = args[0]; + final scope = args.length == 2 ? args[1].toLowerCase() : 'both'; + final wallets = await walletsRepo.getWallets(); + final exists = wallets.any((w) => w.id == walletId); + if (!exists) { + throw ArgumentError('Wallet not found: $walletId'); + } + switch (scope) { + case 'both': + walletsUsecase.setDefaultWallet(walletId); + break; + case 'receive': + walletsUsecase.setDefaultWalletForReceiving(walletId); + break; + case 'send': + walletsUsecase.setDefaultWalletForSending(walletId); + break; + default: + throw ArgumentError('Scope must be receive|send|both (got "$scope")'); + } + stdout.writeln('Default ($scope) set to $walletId'); + final updated = await walletsRepo.getWallets(); + _printWallets(updated, walletsUsecase); + } + + Future _handleMelt( + List args, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + Ndk ndk, + ) async { + final parsed = _parseCashuOpArgs( + args, + usageLine: 'ndk wallets melt [walletId] [--seed ]', + requireValue: true, + valueName: 'bolt11', + ); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return; + } + final wallet = await _resolveCashuWallet( + parsed.walletId, + walletsRepo, + walletsUsecase, + ); + await _ensureSeed(ndk, parsed.seed); + final mintUrl = wallet.metadata['mintUrl'] as String; + + stdout.writeln('Getting melt quote from $mintUrl ...'); + final draft = await ndk.cashu.initiateRedeem( + mintUrl: mintUrl, + request: parsed.value!, + unit: 'sat', + method: 'bolt11', + ); + final meltQuote = draft.qouteMelt; + if (meltQuote != null) { + final fee = meltQuote.feeReserve ?? 0; + stdout.writeln( + 'Quote: amount=${meltQuote.amount} fee=$fee ' + 'total=${meltQuote.amount + fee} sat', + ); + } + stdout.writeln('Melting ...'); + await for (final tx in ndk.cashu.redeem(draftRedeemTransaction: draft)) { + stdout.writeln(' state: ${tx.state.value}'); + if (tx.completionMsg != null) { + stdout.writeln(' msg: ${tx.completionMsg}'); + } + if (tx.state.isDone) break; + } + } + + Future _handleMint( + List args, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + Ndk ndk, + ) async { + final parsed = _parseCashuOpArgs( + args, + usageLine: + 'ndk wallets mint [walletId] ' + '[--seed ] [--wait]', + requireValue: true, + valueName: 'amountSats', + allowWait: true, + ); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return; + } + final amount = int.tryParse(parsed.value!); + if (amount == null || amount <= 0) { + throw ArgumentError('amountSats must be a positive integer'); + } + final wallet = await _resolveCashuWallet( + parsed.walletId, + walletsRepo, + walletsUsecase, + ); + await _ensureSeed(ndk, parsed.seed); + final mintUrl = wallet.metadata['mintUrl'] as String; + + stdout.writeln('Requesting mint quote from $mintUrl for $amount sat ...'); + final draft = await ndk.cashu.initiateFund( + mintUrl: mintUrl, + amount: amount, + unit: 'sat', + method: 'bolt11', + ); + final invoice = draft.qoute?.request; + stdout.writeln('Pay this invoice to mint tokens:'); + stdout.writeln(' $invoice'); + if (!parsed.wait) { + stdout.writeln( + '(quote saved; run again with --wait to poll and mint ' + 'once paid)', + ); + return; + } + stdout.writeln('Polling until paid ... (Ctrl+C to abort)'); + await for (final tx in ndk.cashu.retrieveFunds(draftTransaction: draft)) { + stdout.writeln(' state: ${tx.state.value}'); + if (tx.completionMsg != null) { + stdout.writeln(' msg: ${tx.completionMsg}'); + } + if (tx.state.isDone) break; + } + } + + Future _handleSwapReceive(List args, Ndk ndk) async { + final parsed = _parseCashuOpArgs( + args, + usageLine: 'ndk wallets swap-receive [--seed ]', + requireValue: true, + valueName: 'cashuToken', + ); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return; + } + await _ensureSeed(ndk, parsed.seed); + stdout.writeln('Swapping incoming token ...'); + await for (final tx in ndk.cashu.receive(parsed.value!)) { + stdout.writeln(' state: ${tx.state.value}'); + if (tx.completionMsg != null) { + stdout.writeln(' msg: ${tx.completionMsg}'); + } + if (tx.state.isDone) break; + } + } + + Future _handleSwapSpend( + List args, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + Ndk ndk, + ) async { + final parsed = _parseCashuOpArgs( + args, + usageLine: + 'ndk wallets swap-spend [walletId] ' + '[--seed ]', + requireValue: true, + valueName: 'amountSats', + ); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return; + } + final amount = int.tryParse(parsed.value!); + if (amount == null || amount <= 0) { + throw ArgumentError('amountSats must be a positive integer'); + } + final wallet = await _resolveCashuWallet( + parsed.walletId, + walletsRepo, + walletsUsecase, + ); + await _ensureSeed(ndk, parsed.seed); + final mintUrl = wallet.metadata['mintUrl'] as String; + + final result = await ndk.cashu.initiateSpend( + mintUrl: mintUrl, + amount: amount, + unit: 'sat', + ); + stdout.writeln('Created spendable token ($amount sat):'); + stdout.writeln(result.token.toV4TokenString()); + } + + Future _handlePayStats( + List args, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + Ndk ndk, + ) async { + int? limit = 20; + String? walletId; + for (var i = 0; i < args.length; i++) { + final a = args[i]; + if (a == '--limit' && i + 1 < args.length) { + limit = int.tryParse(args[++i]); + if (limit == null || limit <= 0) { + throw ArgumentError('Invalid --limit "${args[i]}"'); + } + } else if (walletId == null && !a.startsWith('-')) { + walletId = a; + } else { + stderr.writeln('Usage: ndk wallets pay-stats [walletId] [--limit N]'); + return; + } + } + final wallets = await walletsRepo.getWallets(); + if (wallets.isEmpty) { + throw StateError('No wallets available'); + } + final target = + walletId ?? + walletsUsecase.defaultWalletForReceiving?.id ?? + wallets.first.id; + final wallet = wallets.firstWhere( + (w) => w.id == target, + orElse: () => throw ArgumentError('Wallet not found: $target'), + ); + + stdout.writeln('Transactions for ${wallet.id} (${wallet.type.value}):'); + if (wallet.type == WalletType.NWC) { + await _printNwcTransactions(ndk, wallet, limit); + } else { + await _printStoredTransactions(walletsUsecase, wallet.id, limit); + } + } + + Future _printNwcTransactions(Ndk ndk, Wallet wallet, int? limit) async { + final nwcUrl = wallet.metadata['nwcUrl'] as String?; + if (nwcUrl == null || nwcUrl.isEmpty) { + throw StateError('NWC wallet missing metadata["nwcUrl"]'); + } + final connection = await ndk.nwc.connect(nwcUrl, doGetInfoMethod: false); + try { + final resp = await ndk.nwc.listTransactions( + connection, + unpaid: true, + limit: limit, + ); + if (resp.transactions.isEmpty) { + stdout.writeln(' (no transactions returned)'); + return; + } + for (final tx in resp.transactions) { + final dir = tx.isIncoming ? 'IN ' : 'OUT'; + final state = (tx.state ?? 'unknown').padRight(8); + final amt = '${tx.amountSat} sat'.padRight(12); + final fees = (tx.feesPaid ?? 0) > 0 ? ' fee=${tx.feesPaid}msat' : ''; + final settled = tx.settledAt != null + ? ' settled=${_formatUnix(tx.settledAt!)}' + : ''; + stdout.writeln(' $dir $state $amt$fees$settled'); + } + } finally { + await ndk.nwc.disconnect(connection); + } + } + + Future _printStoredTransactions( + Wallets walletsUsecase, + String walletId, + int? limit, + ) async { + final txs = await walletsUsecase.getTransactions( + walletId: walletId, + limit: limit, + ); + if (txs.isEmpty) { + stdout.writeln(' (no transactions stored)'); + return; + } + for (final tx in txs) { + final dir = tx.changeAmount >= 0 ? 'IN ' : 'OUT'; + final state = tx.state.value.padRight(8); + final amt = '${tx.changeAmount.abs()} ${tx.unit}'.padRight(12); + final date = tx.transactionDate != null + ? ' date=${_formatUnixMillis(tx.transactionDate!)}' + : ''; + stdout.writeln(' $dir $state $amt$date'); + } + } + + String _formatUnix(int seconds) { + final dt = DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + return dt.toIso8601String(); + } + + String _formatUnixMillis(int millis) { + final dt = DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true); + return dt.toIso8601String(); + } + + /// Resolves a cashu wallet by id (or default), throwing if it's not cashu. + Future _resolveCashuWallet( + String? walletId, + WalletsRepo walletsRepo, + Wallets walletsUsecase, + ) async { + final wallets = await walletsRepo.getWallets(); + if (wallets.isEmpty) { + throw StateError('No wallets available'); + } + String id; + if (walletId != null) { + id = walletId; + } else { + id = walletsUsecase.defaultWalletForReceiving?.id ?? wallets.first.id; + } + final wallet = wallets.firstWhere( + (w) => w.id == id, + orElse: () => throw ArgumentError('Wallet not found: $id'), + ); + if (wallet.type != WalletType.CASHU) { + throw ArgumentError('Wallet $id is ${wallet.type.value}, expected cashu'); + } + return wallet; + } + + Future _ensureSeed(Ndk ndk, String? seedFlag) async { + if (ndk.cashu.getCashuSeed().isSeedPhraseSet) return; + final seed = seedFlag ?? Platform.environment['NDK_CASHU_SEED']; + if (seed == null || seed.trim().isEmpty) { + throw StateError( + 'Cashu seed phrase not set. ' + 'Pass --seed or set NDK_CASHU_SEED env var.', + ); + } + ndk.cashu.setCashuSeedPhrase(CashuUserSeedphrase(seedPhrase: seed.trim())); + } + + _CashuOpArgs _parseCashuOpArgs( + List args, { + required String usageLine, + required bool requireValue, + required String valueName, + bool allowWait = false, + }) { + String? value; + String? walletId; + String? seed; + var wait = false; + var positionalSeen = 0; + for (var i = 0; i < args.length; i++) { + final a = args[i]; + if (a == '--seed' && i + 1 < args.length) { + seed = args[++i]; + continue; + } + if (allowWait && a == '--wait') { + wait = true; + continue; + } + if (a.startsWith('-')) { + return _CashuOpArgs(error: 'Unknown option: $a\nUsage: $usageLine'); + } + if (positionalSeen == 0) { + value = a; + positionalSeen++; + } else if (positionalSeen == 1) { + walletId = a; + positionalSeen++; + } else { + return _CashuOpArgs(error: 'Too many arguments.\nUsage: $usageLine'); + } + } + if (requireValue && value == null) { + return _CashuOpArgs(error: 'Missing $valueName.\nUsage: $usageLine'); + } + return _CashuOpArgs( + value: value, + walletId: walletId, + seed: seed, + wait: wait, + ); + } + void _printWallets(List wallets, Wallets walletsUsecase) { stdout.writeln(''); if (wallets.isEmpty) { @@ -401,7 +828,13 @@ class WalletsCliCommand implements CliCommand { out.writeln(' receive [walletId]'); out.writeln(' send [walletId]'); out.writeln(' balance [walletId]'); - out.writeln(' budget [walletId]'); + out.writeln(' budget [walletId] (NWC only)'); + out.writeln(' set-default [receive|send|both]'); + out.writeln(' melt [walletId] [--seed ] (cashu only)'); + out.writeln(' mint [walletId] [--seed ] [--wait]'); + out.writeln(' swap-receive [--seed ]'); + out.writeln(' swap-spend [walletId] [--seed ]'); + out.writeln(' pay-stats [walletId] [--limit N]'); out.writeln(''); out.writeln('Examples:'); out.writeln(' ndk wallets list'); @@ -416,6 +849,14 @@ class WalletsCliCommand implements CliCommand { out.writeln(' ndk wallets balance wallet_123'); out.writeln(' ndk wallets budget'); out.writeln(' ndk wallets budget wallet_123'); + out.writeln(' ndk wallets set-default wallet_123 send'); + out.writeln(' ndk wallets mint 100 --wait --seed "word1 word2 ..."'); + out.writeln(' ndk wallets melt "lnbc1..."'); + out.writeln(' ndk wallets swap-receive "cashuA..."'); + out.writeln(' ndk wallets pay-stats --limit 50'); + out.writeln( + 'Cashu operations accept --seed or the NDK_CASHU_SEED env var.', + ); } bool _isHelp(String value) { @@ -427,3 +868,19 @@ class WalletsCliCommand implements CliCommand { return 'wallet_$now'; } } + +class _CashuOpArgs { + final String? value; + final String? walletId; + final String? seed; + final bool wait; + final String? error; + + _CashuOpArgs({ + this.value, + this.walletId, + this.seed, + this.wait = false, + this.error, + }); +} diff --git a/packages/ndk/lib/src/cli/zaps/zaps_cli_command.dart b/packages/ndk/lib/src/cli/zaps/zaps_cli_command.dart new file mode 100644 index 000000000..d98b39040 --- /dev/null +++ b/packages/ndk/lib/src/cli/zaps/zaps_cli_command.dart @@ -0,0 +1,477 @@ +import 'dart:io'; + +import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; + +import '../cli_accounts_store.dart'; +import '../cli_command.dart'; + +/// `ndk zaps` - NIP-57 zap operations. +/// +/// - `invoice` fetch a lightning/zap invoice (no payment) +/// - `zap` pay a zap from a stored NWC sending wallet +/// - `receipts` list zap receipts for a recipient pubkey +class ZapsCliCommand implements CliCommand { + @override + String get name => 'zaps'; + + @override + String get description => 'Zap operations (invoice, zap, receipts)'; + + @override + String get usage => _help; + + static const String _help = '''ndk zaps [args] +Sub-commands: + invoice [options] + Fetch a zap/lightning invoice (no payment) + zap [options] + Pay a zap using the default NWC sending wallet + receipts [options] List zap receipts (kind 9735) for a recipient +Options (common): + --wallet Override the NWC wallet to use (zap) + --comment Zap comment / invoice memo + --pubkey Recipient pubkey (enables true zap encoding) + --event Zap a specific event + --addressable Zap an addressable event (naddr) + --relays (repeatable) Relays to attach to the zap request + --no-zap invoice: force plain LN invoice (skip zap encoding) + --no-receipt zap: don't wait for the zap receipt + --limit receipts: max events (default 50) + --timeout Per-operation timeout (default 15) + -h, --help Show this help'''; + + @override + Future run( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + CliAccountsStore accountsStore, + ) async { + if (args.isEmpty || _isHelp(args.first)) { + stdout.writeln(_help); + return 0; + } + try { + final sub = args.first.toLowerCase(); + final rest = args.sublist(1); + switch (sub) { + case 'invoice': + return await _handleInvoice(rest, ndk); + case 'zap': + return await _handleZap(rest, ndk, walletsRepo); + case 'receipts': + return await _handleReceipts(rest, ndk); + default: + stderr.writeln('Unknown zaps sub-command: "$sub"'); + stdout.writeln(_help); + return 2; + } + } catch (e) { + stderr.writeln('Zaps command failed: $e'); + return 1; + } + } + + // ---- invoice ------------------------------------------------------------ + + Future _handleInvoice(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 2); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final lud16 = parsed.positional[0]; + final amount = _parseAmount(parsed.positional[1]); + if (amount == null) { + stderr.writeln('Invalid amount: ${parsed.positional[1]}'); + return 2; + } + + ZapRequest? zapRequest; + final wantZap = !parsed.noZap; + final pubKey = parsed.pubkey ?? _resolvePubkeyFromAccount(ndk); + if (wantZap && pubKey != null && ndk.accounts.canSign) { + final signer = ndk.accounts.getLoggedAccount()!.signer; + final relays = parsed.relays.isNotEmpty + ? parsed.relays + : _defaultZapRelays(ndk); + zapRequest = await ndk.zaps.createZapRequest( + amountSats: amount, + signer: signer, + pubKey: pubKey, + eventId: parsed.eventId, + addressableId: parsed.addressable, + comment: parsed.comment, + relays: relays, + ); + } + + stdout.writeln('Fetching invoice from $lud16 for $amount sats ...'); + final invoice = await ndk.zaps.fetchInvoice( + lud16Link: lud16, + amountSats: amount, + zapRequest: zapRequest, + comment: parsed.comment, + ); + if (invoice == null) { + stderr.writeln('Failed to fetch invoice from $lud16'); + return 1; + } + stdout.writeln( + 'Invoice ($amount sats${zapRequest != null ? ", zap-encoded" : ""}):', + ); + stdout.writeln(invoice.invoice); + return 0; + } + + // ---- zap ---------------------------------------------------------------- + + Future _handleZap( + List args, + Ndk ndk, + WalletsRepo walletsRepo, + ) async { + final parsed = _parseArgs(args, requirePositional: 2); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final lud16 = parsed.positional[0]; + final amount = _parseAmount(parsed.positional[1]); + if (amount == null) { + stderr.writeln('Invalid amount: ${parsed.positional[1]}'); + return 2; + } + + final wallet = await _resolveNwcWallet(parsed.walletId, walletsRepo, ndk); + final nwcUrl = wallet.metadata['nwcUrl'] as String?; + if (nwcUrl == null || nwcUrl.isEmpty) { + stderr.writeln('Wallet ${wallet.id} has no nwcUrl metadata.'); + return 2; + } + + final pubKey = parsed.pubkey ?? _resolvePubkeyFromAccount(ndk); + final relays = parsed.relays.isNotEmpty + ? parsed.relays + : _defaultZapRelays(ndk); + + stdout.writeln('Connecting to NWC wallet ${wallet.id} ...'); + final connection = await ndk.nwc.connect(nwcUrl, doGetInfoMethod: false); + try { + stdout.writeln('Zapping $lud16 ($amount sats) ...'); + final response = await ndk.zaps.zap( + nwcConnection: connection, + lnurl: lud16, + amountSats: amount, + fetchZapReceipt: !parsed.noReceipt, + signer: ndk.accounts.canSign + ? ndk.accounts.getLoggedAccount()!.signer + : null, + relays: relays, + pubKey: pubKey, + comment: parsed.comment, + eventId: parsed.eventId, + addressableId: parsed.addressable, + ); + + if (response.error != null) { + stderr.writeln('Zap failed: ${response.error}'); + return 1; + } + final pay = response.payInvoiceResponse; + stdout.writeln('Zap paid:'); + stdout.writeln(' preimage: ${pay?.preimage}'); + stdout.writeln(' fees paid: ${pay?.feesPaid ?? 0} msats'); + + if (!parsed.noReceipt && pubKey != null) { + stdout.writeln('Waiting for zap receipt ...'); + final receipt = await response.zapReceipt.timeout( + Duration(seconds: parsed.timeoutSeconds), + onTimeout: () => null, + ); + if (receipt == null) { + stderr.writeln(' (no zap receipt received within timeout)'); + } else { + stdout.writeln(' receipt amount: ${receipt.amountSats} sats'); + stdout.writeln(' receipt paidAt: ${_formatUnix(receipt.paidAt)}'); + } + } + return 0; + } finally { + await ndk.nwc.disconnect(connection); + } + } + + // ---- receipts ----------------------------------------------------------- + + Future _handleReceipts(List args, Ndk ndk) async { + final parsed = _parseArgs(args, requirePositional: 1); + if (parsed.error != null) { + stderr.writeln(parsed.error); + return 2; + } + final rawPubkey = parsed.positional[0]; + final pubkey = _resolvePubkey(rawPubkey); + if (pubkey == null) { + stderr.writeln('Invalid pubkey: $rawPubkey'); + return 2; + } + + stdout.writeln( + 'Fetching zap receipts for ${Nip19.encodePubKey(pubkey)} ...', + ); + var count = 0; + await for (final receipt in ndk.zaps.fetchZappedReceipts( + pubKey: pubkey, + eventId: parsed.eventId, + addressableId: parsed.addressable, + timeout: Duration(seconds: parsed.timeoutSeconds), + )) { + final amount = receipt.amountSats ?? 0; + final sender = receipt.sender != null + ? Nip19.encodePubKey(receipt.sender!) + : '(anonymous)'; + final date = _formatUnix(receipt.paidAt) ?? '?'; + final comment = receipt.comment == null || receipt.comment!.isEmpty + ? '' + : ' "${receipt.comment}"'; + stdout.writeln('$date $amount sat from=$sender$comment'); + if (receipt.eventId != null) { + stdout.writeln(' event: ${receipt.eventId}'); + } + count++; + if (count >= parsed.limit) break; + } + stdout.writeln('Done: $count receipt(s).'); + return 0; + } + + // ---- helpers ------------------------------------------------------------ + + Future _resolveNwcWallet( + String? walletId, + WalletsRepo walletsRepo, + Ndk ndk, + ) async { + final wallets = await ndk.wallets.getWallets(); + if (wallets.isEmpty) { + throw StateError( + 'No wallets available. Use "ndk wallets add nwc ..." first.', + ); + } + if (walletId != null) { + final match = wallets.firstWhere( + (w) => w.id == walletId, + orElse: () => throw ArgumentError('Wallet not found: $walletId'), + ); + if (match.type != WalletType.NWC) { + throw ArgumentError( + 'Wallet $walletId is ${match.type.value}, expected nwc.', + ); + } + return match; + } + final defaultSending = ndk.wallets.defaultWalletForSending; + if (defaultSending != null && defaultSending.type == WalletType.NWC) { + return defaultSending; + } + final anyNwc = wallets.firstWhere( + (w) => w.type == WalletType.NWC, + orElse: () => throw StateError( + 'No NWC wallet configured. Use "ndk wallets add nwc ..." first.', + ), + ); + return anyNwc; + } + + String? _resolvePubkeyFromAccount(Ndk ndk) => ndk.accounts.getPublicKey(); + + List _defaultZapRelays(Ndk ndk) { + final config = ndk.config; + final bootstraps = config.bootstrapRelays; + if (bootstraps.isNotEmpty) return bootstraps.toList(); + return ['wss://relay.damus.io', 'wss://nos.lol']; + } + + int? _parseAmount(String raw) { + final v = int.tryParse(raw); + if (v == null || v <= 0) return null; + return v; + } + + String? _resolvePubkey(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isPubkey(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return null; + } + } + return null; + } + + String? _resolveEventId(String value) { + final t = value.trim(); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(t)) return t.toLowerCase(); + if (Nip19.isNoteId(t)) { + try { + return Nip19.decode(t); + } catch (_) { + return t; + } + } + return t; + } + + String? _formatUnix(int? secondsSinceEpoch) { + if (secondsSinceEpoch == null) return null; + final dt = DateTime.fromMillisecondsSinceEpoch( + secondsSinceEpoch * 1000, + isUtc: true, + ); + return dt.toIso8601String(); + } + + bool _isHelp(String value) => + value == 'help' || value == '--help' || value == '-h'; + + _ZapsArgs _parseArgs(List args, {required int requirePositional}) { + final result = _ZapsArgs(); + var i = 0; + String? takeValue() { + if (i + 1 >= args.length) return null; + final v = args[i + 1]; + i += 2; + return v; + } + + while (i < args.length) { + final raw = args[i]; + String flag; + String? inline; + final eq = raw.indexOf('='); + if (eq > 0 && raw.startsWith('-')) { + flag = raw.substring(0, eq); + inline = raw.substring(eq + 1); + } else { + flag = raw; + inline = null; + } + String? nextOrInline() { + if (inline != null) { + i += 1; + return inline; + } + return takeValue(); + } + + if (flag == '--wallet') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --wallet'); + result.walletId = v; + continue; + } + if (flag == '--comment') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --comment'); + result.comment = v; + continue; + } + if (flag == '--pubkey') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --pubkey'); + result.pubkey = _resolvePubkey(v) ?? v; + continue; + } + if (flag == '--event') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --event'); + result.eventId = _resolveEventId(v); + continue; + } + if (flag == '--addressable') { + final v = nextOrInline(); + if (v == null) { + return _ZapsArgs(error: 'Missing value for --addressable'); + } + result.addressable = v; + continue; + } + if (flag == '--relays') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --relays'); + result.relays.add(v); + continue; + } + if (flag == '--no-zap') { + result.noZap = true; + i += 1; + continue; + } + if (flag == '--no-receipt') { + result.noReceipt = true; + i += 1; + continue; + } + if (flag == '--limit') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --limit'); + final n = int.tryParse(v); + if (n == null || n <= 0) { + return _ZapsArgs(error: 'Invalid --limit "$v"'); + } + result.limit = n; + continue; + } + if (flag == '--timeout') { + final v = nextOrInline(); + if (v == null) return _ZapsArgs(error: 'Missing value for --timeout'); + final n = int.tryParse(v); + if (n == null || n <= 0) { + return _ZapsArgs(error: 'Invalid --timeout "$v"'); + } + result.timeoutSeconds = n; + continue; + } + if (flag == '-h' || flag == '--help') { + i += 1; + continue; + } + if (flag.startsWith('-')) { + return _ZapsArgs(error: 'Unknown option: $flag'); + } + result.positional.add(raw); + i += 1; + } + + if (result.positional.length < requirePositional) { + return _ZapsArgs( + error: + 'Expected $requirePositional positional argument(s), ' + 'got ${result.positional.length}.', + ); + } + return result; + } +} + +class _ZapsArgs { + final List positional = []; + String? walletId; + String? comment; + String? pubkey; + String? eventId; + String? addressable; + final List relays = []; + bool noZap = false; + bool noReceipt = false; + int limit = 50; + int timeoutSeconds = 15; + String? error; + + _ZapsArgs({this.error}); +} diff --git a/packages/ndk/lib/src/rust_lib.dart b/packages/ndk/lib/src/rust_lib.dart index 02a35a4bf..a7e47c935 100644 --- a/packages/ndk/lib/src/rust_lib.dart +++ b/packages/ndk/lib/src/rust_lib.dart @@ -16,17 +16,18 @@ final class QsBuffer extends Struct { /// Verifies a Nostr event signature. /// Returns 1 if valid, 0 if invalid. @Native< - Int32 Function( - Pointer, // eventIdHex - Pointer, // pubKeyHex - Uint64, // createdAt - Uint32, // kind - Pointer>, // tagsData - Pointer, // tagsLengths - Uint32, // tagsCount - Pointer, // content - Pointer, // signatureHex - )>(symbol: 'verify_nostr_event') + Int32 Function( + Pointer, // eventIdHex + Pointer, // pubKeyHex + Uint64, // createdAt + Uint32, // kind + Pointer>, // tagsData + Pointer, // tagsLengths + Uint32, // tagsCount + Pointer, // content + Pointer, // signatureHex + ) +>(symbol: 'verify_nostr_event') external int verifyNostrEventNative( Pointer eventIdHex, Pointer pubKeyHex, @@ -51,11 +52,12 @@ external void qsFreeBuffer(QsBuffer buf); /// [outPk], [outSk]: pointers to QsBuffer structs that will be filled. /// Returns 1 on success, 0 on failure. @Native< - Int32 Function( - Uint32, // level - Pointer, // outPk - Pointer, // outSk - )>(symbol: 'qs_generate_keypair') + Int32 Function( + Uint32, // level + Pointer, // outPk + Pointer, // outSk + ) +>(symbol: 'qs_generate_keypair') external int qsGenerateKeypair( int level, Pointer outPk, @@ -70,14 +72,15 @@ external int qsGenerateKeypair( /// [outSig]: pointer to QsBuffer that will receive the signature. /// Returns 1 on success, 0 on failure. @Native< - Int32 Function( - Uint32, // level - Pointer, // skPtr - IntPtr, // skLen - Pointer, // msgPtr - IntPtr, // msgLen - Pointer, // outSig - )>(symbol: 'qs_sign') + Int32 Function( + Uint32, // level + Pointer, // skPtr + IntPtr, // skLen + Pointer, // msgPtr + IntPtr, // msgLen + Pointer, // outSig + ) +>(symbol: 'qs_sign') external int qsSign( int level, Pointer skPtr, @@ -92,15 +95,16 @@ external int qsSign( /// [level]: security level (2, 3, or 5). /// Returns 1 if valid, 0 if invalid. @Native< - Int32 Function( - Uint32, // level - Pointer, // pkPtr - IntPtr, // pkLen - Pointer, // msgPtr - IntPtr, // msgLen - Pointer, // sigPtr - IntPtr, // sigLen - )>(symbol: 'qs_verify') + Int32 Function( + Uint32, // level + Pointer, // pkPtr + IntPtr, // pkLen + Pointer, // msgPtr + IntPtr, // msgLen + Pointer, // sigPtr + IntPtr, // sigLen + ) +>(symbol: 'qs_verify') external int qsVerify( int level, Pointer pkPtr, diff --git a/packages/ndk/test/broadcast/broadcast_test.dart b/packages/ndk/test/broadcast/broadcast_test.dart index d276f04a8..1d5aaff93 100644 --- a/packages/ndk/test/broadcast/broadcast_test.dart +++ b/packages/ndk/test/broadcast/broadcast_test.dart @@ -36,46 +36,37 @@ void main() { stopwatch.stop(); - expect( - stopwatch.elapsedMilliseconds, - lessThan(5000), - ); + expect(stopwatch.elapsedMilliseconds, lessThan(5000)); }, ); - test( - 'broadcast to 0 relay should not time out', - () async { - final ndk = Ndk.emptyBootstrapRelaysConfig(); - addTearDown(() async => ndk.destroy()); + test('broadcast to 0 relay should not time out', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + addTearDown(() async => ndk.destroy()); - final keyPair = Bip340.generatePrivateKey(); - ndk.accounts.loginPrivateKey( - privkey: keyPair.privateKey!, - pubkey: keyPair.publicKey, - ); + final keyPair = Bip340.generatePrivateKey(); + ndk.accounts.loginPrivateKey( + privkey: keyPair.privateKey!, + pubkey: keyPair.publicKey, + ); - final event = Nip01Event( - pubKey: keyPair.publicKey, - kind: 1, - content: '', - tags: [], - ); + final event = Nip01Event( + pubKey: keyPair.publicKey, + kind: 1, + content: '', + tags: [], + ); - final stopwatch = Stopwatch()..start(); + final stopwatch = Stopwatch()..start(); - final broadcast = ndk.broadcast.broadcast( - nostrEvent: event, - specificRelays: [], - ); - await broadcast.broadcastDoneFuture; + final broadcast = ndk.broadcast.broadcast( + nostrEvent: event, + specificRelays: [], + ); + await broadcast.broadcastDoneFuture; - stopwatch.stop(); + stopwatch.stop(); - expect( - stopwatch.elapsedMilliseconds, - lessThan(5000), - ); - }, - ); + expect(stopwatch.elapsedMilliseconds, lessThan(5000)); + }); } diff --git a/packages/ndk/test/cashu/cashu_bdhke_test.dart b/packages/ndk/test/cashu/cashu_bdhke_test.dart index 11944555f..9bfcf8f13 100644 --- a/packages/ndk/test/cashu/cashu_bdhke_test.dart +++ b/packages/ndk/test/cashu/cashu_bdhke_test.dart @@ -13,15 +13,18 @@ void main() { final secret = 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; final r = BigInt.parse( - '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', - radix: 16); + '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( + blindedMessage.length, + equals(66), + ); // Compressed EC point (33 bytes = 66 hex chars) expect(returnedR, equals(r)); }); @@ -30,8 +33,9 @@ void main() { final secret = 'f1aaf16c2239746f369572c0784d9dd3d032d952c2d992175873fb58fae31a60'; final r = BigInt.parse( - 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', - radix: 16); + 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', + radix: 16, + ); final (blindedMessage, returnedR) = CashuBdhke.blindMessage(secret, r: r); @@ -50,8 +54,10 @@ void main() { // 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) + expect( + blindedMessage.length, + greaterThan(60), + ); // Compressed EC point should be 66 chars (33 bytes) // Should return a valid r value expect(r, isNotNull); @@ -63,8 +69,9 @@ void main() { final secret = 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; final r = BigInt.parse( - '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', - radix: 16); + '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', + radix: 16, + ); final (blindedMessage1, r1) = CashuBdhke.blindMessage(secret, r: r); final (blindedMessage2, r2) = CashuBdhke.blindMessage(secret, r: r); @@ -79,11 +86,13 @@ void main() { final secret = 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; final r1 = BigInt.parse( - '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', - radix: 16); + '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', + radix: 16, + ); final r2 = BigInt.parse( - 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', - radix: 16); + 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', + radix: 16, + ); final (blindedMessage1, _) = CashuBdhke.blindMessage(secret, r: r1); final (blindedMessage2, _) = CashuBdhke.blindMessage(secret, r: r2); diff --git a/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart b/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart index 182221e03..5e067b4c2 100644 --- a/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart +++ b/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'dart:typed_data'; import 'package:convert/convert.dart'; @@ -21,8 +22,7 @@ void main() { seedBytes = Uint8List.fromList(cashuSeed.getSeedBytes()); }); - group('Version 1: Deprecated BIP32 Derivation (keyset ID 009a1f293253e41e)', - () { + group('Version 1: Deprecated BIP32 Derivation (keyset ID 009a1f293253e41e)', () { const keysetId = "009a1f293253e41e"; const keysetIdInt = 864559728; @@ -152,8 +152,7 @@ void main() { }); }); - group('Version 2: Modern HMAC-SHA256 Derivation (keyset ID 015ba18a...)', - () { + group('Version 2: Modern HMAC-SHA256 Derivation (keyset ID 015ba18a...)', () { const keysetId = "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; diff --git a/packages/ndk/test/cashu/cashu_dev_test.dart b/packages/ndk/test/cashu/cashu_dev_test.dart index 415266c0c..494e5047a 100644 --- a/packages/ndk/test/cashu/cashu_dev_test.dart +++ b/packages/ndk/test/cashu/cashu_dev_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print 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'; diff --git a/packages/ndk/test/cashu/cashu_fund_test.dart b/packages/ndk/test/cashu/cashu_fund_test.dart index 29fb2e779..704168687 100644 --- a/packages/ndk/test/cashu/cashu_fund_test.dart +++ b/packages/ndk/test/cashu/cashu_fund_test.dart @@ -71,20 +71,18 @@ void main() { 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), + id: 'test0', + walletId: '', + changeAmount: 5, + unit: 'sat', + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + mintUrl: devMintUrl, + qoute: null, + ), ); - expect( - response, - emitsError(isA()), - ); + expect(response, emitsError(isA())); }); test('fund - retriveFunds exceptions', () async { @@ -101,56 +99,45 @@ void main() { 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()), - ); + 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())); }); }); @@ -201,44 +188,47 @@ void main() { 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'}, - )); + "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'}, - )); + "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"), + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), ); final draftTransaction = await cashu.initiateFund( @@ -248,22 +238,30 @@ void main() { method: "bolt11", ); - final transactionStream = - cashu.retrieveFunds(draftTransaction: draftTransaction); + 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), + 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); + final balanceForMint = allBalances.where( + (element) => element.mintUrl == mockMintUrl, + ); expect(balanceForMint.length, 1); final balance = balanceForMint.first.balances[fundUnit]; @@ -280,8 +278,9 @@ void main() { final cashu = CashuTestTools.mockHttpCashu( customMockClient: myHttpMock, seedPhrase: CashuUserSeedphrase( - seedPhrase: - "reduce invest lunch step couch traffic measure civil want steel trip jar"), + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), ); final draftTransaction = await cashu.initiateFund( @@ -291,22 +290,27 @@ void main() { method: "bolt11", ); - final transactionStream = - cashu.retrieveFunds(draftTransaction: draftTransaction); + 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.pending, + ), emitsError(isA()), ]), ); //check balance final allBalances = await cashu.getBalances(); - final balanceForMint = - allBalances.where((element) => element.mintUrl == mockMintUrl); + final balanceForMint = allBalances.where( + (element) => element.mintUrl == mockMintUrl, + ); expect(balanceForMint.length, 1); final balance = balanceForMint.first.balances[fundUnit]; @@ -326,14 +330,18 @@ void main() { unit: fundUnit, method: "bolt11", ); - final transactionStream = - ndk.cashu.retrieveFunds(draftTransaction: draftTransaction); + 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.pending, + ), isA() .having((t) => t.state, 'state', WalletTransactionState.completed) .having((t) => t.transactionDate!, 'transactionDate', isA()), @@ -341,8 +349,9 @@ void main() { ); // check balance final allBalances = await ndk.cashu.getBalances(); - final balanceForMint = - allBalances.where((element) => element.mintUrl == devMintUrl); + final balanceForMint = allBalances.where( + (element) => element.mintUrl == devMintUrl, + ); expect(balanceForMint.length, 1); final balance = balanceForMint.first.balances[fundUnit]; @@ -363,14 +372,18 @@ void main() { unit: fundUnit, method: "bolt11", ); - final transactionStream = - ndk.cashu.retrieveFunds(draftTransaction: draftTransaction); + 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.pending, + ), isA() .having((t) => t.state, 'state', WalletTransactionState.completed) .having((t) => t.transactionDate!, 'transactionDate', isA()), @@ -378,27 +391,38 @@ void main() { ); // check balance final allBalances = await ndk.cashu.getBalances(); - final balanceForMint = - allBalances.where((element) => element.mintUrl == devMintUrl); + 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"); - await ndk.cashu - .initiateSpend(mintUrl: devMintUrl, amount: 18, unit: "sat"); - await ndk.cashu - .initiateSpend(mintUrl: devMintUrl, amount: 32, unit: "sat"); + final spend200 = await ndk.cashu.initiateSpend( + mintUrl: devMintUrl, + amount: 200, + unit: "sat", + ); + await ndk.cashu.initiateSpend( + mintUrl: devMintUrl, + amount: 18, + unit: "sat", + ); + 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 balanceForMintSpend = allBalancesSpend.where( + (element) => element.mintUrl == devMintUrl, + ); final balanceSpend = balanceForMintSpend.first.balances[fundUnit]; @@ -409,8 +433,11 @@ void main() { await expectLater( rcv, emitsInOrder([ - isA() - .having((t) => t.state, 'state', WalletTransactionState.pending), + isA().having( + (t) => t.state, + 'state', + WalletTransactionState.pending, + ), isA() .having((t) => t.state, 'state', WalletTransactionState.completed) .having((t) => t.transactionDate!, 'transactionDate', isA()), @@ -418,8 +445,9 @@ void main() { ); final allBalancesRcv = await ndk.cashu.getBalances(); - final balanceForMintRcv = - allBalancesRcv.where((element) => element.mintUrl == devMintUrl); + final balanceForMintRcv = allBalancesRcv.where( + (element) => element.mintUrl == devMintUrl, + ); final balanceSpendRcv = balanceForMintRcv.first.balances[fundUnit]; @@ -438,25 +466,29 @@ void main() { final walletsRepo = MemWalletsRepo(); final cashu = Cashu( - cashuRepo: cashuRepo, - walletsRepo: walletsRepo, - 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); + cashuRepo: cashuRepo, + walletsRepo: walletsRepo, + 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( @@ -471,21 +503,27 @@ void main() { expect(proofs.length, equals(2)); final pendingProofs = await cache.getProofs( - mintUrl: mockMintUrl, state: CashuProofState.pending); + mintUrl: mockMintUrl, + state: CashuProofState.pending, + ); expect(pendingProofs.length, equals(0)); final spendProofs = await cache.getProofs( - mintUrl: mockMintUrl, state: CashuProofState.spend); + mintUrl: mockMintUrl, + state: CashuProofState.spend, + ); expect(spendProofs.length, equals(0)); }); }); } Ndk _ndk() { - return Ndk(NdkConfig( - cache: MemCacheManager(), - walletsRepo: MemWalletsRepo(), - eventVerifier: Bip340EventVerifier(), - bootstrapRelays: [], - )); + return Ndk( + NdkConfig( + cache: MemCacheManager(), + walletsRepo: MemWalletsRepo(), + eventVerifier: Bip340EventVerifier(), + bootstrapRelays: [], + ), + ); } diff --git a/packages/ndk/test/cashu/cashu_import_export_test.dart b/packages/ndk/test/cashu/cashu_import_export_test.dart index 9717c0a91..7db4cd504 100644 --- a/packages/ndk/test/cashu/cashu_import_export_test.dart +++ b/packages/ndk/test/cashu/cashu_import_export_test.dart @@ -6,7 +6,10 @@ import 'package:ndk/ndk.dart'; import 'package:test/test.dart'; CashuStateExportImport _export( - CacheManager cache, MemWalletsRepo wallets, CashuSeed seed) { + CacheManager cache, + MemWalletsRepo wallets, + CashuSeed seed, +) { return CashuStateExportImport( cacheManagerCashu: CashuCacheDecorator(cacheManager: cache), walletsRepo: wallets, @@ -15,13 +18,13 @@ CashuStateExportImport _export( } CahsuKeyset _keyset(String mintUrl) => CahsuKeyset( - id: 'keyset1', - mintUrl: mintUrl, - unit: 'sat', - active: true, - inputFeePPK: 0, - mintKeyPairs: {CahsuMintKeyPair(amount: 1, pubkey: 'abc')}, - ); + id: 'keyset1', + mintUrl: mintUrl, + unit: 'sat', + active: true, + inputFeePPK: 0, + mintKeyPairs: {CahsuMintKeyPair(amount: 1, pubkey: 'abc')}, +); void main() { const mintUrl = 'https://mint.test'; @@ -56,8 +59,11 @@ void main() { counter: 42, ); - final exported = await _export(srcCache, srcWallets, seed) - .exportToMap(includeSeedPhrase: true); + final exported = await _export( + srcCache, + srcWallets, + seed, + ).exportToMap(includeSeedPhrase: true); expect(exported['type'], equals(CashuStateExportImport.exportType)); expect(exported['seedPhrase'], equals(seed.getSeedPhrase().sentence)); @@ -69,15 +75,20 @@ void main() { final dstWallets = MemWalletsRepo(); final dstSeed = CashuSeed(); - final result = - await _export(dstCache, dstWallets, dstSeed).importFromMap(exported); + final result = await _export( + dstCache, + dstWallets, + dstSeed, + ).importFromMap(exported); expect(result.restoredProofs, equals(2)); expect(result.restoredKeysets, equals(1)); expect(result.seedPhrase, equals(seed.getSeedPhrase().sentence)); // seed was loaded into the destination seed instance - expect(dstSeed.getSeedPhrase().sentence, - equals(seed.getSeedPhrase().sentence)); + expect( + dstSeed.getSeedPhrase().sentence, + equals(seed.getSeedPhrase().sentence), + ); final restoredProofs = await dstCache.getProofs(mintUrl: mintUrl); expect(restoredProofs.length, equals(2)); @@ -120,13 +131,18 @@ void main() { final jsonString = await _export(cache, wallets, seed).exportToJsonString(); final dstCache = MemCacheManager(); - final result = await _export(dstCache, MemWalletsRepo(), CashuSeed()) - .importFromJsonString(jsonString); + final result = await _export( + dstCache, + MemWalletsRepo(), + CashuSeed(), + ).importFromJsonString(jsonString); expect(result.restoredProofs, equals(2)); expect((await dstCache.getProofs(mintUrl: mintUrl)).length, equals(2)); - expect((await dstCache.getKeysets(mintUrl: mintUrl)).first.id, - equals('keyset1')); + expect( + (await dstCache.getKeysets(mintUrl: mintUrl)).first.id, + equals('keyset1'), + ); // check that the secret amount and unblinded sig round-tripped correctly (they are the most sensitive fields to get wrong in the serialization) final restoredProof = (await dstCache.getProofs(mintUrl: mintUrl)).first; diff --git a/packages/ndk/test/cashu/cashu_proof_select_test.dart b/packages/ndk/test/cashu/cashu_proof_select_test.dart index 9eebb5512..d138072ad 100644 --- a/packages/ndk/test/cashu/cashu_proof_select_test.dart +++ b/packages/ndk/test/cashu/cashu_proof_select_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print 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'; @@ -115,9 +116,13 @@ void main() { test('split test - insufficient', () { expect( - () => CashuProofSelect.selectProofsForSpending( - proofs: myproofs, targetAmount: 9999999, keysets: keysets), - throwsA(isA())); + () => CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + targetAmount: 9999999, + keysets: keysets, + ), + throwsA(isA()), + ); }); test('split test - combination', () { @@ -159,29 +164,31 @@ void main() { expect(combination.needsSplit, true); expect(combination.totalSelected > target, isTrue); expect( - combination.totalSelected - - combination.splitAmount - - combination.fees, - target); + combination.totalSelected - combination.splitAmount - combination.fees, + target, + ); }); test('fee calculation - mixed keysets', () { final mixedProofs = [ CashuProof( - amount: 10, - keysetId: 'test-keyset', - secret: "", - unblindedSig: ""), // 1000 ppk + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), // 1000 ppk CashuProof( - amount: 20, - keysetId: 'other-keyset', - secret: "", - unblindedSig: ""), // 100 ppk + amount: 20, + keysetId: 'other-keyset', + secret: "", + unblindedSig: "", + ), // 100 ppk CashuProof( - amount: 30, - keysetId: 'test-keyset', - secret: "", - unblindedSig: ""), // 1000 ppk + amount: 30, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), // 1000 ppk ]; final fees = CashuProofSelect.calculateFees(mixedProofs, keysets); @@ -192,11 +199,23 @@ void main() { test('fee calculation - breakdown by keyset', () { final mixedProofs = [ CashuProof( - amount: 10, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), CashuProof( - amount: 20, keysetId: 'other-keyset', secret: "", unblindedSig: ""), + amount: 20, + keysetId: 'other-keyset', + secret: "", + unblindedSig: "", + ), CashuProof( - amount: 30, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + amount: 30, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), ]; final breakdown = CashuProofSelect.calculateFeesWithBreakdown( @@ -225,10 +244,11 @@ void main() { test('fee calculation - unknown keyset throws exception', () { final invalidProofs = [ CashuProof( - amount: 10, - keysetId: 'unknown-keyset', - secret: "", - unblindedSig: ""), + amount: 10, + keysetId: 'unknown-keyset', + secret: "", + unblindedSig: "", + ), ]; expect( @@ -240,15 +260,29 @@ void main() { test('proof sorting - amount priority', () { final unsortedProofs = [ CashuProof( - amount: 10, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), CashuProof( - amount: 50, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + amount: 50, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), CashuProof( - amount: 25, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + amount: 25, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), ]; - final sorted = - CashuProofSelect.sortProofsOptimally(unsortedProofs, keysets); + final sorted = CashuProofSelect.sortProofsOptimally( + unsortedProofs, + keysets, + ); expect(sorted[0].amount, 50); expect(sorted[1].amount, 25); expect(sorted[2].amount, 10); @@ -257,19 +291,23 @@ void main() { test('proof sorting - fee priority when amounts equal', () { final equalAmountProofs = [ CashuProof( - amount: 10, - keysetId: 'test-keyset', - secret: "", - unblindedSig: ""), // 1000 ppk + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), // 1000 ppk CashuProof( - amount: 10, - keysetId: 'other-keyset', - secret: "", - unblindedSig: ""), // 100 ppk + amount: 10, + keysetId: 'other-keyset', + secret: "", + unblindedSig: "", + ), // 100 ppk ]; - final sorted = - CashuProofSelect.sortProofsOptimally(equalAmountProofs, keysets); + final sorted = CashuProofSelect.sortProofsOptimally( + equalAmountProofs, + keysets, + ); // Lower fee keyset should come first expect(sorted[0].keysetId, 'other-keyset'); expect(sorted[1].keysetId, 'test-keyset'); @@ -296,15 +334,17 @@ void main() { final cheaperFirst = CashuProofSelect.selectProofsForSpending( proofs: [ CashuProof( - amount: 50, - keysetId: 'test-keyset', - secret: "", - unblindedSig: ""), // 1000 ppk + amount: 50, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), // 1000 ppk CashuProof( - amount: 50, - keysetId: 'other-keyset', - secret: "", - unblindedSig: ""), // 100 ppk + amount: 50, + keysetId: 'other-keyset', + secret: "", + unblindedSig: "", + ), // 100 ppk ], targetAmount: 49, keysets: keysets, @@ -318,12 +358,14 @@ void main() { test('maximum iterations exceeded', () { final manySmallProofs = List.generate( - 20, - (i) => CashuProof( - amount: 1, - keysetId: 'test-keyset', - secret: "", - unblindedSig: "")); + 20, + (i) => CashuProof( + amount: 1, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "", + ), + ); expect( () => CashuProofSelect.selectProofsForSpending( @@ -339,25 +381,29 @@ void main() { test('fee breakdown accuracy', () { final mixedProofs = [ CashuProof( - amount: 10, - keysetId: 'test-keyset', - secret: "proofSecret10-0", - unblindedSig: ""), // 1000 ppk + amount: 10, + keysetId: 'test-keyset', + secret: "proofSecret10-0", + unblindedSig: "", + ), // 1000 ppk CashuProof( - amount: 20, - keysetId: 'test-keyset', - secret: "proofSecret20-0", - unblindedSig: ""), // 1000 ppk + amount: 20, + keysetId: 'test-keyset', + secret: "proofSecret20-0", + unblindedSig: "", + ), // 1000 ppk CashuProof( - amount: 30, - keysetId: 'other-keyset', - secret: "proofSecret30-0", - unblindedSig: ""), // 100 ppk + amount: 30, + keysetId: 'other-keyset', + secret: "proofSecret30-0", + unblindedSig: "", + ), // 100 ppk CashuProof( - amount: 40, - keysetId: 'other-keyset', - secret: "proofSecret40-0", - unblindedSig: ""), // 100 ppk + amount: 40, + keysetId: 'other-keyset', + secret: "proofSecret40-0", + unblindedSig: "", + ), // 100 ppk ]; final result = CashuProofSelect.selectProofsForSpending( @@ -377,22 +423,25 @@ void main() { test('single sat amounts with high fees - impossible', () { final singleSatProofs = List.generate( - 11, - (i) => CashuProof( - amount: 1, - keysetId: 'test-keyset', - secret: "", - unblindedSig: "")); + 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())); + () => CashuProofSelect.selectProofsForSpending( + proofs: singleSatProofs, + targetAmount: 1, + keysets: keysets, + ), + throwsA(isA()), + ); }); test('large value - should converge quickly', () { @@ -400,28 +449,34 @@ void main() { final largeValueProofs = [ // Add some larger proofs ...List.generate( - 10, - (i) => CashuProof( - amount: 1000, - keysetId: 'test-keyset', - secret: "proof1000-$i", - unblindedSig: "")), + 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: "")), + 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: "")), + 30, + (i) => CashuProof( + amount: 10, + keysetId: 'test-keyset', + secret: "proof10-$i", + unblindedSig: "", + ), + ), ]; // Target a large amount (8000 sats) - should converge without hitting max iterations @@ -439,19 +494,23 @@ void main() { // This test reproduces the convergence issue final manyProofs = [ ...List.generate( - 10, - (i) => CashuProof( - amount: 500, - keysetId: 'test-keyset', - secret: "proof500-$i", - unblindedSig: "")), + 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: "")), + 50, + (i) => CashuProof( + amount: 50, + keysetId: 'test-keyset', + secret: "proof50-$i", + unblindedSig: "", + ), + ), ]; // Target 7000 sats - this would previously fail to converge @@ -469,19 +528,23 @@ void main() { // Extreme test with very large target amount final extremeProofs = [ ...List.generate( - 30, - (i) => CashuProof( - amount: 2000, - keysetId: 'test-keyset', - secret: "proof2000-$i", - unblindedSig: "")), + 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: "")), + 100, + (i) => CashuProof( + amount: 100, + keysetId: 'test-keyset', + secret: "proof100-$i", + unblindedSig: "", + ), + ), ]; // Target 50000 sats - should still converge quickly with optimized algorithm @@ -500,19 +563,23 @@ void main() { // Performance test with many proofs final manyProofs = [ ...List.generate( - 50, - (i) => CashuProof( - amount: 5000, - keysetId: 'test-keyset', - secret: "proof5000-$i", - unblindedSig: "")), + 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: "")), + 150, + (i) => CashuProof( + amount: 100, + keysetId: 'test-keyset', + secret: "proof100-$i", + unblindedSig: "", + ), + ), ]; final stopwatch = Stopwatch()..start(); @@ -530,7 +597,8 @@ void main() { // Should be fast (under 50ms for 200 proofs) print( - 'Selection time for 200 proofs: ${stopwatch.elapsedMilliseconds}ms'); + '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 index 046ea33e0..ee60d7578 100644 --- a/packages/ndk/test/cashu/cashu_receive_test.dart +++ b/packages/ndk/test/cashu/cashu_receive_test.dart @@ -19,34 +19,27 @@ void main() { final ndk = Ndk.emptyBootstrapRelaysConfig(); final rcvStream = ndk.cashu.receive("cashuBinvalidtoken"); - expect( - () async => await rcvStream.last, - throwsA(isA()), - ); + 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()), + "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()), + "cashuBo2FtdGh0dHBzOi8vbWludC5pbnZhbGlkYXVjc2F0YXSBomFpSABV3vjPJfyNYXCBpGFhAWFzeEBmYmMxYWY4ZTk1YWQyZTVjMGQzY2U3MTMxNjI3MDBkOGNmN2NhNDQ2Njc1ZTE5NTc0NWE5ZWYzMDI1Zjc0NjdhYWNYIQJYTRSL3snLOVtf2OECtcqM_y7kG1VCQnVeWc9BPzP4zGFko2FlWCAlHMDORr2HAR0NNMsV4tB3s09bCB_s35QvHIEVkqed3mFzWCBLAh8gJ0J0uv7WzGkFC9gn4jZc7sFTpZvEgnitZ6ijrGFyWCC9QCslHjMWBU_2TWwnUNXj-rM7-iP6_8RqxiJMsa1Dcg", ); + + expect(() async => await rcvStream.last, throwsA(isA())); }); }); @@ -66,8 +59,9 @@ void main() { walletsRepo: walletsRepo, cacheManager: cache, cashuKeyDerivation: derivation, - cashuUserSeedphrase: - CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + cashuUserSeedphrase: CashuUserSeedphrase( + seedPhrase: CashuSeed.generateSeedPhrase(), + ), ); final walletsRepo2 = MemWalletsRepo(); @@ -76,8 +70,9 @@ void main() { walletsRepo: walletsRepo2, cacheManager: cache2, cashuKeyDerivation: derivation, - cashuUserSeedphrase: - CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + cashuUserSeedphrase: CashuUserSeedphrase( + seedPhrase: CashuSeed.generateSeedPhrase(), + ), ); const fundAmount = 32; @@ -89,8 +84,9 @@ void main() { unit: fundUnit, method: "bolt11", ); - final transactionStream = - cashu.retrieveFunds(draftTransaction: draftTransaction); + final transactionStream = cashu.retrieveFunds( + draftTransaction: draftTransaction, + ); final transaction = await transactionStream.last; expect(transaction.state, WalletTransactionState.completed); @@ -105,31 +101,30 @@ void main() { 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); + 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()), - ); + 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 index bb37ee9df..ac0197a43 100644 --- a/packages/ndk/test/cashu/cashu_redeem_test.dart +++ b/packages/ndk/test/cashu/cashu_redeem_test.dart @@ -18,36 +18,39 @@ const failingMintUrl = 'https://mint.example.com'; const mockMintUrl = "https://mock.mint"; Ndk _ndk() { - return Ndk(NdkConfig( - cache: MemCacheManager(), - walletsRepo: MemWalletsRepo(), - eventVerifier: Bip340EventVerifier(), - bootstrapRelays: [], - )); + return Ndk( + NdkConfig( + cache: MemCacheManager(), + walletsRepo: MemWalletsRepo(), + eventVerifier: Bip340EventVerifier(), + bootstrapRelays: [], + ), + ); } void main() { setUp(() {}); group('redeem tests - exceptions ', () { - test("redeem - offline mint should fail immediately on initiateRedeem", - () async { - final ndk = _ndk(); - - // 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 initiateRedeem", + () async { + final ndk = _ndk(); + + // 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 { + 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 @@ -75,14 +78,8 @@ void main() { fetchedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, // mark as fresh mintKeyPairs: { - CahsuMintKeyPair( - amount: 1, - pubkey: 'testPubKey-1', - ), - CahsuMintKeyPair( - amount: 2, - pubkey: 'testPubKey-2', - ), + CahsuMintKeyPair(amount: 1, pubkey: 'testPubKey-1'), + CahsuMintKeyPair(amount: 2, pubkey: 'testPubKey-2'), }, ), ); @@ -142,8 +139,9 @@ void main() { ), ); - final redeemStream = - cashu.redeem(draftRedeemTransaction: draftTransaction); + 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) @@ -184,8 +182,9 @@ void main() { mintUrl: devMintUrl, ); - final redeemStream = - ndk.cashu.redeem(draftRedeemTransaction: draftTransaction); + final redeemStream = ndk.cashu.redeem( + draftRedeemTransaction: draftTransaction, + ); await expectLater( () async => await redeemStream.last, @@ -205,8 +204,9 @@ void main() { request: '', ), ); - final redeemStream2 = - ndk.cashu.redeem(draftRedeemTransaction: dTwithQuote); + final redeemStream2 = ndk.cashu.redeem( + draftRedeemTransaction: dTwithQuote, + ); await expectLater( () async => await redeemStream2.last, @@ -215,8 +215,9 @@ void main() { // missing request final dTwithQuoteAndMethod = dTwithQuote.copyWith(method: "bolt11"); - final redeemStream3 = - ndk.cashu.redeem(draftRedeemTransaction: dTwithQuoteAndMethod); + final redeemStream3 = ndk.cashu.redeem( + draftRedeemTransaction: dTwithQuoteAndMethod, + ); await expectLater( () async => await redeemStream3.last, @@ -257,32 +258,37 @@ void main() { 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: '', + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", ), - ], mintUrl: mockMintUrl); + 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, @@ -291,23 +297,28 @@ void main() { method: "bolt11", ); - final redeemStream = - cashu.redeem(draftRedeemTransaction: meltQuoteTransaction); + 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), - ], - )); + 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 { + 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). @@ -371,8 +382,9 @@ void main() { method: "bolt11", ); - final redeemStream = - cashu.redeem(draftRedeemTransaction: meltQuoteTransaction); + final redeemStream = cashu.redeem( + draftRedeemTransaction: meltQuoteTransaction, + ); // Collect all events from the stream final events = await redeemStream.toList(); diff --git a/packages/ndk/test/cashu/cashu_restore_test.dart b/packages/ndk/test/cashu/cashu_restore_test.dart index 3305c33c7..d49e9d82b 100644 --- a/packages/ndk/test/cashu/cashu_restore_test.dart +++ b/packages/ndk/test/cashu/cashu_restore_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print 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'; @@ -12,180 +13,213 @@ 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, - walletsRepo: MemWalletsRepo(), - 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, - walletsRepo: MemWalletsRepo(), - 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; + // cannot test real external mints on tests + test( + skip: true, + '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( - ' Progress: ${result.totalProofsRestored} proofs restored so far...'); - } + '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, + walletsRepo: MemWalletsRepo(), + 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, + walletsRepo: MemWalletsRepo(), + 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('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), + '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); + '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(); + }, + ); }); } diff --git a/packages/ndk/test/cashu/cashu_spend_test.dart b/packages/ndk/test/cashu/cashu_spend_test.dart index b72184480..daffd6f76 100644 --- a/packages/ndk/test/cashu/cashu_spend_test.dart +++ b/packages/ndk/test/cashu/cashu_spend_test.dart @@ -28,18 +28,9 @@ void main() { active: true, inputFeePPK: 0, mintKeyPairs: { - CahsuMintKeyPair( - amount: 1, - pubkey: 'testPubKey-1', - ), - CahsuMintKeyPair( - amount: 2, - pubkey: 'testPubKey-2', - ), - CahsuMintKeyPair( - amount: 4, - pubkey: 'testPubKey-4', - ), + CahsuMintKeyPair(amount: 1, pubkey: 'testPubKey-1'), + CahsuMintKeyPair(amount: 2, pubkey: 'testPubKey-2'), + CahsuMintKeyPair(amount: 4, pubkey: 'testPubKey-4'), }, ), ); @@ -71,8 +62,9 @@ void main() { final cashu = CashuTestTools.mockHttpCashu( customCache: cache, seedPhrase: CashuUserSeedphrase( - seedPhrase: - "reduce invest lunch step couch traffic measure civil want steel trip jar"), + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), ); // This should throw an exception quickly (not hang) @@ -102,8 +94,9 @@ void main() { 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"), + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), ); expect( @@ -127,18 +120,9 @@ void main() { active: true, inputFeePPK: 0, mintKeyPairs: { - CahsuMintKeyPair( - amount: 1, - pubkey: 'testPubKey-1', - ), - CahsuMintKeyPair( - amount: 2, - pubkey: 'testPubKey-2', - ), - CahsuMintKeyPair( - amount: 4, - pubkey: 'testPubKey-2', - ), + CahsuMintKeyPair(amount: 1, pubkey: 'testPubKey-1'), + CahsuMintKeyPair(amount: 2, pubkey: 'testPubKey-2'), + CahsuMintKeyPair(amount: 4, pubkey: 'testPubKey-2'), }, ), ); @@ -150,7 +134,7 @@ void main() { amount: 1, secret: 'testSecret-32', unblindedSig: '', - ) + ), ], mintUrl: mockMintUrl, ); @@ -158,8 +142,9 @@ void main() { final cashu = CashuTestTools.mockHttpCashu( customCache: cache, seedPhrase: CashuUserSeedphrase( - seedPhrase: - "reduce invest lunch step couch traffic measure civil want steel trip jar"), + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), ); expect( @@ -212,8 +197,9 @@ void main() { unit: fundUnit, method: "bolt11", ); - final transactionStream = - cashu.retrieveFunds(draftTransaction: draftTransaction); + final transactionStream = cashu.retrieveFunds( + draftTransaction: draftTransaction, + ); final transaction = await transactionStream.last; expect(transaction.state, WalletTransactionState.completed); @@ -248,15 +234,20 @@ void main() { expect(myCompletedTransaction, isNotEmpty); expect( - myCompletedTransaction.last.state, WalletTransactionState.completed); + myCompletedTransaction.last.state, + WalletTransactionState.completed, + ); expect(myCompletedTransaction.last.transactionDate, isNotNull); - final balance = - await cashu.getBalanceMintUnit(unit: "sat", mintUrl: devMintUrl); + final balance = await cashu.getBalanceMintUnit( + unit: "sat", + mintUrl: devMintUrl, + ); expect(balance, equals(fundAmount - 4)); - final pendingProofs = - await cache.getProofs(state: CashuProofState.pending); + final pendingProofs = await cache.getProofs( + state: CashuProofState.pending, + ); expect(pendingProofs, isEmpty); final spendProofs = await cache.getProofs(state: CashuProofState.spend); diff --git a/packages/ndk/test/cashu/cashu_storage_test.dart b/packages/ndk/test/cashu/cashu_storage_test.dart index 234bba4cb..aaf7cb5e3 100644 --- a/packages/ndk/test/cashu/cashu_storage_test.dart +++ b/packages/ndk/test/cashu/cashu_storage_test.dart @@ -25,10 +25,7 @@ void main() { state: CashuProofState.unspend, ); - final List proofs = [ - proof1, - proof2, - ]; + final List proofs = [proof1, proof2]; await cacheManager.saveProofs(proofs: proofs, mintUrl: "testmint"); diff --git a/packages/ndk/test/cashu/cashu_test_tools.dart b/packages/ndk/test/cashu/cashu_test_tools.dart index ce37625a3..a2ac941d9 100644 --- a/packages/ndk/test/cashu/cashu_test_tools.dart +++ b/packages/ndk/test/cashu/cashu_test_tools.dart @@ -19,21 +19,20 @@ class CashuTestTools { customMockClient ?? MockCashuHttpClient(); final HttpRequestDS httpRequestDS = HttpRequestDS(mockClient); - final CashuRepo cashuRepo = customRepo ?? - CashuRepoImpl( - client: httpRequestDS, - ); + final CashuRepo cashuRepo = + customRepo ?? CashuRepoImpl(client: httpRequestDS); final CacheManager cache = customCache ?? MemCacheManager(); final derivation = DartCashuKeyDerivation(); final cashu = Cashu( - cashuUserSeedphrase: seedPhrase, - walletsRepo: MemWalletsRepo(), - cashuRepo: cashuRepo, - cacheManager: cache, - cashuKeyDerivation: derivation); + cashuUserSeedphrase: seedPhrase, + walletsRepo: MemWalletsRepo(), + 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 index 4ae7d91eb..620b8b1b8 100644 --- a/packages/ndk/test/cashu/mocks/cashu_http_client_mock.dart +++ b/packages/ndk/test/cashu/mocks/cashu_http_client_mock.dart @@ -26,10 +26,10 @@ class MockCashuHttpClient extends http.BaseClient { "unit": "sat", "min_amount": 1, "max_amount": 500000, - "description": true - } + "description": true, + }, ], - "disabled": false + "disabled": false, }, "5": { "methods": [ @@ -37,10 +37,10 @@ class MockCashuHttpClient extends http.BaseClient { "method": "bolt11", "unit": "sat", "min_amount": 1, - "max_amount": 500000 - } + "max_amount": 500000, + }, ], - "disabled": false + "disabled": false, }, "7": {"supported": true}, "8": {"supported": true}, @@ -52,7 +52,7 @@ class MockCashuHttpClient extends http.BaseClient { "15": { "methods": [ {"method": "bolt11", "unit": "sat"}, - ] + ], }, "17": { "supported": [ @@ -62,23 +62,23 @@ class MockCashuHttpClient extends http.BaseClient { "commands": [ "bolt11_mint_quote", "bolt11_melt_quote", - "proof_state" - ] - } - ] + "proof_state", + ], + }, + ], }, "19": { "ttl": 60, "cached_endpoints": [ {"method": "POST", "path": "/v1/mint/bolt11"}, {"method": "POST", "path": "/v1/melt/bolt11"}, - {"method": "POST", "path": "/v1/swap"} - ] + {"method": "POST", "path": "/v1/swap"}, + ], }, - "20": {"supported": true} + "20": {"supported": true}, }, "motd": "Hello world", - "time": 1757162808 + "time": 1757162808, }), 200, headers: {'content-type': 'application/json'}, @@ -92,9 +92,9 @@ class MockCashuHttpClient extends http.BaseClient { "id": "00c726786980c4d9", "unit": "sat", "active": true, - "input_fee_ppk": 0 - } - ] + "input_fee_ppk": 0, + }, + ], }), 200, headers: {'content-type': 'application/json'}, @@ -171,10 +171,10 @@ class MockCashuHttpClient extends http.BaseClient { "8192": "02e47c542ba5c3664a839e6c5e3968b69916ae9e9387b8eaf973897f4f4ff9de72", "8388608": - "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7" - } - } - ] + "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7", + }, + }, + ], }), 200, headers: {'content-type': 'application/json'}, @@ -250,10 +250,10 @@ class MockCashuHttpClient extends http.BaseClient { "8192": "02e47c542ba5c3664a839e6c5e3968b69916ae9e9387b8eaf973897f4f4ff9de72", "8388608": - "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7" - } - } - ] + "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7", + }, + }, + ], }), 200, headers: {'content-type': 'application/json'}, @@ -267,59 +267,52 @@ class MockCashuHttpClient extends http.BaseClient { "amount": 5, "unit": "sat", "state": "UNPAID", - "expiry": now + 60 + "expiry": now + 60, }), 200, headers: {'content-type': 'application/json'}, ); - _responses[ - 'GET:/v1/mint/quote/bolt11/d00e6cbc-04c9-4661-8909-e47c19612bf0'] = + _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'}, - ); + 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": []}, - ), + 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" - }, - ), + 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", - }, - ), + jsonEncode({ + "payment_preimage": "mock_preimage_1234567890abcdef", + "state": "PAID", + }), 200, headers: {'content-type': 'application/json'}, ); @@ -358,7 +351,8 @@ class MockCashuHttpClient extends http.BaseClient { // default 404 return http.StreamedResponse( Stream.value( - utf8.encode(jsonEncode({'error': 'Not found, method: $key'}))), + utf8.encode(jsonEncode({'error': 'Not found, method: $key'})), + ), 404, headers: {'content-type': 'application/json'}, ); diff --git a/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart b/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart index 242a4c7ae..90859ef64 100644 --- a/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart +++ b/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart @@ -41,10 +41,7 @@ class CashuRepoMeltFailAfterSpendMock extends CashuRepoImpl { }) async { // Return that all proofs are spent on the mint return proofPubkeys - .map((y) => CashuTokenStateResponse( - Y: y, - state: CashuProofState.spend, - )) + .map((y) => CashuTokenStateResponse(Y: y, state: CashuProofState.spend)) .toList(); } } diff --git a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.dart b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.dart index 819352402..d4831e300 100644 --- a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.dart +++ b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.dart @@ -6,8 +6,14 @@ import 'package:ndk/ndk.dart'; import 'package:ndk_cache_manager_test_suite/ndk_cache_manager_test_suite.dart'; // This will generate mock classes for our entities -@GenerateMocks( - [UserRelayList, RelaySet, ContactList, Metadata, Nip01Event, Nip05]) +@GenerateMocks([ + UserRelayList, + RelaySet, + ContactList, + Metadata, + Nip01Event, + Nip05, +]) import 'mem_cache_manager_test.mocks.dart'; void main() { @@ -45,8 +51,10 @@ void main() { when(mockUserRelayList1.pubKey).thenReturn('testPubKey1'); when(mockUserRelayList2.pubKey).thenReturn('testPubKey2'); - await cacheManager - .saveUserRelayLists([mockUserRelayList1, mockUserRelayList2]); + await cacheManager.saveUserRelayLists([ + mockUserRelayList1, + mockUserRelayList2, + ]); await cacheManager.removeAllUserRelayLists(); expect(await cacheManager.loadUserRelayList('testPubKey1'), isNull); @@ -96,37 +104,109 @@ void main() { group('Event tests', () { test('saveEvent and loadEvent', () async { - final mockEvent = MockNip01Event(); - when(mockEvent.id).thenReturn('testId'); + final event = Nip01Event( + pubKey: 'testPubKey', + kind: 1, + tags: [], + content: 'test event', + ); - await cacheManager.saveEvent(mockEvent); - final result = await cacheManager.loadEvent('testId'); + await cacheManager.saveEvent(event); + final result = await cacheManager.loadEvent(event.id); - expect(result, equals(mockEvent)); + expect(result, equals(event)); }); test('removeEvent', () async { - final mockEvent = MockNip01Event(); - when(mockEvent.id).thenReturn('testId'); + final event = Nip01Event( + pubKey: 'testPubKey', + kind: 1, + tags: [], + content: 'test event', + ); - await cacheManager.saveEvent(mockEvent); - await cacheManager.removeEvent('testId'); - final result = await cacheManager.loadEvent('testId'); + await cacheManager.saveEvent(event); + await cacheManager.removeEvent(event.id); + final result = await cacheManager.loadEvent(event.id); expect(result, isNull); }); test('removeAllEvents', () async { - final mockEvent1 = MockNip01Event(); - final mockEvent2 = MockNip01Event(); - when(mockEvent1.id).thenReturn('testId1'); - when(mockEvent2.id).thenReturn('testId2'); - - await cacheManager.saveEvents([mockEvent1, mockEvent2]); + final event1 = Nip01Event( + pubKey: 'testPubKey1', + kind: 1, + tags: [], + content: 'test event 1', + ); + final event2 = Nip01Event( + pubKey: 'testPubKey2', + kind: 1, + tags: [], + content: 'test event 2', + ); + + await cacheManager.saveEvents([event1, event2]); await cacheManager.removeAllEvents(); - expect(await cacheManager.loadEvent('testId1'), isNull); - expect(await cacheManager.loadEvent('testId2'), isNull); + expect(await cacheManager.loadEvent(event1.id), isNull); + expect(await cacheManager.loadEvent(event2.id), isNull); + }); + }); + + group('DecryptedEventPayloadRecord tests', () { + test('save and load decrypted payload record', () async { + final record = DecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + scheme: DecryptedPayloadScheme.nip44, + status: DecryptedPayloadStatus.ready, + plaintextContent: 'decrypted payload', + createdAt: 100, + updatedAt: 101, + decryptedAt: 101, + sourceEventPubKey: 'author-1', + sourceEventKind: 4, + ); + + await cacheManager.saveDecryptedEventPayloadRecord(record); + + final loaded = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + ); + + expect(loaded, isNotNull); + expect(loaded!.plaintextContent, 'decrypted payload'); + expect(loaded.scheme, DecryptedPayloadScheme.nip44); + expect(loaded.status, DecryptedPayloadStatus.ready); + }); + + test('removeEvent removes decrypted payload sidecar', () async { + final event = Nip01Event( + pubKey: 'author-1', + kind: 4, + tags: const [], + content: 'ciphertext', + ); + await cacheManager.saveEvent(event); + await cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-1', + plaintextContent: 'plaintext', + createdAt: 100, + updatedAt: 100, + ), + ); + + await cacheManager.removeEvent(event.id); + + final loaded = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-1', + ); + expect(loaded, isNull); }); }); @@ -135,8 +215,9 @@ void main() { final mockRelaySet = MockRelaySet(); when(mockRelaySet.name).thenReturn('testName'); when(mockRelaySet.pubKey).thenReturn('testPubKey'); - when(mockRelaySet.id) - .thenReturn(RelaySet.buildId('testName', 'testPubKey')); + when( + mockRelaySet.id, + ).thenReturn(RelaySet.buildId('testName', 'testPubKey')); await cacheManager.saveRelaySet(mockRelaySet); final result = await cacheManager.loadRelaySet('testName', 'testPubKey'); @@ -148,8 +229,9 @@ void main() { final mockRelaySet = MockRelaySet(); when(mockRelaySet.name).thenReturn('testName'); when(mockRelaySet.pubKey).thenReturn('testPubKey'); - when(mockRelaySet.id) - .thenReturn(RelaySet.buildId('testName', 'testPubKey')); + when( + mockRelaySet.id, + ).thenReturn(RelaySet.buildId('testName', 'testPubKey')); await cacheManager.saveRelaySet(mockRelaySet); await cacheManager.removeRelaySet('testName', 'testPubKey'); @@ -163,40 +245,52 @@ void main() { final mockRelaySet2 = MockRelaySet(); when(mockRelaySet1.name).thenReturn('testName1'); when(mockRelaySet1.pubKey).thenReturn('testPubKey1'); - when(mockRelaySet1.id) - .thenReturn(RelaySet.buildId('testName1', 'testPubKey1')); + when( + mockRelaySet1.id, + ).thenReturn(RelaySet.buildId('testName1', 'testPubKey1')); when(mockRelaySet2.name).thenReturn('testName2'); when(mockRelaySet2.pubKey).thenReturn('testPubKey2'); - when(mockRelaySet2.id) - .thenReturn(RelaySet.buildId('testName2', 'testPubKey2')); + when( + mockRelaySet2.id, + ).thenReturn(RelaySet.buildId('testName2', 'testPubKey2')); await cacheManager.saveRelaySet(mockRelaySet1); await cacheManager.saveRelaySet(mockRelaySet2); await cacheManager.removeAllRelaySets(); expect( - await cacheManager.loadRelaySet('testName1', 'testPubKey1'), isNull); + await cacheManager.loadRelaySet('testName1', 'testPubKey1'), + isNull, + ); expect( - await cacheManager.loadRelaySet('testName2', 'testPubKey2'), isNull); + await cacheManager.loadRelaySet('testName2', 'testPubKey2'), + isNull, + ); }); }); group('ContactList tests', () { test('saveContactList and loadContactList', () async { - final mockContactList = MockContactList(); - when(mockContactList.pubKey).thenReturn('testPubKey'); + final contactList = ContactList( + pubKey: 'testPubKey', + contacts: ['contact1'], + ); - await cacheManager.saveContactList(mockContactList); + await cacheManager.saveContactList(contactList); final result = await cacheManager.loadContactList('testPubKey'); - expect(result, equals(mockContactList)); + expect(result, isNotNull); + expect(result!.pubKey, equals(contactList.pubKey)); + expect(result.contacts, equals(contactList.contacts)); }); test('removeContactList', () async { - final mockContactList = MockContactList(); - when(mockContactList.pubKey).thenReturn('testPubKey'); + final contactList = ContactList( + pubKey: 'testPubKey', + contacts: ['contact1'], + ); - await cacheManager.saveContactList(mockContactList); + await cacheManager.saveContactList(contactList); await cacheManager.removeContactList('testPubKey'); final result = await cacheManager.loadContactList('testPubKey'); @@ -204,12 +298,16 @@ void main() { }); test('removeAllContactLists', () async { - final mockContactList1 = MockContactList(); - final mockContactList2 = MockContactList(); - when(mockContactList1.pubKey).thenReturn('testPubKey1'); - when(mockContactList2.pubKey).thenReturn('testPubKey2'); - - await cacheManager.saveContactLists([mockContactList1, mockContactList2]); + final contactList1 = ContactList( + pubKey: 'testPubKey1', + contacts: ['contact1'], + ); + final contactList2 = ContactList( + pubKey: 'testPubKey2', + contacts: ['contact2'], + ); + + await cacheManager.saveContactLists([contactList1, contactList2]); await cacheManager.removeAllContactLists(); expect(await cacheManager.loadContactList('testPubKey1'), isNull); @@ -219,20 +317,28 @@ void main() { group('Metadata tests', () { test('saveMetadata and loadMetadata', () async { - final mockMetadata = MockMetadata(); - when(mockMetadata.pubKey).thenReturn('testPubKey'); + final metadata = Metadata( + pubKey: 'testPubKey', + name: 'Test User', + updatedAt: 1234, + ); - await cacheManager.saveMetadata(mockMetadata); + await cacheManager.saveMetadata(metadata); final result = await cacheManager.loadMetadata('testPubKey'); - expect(result, equals(mockMetadata)); + expect(result, isNotNull); + expect(result!.pubKey, equals(metadata.pubKey)); + expect(result.name, equals(metadata.name)); }); test('removeMetadata', () async { - final mockMetadata = MockMetadata(); - when(mockMetadata.pubKey).thenReturn('testPubKey'); + final metadata = Metadata( + pubKey: 'testPubKey', + name: 'Test User', + updatedAt: 1234, + ); - await cacheManager.saveMetadata(mockMetadata); + await cacheManager.saveMetadata(metadata); await cacheManager.removeMetadata('testPubKey'); final result = await cacheManager.loadMetadata('testPubKey'); @@ -240,12 +346,18 @@ void main() { }); test('removeAllMetadatas', () async { - final mockMetadata1 = MockMetadata(); - final mockMetadata2 = MockMetadata(); - when(mockMetadata1.pubKey).thenReturn('testPubKey1'); - when(mockMetadata2.pubKey).thenReturn('testPubKey2'); - - await cacheManager.saveMetadatas([mockMetadata1, mockMetadata2]); + final metadata1 = Metadata( + pubKey: 'testPubKey1', + name: 'User 1', + updatedAt: 1000, + ); + final metadata2 = Metadata( + pubKey: 'testPubKey2', + name: 'User 2', + updatedAt: 2000, + ); + + await cacheManager.saveMetadatas([metadata1, metadata2]); await cacheManager.removeAllMetadatas(); expect(await cacheManager.loadMetadata('testPubKey1'), isNull); @@ -253,23 +365,84 @@ void main() { }); test('loadMetadatas', () async { - final mockMetadata1 = MockMetadata(); - final mockMetadata2 = MockMetadata(); - when(mockMetadata1.pubKey).thenReturn('testPubKey1'); - when(mockMetadata2.pubKey).thenReturn('testPubKey2'); - - await cacheManager.saveMetadatas([mockMetadata1, mockMetadata2]); - final results = await cacheManager - .loadMetadatas(['testPubKey1', 'testPubKey2', 'nonExistentKey']); + final metadata1 = Metadata( + pubKey: 'testPubKey1', + name: 'User 1', + updatedAt: 1000, + ); + final metadata2 = Metadata( + pubKey: 'testPubKey2', + name: 'User 2', + updatedAt: 2000, + ); + + await cacheManager.saveMetadatas([metadata1, metadata2]); + final results = await cacheManager.loadMetadatas([ + 'testPubKey1', + 'testPubKey2', + 'nonExistentKey', + ]); // Results should preserve position correspondence with input expect(results.length, equals(3)); - expect(results[0], equals(mockMetadata1)); - expect(results[1], equals(mockMetadata2)); + expect(results[0]?.pubKey, equals(metadata1.pubKey)); + expect(results[1]?.pubKey, equals(metadata2.pubKey)); expect(results[2], isNull); }); }); + group('Eviction tests', () { + test('default eviction removes delivered ephemeral events', () async { + final event = Nip01Event( + pubKey: 'ephemeral-author', + kind: 21133, + tags: const [], + content: 'ephemeral pending-delivery payload', + createdAt: 1700000000, + ); + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: 1700000000, + updatedAt: 1700000001, + completedAt: 1700000001, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy()); + + expect(result.removedDeliveredEphemeral, 1); + expect(await cacheManager.loadEvent(event.id), isNull); + }); + + test('default eviction keeps delivered non-ephemeral events', () async { + final event = Nip01Event( + pubKey: 'regular-author', + kind: 1, + tags: const [], + content: 'regular delivered event', + createdAt: 1700000100, + ); + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: 1700000100, + updatedAt: 1700000101, + completedAt: 1700000101, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy()); + + expect(result.removedDeliveredEphemeral, 0); + expect(await cacheManager.loadEvent(event.id), isNotNull); + }); + }); + // Run shared test suite for comprehensive coverage runCacheManagerTestSuite( name: 'MemCacheManager (Shared Suite)', 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 45a34881a..2aad30854 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 @@ -34,43 +34,23 @@ import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i6; // ignore_for_file: invalid_use_of_internal_member class _FakeNip65_0 extends _i1.SmartFake implements _i2.Nip65 { - _FakeNip65_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeNip65_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeNip01Event_1 extends _i1.SmartFake implements _i3.Nip01Event { - _FakeNip01Event_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeNip01Event_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeMetadata_2 extends _i1.SmartFake implements _i4.Metadata { - _FakeMetadata_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _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, - ); + _FakeNip05_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [UserRelayList]. @@ -82,94 +62,78 @@ class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { } @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); + int get createdAt => + (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) + as int); @override - int get refreshedTimestamp => (super.noSuchMethod( - Invocation.getter(#refreshedTimestamp), - returnValue: 0, - ) as int); + int get refreshedTimestamp => + (super.noSuchMethod( + Invocation.getter(#refreshedTimestamp), + returnValue: 0, + ) + as int); @override - Map get relays => (super.noSuchMethod( - Invocation.getter(#relays), - returnValue: {}, - ) as Map); + Map get relays => + (super.noSuchMethod( + Invocation.getter(#relays), + returnValue: {}, + ) + as Map); @override - Iterable get urls => (super.noSuchMethod( - Invocation.getter(#urls), - returnValue: [], - ) as Iterable); + Iterable get urls => + (super.noSuchMethod(Invocation.getter(#urls), returnValue: []) + as Iterable); @override - Iterable get readUrls => (super.noSuchMethod( - Invocation.getter(#readUrls), - returnValue: [], - ) as Iterable); + Iterable get readUrls => + (super.noSuchMethod(Invocation.getter(#readUrls), returnValue: []) + as Iterable); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter( - #pubKey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#pubKey, value), + returnValueForMissingStub: null, + ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter( - #createdAt, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#createdAt, value), + returnValueForMissingStub: null, + ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter( - #refreshedTimestamp, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#refreshedTimestamp, value), + returnValueForMissingStub: null, + ); @override set relays(Map? value) => super.noSuchMethod( - Invocation.setter( - #relays, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#relays, value), + returnValueForMissingStub: null, + ); @override - _i2.Nip65 toNip65() => (super.noSuchMethod( - Invocation.method( - #toNip65, - [], - ), - returnValue: _FakeNip65_0( - this, - Invocation.method( - #toNip65, - [], - ), - ), - ) as _i2.Nip65); + _i2.Nip65 toNip65() => + (super.noSuchMethod( + Invocation.method(#toNip65, []), + returnValue: _FakeNip65_0(this, Invocation.method(#toNip65, [])), + ) + as _i2.Nip65); } /// A class which mocks [RelaySet]. @@ -181,130 +145,118 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { } @override - String get id => (super.noSuchMethod( - Invocation.getter(#id), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#id), - ), - ) as String); + String get id => + (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + ) + as String); @override - Iterable get urls => (super.noSuchMethod( - Invocation.getter(#urls), - returnValue: [], - ) as Iterable); + 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), - ), - ) as String); + String get name => + (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i7.dummyValue(this, Invocation.getter(#name)), + ) + as String); @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - int get relayMinCountPerPubkey => (super.noSuchMethod( - Invocation.getter(#relayMinCountPerPubkey), - returnValue: 0, - ) as int); + int get relayMinCountPerPubkey => + (super.noSuchMethod( + Invocation.getter(#relayMinCountPerPubkey), + returnValue: 0, + ) + as int); @override - _i10.RelayDirection get direction => (super.noSuchMethod( - Invocation.getter(#direction), - returnValue: _i10.RelayDirection.inbox, - ) as _i10.RelayDirection); + _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>); + Map> get relaysMap => + (super.noSuchMethod( + Invocation.getter(#relaysMap), + returnValue: >{}, + ) + as Map>); @override - bool get fallbackToBootstrapRelays => (super.noSuchMethod( - Invocation.getter(#fallbackToBootstrapRelays), - returnValue: false, - ) as bool); + 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>); + 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( - #name, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#name, value), + returnValueForMissingStub: null, + ); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter( - #pubKey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#pubKey, value), + returnValueForMissingStub: null, + ); @override set relayMinCountPerPubkey(int? value) => super.noSuchMethod( - Invocation.setter( - #relayMinCountPerPubkey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#relayMinCountPerPubkey, value), + returnValueForMissingStub: null, + ); @override set direction(_i10.RelayDirection? value) => super.noSuchMethod( - Invocation.setter( - #direction, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#direction, value), + returnValueForMissingStub: null, + ); @override set relaysMap(Map>? value) => super.noSuchMethod( - Invocation.setter( - #relaysMap, - value, - ), + Invocation.setter(#relaysMap, value), returnValueForMissingStub: null, ); @override set fallbackToBootstrapRelays(bool? value) => super.noSuchMethod( - Invocation.setter( - #fallbackToBootstrapRelays, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#fallbackToBootstrapRelays, value), + returnValueForMissingStub: null, + ); @override set notCoveredPubkeys(List<_i9.NotCoveredPubKey>? value) => super.noSuchMethod( - Invocation.setter( - #notCoveredPubkeys, - value, - ), + Invocation.setter(#notCoveredPubkeys, value), returnValueForMissingStub: null, ); @@ -312,25 +264,15 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { void splitIntoRequests( _i12.Filter? filter, _i13.RequestState? groupRequest, - ) => - super.noSuchMethod( - Invocation.method( - #splitIntoRequests, - [ - filter, - groupRequest, - ], - ), - returnValueForMissingStub: null, - ); + ) => super.noSuchMethod( + Invocation.method(#splitIntoRequests, [filter, groupRequest]), + returnValueForMissingStub: null, + ); @override void addMoreRelays(Map>? more) => super.noSuchMethod( - Invocation.method( - #addMoreRelays, - [more], - ), + Invocation.method(#addMoreRelays, [more]), returnValueForMissingStub: null, ); } @@ -344,191 +286,154 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { } @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - List get contacts => (super.noSuchMethod( - Invocation.getter(#contacts), - returnValue: [], - ) as List); + List get contacts => + (super.noSuchMethod(Invocation.getter(#contacts), returnValue: []) + as List); @override - List get contactRelays => (super.noSuchMethod( - Invocation.getter(#contactRelays), - returnValue: [], - ) as List); + List get contactRelays => + (super.noSuchMethod( + Invocation.getter(#contactRelays), + returnValue: [], + ) + as List); @override - List get petnames => (super.noSuchMethod( - Invocation.getter(#petnames), - returnValue: [], - ) as List); + List get petnames => + (super.noSuchMethod(Invocation.getter(#petnames), returnValue: []) + as List); @override - List get followedTags => (super.noSuchMethod( - Invocation.getter(#followedTags), - returnValue: [], - ) as List); + List get followedTags => + (super.noSuchMethod( + Invocation.getter(#followedTags), + returnValue: [], + ) + as List); @override - List get followedCommunities => (super.noSuchMethod( - Invocation.getter(#followedCommunities), - returnValue: [], - ) as List); + List get followedCommunities => + (super.noSuchMethod( + Invocation.getter(#followedCommunities), + returnValue: [], + ) + as List); @override - List get followedEvents => (super.noSuchMethod( - Invocation.getter(#followedEvents), - returnValue: [], - ) as List); + List get followedEvents => + (super.noSuchMethod( + Invocation.getter(#followedEvents), + returnValue: [], + ) + as List); @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); + int get createdAt => + (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) + as int); @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); + List get sources => + (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) + as List); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter( - #pubKey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#pubKey, value), + returnValueForMissingStub: null, + ); @override set contacts(List? value) => super.noSuchMethod( - Invocation.setter( - #contacts, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#contacts, value), + returnValueForMissingStub: null, + ); @override set contactRelays(List? value) => super.noSuchMethod( - Invocation.setter( - #contactRelays, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#contactRelays, value), + returnValueForMissingStub: null, + ); @override set petnames(List? value) => super.noSuchMethod( - Invocation.setter( - #petnames, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#petnames, value), + returnValueForMissingStub: null, + ); @override set followedTags(List? value) => super.noSuchMethod( - Invocation.setter( - #followedTags, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#followedTags, value), + returnValueForMissingStub: null, + ); @override set followedCommunities(List? value) => super.noSuchMethod( - Invocation.setter( - #followedCommunities, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#followedCommunities, value), + returnValueForMissingStub: null, + ); @override set followedEvents(List? value) => super.noSuchMethod( - Invocation.setter( - #followedEvents, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#followedEvents, value), + returnValueForMissingStub: null, + ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter( - #createdAt, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#createdAt, value), + returnValueForMissingStub: null, + ); @override set loadedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter( - #loadedTimestamp, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#loadedTimestamp, value), + returnValueForMissingStub: null, + ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter( - #sources, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#sources, value), + returnValueForMissingStub: null, + ); + + @override + List> contactsToJson() => + (super.noSuchMethod( + Invocation.method(#contactsToJson, []), + returnValue: >[], + ) + as List>); @override - List> contactsToJson() => (super.noSuchMethod( - Invocation.method( - #contactsToJson, - [], - ), - returnValue: >[], - ) as List>); + List> tagListToJson(List? list, String? tag) => + (super.noSuchMethod( + Invocation.method(#tagListToJson, [list, tag]), + returnValue: >[], + ) + as List>); @override - List> tagListToJson( - List? list, - String? tag, - ) => + _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method( - #tagListToJson, - [ - list, - tag, - ], - ), - returnValue: >[], - ) as List>); - - @override - _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method( - #toEvent, - [], - ), - returnValue: _FakeNip01Event_1( - this, - Invocation.method( - #toEvent, - [], - ), - ), - ) as _i3.Nip01Event); + Invocation.method(#toEvent, []), + returnValue: _FakeNip01Event_1( + this, + Invocation.method(#toEvent, []), + ), + ) + as _i3.Nip01Event); } /// A class which mocks [Metadata]. @@ -540,236 +445,174 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { } @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - Map get content => (super.noSuchMethod( - Invocation.getter(#content), - returnValue: {}, - ) as Map); + Map get content => + (super.noSuchMethod( + Invocation.getter(#content), + returnValue: {}, + ) + as Map); @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); + List get sources => + (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) + as List); @override - List> get tags => (super.noSuchMethod( - Invocation.getter(#tags), - returnValue: >[], - ) as List>); + List> get tags => + (super.noSuchMethod( + Invocation.getter(#tags), + returnValue: >[], + ) + as List>); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter( - #pubKey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#pubKey, value), + returnValueForMissingStub: null, + ); @override set content(Map? value) => super.noSuchMethod( - Invocation.setter( - #content, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#content, value), + returnValueForMissingStub: null, + ); @override set name(String? value) => super.noSuchMethod( - Invocation.setter( - #name, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#name, value), + returnValueForMissingStub: null, + ); @override set displayName(String? value) => super.noSuchMethod( - Invocation.setter( - #displayName, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#displayName, value), + returnValueForMissingStub: null, + ); @override set picture(String? value) => super.noSuchMethod( - Invocation.setter( - #picture, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#picture, value), + returnValueForMissingStub: null, + ); @override set banner(String? value) => super.noSuchMethod( - Invocation.setter( - #banner, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#banner, value), + returnValueForMissingStub: null, + ); @override set website(String? value) => super.noSuchMethod( - Invocation.setter( - #website, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#website, value), + returnValueForMissingStub: null, + ); @override set about(String? value) => super.noSuchMethod( - Invocation.setter( - #about, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#about, value), + returnValueForMissingStub: null, + ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter( - #nip05, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#nip05, value), + returnValueForMissingStub: null, + ); @override set lud16(String? value) => super.noSuchMethod( - Invocation.setter( - #lud16, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#lud16, value), + returnValueForMissingStub: null, + ); @override set lud06(String? value) => super.noSuchMethod( - Invocation.setter( - #lud06, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#lud06, value), + returnValueForMissingStub: null, + ); @override set updatedAt(int? value) => super.noSuchMethod( - Invocation.setter( - #updatedAt, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#updatedAt, value), + returnValueForMissingStub: null, + ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter( - #refreshedTimestamp, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#refreshedTimestamp, value), + returnValueForMissingStub: null, + ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter( - #sources, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#sources, value), + returnValueForMissingStub: null, + ); @override set tags(List>? value) => super.noSuchMethod( - Invocation.setter( - #tags, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#tags, value), + returnValueForMissingStub: null, + ); @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - ) as Map); - - @override - _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method( - #toEvent, - [], - ), - returnValue: _FakeNip01Event_1( - this, - Invocation.method( - #toEvent, - [], - ), - ), - ) as _i3.Nip01Event); - - @override - void setCustomField( - String? key, - dynamic value, - ) => - super.noSuchMethod( - Invocation.method( - #setCustomField, - [ - key, - value, - ], - ), - returnValueForMissingStub: null, - ); + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + ) + as Map); + + @override + _i3.Nip01Event toEvent() => + (super.noSuchMethod( + Invocation.method(#toEvent, []), + returnValue: _FakeNip01Event_1( + this, + Invocation.method(#toEvent, []), + ), + ) + as _i3.Nip01Event); @override - dynamic getCustomField(String? key) => super.noSuchMethod(Invocation.method( - #getCustomField, - [key], - )); + void setCustomField(String? key, dynamic value) => super.noSuchMethod( + Invocation.method(#setCustomField, [key, value]), + returnValueForMissingStub: null, + ); @override - String getName() => (super.noSuchMethod( - Invocation.method( - #getName, - [], - ), - returnValue: _i7.dummyValue( - this, - Invocation.method( - #getName, - [], - ), - ), - ) as String); + dynamic getCustomField(String? key) => + super.noSuchMethod(Invocation.method(#getCustomField, [key])); @override - bool matchesSearch(String? str) => (super.noSuchMethod( - Invocation.method( - #matchesSearch, - [str], - ), - returnValue: false, - ) as bool); + String getName() => + (super.noSuchMethod( + Invocation.method(#getName, []), + returnValue: _i7.dummyValue( + this, + Invocation.method(#getName, []), + ), + ) + as String); + + @override + bool matchesSearch(String? str) => + (super.noSuchMethod( + Invocation.method(#matchesSearch, [str]), + returnValue: false, + ) + as bool); @override _i4.Metadata copyWith({ @@ -790,33 +633,7 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { Map? content, }) => (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, - #tags: tags, - #content: content, - }, - ), - returnValue: _FakeMetadata_2( - this, - Invocation.method( - #copyWith, - [], - { + Invocation.method(#copyWith, [], { #pubKey: pubKey, #name: name, #displayName: displayName, @@ -832,10 +649,29 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { #sources: sources, #tags: tags, #content: content, - }, - ), - ), - ) as _i4.Metadata); + }), + 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, + #tags: tags, + #content: content, + }), + ), + ) + as _i4.Metadata); } /// A class which mocks [Nip01Event]. @@ -847,91 +683,86 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { } @override - String get id => (super.noSuchMethod( - Invocation.getter(#id), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#id), - ), - ) as String); + String get id => + (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + ) + as String); @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); + int get createdAt => + (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) + as int); @override - int get kind => (super.noSuchMethod( - Invocation.getter(#kind), - returnValue: 0, - ) as int); + int get kind => + (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); @override - List> get tags => (super.noSuchMethod( - Invocation.getter(#tags), - returnValue: >[], - ) as List>); + List> get tags => + (super.noSuchMethod( + Invocation.getter(#tags), + returnValue: >[], + ) + as List>); @override - String get content => (super.noSuchMethod( - Invocation.getter(#content), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#content), - ), - ) as String); + String get content => + (super.noSuchMethod( + Invocation.getter(#content), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#content), + ), + ) + as String); @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); + List get sources => + (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) + as List); @override - List get tTags => (super.noSuchMethod( - Invocation.getter(#tTags), - returnValue: [], - ) as List); + List get tTags => + (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) + as List); @override - List get pTags => (super.noSuchMethod( - Invocation.getter(#pTags), - returnValue: [], - ) as List); + List get pTags => + (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) + as List); @override - List get replyETags => (super.noSuchMethod( - Invocation.getter(#replyETags), - returnValue: [], - ) as List); + List get replyETags => + (super.noSuchMethod( + Invocation.getter(#replyETags), + returnValue: [], + ) + as List); @override set id(String? value) => super.noSuchMethod( - Invocation.setter( - #id, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#id, value), + returnValueForMissingStub: null, + ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter( - #createdAt, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#createdAt, value), + returnValueForMissingStub: null, + ); @override _i3.Nip01Event copyWith({ @@ -946,27 +777,7 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method( - #copyWith, - [], - { - #id: id, - #pubKey: pubKey, - #createdAt: createdAt, - #kind: kind, - #tags: tags, - #content: content, - #sig: sig, - #validSig: validSig, - #sources: sources, - }, - ), - returnValue: _FakeNip01Event_1( - this, - Invocation.method( - #copyWith, - [], - { + Invocation.method(#copyWith, [], { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -976,25 +787,35 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }, - ), - ), - ) as _i3.Nip01Event); - - @override - List getTags(String? tag) => (super.noSuchMethod( - Invocation.method( - #getTags, - [tag], - ), - returnValue: [], - ) as List); + }), + 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 + List getTags(String? tag) => + (super.noSuchMethod( + Invocation.method(#getTags, [tag]), + returnValue: [], + ) + as List); @override - String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( - #getFirstTag, - [name], - )) as String?); + String? getFirstTag(String? name) => + (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); } /// A class which mocks [Nip05]. @@ -1006,73 +827,61 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { } @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - String get nip05 => (super.noSuchMethod( - Invocation.getter(#nip05), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#nip05), - ), - ) as String); + String get nip05 => + (super.noSuchMethod( + Invocation.getter(#nip05), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#nip05), + ), + ) + as String); @override - bool get valid => (super.noSuchMethod( - Invocation.getter(#valid), - returnValue: false, - ) as bool); + bool get valid => + (super.noSuchMethod(Invocation.getter(#valid), returnValue: false) + as bool); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter( - #pubKey, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#pubKey, value), + returnValueForMissingStub: null, + ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter( - #nip05, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#nip05, value), + returnValueForMissingStub: null, + ); @override set valid(bool? value) => super.noSuchMethod( - Invocation.setter( - #valid, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#valid, value), + returnValueForMissingStub: null, + ); @override set networkFetchTime(int? value) => super.noSuchMethod( - Invocation.setter( - #networkFetchTime, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#networkFetchTime, value), + returnValueForMissingStub: null, + ); @override set relays(List? value) => super.noSuchMethod( - Invocation.setter( - #relays, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#relays, value), + returnValueForMissingStub: null, + ); @override _i5.Nip05 copyWith({ @@ -1083,30 +892,23 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { List? relays, }) => (super.noSuchMethod( - Invocation.method( - #copyWith, - [], - { - #pubKey: pubKey, - #nip05: nip05, - #valid: valid, - #networkFetchTime: networkFetchTime, - #relays: relays, - }, - ), - returnValue: _FakeNip05_3( - this, - Invocation.method( - #copyWith, - [], - { + Invocation.method(#copyWith, [], { #pubKey: pubKey, #nip05: nip05, #valid: valid, #networkFetchTime: networkFetchTime, #relays: relays, - }, - ), - ), - ) as _i5.Nip05); + }), + 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/cache_manager/sembast_cache_manager_test.dart b/packages/ndk/test/data_layer/cache_manager/sembast_cache_manager_test.dart index 63294d271..54c1eae29 100644 --- a/packages/ndk/test/data_layer/cache_manager/sembast_cache_manager_test.dart +++ b/packages/ndk/test/data_layer/cache_manager/sembast_cache_manager_test.dart @@ -125,7 +125,7 @@ void main() { // Test tags filter (p tag) final eventsByPTag = await cacheManager.loadEvents( tags: { - 'p': ['target_pubkey'] + 'p': ['target_pubkey'], }, ); expect(eventsByPTag.length, equals(1)); @@ -249,6 +249,58 @@ void main() { expect(await cacheManager.loadEvent(events[1].id), isNull); expect(await cacheManager.loadEvent(events[2].id), isNotNull); }); + + test('save and load decrypted payload record', () async { + final record = DecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + scheme: DecryptedPayloadScheme.giftWrap, + status: DecryptedPayloadStatus.ready, + plaintextContent: 'decrypted payload', + createdAt: 100, + updatedAt: 101, + decryptedAt: 101, + ); + + await cacheManager.saveDecryptedEventPayloadRecord(record); + final loaded = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + ); + + expect(loaded, isNotNull); + expect(loaded!.plaintextContent, 'decrypted payload'); + expect(loaded.scheme, DecryptedPayloadScheme.giftWrap); + expect(loaded.status, DecryptedPayloadStatus.ready); + }); + + test('removeEvent removes decrypted payload sidecar', () async { + final event = Nip01Event( + pubKey: 'author-1', + kind: 1059, + tags: const [], + content: 'ciphertext', + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-1', + plaintextContent: 'plaintext', + createdAt: 100, + updatedAt: 100, + ), + ); + + await cacheManager.removeEvent(event.id); + + final loaded = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-1', + ); + expect(loaded, isNull); + }); }); group('Metadata Operations', () { @@ -606,6 +658,58 @@ void main() { expect(() async => await cacheManager.close(), returnsNormally); }); }); + + group('Eviction tests', () { + test('default eviction removes delivered ephemeral events', () async { + final event = Nip01Event( + pubKey: 'ephemeral-author', + kind: 21133, + tags: const [], + content: 'ephemeral pending-delivery payload', + createdAt: 1700000000, + ); + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: 1700000000, + updatedAt: 1700000001, + completedAt: 1700000001, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy()); + + expect(result.removedDeliveredEphemeral, 1); + expect(await cacheManager.loadEvent(event.id), isNull); + }); + + test('default eviction keeps delivered non-ephemeral events', () async { + final event = Nip01Event( + pubKey: 'regular-author', + kind: 1, + tags: const [], + content: 'regular delivered event', + createdAt: 1700000100, + ); + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: 1700000100, + updatedAt: 1700000101, + completedAt: 1700000101, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy()); + + expect(result.removedDeliveredEphemeral, 0); + expect(await cacheManager.loadEvent(event.id), isNotNull); + }); + }); }); // Run shared test suite for comprehensive coverage @@ -613,8 +717,9 @@ void main() { runCacheManagerTestSuite( name: 'SembastCacheManager (Shared Suite)', createCacheManager: () async { - sharedTempDir = - await Directory.systemTemp.createTemp('sembast_shared_test'); + sharedTempDir = await Directory.systemTemp.createTemp( + 'sembast_shared_test', + ); return SembastCacheManager.create(databasePath: sharedTempDir.path); }, cleanUp: (cacheManager) async { diff --git a/packages/ndk/test/data_layer/io/file_io_test.dart b/packages/ndk/test/data_layer/io/file_io_test.dart index 40ef9a33e..c11082fc4 100644 --- a/packages/ndk/test/data_layer/io/file_io_test.dart +++ b/packages/ndk/test/data_layer/io/file_io_test.dart @@ -94,30 +94,33 @@ void main() { expect( chunkedHash, equals( - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ), ); }); - test('should compute correct hash for file with specific byte patterns', - () async { - // Create test file with repeating pattern - final pattern = [0xFF, 0x00, 0xAA, 0x55]; - final testBytes = Uint8List(1000); - for (var i = 0; i < testBytes.length; i++) { - testBytes[i] = pattern[i % pattern.length]; - } - final testFile = File('${tempDir.path}/test_pattern.bin'); - await testFile.writeAsBytes(testBytes); + test( + 'should compute correct hash for file with specific byte patterns', + () async { + // Create test file with repeating pattern + final pattern = [0xFF, 0x00, 0xAA, 0x55]; + final testBytes = Uint8List(1000); + for (var i = 0; i < testBytes.length; i++) { + testBytes[i] = pattern[i % pattern.length]; + } + final testFile = File('${tempDir.path}/test_pattern.bin'); + await testFile.writeAsBytes(testBytes); - // Compute hash using FileIO method (chunked) - final chunkedHash = await computeFinalHash(testFile.path); + // Compute hash using FileIO method (chunked) + final chunkedHash = await computeFinalHash(testFile.path); - // Compute hash using traditional in-memory method - final inMemoryHash = sha256.convert(testBytes).toString(); + // Compute hash using traditional in-memory method + final inMemoryHash = sha256.convert(testBytes).toString(); - // Both methods should produce the same hash - expect(chunkedHash, equals(inMemoryHash)); - }); + // Both methods should produce the same hash + expect(chunkedHash, equals(inMemoryHash)); + }, + ); test('should handle multi-MB files efficiently', () async { // Create a 5MB test file diff --git a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.dart b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.dart index 225e60fdc..3ccf9331d 100644 --- a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.dart +++ b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.dart @@ -42,15 +42,20 @@ void main() { onDone() {} final mockSubscription = MockStreamSubscription(); - when(mockWebsocketDS.listen(any, - onError: anyNamed('onError'), onDone: anyNamed('onDone'))) - .thenReturn(mockSubscription); + when( + mockWebsocketDS.listen( + any, + onError: anyNamed('onError'), + onDone: anyNamed('onDone'), + ), + ).thenReturn(mockSubscription); final result = transport.listen(onData, onError: onError, onDone: onDone); expect(result, equals(mockSubscription)); - verify(mockWebsocketDS.listen(onData, onError: onError, onDone: onDone)) - .called(1); + verify( + mockWebsocketDS.listen(onData, onError: onError, onDone: onDone), + ).called(1); }); test('send should delegate to WebsocketDS send', () { 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 42ee6f861..2d1124169 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 @@ -26,24 +26,14 @@ import 'package:web_socket_channel/web_socket_channel.dart' as _i2; class _FakeWebSocketChannel_0 extends _i1.SmartFake implements _i2.WebSocketChannel { - _FakeWebSocketChannel_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeWebSocketChannel_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamSubscription_1 extends _i1.SmartFake implements _i3.StreamSubscription { - _FakeStreamSubscription_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamSubscription_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [WebsocketDS]. @@ -51,17 +41,19 @@ class _FakeStreamSubscription_1 extends _i1.SmartFake /// See the documentation for Mockito's code generation for more information. class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { @override - _i2.WebSocketChannel get webSocketChannel => (super.noSuchMethod( - Invocation.getter(#webSocketChannel), - returnValue: _FakeWebSocketChannel_0( - this, - Invocation.getter(#webSocketChannel), - ), - returnValueForMissingStub: _FakeWebSocketChannel_0( - this, - Invocation.getter(#webSocketChannel), - ), - ) as _i2.WebSocketChannel); + _i2.WebSocketChannel get webSocketChannel => + (super.noSuchMethod( + Invocation.getter(#webSocketChannel), + returnValue: _FakeWebSocketChannel_0( + this, + Invocation.getter(#webSocketChannel), + ), + returnValueForMissingStub: _FakeWebSocketChannel_0( + this, + Invocation.getter(#webSocketChannel), + ), + ) + as _i2.WebSocketChannel); @override _i3.StreamSubscription listen( @@ -70,74 +62,60 @@ class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { void Function()? onDone, }) => (super.noSuchMethod( - Invocation.method( - #listen, - [onData], - { - #onError: onError, - #onDone: onDone, - }, - ), - returnValue: _FakeStreamSubscription_1( - this, - Invocation.method( - #listen, - [onData], - { - #onError: onError, - #onDone: onDone, - }, - ), - ), - returnValueForMissingStub: _FakeStreamSubscription_1( - this, - Invocation.method( - #listen, - [onData], - { - #onError: onError, - #onDone: onDone, - }, - ), - ), - ) as _i3.StreamSubscription); + Invocation.method( + #listen, + [onData], + {#onError: onError, #onDone: onDone}, + ), + returnValue: _FakeStreamSubscription_1( + this, + Invocation.method( + #listen, + [onData], + {#onError: onError, #onDone: onDone}, + ), + ), + returnValueForMissingStub: _FakeStreamSubscription_1( + this, + Invocation.method( + #listen, + [onData], + {#onError: onError, #onDone: onDone}, + ), + ), + ) + as _i3.StreamSubscription); @override void send(dynamic data) => super.noSuchMethod( - Invocation.method( - #send, - [data], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#send, [data]), + returnValueForMissingStub: null, + ); @override - _i3.Future ready() => (super.noSuchMethod( - Invocation.method( - #ready, - [], - ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + _i3.Future ready() => + (super.noSuchMethod( + Invocation.method(#ready, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); @override - _i3.Future close() => (super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + _i3.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); @override - bool isOpen() => (super.noSuchMethod( - Invocation.method( - #isOpen, - [], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); + bool isOpen() => + (super.noSuchMethod( + Invocation.method(#isOpen, []), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); } diff --git a/packages/ndk/test/entities/blob_nip94_test.dart b/packages/ndk/test/entities/blob_nip94_test.dart index 570334d76..3767c02c0 100644 --- a/packages/ndk/test/entities/blob_nip94_test.dart +++ b/packages/ndk/test/entities/blob_nip94_test.dart @@ -14,10 +14,7 @@ void main() async { "nip94": {"evil": "field"}, }; - expect( - () => BlobDescriptor.fromJson(evilJson), - isNot(throwsException), - ); + expect(() => BlobDescriptor.fromJson(evilJson), isNot(throwsException)); }); test('NIP92 - should handle malformed server response gracefully', () { @@ -39,60 +36,58 @@ void main() async { }); test( - 'NIP92 - should handle malformed server response gracefully - string int', - () { - final Map malformedJson = { - "url": '', - "sha256": '', - "size": "512", - "type": "notype", - "nip94": {"evil": "field"}, - }; + 'NIP92 - should handle malformed server response gracefully - string int', + () { + final Map malformedJson = { + "url": '', + "sha256": '', + "size": "512", + "type": "notype", + "nip94": {"evil": "field"}, + }; - final result = BlobDescriptor.fromJson(malformedJson); + final result = BlobDescriptor.fromJson(malformedJson); - expect(result, isA()); - expect(result.url, isEmpty); - expect(result.sha256, isEmpty); - expect(result.size, equals(512)); - expect(result.type, equals('notype')); - }); + expect(result, isA()); + expect(result.url, isEmpty); + expect(result.sha256, isEmpty); + expect(result.size, equals(512)); + expect(result.type, equals('notype')); + }, + ); test( - 'NIP92 - should handle malformed server response gracefully - dim 1920x1080', - () { - final Map malformedJson = { - "url": '', - "sha256": '', - "size": "512", - "type": "notype", - "nip94": { - "dim": "1920x1080", - }, - }; + 'NIP92 - should handle malformed server response gracefully - dim 1920x1080', + () { + final Map malformedJson = { + "url": '', + "sha256": '', + "size": "512", + "type": "notype", + "nip94": {"dim": "1920x1080"}, + }; - final Map malformedJson2 = { - "url": '', - "sha256": '', - "size": "512", - "dim": 100, - "type": "notype", - "nip94": { + final Map malformedJson2 = { + "url": '', + "sha256": '', + "size": "512", "dim": 100, - }, - }; + "type": "notype", + "nip94": {"dim": 100}, + }; - final result = BlobDescriptor.fromJson(malformedJson); - final result2 = BlobDescriptor.fromJson(malformedJson2); + final result = BlobDescriptor.fromJson(malformedJson); + final result2 = BlobDescriptor.fromJson(malformedJson2); - expect(result.nip94!.dimenssions, equals("1920x1080")); - expect(result2.nip94!.dimenssions, equals("100")); - }); + expect(result.nip94!.dimenssions, equals("1920x1080")); + expect(result2.nip94!.dimenssions, equals("100")); + }, + ); test( - 'NIP92 - should handle multiple repeated fields - image / thumb / fallback', - () { - final json = '''{ + 'NIP92 - should handle multiple repeated fields - image / thumb / fallback', + () { + final json = '''{ "url": "https://nostr.download/aaaa.mp4", "duration": "24.293322", "bitrate": "2227033", @@ -105,14 +100,20 @@ void main() async { "fallback": "https://nostr.download/bbbb.mp4" }'''; - final obj = jsonDecode(json); - final result = BlobNip94.fromJson(obj); - expect(result.dimenssions, equals("590x1280")); - // replaced by second instance (BAD!!) - // https://github.com/hzrd149/blossom/pull/60 - expect(result.fallback!.first, equals("https://nostr.download/bbbb.mp4")); - expect(result.thumbnail!.first, - equals("https://nostr.download/thumb/aaaa.webp")); - }); + final obj = jsonDecode(json); + final result = BlobNip94.fromJson(obj); + expect(result.dimenssions, equals("590x1280")); + // replaced by second instance (BAD!!) + // https://github.com/hzrd149/blossom/pull/60 + expect( + result.fallback!.first, + equals("https://nostr.download/bbbb.mp4"), + ); + expect( + result.thumbnail!.first, + equals("https://nostr.download/thumb/aaaa.webp"), + ); + }, + ); }); } diff --git a/packages/ndk/test/entities/event_cache_records_test.dart b/packages/ndk/test/entities/event_cache_records_test.dart new file mode 100644 index 000000000..c146e943d --- /dev/null +++ b/packages/ndk/test/entities/event_cache_records_test.dart @@ -0,0 +1,148 @@ +import 'package:ndk/entities.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventCacheStateRecord', () { + test('derives coordinate and expiration fields for addressable events', () { + final event = Nip01Event( + id: 'event-1', + pubKey: 'pubkey-1', + createdAt: 1700000000, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ['expiration', '1700009999'], + ], + content: 'hello', + sig: 'sig-1', + ); + + final record = EventCacheStateRecord.buildForEvents([ + event, + ], now: 1700000100).single; + + expect(record.eventId, event.id); + expect(record.coordinateKey, '30023:pubkey-1:article-1'); + expect(record.expirationAt, 1700009999); + expect(record.isCurrent, isTrue); + expect(record.deletedByEventId, isNull); + }); + + test('round trips through json', () { + const original = EventCacheStateRecord( + eventId: 'event-2', + pubKey: 'pubkey-2', + kind: 1, + createdAt: 1700000001, + coordinateKey: null, + isCurrent: false, + expirationAt: 1700009999, + deletedByEventId: 'delete-1', + ); + + final restored = EventCacheStateRecord.fromJson(original.toJson()); + + expect(restored.toJson(), original.toJson()); + expect(restored.isDeleted, isTrue); + }); + + test('uses lower event id as tie breaker for addressable winners', () { + final records = EventCacheStateRecord.buildForEvents([ + Nip01Event( + id: 'bbbb', + pubKey: 'pubkey-3', + createdAt: 1700000002, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ], + content: 'older', + sig: 'sig-3', + ), + Nip01Event( + id: 'aaaa', + pubKey: 'pubkey-3', + createdAt: 1700000002, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ], + content: 'newer', + sig: 'sig-4', + ), + ]); + + final byId = {for (final record in records) record.eventId: record}; + expect(byId['aaaa']!.isCurrent, isTrue); + expect(byId['bbbb']!.isCurrent, isFalse); + }); + }); + + group('EventDeliveryRecord', () { + test('round trips through json', () { + final original = EventDeliveryRecord( + eventId: 'event-3', + status: EventDeliveryStatus.partiallyDelivered, + signingState: EventSigningState.transientFailure, + createdAt: 1700001000, + updatedAt: 1700001010, + signedAt: 1700001005, + completedAt: 1700001020, + requiresInteractiveSigning: true, + signAttemptCount: 2, + lastSignAttemptAt: 1700001008, + nextSignRetryAt: 1700001100, + lastSignError: 'timed out', + ); + + final restored = EventDeliveryRecord.fromJson(original.toJson()); + + expect(restored.toJson(), original.toJson()); + expect(restored.isComplete, isFalse); + }); + }); + + group('RelayDeliveryTarget', () { + test('round trips through json', () { + const original = RelayDeliveryTarget( + eventId: 'event-4', + relayUrl: 'wss://relay.two', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.transientFailure, + attemptCount: 2, + nextRetryAt: 1700003000, + lastError: 'timeout', + ); + + final restored = RelayDeliveryTarget.fromJson(original.toJson()); + + expect(restored.toJson(), original.toJson()); + expect(restored.key, 'event-4|wss://relay.two'); + }); + + test('copyWith can clear nullable retry metadata fields', () { + const original = RelayDeliveryTarget( + eventId: 'event-4', + relayUrl: 'wss://relay.two', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.transientFailure, + attemptCount: 2, + lastAttemptAt: 1700002000, + nextRetryAt: 1700003000, + lastError: 'timeout', + lastOkMessage: 'ok', + ); + + final cleared = original.copyWith( + nextRetryAt: null, + lastError: null, + lastOkMessage: null, + ); + + expect(cleared.nextRetryAt, isNull); + expect(cleared.lastError, isNull); + expect(cleared.lastOkMessage, isNull); + expect(cleared.lastAttemptAt, original.lastAttemptAt); + }); + }); +} diff --git a/packages/ndk/test/entities/filter_fetched_ranges_test.dart b/packages/ndk/test/entities/filter_fetched_ranges_test.dart index 5e909a52e..ec1b65463 100644 --- a/packages/ndk/test/entities/filter_fetched_ranges_test.dart +++ b/packages/ndk/test/entities/filter_fetched_ranges_test.dart @@ -4,10 +4,7 @@ import 'package:ndk/ndk.dart'; void main() { group('TimeRange', () { test('constructor initializes correctly', () { - final range = TimeRange( - since: 1704067200, - until: 1704153600, - ); + final range = TimeRange(since: 1704067200, until: 1704153600); expect(range.since, 1704067200); expect(range.until, 1704153600); @@ -67,10 +64,7 @@ void main() { }); test('toJson and fromJson work correctly', () { - final range = TimeRange( - since: 1704067200, - until: 1704153600, - ); + final range = TimeRange(since: 1704067200, until: 1704153600); final json = range.toJson(); final restored = TimeRange.fromJson(json); @@ -120,17 +114,13 @@ void main() { final fetchedRangesNotReached = RelayFetchedRanges( relayUrl: 'wss://relay.example.com', filter: Filter(kinds: [1]), - ranges: [ - TimeRange(since: 100, until: 500), - ], + ranges: [TimeRange(since: 100, until: 500)], ); final fetchedRangesReached = RelayFetchedRanges( relayUrl: 'wss://relay.example.com', filter: Filter(kinds: [1]), - ranges: [ - TimeRange(since: 0, until: 500), - ], + ranges: [TimeRange(since: 0, until: 500)], ); expect(fetchedRangesNotReached.reachedOldest, isFalse); @@ -155,9 +145,7 @@ void main() { final fetchedRanges = RelayFetchedRanges( relayUrl: 'wss://relay.example.com', filter: Filter(kinds: [1]), - ranges: [ - TimeRange(since: 200, until: 300), - ], + ranges: [TimeRange(since: 200, until: 300)], ); final gaps = fetchedRanges.findGaps(100, 500); @@ -197,9 +185,7 @@ void main() { final fetchedRanges = RelayFetchedRanges( relayUrl: 'wss://relay.example.com', filter: Filter(kinds: [1]), - ranges: [ - TimeRange(since: 100, until: 500), - ], + ranges: [TimeRange(since: 100, until: 500)], ); final gaps = fetchedRanges.findGaps(200, 400); @@ -211,9 +197,7 @@ void main() { final fetchedRanges = RelayFetchedRanges( relayUrl: 'wss://relay.example.com', filter: Filter(kinds: [1]), - ranges: [ - TimeRange(since: 200, until: 300), - ], + ranges: [TimeRange(since: 200, until: 300)], ); final gaps = fetchedRanges.getGaps(100, 500); diff --git a/packages/ndk/test/entities/filter_test.dart b/packages/ndk/test/entities/filter_test.dart index 7f2579595..46348b2ed 100644 --- a/packages/ndk/test/entities/filter_test.dart +++ b/packages/ndk/test/entities/filter_test.dart @@ -151,9 +151,7 @@ void main() { }); test('cloneWithAuthors updates authors', () { - var filter = Filter( - authors: ['author1', 'author2'], - ); + var filter = Filter(authors: ['author1', 'author2']); var newAuthors = ['author3', 'author4']; var updatedFilter = filter.cloneWithAuthors(newAuthors); @@ -162,9 +160,7 @@ void main() { }); test('cloneWithPTags updates pTags', () { - var filter = Filter( - pTags: ['ptag1'], - ); + var filter = Filter(pTags: ['ptag1']); var newPTags = ['ptag2', 'ptag3']; var updatedFilter = filter.cloneWithPTags(newPTags); diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index c6f6ffab8..b8c25d01c 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -36,15 +36,15 @@ void main() { 'nostr+walletconnect://a?relay=wss://relay.example&secret=secret', ); - final updated = - wallet.withCachedPermissions({NwcMethod.PAY_INVOICE.name}); + final updated = wallet.withCachedPermissions({ + NwcMethod.PAY_INVOICE.name, + }); expect(updated.canSend, isTrue); expect(updated.canReceive, isFalse); - expect( - updated.metadata[NwcWallet.kPermissionsMetadataKey], - [NwcMethod.PAY_INVOICE.name], - ); + expect(updated.metadata[NwcWallet.kPermissionsMetadataKey], [ + NwcMethod.PAY_INVOICE.name, + ]); }); }); } diff --git a/packages/ndk/test/mocks/mock_blossom_server.dart b/packages/ndk/test/mocks/mock_blossom_server.dart index 6377b3309..56cbcbaa1 100644 --- a/packages/ndk/test/mocks/mock_blossom_server.dart +++ b/packages/ndk/test/mocks/mock_blossom_server.dart @@ -35,14 +35,18 @@ class MockBlossomServer { if (!_blobs.containsKey(sha256)) { return Response.notFound('Blob not found'); } - return Response.ok(_blobs[sha256]!.data, - headers: {'Content-Type': _blobs[sha256]!.contentType}); + return Response.ok( + _blobs[sha256]!.data, + headers: {'Content-Type': _blobs[sha256]!.contentType}, + ); }); // GET /static/test.txt - Static test file endpoint router.get('/static/test.txt', (Request request) { - return Response.ok('Static file content for testing', - headers: {'Content-Type': 'text/plain'}); + return Response.ok( + 'Static file content for testing', + headers: {'Content-Type': 'text/plain'}, + ); }); // HEAD / - Has Blob @@ -50,10 +54,13 @@ class MockBlossomServer { if (!_blobs.containsKey(sha256)) { return Response.notFound('Blob not found'); } - return Response(200, headers: { - 'Content-Length': _blobs[sha256]!.data.length.toString(), - 'Content-Type': _blobs[sha256]!.contentType, - }); + return Response( + 200, + headers: { + 'Content-Length': _blobs[sha256]!.data.length.toString(), + 'Content-Type': _blobs[sha256]!.contentType, + }, + ); }); // PUT /upload - Upload Blob @@ -66,8 +73,9 @@ class MockBlossomServer { } try { - final authEvent = - json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + final authEvent = json.decode( + utf8.decode(base64Decode(authHeader.split(' ')[1])), + ); if (!_verifyAuthEvent(authEvent, 'upload')) { return Response.forbidden('Invalid authorization event'); } @@ -113,8 +121,9 @@ class MockBlossomServer { } try { - final authEvent = - json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + final authEvent = json.decode( + utf8.decode(base64Decode(authHeader.split(' ')[1])), + ); if (!_verifyAuthEvent(authEvent, 'media')) { return Response.forbidden('Invalid authorization event'); } @@ -158,8 +167,9 @@ class MockBlossomServer { } try { - final authEvent = - json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + final authEvent = json.decode( + utf8.decode(base64Decode(authHeader.split(' ')[1])), + ); if (!_verifyAuthEvent(authEvent, 'list')) { return Response.forbidden('Invalid authorization event'); } @@ -179,14 +189,15 @@ class MockBlossomServer { if (until != null && timestamp > until) return false; return true; }) - .map((entry) => { - 'url': 'http://localhost:$port/${entry.key}', - 'sha256': entry.key, - 'size': entry.value.data.length, - 'type': entry.value.contentType, - 'uploaded': - entry.value.uploadedAt.millisecondsSinceEpoch ~/ 1000, - }) + .map( + (entry) => { + 'url': 'http://localhost:$port/${entry.key}', + 'sha256': entry.key, + 'size': entry.value.data.length, + 'type': entry.value.contentType, + 'uploaded': entry.value.uploadedAt.millisecondsSinceEpoch ~/ 1000, + }, + ) .toList(); return Response.ok( @@ -203,8 +214,9 @@ class MockBlossomServer { } try { - final authEvent = - json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + final authEvent = json.decode( + utf8.decode(base64Decode(authHeader.split(' ')[1])), + ); if (!_verifyAuthEvent(authEvent, 'delete')) { return Response.forbidden('Invalid authorization event'); @@ -230,8 +242,9 @@ class MockBlossomServer { } try { - final authEvent = - json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + final authEvent = json.decode( + utf8.decode(base64Decode(authHeader.split(' ')[1])), + ); if (!_verifyAuthEvent(authEvent, 'upload')) { return Response.forbidden('Invalid authorization event'); } @@ -246,7 +259,8 @@ class MockBlossomServer { requestData = json.decode(body); if (!requestData.containsKey('url')) { return Response.badRequest( - body: 'Request body must contain a "url" field'); + body: 'Request body must contain a "url" field', + ); } } catch (e) { return Response.badRequest(body: 'Invalid JSON body'); @@ -263,8 +277,8 @@ class MockBlossomServer { await response.drain(); httpClient.close(); return Response.internalServerError( - body: - 'Failed to download from source URL: ${response.statusCode}'); + body: 'Failed to download from source URL: ${response.statusCode}', + ); } // Read the response data @@ -278,7 +292,8 @@ class MockBlossomServer { // Store the blob _blobs[computedSha256] = _BlobEntry( data: data, - contentType: response.headers.contentType?.toString() ?? + contentType: + response.headers.contentType?.toString() ?? 'application/octet-stream', uploader: 'test_pubkey', uploadedAt: DateTime.now(), @@ -299,7 +314,8 @@ class MockBlossomServer { ); } catch (e) { return Response.internalServerError( - body: 'Failed to mirror blob: ${e.toString()}'); + body: 'Failed to mirror blob: ${e.toString()}', + ); } }); @@ -314,8 +330,10 @@ class MockBlossomServer { if (requestData['kind'] != kReport) { return Response.badRequest(body: 'Invalid kind'); } - return Response.ok('{"status": "ok"}', - headers: {'Content-Type': 'application/json'}); + return Response.ok( + '{"status": "ok"}', + headers: {'Content-Type': 'application/json'}, + ); }); return router; @@ -345,8 +363,9 @@ class MockBlossomServer { if (event['kind'] != 24242) return false; final tags = List>.from(event['tags']); - final hasTypeTag = - tags.any((tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == type); + final hasTypeTag = tags.any( + (tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == type, + ); return hasTypeTag; } diff --git a/packages/ndk/test/mocks/mock_failing_signer.dart b/packages/ndk/test/mocks/mock_failing_signer.dart index 8b8c2dd7d..1fb16432a 100644 --- a/packages/ndk/test/mocks/mock_failing_signer.dart +++ b/packages/ndk/test/mocks/mock_failing_signer.dart @@ -11,6 +11,15 @@ class MockFailingSigner implements EventSigner { MockFailingSigner({required String publicKey}) : _publicKey = publicKey; + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + @override Future sign(Nip01Event event) async { throw SignerRequestRejectedException( diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index f12c32cd9..6aa8cb3b5 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'dart:math' show Random; import 'package:bip340/bip340.dart'; import 'package:ndk/domain_layer/usecases/bunkers/models/bunker_request.dart'; @@ -14,8 +15,12 @@ import 'package:ndk/shared/nips/nip04/nip04.dart'; import 'package:ndk/shared/nips/nip44/nip44.dart'; class MockRelay { + static final Random _random = Random.secure(); + static final Set _reservedPorts = {}; + String name; int? _port; + final int? _explicitPort; HttpServer? server; Map? _nip65s; Map? textNotes; @@ -24,9 +29,17 @@ class MockRelay { Map _nip85Assertions = {}; // NIP-85 assertions keyed by "author:dTag" final Set _storedEvents = {}; // Store received events + final List _receivedEvents = []; // Track all connected clients with their subscriptions final Map>> _clientSubscriptions = {}; + + int get connectedClientCount => _clientSubscriptions.length; + + int get activeSubscriptionCount => _clientSubscriptions.values.fold( + 0, + (count, subscriptions) => count + subscriptions.length, + ); bool signEvents; bool requireAuthForRequests; bool requireAuthForEvents; @@ -37,6 +50,8 @@ class MockRelay { int? maxEventsPerRequest; int signEventCreatedAtOffsetSeconds; String? signEventContentOverride; + int rejectFirstEventPublishes; + String rejectEventMessage; // NIP-46 Remote Signer Support static const int kNip46Kind = BunkerRequest.kKind; @@ -46,10 +61,59 @@ class MockRelay { "e7158a4379e743889f8ea8cfcdf4bd904cdfde4ff8a1c545aad4590d8a3acccc"; static const String remoteSignerPublicKey = "52f58988d7aaea17936581db7ff19074633557fad37f354323cea579b1025cef"; + String get url => "ws://localhost:$_port"; - static int _startPort = 4040; + static int _pickRandomPort() { + while (true) { + final candidate = 20000 + _random.nextInt(40000); + if (_reservedPorts.add(candidate)) { + return candidate; + } + } + } - String get url => "ws://localhost:$_port"; + static void _releaseReservedPort(int? port) { + if (port != null) { + _reservedPorts.remove(port); + } + } + + List matchingEvents(Filter filter) { + final events = {}; + events.addAll(_storedEvents); + if (textNotes != null) { + events.addAll(textNotes!.values); + } + + return events.where((event) { + if (filter.ids != null && + filter.ids!.isNotEmpty && + !filter.ids!.contains(event.id)) { + return false; + } + if (filter.authors != null && + filter.authors!.isNotEmpty && + !filter.authors!.contains(event.pubKey)) { + return false; + } + if (filter.kinds != null && + filter.kinds!.isNotEmpty && + !filter.kinds!.contains(event.kind)) { + return false; + } + if (filter.tags != null && filter.tags!.isNotEmpty) { + for (final entry in filter.tags!.entries) { + final eventValues = event.getTags(entry.key); + if (!entry.value.any((value) => eventValues.contains(value))) { + return false; + } + } + } + return true; + }).toList(); + } + + List get receivedEvents => List.unmodifiable(_receivedEvents); MockRelay({ required this.name, @@ -64,15 +128,12 @@ class MockRelay { this.maxEventsPerRequest, this.signEventCreatedAtOffsetSeconds = 0, this.signEventContentOverride, + this.rejectFirstEventPublishes = 0, + this.rejectEventMessage = 'rate-limited: retry later', int? explicitPort, - }) : _nip65s = nip65s { - if (explicitPort != null) { - _port = explicitPort; - } else { - _port = _startPort; - _startPort++; - } - } + }) : _nip65s = nip65s, + _explicitPort = explicitPort, + _port = explicitPort ?? _pickRandomPort(); Future startServer({ Map? nip65s, @@ -100,8 +161,39 @@ class MockRelay { _nip85Assertions = nip85Assertions; } - var server = await HttpServer.bind(InternetAddress.loopbackIPv4, _port!, - shared: true); + HttpServer? server; + Object? lastBindError; + StackTrace? lastBindStackTrace; + + for (var attempt = 0; attempt < 10; attempt++) { + try { + server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + _port!, + shared: false, + ); + break; + } on SocketException catch (e, stackTrace) { + lastBindError = e; + lastBindStackTrace = stackTrace; + + if (_explicitPort != null) { + rethrow; + } + + _releaseReservedPort(_port); + _port = _pickRandomPort(); + } + } + + if (server == null) { + Error.throwWithStackTrace( + lastBindError ?? + StateError('Failed to bind mock relay server after retries'), + lastBindStackTrace ?? StackTrace.current, + ); + } + this.server = server; var stream = server.transform(WebSocketTransformer()); @@ -109,205 +201,246 @@ class MockRelay { final String challenge = Helpers.getRandomString(10); Set authenticatedPubkeys = {}; - stream.listen((webSocket) { - // Register this client - _clientSubscriptions[webSocket] = {}; + stream.listen( + (webSocket) { + // Register this client + _clientSubscriptions[webSocket] = {}; - if (customWelcomeMessage != null) { - webSocket.add(customWelcomeMessage!); - } - if ((requireAuthForRequests || requireAuthForEvents) && - sendAuthChallenge) { - webSocket.add(jsonEncode(["AUTH", challenge])); - } - webSocket.listen((message) async { - if (allwaysSendBadJson) { - webSocket.add('{"bad_json":,}'); - return; + if (customWelcomeMessage != null) { + webSocket.add(customWelcomeMessage!); } - if (delayResponse != null) { - await Future.delayed(delayResponse); + if ((requireAuthForRequests || requireAuthForEvents) && + sendAuthChallenge) { + webSocket.add(jsonEncode(["AUTH", challenge])); } - if (message == "ping") { - webSocket.add("pong"); - return; - } - var eventJson = json.decode(message); - - if (eventJson[0] == "AUTH") { - Nip01Event event = Nip01EventModel.fromJson(eventJson[1]); - bool authSuccess = false; - if (verify(event.pubKey, event.id, event.sig!)) { - String? relay = event.getFirstTag("relay"); - String? eventChallenge = event.getFirstTag("challenge"); - if (eventChallenge == challenge && relay == url) { - authenticatedPubkeys.add(event.pubKey); - authSuccess = true; + webSocket.listen( + (message) async { + if (allwaysSendBadJson) { + webSocket.add('{"bad_json":,}'); + return; } - } - - webSocket.add(jsonEncode([ - "OK", - event.id, - authSuccess, - authSuccess ? "" : "auth-required: authentication failed" - ])); - return; - } - if (eventJson[0] == "EVENT") { - Nip01Event newEvent = Nip01EventModel.fromJson(eventJson[1]); - if (verify(newEvent.pubKey, newEvent.id, newEvent.sig!)) { - bool shouldBroadcastToSubscriptions = true; - - // Check auth for events if required (any authenticated user is OK) - if (requireAuthForEvents && authenticatedPubkeys.isEmpty) { - webSocket.add(jsonEncode([ - "OK", - newEvent.id, - false, - "auth-required: we only accept events from authenticated users" - ])); + if (delayResponse != null) { + await Future.delayed(delayResponse); + } + if (message == "ping") { + webSocket.add("pong"); return; } - if (newEvent.kind == ContactList.kKind) { - final existing = _contactLists[newEvent.pubKey]; - if (existing == null || _shouldReplace(existing, newEvent)) { - _contactLists[newEvent.pubKey] = newEvent; - } else { - shouldBroadcastToSubscriptions = false; - } - } else if (newEvent.kind == Metadata.kKind) { - final existing = _metadatas[newEvent.pubKey]; - if (existing == null || _shouldReplace(existing, newEvent)) { - _metadatas[newEvent.pubKey] = newEvent; - } else { - shouldBroadcastToSubscriptions = false; - } - } else if (newEvent.kind == Deletion.kKind) { - final eventIdsToDelete = newEvent.getTags("e"); - for (final idToDelete in eventIdsToDelete) { - _storedEvents.removeWhere((e) => idToDelete == e.id); - // remove from textNotes map - if (textNotes != null) { - textNotes.removeWhere((key, event) => event.id == idToDelete); + var eventJson = json.decode(message); + + if (eventJson[0] == "AUTH") { + Nip01Event event = Nip01EventModel.fromJson(eventJson[1]); + bool authSuccess = false; + if (verify(event.pubKey, event.id, event.sig!)) { + String? relay = event.getFirstTag("relay"); + String? eventChallenge = event.getFirstTag("challenge"); + if (eventChallenge == challenge && relay == url) { + authenticatedPubkeys.add(event.pubKey); + authSuccess = true; } - //remove from contact lists and metadata - _contactLists - .removeWhere((key, event) => event.id == idToDelete); - _metadatas.removeWhere((key, event) => event.id == idToDelete); - } - } else if (_isEphemeralKind(newEvent.kind)) { - // Ephemeral events (kinds 20000-29999) are broadcast but NOT stored - // Also handle NIP-46 if targeting our mock signer - if (newEvent.kind == kNip46Kind) { - _handleNip46Request(newEvent, webSocket); } - } else if (_isReplaceableKind(newEvent.kind)) { - // NIP-01 replaceable: only one event per (pubkey, kind) - final existing = _storedEvents.where((e) => - e.pubKey == newEvent.pubKey && e.kind == newEvent.kind); - if (existing.isEmpty) { - _storedEvents.add(newEvent); - } else { - final current = existing.first; - if (_shouldReplace(current, newEvent)) { - _storedEvents.remove(current); - _storedEvents.add(newEvent); - } else { - shouldBroadcastToSubscriptions = false; + + webSocket.add( + jsonEncode([ + "OK", + event.id, + authSuccess, + authSuccess ? "" : "auth-required: authentication failed", + ]), + ); + return; + } + if (eventJson[0] == "EVENT") { + Nip01Event newEvent = Nip01EventModel.fromJson(eventJson[1]); + if (verify(newEvent.pubKey, newEvent.id, newEvent.sig!)) { + _receivedEvents.add(newEvent); + if (rejectFirstEventPublishes > 0) { + rejectFirstEventPublishes--; + webSocket.add( + jsonEncode(["OK", newEvent.id, false, rejectEventMessage]), + ); + return; } - } - } else if (_isAddressableKind(newEvent.kind)) { - // NIP-01 addressable: only one event per (pubkey, kind, d-tag) - final dTag = newEvent.getDtag() ?? ''; - final existing = _storedEvents.where((e) => - e.pubKey == newEvent.pubKey && - e.kind == newEvent.kind && - (e.getDtag() ?? '') == dTag); - if (existing.isEmpty) { - _storedEvents.add(newEvent); - } else { - final current = existing.first; - if (_shouldReplace(current, newEvent)) { - _storedEvents.remove(current); + bool shouldBroadcastToSubscriptions = true; + + // Check auth for events if required (any authenticated user is OK) + if (requireAuthForEvents && authenticatedPubkeys.isEmpty) { + webSocket.add( + jsonEncode([ + "OK", + newEvent.id, + false, + "auth-required: we only accept events from authenticated users", + ]), + ); + return; + } + if (newEvent.kind == ContactList.kKind) { + final existing = _contactLists[newEvent.pubKey]; + if (existing == null || _shouldReplace(existing, newEvent)) { + _contactLists[newEvent.pubKey] = newEvent; + } else { + shouldBroadcastToSubscriptions = false; + } + } else if (newEvent.kind == Metadata.kKind) { + final existing = _metadatas[newEvent.pubKey]; + if (existing == null || _shouldReplace(existing, newEvent)) { + _metadatas[newEvent.pubKey] = newEvent; + } else { + shouldBroadcastToSubscriptions = false; + } + } else if (newEvent.kind == Deletion.kKind) { _storedEvents.add(newEvent); + final eventIdsToDelete = newEvent.getTags("e"); + for (final idToDelete in eventIdsToDelete) { + _storedEvents.removeWhere((e) => idToDelete == e.id); + // remove from textNotes map + if (textNotes != null) { + textNotes.removeWhere( + (key, event) => event.id == idToDelete, + ); + } + //remove from contact lists and metadata + _contactLists.removeWhere( + (key, event) => event.id == idToDelete, + ); + _metadatas.removeWhere( + (key, event) => event.id == idToDelete, + ); + } + } else if (_isEphemeralKind(newEvent.kind)) { + // Ephemeral events (kinds 20000-29999) are broadcast but NOT stored + // Also handle NIP-46 if targeting our mock signer + if (newEvent.kind == kNip46Kind) { + _handleNip46Request(newEvent, webSocket); + } + } else if (_isReplaceableKind(newEvent.kind)) { + // NIP-01 replaceable: only one event per (pubkey, kind) + final existing = _storedEvents.where( + (e) => + e.pubKey == newEvent.pubKey && e.kind == newEvent.kind, + ); + if (existing.isEmpty) { + _storedEvents.add(newEvent); + } else { + final current = existing.first; + if (_shouldReplace(current, newEvent)) { + _storedEvents.remove(current); + _storedEvents.add(newEvent); + } else { + shouldBroadcastToSubscriptions = false; + } + } + } else if (_isAddressableKind(newEvent.kind)) { + // NIP-01 addressable: only one event per (pubkey, kind, d-tag) + final dTag = newEvent.getDtag() ?? ''; + final existing = _storedEvents.where( + (e) => + e.pubKey == newEvent.pubKey && + e.kind == newEvent.kind && + (e.getDtag() ?? '') == dTag, + ); + if (existing.isEmpty) { + _storedEvents.add(newEvent); + } else { + final current = existing.first; + if (_shouldReplace(current, newEvent)) { + _storedEvents.remove(current); + _storedEvents.add(newEvent); + } else { + shouldBroadcastToSubscriptions = false; + } + } } else { - shouldBroadcastToSubscriptions = false; + _storedEvents.add(newEvent); } + if (shouldBroadcastToSubscriptions) { + _broadcastEventToSubscriptions(newEvent); + } + webSocket.add(jsonEncode(["OK", newEvent.id, true, ""])); + } else { + webSocket.add( + jsonEncode(["OK", newEvent.id, false, "invalid signature"]), + ); } - } else { - _storedEvents.add(newEvent); - } - if (shouldBroadcastToSubscriptions) { - _broadcastEventToSubscriptions(newEvent); + return; } - webSocket.add(jsonEncode(["OK", newEvent.id, true, ""])); - } else { - webSocket.add( - jsonEncode(["OK", newEvent.id, false, "invalid signature"])); - } - return; - } - if (eventJson[0] == "REQ") { - String requestId = eventJson[1]; - List filters = []; - if (eventJson.length > 2) { - for (int i = 2; i < eventJson.length; i++) { - if (eventJson[i] is Map) { - try { - filters.add(Filter.fromMap(eventJson[i])); - } catch (e) { - log("MockRelay: Error parsing filter item in REQ: ${eventJson[i]}, error: $e"); + if (eventJson[0] == "REQ") { + String requestId = eventJson[1]; + List filters = []; + if (eventJson.length > 2) { + for (int i = 2; i < eventJson.length; i++) { + if (eventJson[i] is Map) { + try { + filters.add(Filter.fromMap(eventJson[i])); + } catch (e) { + log( + "MockRelay: Error parsing filter item in REQ: ${eventJson[i]}, error: $e", + ); + } + } else { + log( + "MockRelay: Malformed filter item in REQ (not a Map): ${eventJson[i]}", + ); + } } - } else { - log("MockRelay: Malformed filter item in REQ (not a Map): ${eventJson[i]}"); } - } - } - // Check auth: any authenticated user can access all data - if (requireAuthForRequests && authenticatedPubkeys.isEmpty) { - webSocket.add(jsonEncode([ - "CLOSED", - requestId, - "auth-required: we can't serve requests to unauthenticated users" - ])); - return; - } + // Check auth: any authenticated user can access all data + if (requireAuthForRequests && authenticatedPubkeys.isEmpty) { + webSocket.add( + jsonEncode([ + "CLOSED", + requestId, + "auth-required: we can't serve requests to unauthenticated users", + ]), + ); + return; + } - if (filters.isNotEmpty) { - // Store the subscription for this client - _clientSubscriptions[webSocket]?[requestId] = filters; - _respondToRequest(webSocket, filters, requestId); - } else { - // If no valid filters are provided, send EOSE immediately for this request ID - log("MockRelay: No valid filters provided for REQ $requestId, sending EOSE."); - webSocket.add(jsonEncode(["EOSE", requestId])); - } - return; - } + if (filters.isNotEmpty) { + // Store the subscription for this client + _clientSubscriptions[webSocket]?[requestId] = filters; + _respondToRequest(webSocket, filters, requestId); + } else { + // If no valid filters are provided, send EOSE immediately for this request ID + log( + "MockRelay: No valid filters provided for REQ $requestId, sending EOSE.", + ); + webSocket.add(jsonEncode(["EOSE", requestId])); + } + return; + } - if (eventJson[0] == "CLOSE") { - String subscriptionId = eventJson[1]; - // Remove the subscription for this client - if (_clientSubscriptions[webSocket]?.containsKey(subscriptionId) ?? - false) { - _clientSubscriptions[webSocket]?.remove(subscriptionId); - log("MockRelay: Closed subscription $subscriptionId"); - } else { - log("MockRelay: Attempted to close non-existent subscription $subscriptionId"); - } - return; - } - }, onDone: () { - // Clean up when client disconnects - _clientSubscriptions.remove(webSocket); - log("MockRelay: Client disconnected"); - }); - }, onError: (error) { - log('Error: $error'); - }); + if (eventJson[0] == "CLOSE") { + String subscriptionId = eventJson[1]; + // Remove the subscription for this client + if (_clientSubscriptions[webSocket]?.containsKey( + subscriptionId, + ) ?? + false) { + _clientSubscriptions[webSocket]?.remove(subscriptionId); + log("MockRelay: Closed subscription $subscriptionId"); + } else { + log( + "MockRelay: Attempted to close non-existent subscription $subscriptionId", + ); + } + return; + } + }, + onDone: () { + // Clean up when client disconnects + _clientSubscriptions.remove(webSocket); + log("MockRelay: Client disconnected"); + }, + ); + }, + onError: (error) { + log('Error: $error'); + }, + ); log('Listening on localhost:${server.port}'); myPromise.complete(); @@ -316,7 +449,10 @@ class MockRelay { } void _respondToRequest( - WebSocket webSocket, List filters, String requestId) { + WebSocket webSocket, + List filters, + String requestId, + ) { if (sendMalformedEvents) { final malformedEventJson = '["EVENT", "$requestId", {"id":null,"pubkey":null,"created_at":${DateTime.now().millisecondsSinceEpoch ~/ 1000},"kind":0,"tags":[],"content":null,"sig":null}]'; @@ -335,61 +471,78 @@ class MockRelay { filter.kinds!.contains(ContactList.kKind) && filter.authors != null && filter.authors!.isNotEmpty) { - eventsForThisFilter.addAll(_contactLists.values - .where((e) => - filter.authors!.contains(e.pubKey) && - _matchesTimeFilter(e, filter)) - .toList()); + eventsForThisFilter.addAll( + _contactLists.values + .where( + (e) => + filter.authors!.contains(e.pubKey) && + _matchesTimeFilter(e, filter), + ) + .toList(), + ); } // Match against metadatas else if (filter.kinds != null && filter.kinds!.contains(Metadata.kKind) && filter.authors != null && filter.authors!.isNotEmpty) { - eventsForThisFilter.addAll(_metadatas.values - .where((e) => - filter.authors!.contains(e.pubKey) && - _matchesTimeFilter(e, filter)) - .toList()); + eventsForThisFilter.addAll( + _metadatas.values + .where( + (e) => + filter.authors!.contains(e.pubKey) && + _matchesTimeFilter(e, filter), + ) + .toList(), + ); } // Match against NIP-85 assertions (kinds 30382-30385) else if (filter.kinds != null && filter.kinds!.any((k) => k >= 30382 && k <= 30385) && filter.authors != null && filter.authors!.isNotEmpty) { - eventsForThisFilter.addAll(_nip85Assertions.values.where((e) { - bool kindMatches = filter.kinds!.contains(e.kind); - bool authorMatches = filter.authors!.contains(e.pubKey); - bool dTagMatches = filter.dTags == null || - filter.dTags!.isEmpty || - filter.dTags!.contains(e.getDtag()); - return kindMatches && authorMatches && dTagMatches; - }).toList()); + eventsForThisFilter.addAll( + _nip85Assertions.values.where((e) { + bool kindMatches = filter.kinds!.contains(e.kind); + bool authorMatches = filter.authors!.contains(e.pubKey); + bool dTagMatches = + filter.dTags == null || + filter.dTags!.isEmpty || + filter.dTags!.contains(e.getDtag()); + return kindMatches && authorMatches && dTagMatches; + }).toList(), + ); } // General event matching (storedEvents and textNotes) else { - eventsForThisFilter.addAll(_storedEvents.where((event) { - bool kindMatches = - filter.kinds == null || filter.kinds!.contains(event.kind); - bool authorMatches = - filter.authors == null || filter.authors!.contains(event.pubKey); - bool idsMatches = - filter.ids == null || filter.ids!.contains(event.id); - bool timeMatches = _matchesTimeFilter(event, filter); - return kindMatches && authorMatches && idsMatches && timeMatches; - }).toList()); - - if (textNotes != null) { - eventsForThisFilter.addAll(textNotes!.values.where((event) { + eventsForThisFilter.addAll( + _storedEvents.where((event) { bool kindMatches = filter.kinds == null || filter.kinds!.contains(event.kind); - bool authorMatches = filter.authors == null || + bool authorMatches = + filter.authors == null || filter.authors!.contains(event.pubKey); bool idsMatches = filter.ids == null || filter.ids!.contains(event.id); bool timeMatches = _matchesTimeFilter(event, filter); return kindMatches && authorMatches && idsMatches && timeMatches; - }).toList()); + }).toList(), + ); + + if (textNotes != null) { + eventsForThisFilter.addAll( + textNotes!.values.where((event) { + bool kindMatches = + filter.kinds == null || filter.kinds!.contains(event.kind); + bool authorMatches = + filter.authors == null || + filter.authors!.contains(event.pubKey); + bool idsMatches = + filter.ids == null || filter.ids!.contains(event.id); + bool timeMatches = _matchesTimeFilter(event, filter); + return kindMatches && authorMatches && idsMatches && timeMatches; + }).toList(), + ); } } @@ -399,15 +552,17 @@ class MockRelay { if (filter.authors != null && filter.authors!.contains(entry.key.publicKey) && (filter.kinds == null || filter.kinds!.contains(Nip65.kKind))) { - Nip01Event eventToAdd = - entry.value.toEvent(); // Creates a new event instance + Nip01Event eventToAdd = entry.value + .toEvent(); // Creates a new event instance if (!_matchesTimeFilter(eventToAdd, filter)) continue; final Nip01Event? eventToAddSigned; if (signEvents && entry.key.privateKey != null) { // Sign the new instance, not the one in _nip65s eventToAddSigned = Nip01Utils.signWithPrivateKey( - event: eventToAdd, privateKey: entry.key.privateKey!); + event: eventToAdd, + privateKey: entry.key.privateKey!, + ); } else { eventToAddSigned = null; } @@ -423,15 +578,19 @@ class MockRelay { // For now, ensuring signing is handled correctly if events are matched here. if (textNotes != null) { for (final entry in textNotes!.entries) { - bool authorsMatch = filter.authors != null && + bool authorsMatch = + filter.authors != null && filter.authors!.contains(entry.key.publicKey); - bool kindsMatch = filter.kinds == null || + bool kindsMatch = + filter.kinds == null || filter.kinds!.contains(entry.value.kind) || (entry.value.kind == Nip01Event.kTextNodeKind && filter.kinds!.contains(Nip01Event.kTextNodeKind)) || - (filter.kinds!.any((k) => - Nip51List.kPossibleKinds.contains(k) && - Nip51List.kPossibleKinds.contains(entry.value.kind))); + (filter.kinds!.any( + (k) => + Nip51List.kPossibleKinds.contains(k) && + Nip51List.kPossibleKinds.contains(entry.value.kind), + )); bool timeMatches = _matchesTimeFilter(entry.value, filter); if (authorsMatch && kindsMatch && timeMatches) { @@ -440,7 +599,9 @@ class MockRelay { Nip01Event? eventToAddSigned; if (signEvents && entry.key.privateKey != null) { eventToAddSigned = Nip01Utils.signWithPrivateKey( - event: eventToAdd, privateKey: entry.key.privateKey!); + event: eventToAdd, + privateKey: entry.key.privateKey!, + ); } else { eventToAddSigned = null; } @@ -471,8 +632,13 @@ class MockRelay { } for (final event in eventsToSend) { - webSocket.add(jsonEncode( - ["EVENT", requestId, Nip01EventModel.fromEntity(event).toJson()])); + webSocket.add( + jsonEncode([ + "EVENT", + requestId, + Nip01EventModel.fromEntity(event).toJson(), + ]), + ); } webSocket.add(jsonEncode(["EOSE", requestId])); @@ -503,7 +669,9 @@ class MockRelay { Nip01Event? signedEvent; if (keyPair != null) { signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: keyPair.privateKey!); + event: event, + privateKey: keyPair.privateKey!, + ); } final eventToSend = signedEvent ?? event; @@ -558,11 +726,13 @@ class MockRelay { for (var filter in filters) { if (_eventMatchesFilter(event, filter)) { - clientSocket.add(jsonEncode([ - "EVENT", - subscriptionId, - Nip01EventModel.fromEntity(event).toJson() - ])); + clientSocket.add( + jsonEncode([ + "EVENT", + subscriptionId, + Nip01EventModel.fromEntity(event).toJson(), + ]), + ); break; // Only send once per subscription } } @@ -608,18 +778,33 @@ class MockRelay { ? filterTag.key.substring(1) : filterTag.key; - return event.tags.any((eventTag) => - eventTag.length > 1 && - eventTag[0] == tagName && - filterTag.value.contains(eventTag[1])); + return event.tags.any( + (eventTag) => + eventTag.length > 1 && + eventTag[0] == tagName && + filterTag.value.contains(eventTag[1]), + ); }); } + /// Closes all connected client sockets while keeping the server running, + /// simulating a relay-side disconnect. + Future closeClientSockets() async { + final sockets = _clientSubscriptions.keys.toList(); + for (final socket in sockets) { + await socket.close(); + } + _clientSubscriptions.clear(); + } + Future stopServer() async { if (server != null) { log('Closing server on localhost:$url'); - await server!.close(); + await server!.close(force: true); + server = null; + _clientSubscriptions.clear(); } + _releaseReservedPort(_port); } /// Handle NIP-46 remote signer requests @@ -636,7 +821,10 @@ class MockRelay { String decryptedContent; try { decryptedContent = await Nip44.decryptMessage( - event.content, _remoteSignerPrivateKey, event.pubKey); + event.content, + _remoteSignerPrivateKey, + event.pubKey, + ); } catch (e) { log('MockRelay: Failed to decrypt NIP-46 request: $e'); return; @@ -662,7 +850,10 @@ class MockRelay { String encryptedResponse; try { encryptedResponse = await Nip44.encryptMessage( - responseContent, _remoteSignerPrivateKey, event.pubKey); + responseContent, + _remoteSignerPrivateKey, + event.pubKey, + ); } catch (e) { log('MockRelay: Failed to encrypt NIP-46 response: $e'); return; @@ -679,7 +870,9 @@ class MockRelay { createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); Nip01Event responseEvent = Nip01Utils.signWithPrivateKey( - event: responseEventUnsinged, privateKey: _remoteSignerPrivateKey); + event: responseEventUnsinged, + privateKey: _remoteSignerPrivateKey, + ); // NIP-46 events are ephemeral (kind 24133), broadcast to matching subscriptions _broadcastEventToSubscriptions(responseEvent); @@ -690,7 +883,9 @@ class MockRelay { /// Process NIP-46 methods Future> _processNip46Method( - String method, List? params) async { + String method, + List? params, + ) async { try { switch (method) { case 'connect': @@ -700,14 +895,10 @@ class MockRelay { String? secret = params[0]; log('MockRelay: NIP-46 connect with secret: ${secret != null}'); } - return { - 'result': 'ack', - }; + return {'result': 'ack'}; case 'ping': - return { - 'result': 'pong', - }; + return {'result': 'pong'}; case 'get_relays': // Return the relay URL where this signer is available @@ -719,14 +910,10 @@ class MockRelay { case 'disconnect': // Handle disconnection - return { - 'result': 'ack', - }; + return {'result': 'ack'}; case 'get_public_key': - return { - 'result': remoteSignerPublicKey, - }; + return {'result': remoteSignerPublicKey}; case 'sign_event': if (params == null || params.isEmpty) { @@ -751,11 +938,13 @@ class MockRelay { content: signEventContentOverride ?? eventData["content"] ?? "", createdAt: (eventData["created_at"] ?? eventData["createdAt"] ?? 0) + - signEventCreatedAtOffsetSeconds, + signEventCreatedAtOffsetSeconds, ); final Nip01Event signedEvent = Nip01Utils.signWithPrivateKey( - event: eventToSign, privateKey: _remoteSignerPrivateKey); + event: eventToSign, + privateKey: _remoteSignerPrivateKey, + ); return { 'result': Nip01EventModel.fromEntity(signedEvent).toJsonString(), @@ -768,12 +957,13 @@ class MockRelay { String pubkey = params[0]; String plaintext = params[1]; - String encrypted = - Nip04.encrypt(_remoteSignerPrivateKey, pubkey, plaintext); + String encrypted = Nip04.encrypt( + _remoteSignerPrivateKey, + pubkey, + plaintext, + ); - return { - 'result': encrypted, - }; + return {'result': encrypted}; case 'nip04_decrypt': if (params == null || params.length < 2) { @@ -782,12 +972,13 @@ class MockRelay { String pubkey = params[0]; String ciphertext = params[1]; - String decrypted = - Nip04.decrypt(_remoteSignerPrivateKey, pubkey, ciphertext); + String decrypted = Nip04.decrypt( + _remoteSignerPrivateKey, + pubkey, + ciphertext, + ); - return { - 'result': decrypted, - }; + return {'result': decrypted}; case 'nip44_encrypt': if (params == null || params.length < 2) { @@ -797,11 +988,12 @@ class MockRelay { String pubkey = params[0]; String plaintext = params[1]; String encrypted = await Nip44.encryptMessage( - plaintext, _remoteSignerPrivateKey, pubkey); + plaintext, + _remoteSignerPrivateKey, + pubkey, + ); - return { - 'result': encrypted, - }; + return {'result': encrypted}; case 'nip44_decrypt': if (params == null || params.length < 2) { @@ -811,21 +1003,18 @@ class MockRelay { String pubkey = params[0]; String ciphertext = params[1]; String decrypted = await Nip44.decryptMessage( - ciphertext, _remoteSignerPrivateKey, pubkey); + ciphertext, + _remoteSignerPrivateKey, + pubkey, + ); - return { - 'result': decrypted, - }; + return {'result': decrypted}; default: - return { - 'error': 'Unknown method: $method', - }; + return {'error': 'Unknown method: $method'}; } } catch (e) { - return { - 'error': 'Error processing method $method: $e', - }; + return {'error': 'Error processing method $method: $e'}; } } } diff --git a/packages/ndk/test/mocks/mock_relay_ephemeral_test.dart b/packages/ndk/test/mocks/mock_relay_ephemeral_test.dart index 5444f38cb..5c4868a4f 100644 --- a/packages/ndk/test/mocks/mock_relay_ephemeral_test.dart +++ b/packages/ndk/test/mocks/mock_relay_ephemeral_test.dart @@ -12,10 +12,7 @@ void main() { late MockRelay mockRelay; setUp(() async { - mockRelay = MockRelay( - name: 'ephemeral-test-relay', - explicitPort: 4050, - ); + mockRelay = MockRelay(name: 'ephemeral-test-relay', explicitPort: 4050); await mockRelay.startServer(); }); @@ -23,89 +20,98 @@ void main() { await mockRelay.stopServer(); }); - test('ephemeral events should be broadcast to matching subscriptions', - () async { - final keyPair1 = Bip340.generatePrivateKey(); - final keyPair2 = Bip340.generatePrivateKey(); - - // Create two NDK clients - final ndk1 = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - final ndk2 = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - // Client 2 subscribes to ephemeral events (kind 21133) tagged with their pubkey - final receivedEvents = []; - final completer = Completer(); - - final subscription = ndk2.requests.subscription( - filter: Filter( - kinds: [21133], - pTags: [keyPair2.publicKey], - ), - ); - - subscription.stream.listen((event) { - receivedEvents.add(event); - if (!completer.isCompleted) { - completer.complete(); + Future waitForSubscriptionRegistration() async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (mockRelay.activeSubscriptionCount > 0) { + return; } - }); - - // Wait for subscription to be established - await Future.delayed(Duration(milliseconds: 200)); - - // Client 1 sends an ephemeral event (kind 21133 - NIP-46) - final ephemeralEvent = Nip01Event( - pubKey: keyPair1.publicKey, - kind: 21133, - tags: [ - ['p', keyPair2.publicKey] - ], - content: 'test ephemeral content', - ); - final signedEvent = Nip01Utils.signWithPrivateKey( - event: ephemeralEvent, - privateKey: keyPair1.privateKey!, - ); - - final broadcast = ndk1.broadcast.broadcast( - nostrEvent: signedEvent, - specificRelays: [mockRelay.url], - ); - await broadcast.broadcastDoneFuture; - - // Wait for event to be broadcast (with timeout) - await completer.future.timeout( - Duration(seconds: 2), - onTimeout: () => null, - ); - - // Verify client2 received the ephemeral event - expect( - receivedEvents.length, - equals(1), - reason: - 'Ephemeral events should be broadcast to matching subscriptions', - ); - expect(receivedEvents.first.kind, equals(21133)); - expect(receivedEvents.first.content, equals('test ephemeral content')); - - // Cleanup - await ndk1.destroy(); - await ndk2.destroy(); - }); + await Future.delayed(const Duration(milliseconds: 25)); + } + throw TimeoutException('MockRelay did not register the subscription.'); + } + + test( + 'ephemeral events should be broadcast to matching subscriptions', + () async { + final keyPair1 = Bip340.generatePrivateKey(); + final keyPair2 = Bip340.generatePrivateKey(); + + // Create two NDK clients + final ndk1 = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final ndk2 = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + // Client 2 subscribes to ephemeral events (kind 21133) tagged with their pubkey + final receivedEvents = []; + final completer = Completer(); + + final subscription = ndk2.requests.subscription( + filter: Filter(kinds: [21133], pTags: [keyPair2.publicKey]), + ); + + subscription.stream.listen((event) { + receivedEvents.add(event); + if (!completer.isCompleted) { + completer.complete(); + } + }); + + await waitForSubscriptionRegistration(); + + // Client 1 sends an ephemeral event (kind 21133 - NIP-46) + final ephemeralEvent = Nip01Event( + pubKey: keyPair1.publicKey, + kind: 21133, + tags: [ + ['p', keyPair2.publicKey], + ], + content: 'test ephemeral content', + ); + final signedEvent = Nip01Utils.signWithPrivateKey( + event: ephemeralEvent, + privateKey: keyPair1.privateKey!, + ); + + final broadcast = ndk1.broadcast.broadcast( + nostrEvent: signedEvent, + specificRelays: [mockRelay.url], + ); + await broadcast.broadcastDoneFuture; + + // Wait for event to be broadcast (with timeout) + await completer.future.timeout( + Duration(seconds: 2), + onTimeout: () => null, + ); + + // Verify client2 received the ephemeral event + expect( + receivedEvents.length, + equals(1), + reason: + 'Ephemeral events should be broadcast to matching subscriptions', + ); + expect(receivedEvents.first.kind, equals(21133)); + expect(receivedEvents.first.content, equals('test ephemeral content')); + + // Cleanup + await ndk1.destroy(); + await ndk2.destroy(); + }, + ); test('ephemeral events should NOT be stored for later retrieval', () async { final keyPair1 = Bip340.generatePrivateKey(); @@ -124,7 +130,7 @@ void main() { pubKey: keyPair1.publicKey, kind: 21133, tags: [ - ['p', keyPair2.publicKey] + ['p', keyPair2.publicKey], ], content: 'ephemeral content', ); @@ -154,10 +160,7 @@ void main() { final receivedEvents = []; final response = ndk2.requests.query( - filter: Filter( - kinds: [21133], - authors: [keyPair1.publicKey], - ), + filter: Filter(kinds: [21133], authors: [keyPair1.publicKey]), ); await for (final event in response.stream) { @@ -165,8 +168,11 @@ void main() { } // Ephemeral events should NOT be returned in historical queries - expect(receivedEvents.length, equals(0), - reason: 'Ephemeral events should not be stored for later retrieval'); + expect( + receivedEvents.length, + equals(0), + reason: 'Ephemeral events should not be stored for later retrieval', + ); // Cleanup await ndk1.destroy(); diff --git a/packages/ndk/test/mocks/mock_relay_live_subscription_test.dart b/packages/ndk/test/mocks/mock_relay_live_subscription_test.dart index 127cd75a3..093f3404c 100644 --- a/packages/ndk/test/mocks/mock_relay_live_subscription_test.dart +++ b/packages/ndk/test/mocks/mock_relay_live_subscription_test.dart @@ -23,6 +23,17 @@ void main() { await mockRelay.stopServer(); }); + Future waitForSubscriptionRegistration() async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (mockRelay.activeSubscriptionCount > 0) { + return; + } + await Future.delayed(const Duration(milliseconds: 25)); + } + throw TimeoutException('MockRelay did not register the subscription.'); + } + test('NDK A receives matching event broadcast by NDK B', () async { final keyPair = Bip340.generatePrivateKey(); @@ -58,7 +69,7 @@ void main() { } }); - await Future.delayed(Duration(milliseconds: 200)); + await waitForSubscriptionRegistration(); final event = Nip01Event( pubKey: keyPair.publicKey, @@ -88,8 +99,110 @@ void main() { expect(eventFromSubscription.content, equals('live subscription event')); }); - test('stale replaceable events are not broadcast to subscriptions', - () async { + test( + 'stale replaceable events are not broadcast to subscriptions', + () async { + final keyPair = Bip340.generatePrivateKey(); + + final ndkA = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + addTearDown(ndkA.destroy); + + final ndkB = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + addTearDown(ndkB.destroy); + + final receivedEvents = []; + final firstEventReceived = Completer(); + final unexpectedSecondEvent = Completer(); + final subscription = ndkA.requests.subscription( + filter: Filter(kinds: [10000], authors: [keyPair.publicKey]), + ); + + subscription.stream.listen((event) { + receivedEvents.add(event); + if (!firstEventReceived.isCompleted) { + firstEventReceived.complete(event); + } else if (!unexpectedSecondEvent.isCompleted) { + unexpectedSecondEvent.complete(event); + } + }); + + await waitForSubscriptionRegistration(); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final currentEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 10000, + tags: [], + content: 'current replaceable event', + createdAt: now, + ); + final signedCurrentEvent = Nip01Utils.signWithPrivateKey( + event: currentEvent, + privateKey: keyPair.privateKey!, + ); + + await ndkB.broadcast + .broadcast( + nostrEvent: signedCurrentEvent, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + final firstEventFromSubscription = await firstEventReceived.future + .timeout( + Duration(seconds: 2), + onTimeout: () => throw TimeoutException( + 'NDK A did not receive the current replaceable event.', + ), + ); + + final staleEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 10000, + tags: [], + content: 'stale replaceable event', + createdAt: now - 100, + ); + final signedStaleEvent = Nip01Utils.signWithPrivateKey( + event: staleEvent, + privateKey: keyPair.privateKey!, + ); + + await ndkB.broadcast + .broadcast( + nostrEvent: signedStaleEvent, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + final staleEventWasBroadcast = await unexpectedSecondEvent.future + .then((_) => true) + .timeout(Duration(milliseconds: 300), onTimeout: () => false); + + expect(firstEventFromSubscription.id, equals(signedCurrentEvent.id)); + expect(receivedEvents, hasLength(1)); + expect(receivedEvents.single.id, equals(signedCurrentEvent.id)); + expect( + staleEventWasBroadcast, + isFalse, + reason: 'Stale replaceable events should not be broadcast live.', + ); + }, + ); + + test('live subscription resumes after relay closes the socket', () async { final keyPair = Bip340.generatePrivateKey(); final ndkA = Ndk( @@ -110,82 +223,56 @@ void main() { ); addTearDown(ndkB.destroy); - final receivedEvents = []; - final firstEventReceived = Completer(); - final unexpectedSecondEvent = Completer(); + final eventAfterReconnect = Completer(); final subscription = ndkA.requests.subscription( filter: Filter( - kinds: [10000], + kinds: [Nip01Event.kTextNodeKind], authors: [keyPair.publicKey], ), ); subscription.stream.listen((event) { - receivedEvents.add(event); - if (!firstEventReceived.isCompleted) { - firstEventReceived.complete(event); - } else if (!unexpectedSecondEvent.isCompleted) { - unexpectedSecondEvent.complete(event); + if (!eventAfterReconnect.isCompleted) { + eventAfterReconnect.complete(event); } }); - await Future.delayed(Duration(milliseconds: 200)); + await waitForSubscriptionRegistration(); + + // Backdate the last connect attempt so the reconnect-on-close path is + // not suppressed by the FAIL_RELAY_CONNECT_TRY_AFTER_SECONDS throttle. + ndkA.relays.globalState.relays[mockRelay.url]!.relay.lastConnectTry = 0; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final currentEvent = Nip01Event( + // Relay-side disconnect: the server stays up, only the sockets die. + await mockRelay.closeClientSockets(); + + // NDK A must reconnect and re-send its in-flight REQ on its own. + await waitForSubscriptionRegistration(); + + final event = Nip01Event( pubKey: keyPair.publicKey, - kind: 10000, + kind: Nip01Event.kTextNodeKind, tags: [], - content: 'current replaceable event', - createdAt: now, + content: 'event after reconnect', ); - final signedCurrentEvent = Nip01Utils.signWithPrivateKey( - event: currentEvent, + final signedEvent = Nip01Utils.signWithPrivateKey( + event: event, privateKey: keyPair.privateKey!, ); - await ndkB.broadcast.broadcast( - nostrEvent: signedCurrentEvent, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; + await ndkB.broadcast + .broadcast(nostrEvent: signedEvent, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; - final firstEventFromSubscription = - await firstEventReceived.future.timeout( - Duration(seconds: 2), + final received = await eventAfterReconnect.future.timeout( + Duration(seconds: 5), onTimeout: () => throw TimeoutException( - 'NDK A did not receive the current replaceable event.', + 'NDK A did not receive events after the relay closed the socket.', ), ); - final staleEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 10000, - tags: [], - content: 'stale replaceable event', - createdAt: now - 100, - ); - final signedStaleEvent = Nip01Utils.signWithPrivateKey( - event: staleEvent, - privateKey: keyPair.privateKey!, - ); - - await ndkB.broadcast.broadcast( - nostrEvent: signedStaleEvent, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; - - final staleEventWasBroadcast = await unexpectedSecondEvent.future - .then((_) => true) - .timeout(Duration(milliseconds: 300), onTimeout: () => false); - - expect(firstEventFromSubscription.id, equals(signedCurrentEvent.id)); - expect(receivedEvents, hasLength(1)); - expect(receivedEvents.single.id, equals(signedCurrentEvent.id)); - expect( - staleEventWasBroadcast, - isFalse, - reason: 'Stale replaceable events should not be broadcast live.', - ); + expect(received.id, equals(signedEvent.id)); + expect(received.content, equals('event after reconnect')); }); }); } diff --git a/packages/ndk/test/mocks/mock_relay_replaceable_test.dart b/packages/ndk/test/mocks/mock_relay_replaceable_test.dart index 0d403c7cc..cc7bbb327 100644 --- a/packages/ndk/test/mocks/mock_relay_replaceable_test.dart +++ b/packages/ndk/test/mocks/mock_relay_replaceable_test.dart @@ -10,10 +10,7 @@ void main() { late MockRelay mockRelay; setUp(() async { - mockRelay = MockRelay( - name: 'replaceable-test-relay', - explicitPort: 4060, - ); + mockRelay = MockRelay(name: 'replaceable-test-relay', explicitPort: 4060); await mockRelay.startServer(); }); @@ -22,183 +19,179 @@ void main() { }); test( - 'replaceable events (kind 10002) should be replaced by newer version from same author', - () async { - final keyPair = Bip340.generatePrivateKey(); - - final ndkWriter = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // Older version - final oldEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 10002, - tags: [ - ['r', 'wss://old.example.com'], - ], - content: '', - createdAt: now - 100, - ); - final signedOld = Nip01Utils.signWithPrivateKey( - event: oldEvent, - privateKey: keyPair.privateKey!, - ); - await ndkWriter.broadcast.broadcast( - nostrEvent: signedOld, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; - - // Newer version (same author, same kind) - final newEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 10002, - tags: [ - ['r', 'wss://new.example.com'], - ], - content: '', - createdAt: now, - ); - final signedNew = Nip01Utils.signWithPrivateKey( - event: newEvent, - privateKey: keyPair.privateKey!, - ); - await ndkWriter.broadcast.broadcast( - nostrEvent: signedNew, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; - - // Query from a fresh client to bypass any local cache - final ndkReader = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - final received = []; - final response = ndkReader.requests.query( - filter: Filter( - kinds: [10002], - authors: [keyPair.publicKey], - ), - ); - await for (final event in response.stream) { - received.add(event); - } - - expect( - received.length, - equals(1), - reason: - 'NIP-01 replaceable events should keep only the latest (pubkey, kind); ' - 'mock currently returns every version ever sent.', - ); - expect( - received.first.getFirstTag('r'), - equals('wss://new.example.com'), - reason: 'The retained event should be the newer one.', - ); - - await ndkWriter.destroy(); - await ndkReader.destroy(); - }); + 'replaceable events (kind 10002) should be replaced by newer version from same author', + () async { + final keyPair = Bip340.generatePrivateKey(); + + final ndkWriter = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Older version + final oldEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 10002, + tags: [ + ['r', 'wss://old.example.com'], + ], + content: '', + createdAt: now - 100, + ); + final signedOld = Nip01Utils.signWithPrivateKey( + event: oldEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast(nostrEvent: signedOld, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + // Newer version (same author, same kind) + final newEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 10002, + tags: [ + ['r', 'wss://new.example.com'], + ], + content: '', + createdAt: now, + ); + final signedNew = Nip01Utils.signWithPrivateKey( + event: newEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast(nostrEvent: signedNew, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + // Query from a fresh client to bypass any local cache + final ndkReader = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final received = []; + final response = ndkReader.requests.query( + filter: Filter(kinds: [10002], authors: [keyPair.publicKey]), + ); + await for (final event in response.stream) { + received.add(event); + } + + expect( + received.length, + equals(1), + reason: + 'NIP-01 replaceable events should keep only the latest (pubkey, kind); ' + 'mock currently returns every version ever sent.', + ); + expect( + received.first.getFirstTag('r'), + equals('wss://new.example.com'), + reason: 'The retained event should be the newer one.', + ); + + await ndkWriter.destroy(); + await ndkReader.destroy(); + }, + ); test( - 'addressable events (kind 30023) should be replaced by newer version with same d-tag', - () async { - final keyPair = Bip340.generatePrivateKey(); - - final ndkWriter = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - const dTag = 'my-article'; - - // Older version with d-tag - final oldEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 30023, - tags: [ - ['d', dTag], - ], - content: 'first draft', - createdAt: now - 100, - ); - final signedOld = Nip01Utils.signWithPrivateKey( - event: oldEvent, - privateKey: keyPair.privateKey!, - ); - await ndkWriter.broadcast.broadcast( - nostrEvent: signedOld, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; - - // Newer version with same d-tag - final newEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 30023, - tags: [ - ['d', dTag], - ], - content: 'final version', - createdAt: now, - ); - final signedNew = Nip01Utils.signWithPrivateKey( - event: newEvent, - privateKey: keyPair.privateKey!, - ); - await ndkWriter.broadcast.broadcast( - nostrEvent: signedNew, - specificRelays: [mockRelay.url], - ).broadcastDoneFuture; - - final ndkReader = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: MockEventVerifier(), - bootstrapRelays: [mockRelay.url], - ), - ); - - final received = []; - final response = ndkReader.requests.query( - filter: Filter( - kinds: [30023], - authors: [keyPair.publicKey], - dTags: [dTag], - ), - ); - await for (final event in response.stream) { - received.add(event); - } - - expect( - received.length, - equals(1), - reason: 'NIP-01 addressable events should keep only the latest ' - '(pubkey, kind, d-tag); mock currently returns every version sent.', - ); - expect( - received.first.content, - equals('final version'), - reason: 'The retained event should be the newer one.', - ); - - await ndkWriter.destroy(); - await ndkReader.destroy(); - }); + 'addressable events (kind 30023) should be replaced by newer version with same d-tag', + () async { + final keyPair = Bip340.generatePrivateKey(); + + final ndkWriter = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + const dTag = 'my-article'; + + // Older version with d-tag + final oldEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 30023, + tags: [ + ['d', dTag], + ], + content: 'first draft', + createdAt: now - 100, + ); + final signedOld = Nip01Utils.signWithPrivateKey( + event: oldEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast(nostrEvent: signedOld, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + // Newer version with same d-tag + final newEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 30023, + tags: [ + ['d', dTag], + ], + content: 'final version', + createdAt: now, + ); + final signedNew = Nip01Utils.signWithPrivateKey( + event: newEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast(nostrEvent: signedNew, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + final ndkReader = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final received = []; + final response = ndkReader.requests.query( + filter: Filter( + kinds: [30023], + authors: [keyPair.publicKey], + dTags: [dTag], + ), + ); + await for (final event in response.stream) { + received.add(event); + } + + expect( + received.length, + equals(1), + reason: + 'NIP-01 addressable events should keep only the latest ' + '(pubkey, kind, d-tag); mock currently returns every version sent.', + ); + expect( + received.first.content, + equals('final version'), + reason: 'The retained event should be the newer one.', + ); + + await ndkWriter.destroy(); + await ndkReader.destroy(); + }, + ); }); } diff --git a/packages/ndk/test/mocks/mock_relay_tag_filter_test.dart b/packages/ndk/test/mocks/mock_relay_tag_filter_test.dart index 471f1f4c6..96cd82c5a 100644 --- a/packages/ndk/test/mocks/mock_relay_tag_filter_test.dart +++ b/packages/ndk/test/mocks/mock_relay_tag_filter_test.dart @@ -51,10 +51,7 @@ void main() { port: 4081, relayName: 'p-tag-filter-test-relay', events: [matchingEvent, nonMatchingEvent], - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - pTags: [taggedPubkey], - ), + filter: Filter(kinds: [Nip01Event.kTextNodeKind], pTags: [taggedPubkey]), ); expect( @@ -80,10 +77,7 @@ void main() { port: 4082, relayName: 'e-tag-filter-test-relay', events: [matchingEvent, nonMatchingEvent], - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - eTags: [taggedEventId], - ), + filter: Filter(kinds: [Nip01Event.kTextNodeKind], eTags: [taggedEventId]), ); expect( @@ -191,10 +185,9 @@ Future> _queryRelay({ addTearDown(writer.destroy); for (final event in events) { - await writer.broadcast.broadcast( - nostrEvent: event, - specificRelays: [relay.url], - ).broadcastDoneFuture; + await writer.broadcast + .broadcast(nostrEvent: event, specificRelays: [relay.url]) + .broadcastDoneFuture; } final reader = Ndk( diff --git a/packages/ndk/test/mocks/mock_slow_signer.dart b/packages/ndk/test/mocks/mock_slow_signer.dart index 89bb45555..4a4ae1025 100644 --- a/packages/ndk/test/mocks/mock_slow_signer.dart +++ b/packages/ndk/test/mocks/mock_slow_signer.dart @@ -3,16 +3,25 @@ import 'package:ndk/domain_layer/entities/pending_signer_request.dart'; import 'package:ndk/domain_layer/repositories/event_signer.dart'; /// A wrapper signer that adds a delay to simulate slow signing -/// (like NIP-46, Amber, etc.) where user interaction is required. +/// (like NIP-46, NIP-55, etc.) where user interaction is required. class MockSlowSigner implements EventSigner { final EventSigner _innerSigner; final Duration _delay; - MockSlowSigner({ - required EventSigner innerSigner, - required Duration delay, - }) : _innerSigner = innerSigner, - _delay = delay; + MockSlowSigner({required EventSigner innerSigner, required Duration delay}) + : _innerSigner = innerSigner, + _delay = delay; + + @override + bool get requiresInteractiveSigning => + _innerSigner.requiresInteractiveSigning; + + @override + bool get requiresSignerNetwork => _innerSigner.requiresSignerNetwork; + + @override + Iterable get signerTransportRelayUrls => + _innerSigner.signerTransportRelayUrls; @override Future sign(Nip01Event event) async { diff --git a/packages/ndk/test/ndk_test.dart b/packages/ndk/test/ndk_test.dart index 02b819b1c..3be024a89 100644 --- a/packages/ndk/test/ndk_test.dart +++ b/packages/ndk/test/ndk_test.dart @@ -30,7 +30,9 @@ void main() async { createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: key2.privateKey!); + event: event, + privateKey: key2.privateKey!, + ); return signedEvent; } @@ -38,94 +40,135 @@ void main() async { Map key1TextNotes = {key1: textNote(key1)}; group('Ndk', () { - test('query simple note LISTS', - timeout: const Timeout(Duration(seconds: 3)), () async { - MockRelay relay1 = - MockRelay(name: "relay 1", explicitPort: 3960, signEvents: false); - await relay1.startServer(textNotes: key1TextNotes); - - final ndk = Ndk( - NdkConfig( + test( + 'query simple note LISTS', + timeout: const Timeout(Duration(seconds: 3)), + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3960, + signEvents: false, + ); + await relay1.startServer(textNotes: key1TextNotes); + + final ndk = Ndk( + NdkConfig( eventVerifier: Bip340EventVerifier(), cache: MemCacheManager(), engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url]), - ); - await ndk.relays.seedRelaysConnected; - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); - - final response = ndk.requests.query(filters: [ - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]) - ]); - - await expectLater(response.stream, emitsInAnyOrder(key1TextNotes.values)); - - final response2 = ndk.requests.query(filters: [ - Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey], - ) - ]); - - await expectLater( - response2.stream, emitsInAnyOrder(key1TextNotes.values)); - - await relay1.stopServer(); - }); - - test('query simple event by id', - timeout: const Timeout(Duration(seconds: 3)), () async { - MockRelay relay1 = - MockRelay(name: "relay 1", explicitPort: 3961, signEvents: false); - await relay1.startServer(textNotes: key1TextNotes); - - final cache = MemCacheManager(); + bootstrapRelays: [relay1.url], + ), + ); + await ndk.relays.seedRelaysConnected; + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + final response = ndk.requests.query( + filters: [ + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + ], + ); + + await expectLater( + response.stream, + emitsInAnyOrder(key1TextNotes.values), + ); + + final response2 = ndk.requests.query( + filters: [ + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey, key2.publicKey], + ), + ], + ); + + await expectLater( + response2.stream, + emitsInAnyOrder(key1TextNotes.values), + ); + + await relay1.stopServer(); + }, + ); - final ndk = Ndk( - NdkConfig( + test( + 'query simple event by id', + timeout: const Timeout(Duration(seconds: 3)), + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3961, + signEvents: false, + ); + await relay1.startServer(textNotes: key1TextNotes); + + final cache = MemCacheManager(); + + final ndk = Ndk( + NdkConfig( eventVerifier: Bip340EventVerifier(), cache: cache, engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url]), - ); - await ndk.relays.seedRelaysConnected; - - final response = ndk.requests.query(filters: [ - Filter(ids: [key1TextNotes[key1]!.id]) - ]); - - await expectLater(response.stream, emitsInAnyOrder(key1TextNotes.values)); - - await cache.saveEvent(key1TextNotes[key1]!); - - final response2 = ndk.requests.query(filters: [ - Filter(ids: [key1TextNotes[key1]!.id]) - ]); - - await expectLater( - response2.stream, emitsInAnyOrder(key1TextNotes.values)); - - await relay1.stopServer(); - }); + bootstrapRelays: [relay1.url], + ), + ); + await ndk.relays.seedRelaysConnected; + + final response = ndk.requests.query( + filters: [ + Filter(ids: [key1TextNotes[key1]!.id]), + ], + ); + + await expectLater( + response.stream, + emitsInAnyOrder(key1TextNotes.values), + ); + + await cache.saveEvent(key1TextNotes[key1]!); + + final response2 = ndk.requests.query( + filters: [ + Filter(ids: [key1TextNotes[key1]!.id]), + ], + ); + + await expectLater( + response2.stream, + emitsInAnyOrder(key1TextNotes.values), + ); + + await relay1.stopServer(); + }, + ); // ================================================================================================ test('verify signatures of events', () async { - MockRelay relay1 = - MockRelay(name: "relay 1", explicitPort: 3962, signEvents: false); + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3962, + signEvents: false, + ); await relay1.startServer(textNotes: key1TextNotes); final ndk = Ndk( NdkConfig( - eventVerifier: MockEventVerifier(result: false), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url]), + eventVerifier: MockEventVerifier(result: false), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), ); final response = ndk.requests.query( filters: [ - Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]) + Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]), ], ); // ignore: unused_local_variable @@ -136,54 +179,57 @@ void main() async { }); test( - 'emptyBootstrapRelaysConfig with non-list welcome message from explicit relay', - () async { - final welcomeMessage = - '{"welcome": {"motd": "test message"}, "type": "welcome"}'; - MockRelay explicitRelay = MockRelay( - name: "explicitRelay", - explicitPort: 3963, - customWelcomeMessage: welcomeMessage, - ); - await explicitRelay.startServer(); - - final ndk = Ndk.emptyBootstrapRelaysConfig(); - - final query = ndk.requests.query( - filters: [ - Filter(kinds: [0]), - ], - explicitRelays: [explicitRelay.url], - ); - - await query.future; - - ndk.destroy(); - await explicitRelay.stopServer(); - }); - - test('should handle null values in events from relays gracefully', - () async { - MockRelay explicitRelay = MockRelay( - name: "malformedRelay", - explicitPort: 3966, - sendMalformedEvents: true, - ); - await explicitRelay.startServer(textNotes: key1TextNotes); - - final ndk = Ndk.emptyBootstrapRelaysConfig(); - - final query = ndk.requests.query( - filters: [ - Filter(kinds: [0]), - ], - explicitRelays: [explicitRelay.url], - ); - - await query.future; + 'emptyBootstrapRelaysConfig with non-list welcome message from explicit relay', + () async { + final welcomeMessage = + '{"welcome": {"motd": "test message"}, "type": "welcome"}'; + MockRelay explicitRelay = MockRelay( + name: "explicitRelay", + explicitPort: 3963, + customWelcomeMessage: welcomeMessage, + ); + await explicitRelay.startServer(); + + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final query = ndk.requests.query( + filters: [ + Filter(kinds: [0]), + ], + explicitRelays: [explicitRelay.url], + ); + + await query.future; + + ndk.destroy(); + await explicitRelay.stopServer(); + }, + ); - ndk.destroy(); - await explicitRelay.stopServer(); - }); + test( + 'should handle null values in events from relays gracefully', + () async { + MockRelay explicitRelay = MockRelay( + name: "malformedRelay", + explicitPort: 3966, + sendMalformedEvents: true, + ); + await explicitRelay.startServer(textNotes: key1TextNotes); + + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final query = ndk.requests.query( + filters: [ + Filter(kinds: [0]), + ], + explicitRelays: [explicitRelay.url], + ); + + await query.future; + + ndk.destroy(); + await explicitRelay.stopServer(); + }, + ); }); } diff --git a/packages/ndk/test/nips/nip01_test.dart b/packages/ndk/test/nips/nip01_test.dart index 1fa43be44..5069982fb 100644 --- a/packages/ndk/test/nips/nip01_test.dart +++ b/packages/ndk/test/nips/nip01_test.dart @@ -12,8 +12,9 @@ void main() { test('sign and verify', () { final keyPair = Bip340.generatePrivateKey(); const message = 'Hello, World!'; - final messageSha256 = - Uint8List.fromList(sha256.convert(utf8.encode(message)).bytes); + final messageSha256 = Uint8List.fromList( + sha256.convert(utf8.encode(message)).bytes, + ); final messageHex = hex.encode(messageSha256); final signature = Bip340.sign(messageHex, keyPair.privateKey!); expect(Bip340.verify(messageHex, signature, keyPair.publicKey), isTrue); @@ -22,7 +23,9 @@ void main() { test('getPublicKey', () { final keyPair = Bip340.generatePrivateKey(); expect( - Bip340.getPublicKey(keyPair.privateKey!), equals(keyPair.publicKey)); + Bip340.getPublicKey(keyPair.privateKey!), + equals(keyPair.publicKey), + ); }); }); group('Metadata', () { @@ -43,7 +46,7 @@ void main() { 'pubKey': 'pubKey1', 'content': contentData, 'tags': [ - ['i', 'test'] + ['i', 'test'], ], 'updatedAt': 1234567890, }; diff --git a/packages/ndk/test/nips/nip02_test.dart b/packages/ndk/test/nips/nip02_test.dart index 2374d499e..aced9fc9b 100644 --- a/packages/ndk/test/nips/nip02_test.dart +++ b/packages/ndk/test/nips/nip02_test.dart @@ -22,12 +22,7 @@ void main() { ], ); final nip02 = ContactList.fromEvent(event); - expect(nip02.contacts, [ - 'contact1', - 'contact2', - 'contact3', - 'contact4', - ]); + expect(nip02.contacts, ['contact1', 'contact2', 'contact3', 'contact4']); expect(ContactList.relaysFromContent(event), { "wss://nos.lol": ReadWriteMarker.readWrite, "wss://relay.damus.io": ReadWriteMarker.readWrite, @@ -35,21 +30,21 @@ void main() { }); test('toEvent', () { - final nip02 = ContactList(pubKey: 'pubkey123', contacts: [ - 'contact1', - 'contact2', - 'contact3', - ]); + final nip02 = ContactList( + pubKey: 'pubkey123', + contacts: ['contact1', 'contact2', 'contact3'], + ); final myEvent = nip02.toEvent(); expect(myEvent.pubKey, equals('pubkey123')); expect(myEvent.kind, equals(ContactList.kKind)); expect( - myEvent.tags, - equals([ - ['p', 'contact1', '', ''], - ['p', 'contact2', '', ''], - ['p', 'contact3', '', ''], - ])); + myEvent.tags, + equals([ + ['p', 'contact1', '', ''], + ['p', 'contact2', '', ''], + ['p', 'contact3', '', ''], + ]), + ); expect(myEvent.content, equals('')); expect(myEvent.createdAt, equals(nip02.createdAt)); }); diff --git a/packages/ndk/test/nips/nip04_test.dart b/packages/ndk/test/nips/nip04_test.dart index 8ec9b2435..b23fb9185 100644 --- a/packages/ndk/test/nips/nip04_test.dart +++ b/packages/ndk/test/nips/nip04_test.dart @@ -11,8 +11,7 @@ void main() { EventSigner eventSignerFactory({ String? privateKey, required String publicKey, - }) => - Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); + }) => Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); group('Nip04', () { test('decrypt', () async { @@ -21,10 +20,7 @@ void main() { const pub = "b1a5c93edcc8d586566fde53a20bdb50049a97b15483cb763854e57016e0fa3d"; - EventSigner signer = eventSignerFactory( - privateKey: priv, - publicKey: pub, - ); + EventSigner signer = eventSignerFactory(privateKey: priv, publicKey: pub); const ciphertext = "VezuSvWak++ASjFMRqBPWS3mK5pZ0vRLL325iuIL4S+r8n9z+DuMau5vMElz1tGC/UqCDmbzE2kwplafaFo/FnIZMdEj4pdxgptyBV1ifZpH3TEF6OMjEtqbYRRqnxgIXsuOSXaerWgpi0pm+raHQPseoELQI/SZ1cvtFqEUCXdXpa5AYaSd+quEuthAEw7V1jP+5TDRCEC8jiLosBVhCtaPpLcrm8HydMYJ2XB6Ixs=?iv=/rtV49RFm0XyFEwG62Eo9A=="; @@ -32,12 +28,12 @@ void main() { final desiredResult = [ [ "p", - "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", ], [ "p", - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" - ] + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + ], ]; final result = await signer.decrypt(ciphertext, pub); @@ -52,20 +48,17 @@ void main() { const pub = "b1a5c93edcc8d586566fde53a20bdb50049a97b15483cb763854e57016e0fa3d"; - EventSigner signer = eventSignerFactory( - privateKey: priv, - publicKey: pub, - ); + EventSigner signer = eventSignerFactory(privateKey: priv, publicKey: pub); const clearTextObj = [ [ "p", - "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", ], [ "p", - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" - ] + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + ], ]; const desiredResult = @@ -81,11 +74,17 @@ void main() { KeyPair key1 = Bip340.generatePrivateKey(); var agreement = Nip04.getAgreement(key1.privateKey!); String content = "some content"; - var encrypted = - Nip04.encryptWithAgreement(content, agreement, key1.publicKey); + var encrypted = Nip04.encryptWithAgreement( + content, + agreement, + key1.publicKey, + ); - var decrypted = - Nip04.decryptWithAgreement(encrypted, agreement, key1.publicKey); + var decrypted = Nip04.decryptWithAgreement( + encrypted, + agreement, + key1.publicKey, + ); expect(content, decrypted); }); }); diff --git a/packages/ndk/test/nips/nip05_test.mocks.mocks.dart b/packages/ndk/test/nips/nip05_test.mocks.mocks.dart index 7b6db19b5..8ef01b8f7 100644 --- a/packages/ndk/test/nips/nip05_test.mocks.mocks.dart +++ b/packages/ndk/test/nips/nip05_test.mocks.mocks.dart @@ -25,24 +25,14 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: subtype_of_sealed_class class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Client]. @@ -54,46 +44,30 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> post( @@ -103,28 +77,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> put( @@ -134,28 +103,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> patch( @@ -165,28 +129,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> delete( @@ -196,49 +155,36 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => + _i3.Future read(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future); + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); @override _i3.Future<_i6.Uint8List> readBytes( @@ -246,37 +192,27 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), - ) as _i3.Future<_i6.Uint8List>); + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); } diff --git a/packages/ndk/test/nips/nip11_relay_info.dart b/packages/ndk/test/nips/nip11_relay_info.dart index 1d810d719..5b1e1e025 100644 --- a/packages/ndk/test/nips/nip11_relay_info.dart +++ b/packages/ndk/test/nips/nip11_relay_info.dart @@ -13,17 +13,17 @@ void main() { data["payments_url"] = "https://my-relay/payments"; data["fees"] = { "admission": [ - {"amount": 1000000, "unit": "msats"} + {"amount": 1000000, "unit": "msats"}, ], "subscription": [ - {"amount": 5000000, "unit": "msats", "period": 2592000} + {"amount": 5000000, "unit": "msats", "period": 2592000}, ], "publication": [ { "kinds": [4], "amount": 100, - "unit": "msats" - } + "unit": "msats", + }, ], }; data['icon'] = "https://bla.com/favicon.ico"; diff --git a/packages/ndk/test/nips/nip13_test.dart b/packages/ndk/test/nips/nip13_test.dart index 038624b0f..23bbe5c40 100644 --- a/packages/ndk/test/nips/nip13_test.dart +++ b/packages/ndk/test/nips/nip13_test.dart @@ -11,78 +11,156 @@ void main() { group('countLeadingZeroBits', () { test('should count single hex digits correctly', () { // Test each hex digit (0-F) individually - expect(Nip13.countLeadingZeroBits('0'), equals(4), - reason: '0000 has 4 leading zeros'); - expect(Nip13.countLeadingZeroBits('1'), equals(3), - reason: '0001 has 3 leading zeros'); - expect(Nip13.countLeadingZeroBits('2'), equals(2), - reason: '0010 has 2 leading zeros'); - expect(Nip13.countLeadingZeroBits('3'), equals(2), - reason: '0011 has 2 leading zeros'); - expect(Nip13.countLeadingZeroBits('4'), equals(1), - reason: '0100 has 1 leading zero'); - expect(Nip13.countLeadingZeroBits('5'), equals(1), - reason: '0101 has 1 leading zero'); - expect(Nip13.countLeadingZeroBits('6'), equals(1), - reason: '0110 has 1 leading zero'); - expect(Nip13.countLeadingZeroBits('7'), equals(1), - reason: '0111 has 1 leading zero'); - expect(Nip13.countLeadingZeroBits('8'), equals(0), - reason: '1000 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('9'), equals(0), - reason: '1001 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('a'), equals(0), - reason: '1010 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('b'), equals(0), - reason: '1011 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('c'), equals(0), - reason: '1100 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('d'), equals(0), - reason: '1101 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('e'), equals(0), - reason: '1110 has 0 leading zeros'); - expect(Nip13.countLeadingZeroBits('f'), equals(0), - reason: '1111 has 0 leading zeros'); + expect( + Nip13.countLeadingZeroBits('0'), + equals(4), + reason: '0000 has 4 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('1'), + equals(3), + reason: '0001 has 3 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('2'), + equals(2), + reason: '0010 has 2 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('3'), + equals(2), + reason: '0011 has 2 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('4'), + equals(1), + reason: '0100 has 1 leading zero', + ); + expect( + Nip13.countLeadingZeroBits('5'), + equals(1), + reason: '0101 has 1 leading zero', + ); + expect( + Nip13.countLeadingZeroBits('6'), + equals(1), + reason: '0110 has 1 leading zero', + ); + expect( + Nip13.countLeadingZeroBits('7'), + equals(1), + reason: '0111 has 1 leading zero', + ); + expect( + Nip13.countLeadingZeroBits('8'), + equals(0), + reason: '1000 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('9'), + equals(0), + reason: '1001 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('a'), + equals(0), + reason: '1010 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('b'), + equals(0), + reason: '1011 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('c'), + equals(0), + reason: '1100 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('d'), + equals(0), + reason: '1101 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('e'), + equals(0), + reason: '1110 has 0 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('f'), + equals(0), + reason: '1111 has 0 leading zeros', + ); }); test('should handle multi-digit hex strings correctly', () { // Test cases with multiple hex digits - expect(Nip13.countLeadingZeroBits('00f'), equals(8), - reason: '0000 0000 1111 has 8 leading zeros'); - expect(Nip13.countLeadingZeroBits('001'), equals(11), - reason: '0000 0000 0001 has 11 leading zeros'); - expect(Nip13.countLeadingZeroBits('007'), equals(9), - reason: '0000 0000 0111 has 9 leading zeros'); - expect(Nip13.countLeadingZeroBits('010'), equals(7), - reason: '0000 0001 0000 has 7 leading zeros'); - expect(Nip13.countLeadingZeroBits('100'), equals(3), - reason: '0001 0000 0000 has 3 leading zeros'); + expect( + Nip13.countLeadingZeroBits('00f'), + equals(8), + reason: '0000 0000 1111 has 8 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('001'), + equals(11), + reason: '0000 0000 0001 has 11 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('007'), + equals(9), + reason: '0000 0000 0111 has 9 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('010'), + equals(7), + reason: '0000 0001 0000 has 7 leading zeros', + ); + expect( + Nip13.countLeadingZeroBits('100'), + equals(3), + reason: '0001 0000 0000 has 3 leading zeros', + ); }); test('should handle NIP-13 specification examples', () { // Example from NIP-13 spec: "002f" should have 10 leading zero bits - expect(Nip13.countLeadingZeroBits('002f'), equals(10), - reason: 'NIP-13 spec states "002f" has 10 leading zero bits'); + expect( + Nip13.countLeadingZeroBits('002f'), + equals(10), + reason: 'NIP-13 spec states "002f" has 10 leading zero bits', + ); // Real example from NIP-13 spec final specExampleId = "000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358"; final actualDifficulty = Nip13.countLeadingZeroBits(specExampleId); - expect(actualDifficulty, equals(21), - reason: - 'NIP-13 spec example should have 21 leading zero bits (000006 = 20 + 1 from "6")'); + expect( + actualDifficulty, + equals(21), + reason: + 'NIP-13 spec example should have 21 leading zero bits (000006 = 20 + 1 from "6")', + ); }); test('should handle edge cases', () { // Empty string (though this might not be a valid use case) - expect(Nip13.countLeadingZeroBits(''), equals(0), - reason: 'Empty string should return 0'); + expect( + Nip13.countLeadingZeroBits(''), + equals(0), + reason: 'Empty string should return 0', + ); // All zeros - expect(Nip13.countLeadingZeroBits('0000'), equals(16), - reason: '16 zero bits should return 16'); - expect(Nip13.countLeadingZeroBits('00000'), equals(20), - reason: '20 zero bits should return 20'); + expect( + Nip13.countLeadingZeroBits('0000'), + equals(16), + reason: '16 zero bits should return 16', + ); + expect( + Nip13.countLeadingZeroBits('00000'), + equals(20), + reason: '20 zero bits should return 20', + ); }); }); @@ -98,10 +176,15 @@ void main() { ); final minedEvent = await pow.minePoW( - event: event, targetDifficulty: 4, maxIterations: 100000); + event: event, + targetDifficulty: 4, + maxIterations: 100000, + ); - expect(pow.checkPoWDifficulty(event: minedEvent, targetDifficulty: 2), - isTrue); + expect( + pow.checkPoWDifficulty(event: minedEvent, targetDifficulty: 2), + isTrue, + ); }); test('should return null when no nonce tag present', () { @@ -114,10 +197,14 @@ void main() { createdAt: 1234567890, ); - final targetDifficulty = - Nip13.getTargetDifficultyFromEvent(eventWithoutNonce); - expect(targetDifficulty, isNull, - reason: 'Should return null when no nonce tag present'); + final targetDifficulty = Nip13.getTargetDifficultyFromEvent( + eventWithoutNonce, + ); + expect( + targetDifficulty, + isNull, + reason: 'Should return null when no nonce tag present', + ); }); test('should handle malformed nonce tags gracefully', () { @@ -126,16 +213,20 @@ void main() { pubKey: keypair.publicKey, kind: 1, tags: [ - ['nonce', '12345'] + ['nonce', '12345'], ], // Missing difficulty content: 'Test event', createdAt: 1234567890, ); - final targetDifficulty = - Nip13.getTargetDifficultyFromEvent(eventWithBadNonce); - expect(targetDifficulty, isNull, - reason: 'Should return null for malformed nonce tag'); + final targetDifficulty = Nip13.getTargetDifficultyFromEvent( + eventWithBadNonce, + ); + expect( + targetDifficulty, + isNull, + reason: 'Should return null for malformed nonce tag', + ); }); test('check target difficulty', () async { @@ -165,19 +256,21 @@ void main() { "created_at": 1234567890, "kind": 1, "tags": [ - ["nonce", "3613431634", "10"] + ["nonce", "3613431634", "10"], ], "content": "Hello, Nostr!", - "sig": "" + "sig": "", }); final value = Nip13.validateEvent(minedEvent); expect(value, isTrue); - final invalidEvent = minedEvent.copyWith(tags: [ - ['nonce', '123', '14'] - ]); + final invalidEvent = minedEvent.copyWith( + tags: [ + ['nonce', '123', '14'], + ], + ); final invalidValue = Nip13.validateEvent(invalidEvent); expect(invalidValue, isFalse); @@ -200,9 +293,11 @@ void main() { expect(value, isTrue); - final invalidEvent = minedEvent.copyWith(tags: [ - ['nonce', '123', '4'] - ]); + final invalidEvent = minedEvent.copyWith( + tags: [ + ['nonce', '123', '4'], + ], + ); final invalidValue = Nip01Utils.isIdValid(invalidEvent); expect(invalidValue, isFalse); diff --git a/packages/ndk/test/nips/nip19_event_getters_test.dart b/packages/ndk/test/nips/nip19_event_getters_test.dart index 06a3d4d8a..e267aef90 100644 --- a/packages/ndk/test/nips/nip19_event_getters_test.dart +++ b/packages/ndk/test/nips/nip19_event_getters_test.dart @@ -37,8 +37,9 @@ void main() { content: 'Hello Nostr!', createdAt: 1234567890, ); - final event = eventInit - .copyWith(sources: ['wss://nos.lol/', 'wss://relay.damus.io/']); + final event = eventInit.copyWith( + sources: ['wss://nos.lol/', 'wss://relay.damus.io/'], + ); final eventModel = Nip01EventModel.fromEntity(event); @@ -96,9 +97,9 @@ void main() { final event = Nip01Event( pubKey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c', - kind: 0, + kind: 30001, tags: [ - ['d', ''], + ['d', 'bla'], ], content: '{"name":"Alice"}', createdAt: 1234567890, @@ -109,8 +110,8 @@ void main() { expect(naddr, isNotNull); final decoded = Nip19.decodeNaddr(naddr!); - expect(decoded.identifier, ''); - expect(decoded.kind, 0); + expect(decoded.identifier, event.getDtag()); + expect(decoded.kind, event.kind); }); test('should return null for non-addressable event', () { @@ -158,8 +159,9 @@ void main() { content: '{}', createdAt: 1234567890, ); - final eventWithSources = - event.copyWith(sources: ['wss://relay.example.com']); + final eventWithSources = event.copyWith( + sources: ['wss://relay.example.com'], + ); final eventModel = Nip01EventModel.fromEntity(eventWithSources); @@ -188,13 +190,16 @@ void main() { ); final eventModel = Nip01EventModel.fromEntity(event); - expect(eventModel.naddr, isNotNull, - reason: 'Kind $kind should be addressable'); + expect( + eventModel.naddr, + isNotNull, + reason: 'Kind $kind should be addressable', + ); } }); test('should recognize parameterized replaceable event kinds', () { - final kinds = [10000, 15000, 19999, 30000, 35000, 39999]; + final kinds = [30000, 35000, 39999]; for (final kind in kinds) { final event = Nip01Event( pubKey: testPubkey, @@ -206,8 +211,11 @@ void main() { ); final eventModel = Nip01EventModel.fromEntity(event); - expect(eventModel.naddr, isNotNull, - reason: 'Kind $kind should be addressable'); + expect( + eventModel.naddr, + isNotNull, + reason: 'Kind $kind should be addressable', + ); } }); @@ -223,8 +231,11 @@ void main() { content: '', ); final eventModel = Nip01EventModel.fromEntity(event); - expect(eventModel.naddr, isNull, - reason: 'Kind $kind should not be addressable'); + expect( + eventModel.naddr, + isNull, + reason: 'Kind $kind should not be addressable', + ); } }); }); diff --git a/packages/ndk/test/nips/nip19_test.dart b/packages/ndk/test/nips/nip19_test.dart index 95d8f259b..bf865bdaf 100644 --- a/packages/ndk/test/nips/nip19_test.dart +++ b/packages/ndk/test/nips/nip19_test.dart @@ -15,8 +15,10 @@ void main() { final result = nip19_utils.Nip19Utils.convertBits(data, 8, 5, true); expect(result, isNotEmpty); - expect(result.every((v) => v >= 0 && v < 32), - true); // All values should be 5-bit + expect( + result.every((v) => v >= 0 && v < 32), + true, + ); // All values should be 5-bit }); test('convertBits should convert from 5 to 8 bits with padding', () { @@ -28,25 +30,29 @@ void main() { 3, 12, 15, - 0 + 0, ]; // 5-bit values, padded correctly final result = nip19_utils.Nip19Utils.convertBits(data, 5, 8, true); expect(result, isNotEmpty); - expect(result.every((v) => v >= 0 && v < 256), - true); // All values should be 8-bit + expect( + result.every((v) => v >= 0 && v < 256), + true, + ); // All values should be 8-bit }); - test('convertBits should throw InvalidPadding for illegal zero padding', - () { - // Create data that will have bits >= from when pad is false - final data = [1, 2, 3, 4, 5]; + test( + 'convertBits should throw InvalidPadding for illegal zero padding', + () { + // Create data that will have bits >= from when pad is false + final data = [1, 2, 3, 4, 5]; - expect( - () => nip19_utils.Nip19Utils.convertBits(data, 5, 8, false), - throwsA(isA()), - ); - }); + expect( + () => nip19_utils.Nip19Utils.convertBits(data, 5, 8, false), + throwsA(isA()), + ); + }, + ); test('convertBits should throw InvalidPadding for non-zero padding', () { // Create data with non-zero remaining bits that violate padding rules @@ -86,12 +92,20 @@ void main() { final original = [72, 101, 108, 108, 111]; // "Hello" // Convert 8 to 5 bits - final converted5bit = - nip19_utils.Nip19Utils.convertBits(original, 8, 5, true); + final converted5bit = nip19_utils.Nip19Utils.convertBits( + original, + 8, + 5, + true, + ); // Convert back 5 to 8 bits - final converted8bit = - nip19_utils.Nip19Utils.convertBits(converted5bit, 5, 8, false); + final converted8bit = nip19_utils.Nip19Utils.convertBits( + converted5bit, + 5, + 8, + false, + ); expect(converted8bit, original); }); @@ -104,7 +118,7 @@ void main() { // Type 1, Length 3, Value [0x03, 0x04, 0x05] final data = [ 0, 2, 0x01, 0x02, // TLV 1 - 1, 3, 0x03, 0x04, 0x05 // TLV 2 + 1, 3, 0x03, 0x04, 0x05, // TLV 2 ]; final result = Nip19TLV.parseTLV(data); @@ -152,7 +166,7 @@ void main() { 0, 5, 1, - 2 + 2, ]; // Type 0, Length 5, but only 2 bytes of value expect(() => Nip19TLV.parseTLV(data), throwsA(isA())); }); @@ -253,10 +267,7 @@ void main() { '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177'; const relays = ['wss://relay.example.com', 'wss://relay2.example.com']; - final nprofile = Nip19.encodeNprofile( - pubkey: pubkey, - relays: relays, - ); + final nprofile = Nip19.encodeNprofile(pubkey: pubkey, relays: relays); expect(nprofile.startsWith('nprofile1'), true); @@ -292,9 +303,11 @@ void main() { final str = naddr.toString(); expect(str.contains('test-id'), true); expect( - str.contains( - '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c'), - true); + str.contains( + '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c', + ), + true, + ); expect(str.contains('30023'), true); expect(str.contains('wss://relay.example.com'), true); }); @@ -311,13 +324,17 @@ void main() { final str = nevent.toString(); expect( - str.contains( - 'a12ff3d33a94fa408d71e2435e6382700647f0cd3c0c09d56ec2cc64d5164b43'), - true); + str.contains( + 'a12ff3d33a94fa408d71e2435e6382700647f0cd3c0c09d56ec2cc64d5164b43', + ), + true, + ); expect( - str.contains( - '76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa'), - true); + str.contains( + '76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa', + ), + true, + ); expect(str.contains('1'), true); expect(str.contains('wss://nos.lol/'), true); }); @@ -331,9 +348,11 @@ void main() { final str = nprofile.toString(); expect( - str.contains( - '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177'), - true); + str.contains( + '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177', + ), + true, + ); expect(str.contains('wss://relay.example.com'), true); }); }); @@ -343,8 +362,9 @@ void main() { // Use valid hex but wrong length (30 bytes instead of 32) expect( () => Nip19.encodeNevent( - eventId: - 'a12ff3d33a94fa408d71e2435e6382700647f0cd3c0c09d56ec2cc64d516'), + eventId: + 'a12ff3d33a94fa408d71e2435e6382700647f0cd3c0c09d56ec2cc64d516', + ), throwsA(isA()), ); }); @@ -405,8 +425,9 @@ void main() { // Use valid hex but wrong length (30 bytes instead of 32) expect( () => Nip19.encodeNprofile( - pubkey: - '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f'), + pubkey: + '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f', + ), throwsA(isA()), ); }); @@ -426,7 +447,8 @@ void main() { test('decodeNaddr should throw on missing identifier', () { // Create naddr with pubkey (type 2) and kind (type 3) but NO identifier (type 0) final pubkeyBytes = hex.decode( - '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c'); + '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c', + ); final tlvData = [ 2, 32, // type 2 (pubkey), length 32 ...pubkeyBytes, // pubkey value @@ -440,10 +462,7 @@ void main() { 'naddr'.length + bech32Data.length + 10, ); - expect( - () => Nip19.decodeNaddr(naddr), - throwsA(isA()), - ); + expect(() => Nip19.decodeNaddr(naddr), throwsA(isA())); }); test('decodeNprofile should throw on invalid HRP', () { @@ -512,10 +531,7 @@ void main() { 'nevent'.length + bech32Data.length + 10, ); - expect( - () => Nip19.decodeNevent(nevent), - throwsA(isA()), - ); + expect(() => Nip19.decodeNevent(nevent), throwsA(isA())); }); test('decodeNaddr should throw on missing pubkey field', () { @@ -534,16 +550,14 @@ void main() { 'naddr'.length + bech32Data.length + 10, ); - expect( - () => Nip19.decodeNaddr(naddr), - throwsA(isA()), - ); + expect(() => Nip19.decodeNaddr(naddr), throwsA(isA())); }); test('decodeNaddr should throw on missing kind field', () { // Create naddr with identifier and pubkey but no kind final pubkeyBytes = hex.decode( - '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c'); + '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c', + ); final identifier = 'test'; final tlvData = [ 0, identifier.length, // type 0 (identifier), length @@ -558,10 +572,7 @@ void main() { 'naddr'.length + bech32Data.length + 10, ); - expect( - () => Nip19.decodeNaddr(naddr), - throwsA(isA()), - ); + expect(() => Nip19.decodeNaddr(naddr), throwsA(isA())); }); }); @@ -692,14 +703,11 @@ void main() { const relays = [ 'wss://relay.example.com', 'wss://relay2.example.com', - 'wss://nos.lol/' + 'wss://nos.lol/', ]; // Encode - final encoded = Nip19.encodeNprofile( - pubkey: pubkey, - relays: relays, - ); + final encoded = Nip19.encodeNprofile(pubkey: pubkey, relays: relays); // Decode final decoded = Nip19.decodeNprofile(encoded); @@ -879,10 +887,7 @@ void main() { ); // Encode as nprofile (using author pubkey) - final nprofile = Nip19.encodeNprofile( - pubkey: pubkey, - relays: relays, - ); + final nprofile = Nip19.encodeNprofile(pubkey: pubkey, relays: relays); // Encode as note (just event ID) final note = Nip19.encodeNoteId(eventId); diff --git a/packages/ndk/test/nips/nip44_test.dart b/packages/ndk/test/nips/nip44_test.dart index 3266060b7..545fbefa9 100644 --- a/packages/ndk/test/nips/nip44_test.dart +++ b/packages/ndk/test/nips/nip44_test.dart @@ -26,24 +26,31 @@ String bytesToHex(Uint8List bytes) { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } -void assertConversationKeyGeneration(String privKeyHex, String pubKeyHex, - String expectedConversationKeyHex) async { +void assertConversationKeyGeneration( + String privKeyHex, + String pubKeyHex, + String expectedConversationKeyHex, +) async { final sharedSecret = Nip44.computeSharedSecret(privKeyHex, pubKeyHex); final conversationKey = Nip44.deriveConversationKey(sharedSecret); final expectedConversationKey = hexToBytes(expectedConversationKeyHex); - expect(conversationKey, equals(expectedConversationKey), - reason: 'Conversation key does not match expected value'); + expect( + conversationKey, + equals(expectedConversationKey), + reason: 'Conversation key does not match expected value', + ); } Future assertCryptPriv( - String sk1Hex, - String sk2Hex, - String expectedConversationKeyHex, - String nonceHex, - String plaintext, - String expectedCiphertext) async { + String sk1Hex, + String sk2Hex, + String expectedConversationKeyHex, + String nonceHex, + String plaintext, + String expectedCiphertext, +) async { // Compute public keys from private keys final ec = elliptic.getS256(); final sk1 = elliptic.PrivateKey.fromHex(ec, sk1Hex); @@ -62,8 +69,11 @@ Future assertCryptPriv( customNonce: nonce, ); - expect(encryptedMessage, equals(expectedCiphertext), - reason: 'Encrypted message does not match expected value'); + expect( + encryptedMessage, + equals(expectedCiphertext), + reason: 'Encrypted message does not match expected value', + ); final decryptedMessage = await Nip44.decryptMessage( encryptedMessage, @@ -71,8 +81,11 @@ Future assertCryptPriv( pk1Hex, ); - expect(decryptedMessage, equals(plaintext), - reason: 'Decrypted message does not match plaintext'); + expect( + decryptedMessage, + equals(plaintext), + reason: 'Decrypted message does not match plaintext', + ); } Future decryptMessageWithConversationKey( @@ -96,8 +109,11 @@ Future decryptMessageWithConversationKey( verifyMac(hmacKey, nonce, ciphertext, mac); - final paddedPlaintext = - await decryptChaCha20(chachaKey, chachaNonce, ciphertext); + final paddedPlaintext = await decryptChaCha20( + chachaKey, + chachaNonce, + ciphertext, + ); final plaintextBytes = unpad(paddedPlaintext); @@ -115,16 +131,16 @@ Future assertDecryptFail( try { // Attempt to decrypt the message using the conversation key - await decryptMessageWithConversationKey( - ciphertext, - conversationKey, - ); + await decryptMessageWithConversationKey(ciphertext, conversationKey); // If no exception is thrown, the test should fail fail('Expected decryption to fail, but it succeeded'); } catch (e) { // Check that the error message contains the expected substring - expect(e.toString(), contains(expectedErrorMessage), - reason: 'Error message does not contain expected text'); + expect( + e.toString(), + contains(expectedErrorMessage), + reason: 'Error message does not contain expected text', + ); } } @@ -142,8 +158,11 @@ Future assertConversationKeyFail( fail('Expected conversation key generation to fail, but it succeeded'); } catch (e) { // Check that the error message contains the expected substring - expect(e.toString(), contains(expectedErrorMessage), - reason: 'Error message does not contain expected text'); + expect( + e.toString(), + contains(expectedErrorMessage), + reason: 'Error message does not contain expected text', + ); } } @@ -165,8 +184,11 @@ Future encryptMessageWithConversationKey( final paddedPlaintext = pad(utf8.encode(plaintext)); // Step 4: Encrypt - final ciphertext = - await encryptChaCha20(chachaKey, chachaNonce, paddedPlaintext); + final ciphertext = await encryptChaCha20( + chachaKey, + chachaNonce, + paddedPlaintext, + ); // Step 5: Calculate MAC final mac = calculateMac(hmacKey, nonce, ciphertext); @@ -193,12 +215,16 @@ Future assertCryptLong( // Compute SHA256 hash of plaintext final plaintextBytes = utf8.encode(plaintext); final actualPlaintextSha256 = sha256.convert(plaintextBytes).bytes; - final actualPlaintextSha256Hex = - bytesToHex(Uint8List.fromList(actualPlaintextSha256)); + final actualPlaintextSha256Hex = bytesToHex( + Uint8List.fromList(actualPlaintextSha256), + ); // Compare plaintext hash - expect(actualPlaintextSha256Hex, equals(expectedPlaintextSha256Hex), - reason: 'Plaintext SHA256 hash does not match expected value'); + expect( + actualPlaintextSha256Hex, + equals(expectedPlaintextSha256Hex), + reason: 'Plaintext SHA256 hash does not match expected value', + ); // Encrypt plaintext final encryptedMessage = await encryptMessageWithConversationKey( @@ -210,12 +236,16 @@ Future assertCryptLong( // Compute SHA256 hash of payload final payloadBytes = utf8.encode(encryptedMessage); final actualPayloadSha256 = sha256.convert(payloadBytes).bytes; - final actualPayloadSha256Hex = - bytesToHex(Uint8List.fromList(actualPayloadSha256)); + final actualPayloadSha256Hex = bytesToHex( + Uint8List.fromList(actualPayloadSha256), + ); // Compare payload hash - expect(actualPayloadSha256Hex, equals(expectedPayloadSha256Hex), - reason: 'Payload SHA256 hash does not match expected value'); + expect( + actualPayloadSha256Hex, + equals(expectedPayloadSha256Hex), + reason: 'Payload SHA256 hash does not match expected value', + ); } void assertMessageKeyGeneration( @@ -237,12 +267,21 @@ void assertMessageKeyGeneration( final expectedChachaNonce = hexToBytes(expectedChachaNonceHex); final expectedHmacKey = hexToBytes(expectedHmacKeyHex); - expect(chachaKey, equals(expectedChachaKey), - reason: 'ChaCha20 key does not match expected value'); - expect(chachaNonce, equals(expectedChachaNonce), - reason: 'ChaCha20 nonce does not match expected value'); - expect(hmacKey, equals(expectedHmacKey), - reason: 'HMAC key does not match expected value'); + expect( + chachaKey, + equals(expectedChachaKey), + reason: 'ChaCha20 key does not match expected value', + ); + expect( + chachaNonce, + equals(expectedChachaNonce), + reason: 'ChaCha20 nonce does not match expected value', + ); + expect( + hmacKey, + equals(expectedHmacKey), + reason: 'HMAC key does not match expected value', + ); } Uint8List generateConversationKey(String privKeyHex, String pubKeyHex) { @@ -252,12 +291,18 @@ Uint8List generateConversationKey(String privKeyHex, String pubKeyHex) { } void assertConversationKeyGenerationPub( - String privKeyHex, String pubKeyHex, String expectedConversationKeyHex) { + String privKeyHex, + String pubKeyHex, + String expectedConversationKeyHex, +) { final expectedConversationKey = hexToBytes(expectedConversationKeyHex); //final pk = '02$pubKeyHex'; final actualConversationKey = generateConversationKey(privKeyHex, pubKeyHex); - expect(actualConversationKey, equals(expectedConversationKey), - reason: 'Conversation key does not match expected value'); + expect( + actualConversationKey, + equals(expectedConversationKey), + reason: 'Conversation key does not match expected value', + ); } void main() { diff --git a/packages/ndk/test/nips/nip51_test.dart b/packages/ndk/test/nips/nip51_test.dart index f7c2e860f..a49d4f115 100644 --- a/packages/ndk/test/nips/nip51_test.dart +++ b/packages/ndk/test/nips/nip51_test.dart @@ -11,8 +11,7 @@ void main() { EventSigner eventSignerFactory({ String? privateKey, required String publicKey, - }) => - Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); + }) => Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); group('Nip51 Relay Sets', () { test('fromEvent public', () async { @@ -29,8 +28,10 @@ void main() { ], ); final nip51RelaySet = await Nip51Set.fromEvent(event, null); - expect(['wss://example.com', 'wss://example.org'], - nip51RelaySet!.publicRelays); + expect([ + 'wss://example.com', + 'wss://example.org', + ], nip51RelaySet!.publicRelays); Nip01Event toEvent = await nip51RelaySet.toEvent(null); event.tags.removeLast(); @@ -48,11 +49,12 @@ void main() { ); Nip51Set relaySet = Nip51Set( - pubKey: key1.publicKey, - kind: Nip51List.kRelaySet, - name: "test", - createdAt: Helpers.now, - elements: []); + pubKey: key1.publicKey, + kind: Nip51List.kRelaySet, + name: "test", + createdAt: Helpers.now, + elements: [], + ); relaySet.privateRelays = ['wss://example.com', 'wss://example.org']; Nip01Event event = await relaySet.toEvent(signer); Nip51Set? from = await Nip51Set.fromEvent(event, signer); @@ -74,8 +76,10 @@ void main() { ], ); final nip51RelayList = await Nip51List.fromEvent(event, null); - expect(['wss://example.com', 'wss://example.org'], - nip51RelayList.publicRelays); + expect([ + 'wss://example.com', + 'wss://example.org', + ], nip51RelayList.publicRelays); Nip01Event toEvent = await nip51RelayList.toEvent(null); event.tags.removeLast(); @@ -93,10 +97,11 @@ void main() { ); Nip51List relayList = Nip51List( - pubKey: key1.publicKey, - kind: Nip51List.kSearchRelays, - createdAt: Helpers.now, - elements: []); + pubKey: key1.publicKey, + kind: Nip51List.kSearchRelays, + createdAt: Helpers.now, + elements: [], + ); relayList.privateRelays = ['wss://example.com', 'wss://example.org']; Nip01Event event = await relayList.toEvent(signer); Nip51List? from = await Nip51List.fromEvent(event, signer); diff --git a/packages/ndk/test/nips/nip65/nip65_test.dart b/packages/ndk/test/nips/nip65/nip65_test.dart index eb195e14d..5352027e0 100644 --- a/packages/ndk/test/nips/nip65/nip65_test.dart +++ b/packages/ndk/test/nips/nip65/nip65_test.dart @@ -60,15 +60,13 @@ void main() { expect(myEvent.pubKey, equals(pubKey)); expect(myEvent.kind, equals(Nip65.kKind)); expect( - myEvent.tags, - equals([ - [ - 'r', - 'wss://example.com', - ], - ['r', 'wss://example.org', 'read'], - ['r', 'wss://example.net', 'write'], - ])); + myEvent.tags, + equals([ + ['r', 'wss://example.com'], + ['r', 'wss://example.org', 'read'], + ['r', 'wss://example.net', 'write'], + ]), + ); expect(myEvent.content, equals('')); expect(myEvent.createdAt, equals(nip65.createdAt)); }); @@ -76,10 +74,14 @@ void main() { group('ReadWriteMarker', () { test('from', () { - expect(ReadWriteMarker.from(read: true, write: true), - ReadWriteMarker.readWrite); - expect(() => ReadWriteMarker.from(read: false, write: false), - throwsException); + expect( + ReadWriteMarker.from(read: true, write: true), + ReadWriteMarker.readWrite, + ); + expect( + () => ReadWriteMarker.from(read: false, write: false), + throwsException, + ); }); test('isRead', () { diff --git a/packages/ndk/test/nips/nip65/relay_ranking_test.dart b/packages/ndk/test/nips/nip65/relay_ranking_test.dart index 9dafd58ce..2d4a0bc22 100644 --- a/packages/ndk/test/nips/nip65/relay_ranking_test.dart +++ b/packages/ndk/test/nips/nip65/relay_ranking_test.dart @@ -76,8 +76,9 @@ void main() { // check that the notCoveredPubkeys are the ones that have no data for (var i = 10; i < 20; i++) { - bool foundInNotCovered = result.notCoveredPubkeys - .any((cp) => cp.pubkey == searchingPubkeys[i].pubkey); + bool foundInNotCovered = result.notCoveredPubkeys.any( + (cp) => cp.pubkey == searchingPubkeys[i].pubkey, + ); expect(foundInNotCovered, true); } }); @@ -86,7 +87,8 @@ void main() { final _random = Random(); String getRandomString(int length) => String.fromCharCodes( - Iterable.generate(length, (_) => _random.nextInt(26) + 97)); + Iterable.generate(length, (_) => _random.nextInt(26) + 97), +); String getRandomReadWrite() { final options = ['read', 'write', 'readwrite']; @@ -98,12 +100,12 @@ List> getRandomTags() { [ 'r', 'wss://relay-${getRandomString(3)}.${getRandomReadWrite()}', - getRandomReadWrite() + getRandomReadWrite(), ], [ 'r', 'wss://relay-${getRandomString(3)}.${getRandomReadWrite()}', - getRandomReadWrite() + getRandomReadWrite(), ], ['r', 'wss://relay-${getRandomString(3)}.${getRandomReadWrite()}'], ]; diff --git a/packages/ndk/test/nips/nip77_test.dart b/packages/ndk/test/nips/nip77_test.dart index 5d2ad980e..15b3d4920 100644 --- a/packages/ndk/test/nips/nip77_test.dart +++ b/packages/ndk/test/nips/nip77_test.dart @@ -103,8 +103,9 @@ void main() { }); test('bytesToHex converts correctly', () { - final hex = - NegentropyEncoder.bytesToHex(Uint8List.fromList([1, 2, 3, 4, 5])); + final hex = NegentropyEncoder.bytesToHex( + Uint8List.fromList([1, 2, 3, 4, 5]), + ); expect(hex, equals('0102030405')); }); @@ -128,8 +129,9 @@ void main() { test('encodes and decodes with prefix', () { final prefix = Uint8List.fromList([1, 2, 3, 4]); final encoded = NegentropyEncoder.encodeBound(5678, prefix); - final (ts, decodedPrefix, consumed) = - NegentropyEncoder.decodeBound(encoded); + final (ts, decodedPrefix, consumed) = NegentropyEncoder.decodeBound( + encoded, + ); expect(ts, equals(5678)); expect(decodedPrefix, equals(prefix)); expect(consumed, equals(encoded.length)); @@ -151,14 +153,18 @@ void main() { test('creates initial message with version byte', () { final items = []; final msg = NegentropyEncoder.createInitialMessage( - items, NegentropyEncoder.idSize); + items, + NegentropyEncoder.idSize, + ); expect(msg[0], equals(NegentropyEncoder.protocolVersion)); }); test('creates initial message for empty items with fingerprint mode', () { final items = []; final msg = NegentropyEncoder.createInitialMessage( - items, NegentropyEncoder.idSize); + items, + NegentropyEncoder.idSize, + ); // Should have: version(1) + bound + mode(1) + fingerprint(16) expect(msg.length, greaterThanOrEqualTo(1 + 16)); // Should use fingerprint mode, not skip @@ -174,7 +180,9 @@ void main() { ), ]; final msg = NegentropyEncoder.createInitialMessage( - items, NegentropyEncoder.idSize); + items, + NegentropyEncoder.idSize, + ); expect(msg[0], equals(NegentropyEncoder.protocolVersion)); expect(msg.length, greaterThan(1)); }); @@ -193,7 +201,9 @@ void main() { ), ]; final msg = NegentropyEncoder.createInitialMessage( - items, NegentropyEncoder.idSize); + items, + NegentropyEncoder.idSize, + ); expect(msg[0], equals(NegentropyEncoder.protocolVersion)); // Should contain fingerprint (16 bytes) plus overhead expect(msg.length, greaterThanOrEqualTo(1 + 16)); @@ -219,7 +229,9 @@ void main() { ]; // createInitialMessage sorts internally final msg = NegentropyEncoder.createInitialMessage( - items, NegentropyEncoder.idSize); + items, + NegentropyEncoder.idSize, + ); expect(msg[0], equals(NegentropyEncoder.protocolVersion)); }); }); @@ -243,9 +255,13 @@ void main() { ]; final relayMsg = NegentropyEncoder.createInitialMessage( - relayItems, NegentropyEncoder.idSize); - final (response, needIds, haveIds) = - NegentropyEncoder.reconcile(relayMsg, localItems); + relayItems, + NegentropyEncoder.idSize, + ); + final (response, needIds, haveIds) = NegentropyEncoder.reconcile( + relayMsg, + localItems, + ); // When fingerprints match, no IDs needed expect(needIds, isEmpty); @@ -259,9 +275,7 @@ void main() { 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210'; // Local has only id1 - final localItems = [ - NegentropyItem.fromHex(timestamp: 1000, idHex: id1), - ]; + final localItems = [NegentropyItem.fromHex(timestamp: 1000, idHex: id1)]; // Relay has both final relayItems = [ @@ -270,7 +284,9 @@ void main() { ]; final relayMsg = NegentropyEncoder.createInitialMessage( - relayItems, NegentropyEncoder.idSize); + relayItems, + NegentropyEncoder.idSize, + ); NegentropyEncoder.reconcile(relayMsg, localItems); // Fingerprints won't match, but full reconciliation needs multiple rounds @@ -290,9 +306,13 @@ void main() { ]; final relayMsg = NegentropyEncoder.createInitialMessage( - relayItems, NegentropyEncoder.idSize); - final (response, needIds, haveIds) = - NegentropyEncoder.reconcile(relayMsg, localItems); + relayItems, + NegentropyEncoder.idSize, + ); + final (response, needIds, haveIds) = NegentropyEncoder.reconcile( + relayMsg, + localItems, + ); // Should produce a valid response expect(response[0], equals(NegentropyEncoder.protocolVersion)); @@ -310,9 +330,13 @@ void main() { final relayItems = []; final relayMsg = NegentropyEncoder.createInitialMessage( - relayItems, NegentropyEncoder.idSize); - final (response, needIds, haveIds) = - NegentropyEncoder.reconcile(relayMsg, localItems); + relayItems, + NegentropyEncoder.idSize, + ); + final (response, needIds, haveIds) = NegentropyEncoder.reconcile( + relayMsg, + localItems, + ); // Should produce a valid response expect(response[0], equals(NegentropyEncoder.protocolVersion)); @@ -407,8 +431,9 @@ void main() { }); test('bytesToHex always lowercase', () { - final hex = - NegentropyEncoder.bytesToHex(Uint8List.fromList([0xAB, 0xCD, 0xEF])); + final hex = NegentropyEncoder.bytesToHex( + Uint8List.fromList([0xAB, 0xCD, 0xEF]), + ); expect(hex, equals('abcdef')); }); diff --git a/packages/ndk/test/relays/nip42_test.dart b/packages/ndk/test/relays/nip42_test.dart index 3efe0c0b0..7df4fd075 100644 --- a/packages/ndk/test/relays/nip42_test.dart +++ b/packages/ndk/test/relays/nip42_test.dart @@ -13,9 +13,7 @@ void main() async { group('NIP-42', () { KeyPair key1 = Bip340.generatePrivateKey(); - Map keyNames = { - key1: "key1", - }; + Map keyNames = {key1: "key1"}; Nip01Event textNote(KeyPair key) { Nip01Event event = Nip01Event( @@ -26,7 +24,9 @@ void main() async { createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: key.privateKey!); + event: event, + privateKey: key.privateKey!, + ); return signedEvent; } @@ -50,8 +50,10 @@ void main() async { ), ); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); await Future.delayed(Duration(seconds: 1)); final response = ndk.requests.query( @@ -67,36 +69,38 @@ void main() async { await relay1.stopServer(); }); - test("check that relay does not return events if we don't provide a signer", - () async { - MockRelay relay1 = MockRelay( - name: "relay 1", - explicitPort: 3900, - requireAuthForRequests: true, - ); - await relay1.startServer(textNotes: key1TextNotes); - - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - // logLevel: Logger.logLevels.trace, - bootstrapRelays: [relay1.url], - ), - ); - - await Future.delayed(Duration(seconds: 1)); - final response = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - ), - ); - List events = await response.future; - expect(events, isEmpty); - await ndk.destroy(); - await relay1.stopServer(); - }); + test( + "check that relay does not return events if we don't provide a signer", + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3900, + requireAuthForRequests: true, + ); + await relay1.startServer(textNotes: key1TextNotes); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + // logLevel: Logger.logLevels.trace, + bootstrapRelays: [relay1.url], + ), + ); + + await Future.delayed(Duration(seconds: 1)); + final response = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + ); + List events = await response.future; + expect(events, isEmpty); + await ndk.destroy(); + await relay1.stopServer(); + }, + ); }); group('NIP-42 authenticateAs', () { @@ -112,7 +116,9 @@ void main() async { createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: key.privateKey!); + event: event, + privateKey: key.privateKey!, + ); return signedEvent; } @@ -235,205 +241,212 @@ void main() async { await relay1.stopServer(); }); - test('late AUTH - second subscription gets AUTH from stored challenge', - () async { - MockRelay relay1 = MockRelay( - name: "relay 1", - explicitPort: 3903, - requireAuthForRequests: true, - signEvents: false, - ); - - final note1 = textNote(key1, "note from key1"); - final note2 = textNote(key2, "note from key2"); - await relay1.startServer(textNotes: {key1: note1, key2: note2}); - - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay1.url], - ), - ); - - // Add both accounts - final account1 = Account( - pubkey: key1.publicKey, - type: AccountType.privateKey, - signer: Bip340EventSigner( - privateKey: key1.privateKey!, - publicKey: key1.publicKey, - ), - ); - final account2 = Account( - pubkey: key2.publicKey, - type: AccountType.privateKey, - signer: Bip340EventSigner( - privateKey: key2.privateKey!, - publicKey: key2.publicKey, - ), - ); - ndk.accounts.addAccount( - pubkey: account1.pubkey, - type: account1.type, - signer: account1.signer, - ); - ndk.accounts.addAccount( - pubkey: account2.pubkey, - type: account2.type, - signer: account2.signer, - ); - - await Future.delayed(Duration(seconds: 1)); - - // First subscription with account1 - final response1 = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - ), - authenticateAs: [account1], - ); - List events1 = await response1.future; - expect(events1, isNotEmpty); - expect(events1.first.content, equals("note from key1")); - - // Second subscription with account2 - should use stored challenge - final response2 = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key2.publicKey], - ), - authenticateAs: [account2], - ); - List events2 = await response2.future; - expect(events2, isNotEmpty); - expect(events2.first.content, equals("note from key2")); - - await ndk.destroy(); - await relay1.stopServer(); - }); + test( + 'late AUTH - second subscription gets AUTH from stored challenge', + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3903, + requireAuthForRequests: true, + signEvents: false, + ); + + final note1 = textNote(key1, "note from key1"); + final note2 = textNote(key2, "note from key2"); + await relay1.startServer(textNotes: {key1: note1, key2: note2}); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay1.url], + ), + ); + + // Add both accounts + final account1 = Account( + pubkey: key1.publicKey, + type: AccountType.privateKey, + signer: Bip340EventSigner( + privateKey: key1.privateKey!, + publicKey: key1.publicKey, + ), + ); + final account2 = Account( + pubkey: key2.publicKey, + type: AccountType.privateKey, + signer: Bip340EventSigner( + privateKey: key2.privateKey!, + publicKey: key2.publicKey, + ), + ); + ndk.accounts.addAccount( + pubkey: account1.pubkey, + type: account1.type, + signer: account1.signer, + ); + ndk.accounts.addAccount( + pubkey: account2.pubkey, + type: account2.type, + signer: account2.signer, + ); + + await Future.delayed(Duration(seconds: 1)); + + // First subscription with account1 + final response1 = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + authenticateAs: [account1], + ); + List events1 = await response1.future; + expect(events1, isNotEmpty); + expect(events1.first.content, equals("note from key1")); + + // Second subscription with account2 - should use stored challenge + final response2 = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key2.publicKey], + ), + authenticateAs: [account2], + ); + List events2 = await response2.future; + expect(events2, isNotEmpty); + expect(events2.first.content, equals("note from key2")); + + await ndk.destroy(); + await relay1.stopServer(); + }, + ); // NOTE: This test was testing a "restricted access" model where you can only // see events from pubkeys you're authenticated as. This is NOT basic NIP-42. // Basic NIP-42 is "any auth" - once authenticated, you can access all data. // Restricted access requires: gift wrap, "+" tag on events, or relay-specific // config to restrict access by author. See draft NIP for "Restricted Events". - test('request for non-authenticated account is rejected', - skip: - 'Not basic NIP-42 - would require restricted events implementation', - () async { - MockRelay relay1 = MockRelay( - name: "relay 1", - explicitPort: 3905, - requireAuthForRequests: true, - signEvents: false, - ); - - final note1 = textNote(key1, "note from key1"); - final note2 = textNote(key2, "note from key2"); - await relay1.startServer(textNotes: {key1: note1, key2: note2}); - - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay1.url], - ), - ); - - // Only add account1, NOT account2 - final account1 = Account( - pubkey: key1.publicKey, - type: AccountType.privateKey, - signer: Bip340EventSigner( - privateKey: key1.privateKey!, - publicKey: key1.publicKey, - ), - ); - ndk.accounts.addAccount( - pubkey: account1.pubkey, - type: account1.type, - signer: account1.signer, - ); - - await Future.delayed(Duration(seconds: 1)); - - // Authenticate only account1 - final response1 = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - ), - authenticateAs: [account1], - ); - List events1 = await response1.future; - expect( - events1, - isNotEmpty, - reason: "account1 is authenticated, should get events", - ); - - // Try to request key2's data WITHOUT authenticating key2 - // This should fail because key2 is not authenticated - final response2 = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], authors: [key2.publicKey]), - // NOT passing authenticateAs for key2, and key2 is not logged in - ); - List events2 = await response2.future; - expect( - events2, - isEmpty, - reason: "key2 is NOT authenticated, should NOT get events", - ); - - await ndk.destroy(); - await relay1.stopServer(); - }); - - test('fallback to logged account when authenticateAs not specified', - () async { - MockRelay relay1 = MockRelay( - name: "relay 1", - explicitPort: 3904, - requireAuthForRequests: true, - signEvents: false, - ); - - final note1 = textNote(key1, "note from key1"); - await relay1.startServer(textNotes: {key1: note1}); - - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay1.url], - ), - ); - - // Login with key1 (sets as logged account) - ndk.accounts.loginPrivateKey( - pubkey: key1.publicKey, - privkey: key1.privateKey!, - ); - - await Future.delayed(Duration(seconds: 1)); - - // Query without authenticateAs - should fallback to logged account - final response = ndk.requests.query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - ), - ); - - List events = await response.future; - expect(events, isNotEmpty); - expect(events.first.content, equals("note from key1")); - - await ndk.destroy(); - await relay1.stopServer(); - }); + test( + 'request for non-authenticated account is rejected', + skip: 'Not basic NIP-42 - would require restricted events implementation', + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3905, + requireAuthForRequests: true, + signEvents: false, + ); + + final note1 = textNote(key1, "note from key1"); + final note2 = textNote(key2, "note from key2"); + await relay1.startServer(textNotes: {key1: note1, key2: note2}); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay1.url], + ), + ); + + // Only add account1, NOT account2 + final account1 = Account( + pubkey: key1.publicKey, + type: AccountType.privateKey, + signer: Bip340EventSigner( + privateKey: key1.privateKey!, + publicKey: key1.publicKey, + ), + ); + ndk.accounts.addAccount( + pubkey: account1.pubkey, + type: account1.type, + signer: account1.signer, + ); + + await Future.delayed(Duration(seconds: 1)); + + // Authenticate only account1 + final response1 = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + authenticateAs: [account1], + ); + List events1 = await response1.future; + expect( + events1, + isNotEmpty, + reason: "account1 is authenticated, should get events", + ); + + // Try to request key2's data WITHOUT authenticating key2 + // This should fail because key2 is not authenticated + final response2 = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key2.publicKey], + ), + // NOT passing authenticateAs for key2, and key2 is not logged in + ); + List events2 = await response2.future; + expect( + events2, + isEmpty, + reason: "key2 is NOT authenticated, should NOT get events", + ); + + await ndk.destroy(); + await relay1.stopServer(); + }, + ); + + test( + 'fallback to logged account when authenticateAs not specified', + () async { + MockRelay relay1 = MockRelay( + name: "relay 1", + explicitPort: 3904, + requireAuthForRequests: true, + signEvents: false, + ); + + final note1 = textNote(key1, "note from key1"); + await relay1.startServer(textNotes: {key1: note1}); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay1.url], + ), + ); + + // Login with key1 (sets as logged account) + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + await Future.delayed(Duration(seconds: 1)); + + // Query without authenticateAs - should fallback to logged account + final response = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + ); + + List events = await response.future; + expect(events, isNotEmpty); + expect(events.first.content, equals("note from key1")); + + await ndk.destroy(); + await relay1.stopServer(); + }, + ); }); } diff --git a/packages/ndk/test/relays/relay_info_test.dart b/packages/ndk/test/relays/relay_info_test.dart index f73f0e738..aa67220f2 100644 --- a/packages/ndk/test/relays/relay_info_test.dart +++ b/packages/ndk/test/relays/relay_info_test.dart @@ -12,7 +12,7 @@ void main() { "supported_nips": [1, 2, 3], "software": "relay-software", "version": "1.0.0", - "icon": "https://example.com/icon.png" + "icon": "https://example.com/icon.png", }; final relayInfo = RelayInfo.fromJson(json, "https://example.com"); diff --git a/packages/ndk/test/relays/relay_manager_test.dart b/packages/ndk/test/relays/relay_manager_test.dart index 2ae8c1cf7..59fdaa136 100644 --- a/packages/ndk/test/relays/relay_manager_test.dart +++ b/packages/ndk/test/relays/relay_manager_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ndk/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; +import 'package:ndk/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart'; import 'package:ndk/domain_layer/entities/ndk_request.dart'; import 'package:ndk/domain_layer/usecases/relay_manager.dart'; import 'package:ndk/entities.dart'; @@ -26,11 +27,13 @@ void main() async { ); await manager .connectRelay( - dirtyUrl: relay1.url, connectionSource: ConnectionSource.seed) + dirtyUrl: relay1.url, + connectionSource: ConnectionSource.seed, + ) .then((value) {}) .onError((error, stackTrace) async { - await relay1.stopServer(); - }); + await relay1.stopServer(); + }); await relay1.stopServer(); }); @@ -45,16 +48,79 @@ void main() async { ); await manager .connectRelay( - dirtyUrl: relay1.url, connectionSource: ConnectionSource.seed) + dirtyUrl: relay1.url, + connectionSource: ConnectionSource.seed, + ) .then((value) {}) .onError((error, stackTrace) async { - await relay1.stopServer(); - }); + await relay1.stopServer(); + }); + + expect( + manager.globalState.relays[relay1.url]!.relay + .wasLastConnectTryLongerThanSeconds(120), + false, + ); + await relay1.stopServer(); + }); + + test('reconnects after relay-side socket close', () async { + // The plain WebSocketNostrTransport has no internal auto-reconnect, so + // recovering from a relay-side close relies entirely on the + // reconnect-on-close path in _startListeningToSocket's onDone handler. + MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 5046); + await relay1.startServer(); + + RelayManager manager = RelayManager( + globalState: GlobalState(), + bootstrapRelays: [relay1.url], + nostrTransportFactory: WebSocketNostrTransportFactory(), + ); + await manager.connectRelay( + dirtyUrl: relay1.url, + connectionSource: ConnectionSource.seed, + ); + expect(manager.isRelayConnected(relay1.url), true); + + // The server registers the client asynchronously; closing before that + // would close zero sockets and leave the client connected. + final registrationDeadline = DateTime.now().add(Duration(seconds: 5)); + while (DateTime.now().isBefore(registrationDeadline) && + relay1.connectedClientCount == 0) { + await Future.delayed(Duration(milliseconds: 10)); + } + expect(relay1.connectedClientCount, 1); + + final relayConnectivity = manager.globalState.relays[relay1.url]!; + expect(relayConnectivity.stats.connections, 1); + // Backdate the last connect attempt so the reconnect is not suppressed + // by the FAIL_RELAY_CONNECT_TRY_AFTER_SECONDS throttle. + relayConnectivity.relay.lastConnectTry = 0; + + // Relay-side disconnect: the server stays up, only the socket dies. + await relay1.closeClientSockets(); + + // A second successful connection can only come from the + // reconnect-on-close path; the counter is monotonic, so this cannot + // race with how fast the reconnect happens. + final reconnectDeadline = DateTime.now().add(Duration(seconds: 5)); + while (DateTime.now().isBefore(reconnectDeadline) && + relayConnectivity.stats.connections < 2) { + await Future.delayed(Duration(milliseconds: 50)); + } expect( - manager.globalState.relays[relay1.url]!.relay - .wasLastConnectTryLongerThanSeconds(120), - false); + relayConnectivity.stats.connections, + greaterThanOrEqualTo(2), + reason: + 'RelayManager should reconnect after the relay closed the socket.', + ); + expect(manager.isRelayConnected(relay1.url), true); + + // Keep the teardown from racing another reconnect attempt against the + // stopped server. + manager.allowReconnectRelays = false; + await manager.globalState.relays[relay1.url]?.close(); await relay1.stopServer(); }); @@ -68,13 +134,15 @@ void main() async { MockRelay relay1 = MockRelay(name: "relay 1"); try { await manager.connectRelay( - dirtyUrl: relay1.url, connectionSource: ConnectionSource.seed); + dirtyUrl: relay1.url, + connectionSource: ConnectionSource.seed, + ); fail("should throw exception"); } catch (e) { // success } }); - test('Try to connect to wss://brb.io', () async { + test('Try to connect to wss://brb.io', skip: true, () async { RelayManager manager = RelayManager( nostrTransportFactory: webSocketNostrTransportFactory, bootstrapRelays: [], @@ -83,7 +151,9 @@ void main() async { try { await manager.connectRelay( - dirtyUrl: "wss://brb.io", connectionSource: ConnectionSource.seed); + dirtyUrl: "wss://brb.io", + connectionSource: ConnectionSource.seed, + ); fail("should throw exception"); } catch (e) { // success @@ -91,128 +161,148 @@ void main() async { }); test( - 'CLOSED message bug - should not remove entire request from inFlightRequests', - () async { - // This test exposes a bug where receiving a CLOSED message from one relay - // removes the entire request from globalState.inFlightRequests, causing - // events from other relays to be lost since there's no request entry to handle them. + 'CLOSED message bug - should not remove entire request from inFlightRequests', + () async { + // This test exposes a bug where receiving a CLOSED message from one relay + // removes the entire request from globalState.inFlightRequests, causing + // events from other relays to be lost since there's no request entry to handle them. - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 5050); - MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 5051); + MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 5050); + MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 5051); - await relay1.startServer(); - await relay2.startServer(); + await relay1.startServer(); + await relay2.startServer(); - RelayManager manager = RelayManager( - globalState: GlobalState(), - bootstrapRelays: [], - nostrTransportFactory: webSocketNostrTransportFactory, - ); - - // Connect to both relays - await manager.connectRelay( - dirtyUrl: relay1.url, connectionSource: ConnectionSource.seed); - await manager.connectRelay( - dirtyUrl: relay2.url, connectionSource: ConnectionSource.seed); - - // Create test filters for the request - final testFilters = [ - Filter(kinds: [1], limit: 10) - ]; - final requestId = "test_request_123"; - - // Create a request state in globalState.inFlightRequests - final request = NdkRequest.query( - requestId, - name: "test_request", - filters: testFilters, - closeOnEOSE: false, // This is a subscription, not a one-time request - timeoutDuration: Duration(seconds: 30), - ); - - manager.globalState.inFlightRequests[requestId] = RequestState(request); - - // Register the request with both relays - manager.registerRelayRequest( - reqId: requestId, - relayUrl: relay1.url, - filters: testFilters, - ); - manager.registerRelayRequest( - reqId: requestId, - relayUrl: relay2.url, - filters: testFilters, - ); + RelayManager manager = RelayManager( + globalState: GlobalState(), + bootstrapRelays: [], + nostrTransportFactory: webSocketNostrTransportFactory, + ); - // Verify both relay requests are registered - expect(manager.globalState.inFlightRequests[requestId], isNotNull); - expect( + // Connect to both relays + await manager.connectRelay( + dirtyUrl: relay1.url, + connectionSource: ConnectionSource.seed, + ); + await manager.connectRelay( + dirtyUrl: relay2.url, + connectionSource: ConnectionSource.seed, + ); + + // Create test filters for the request + final testFilters = [ + Filter(kinds: [1], limit: 10), + ]; + final requestId = "test_request_123"; + + // Create a request state in globalState.inFlightRequests + final request = NdkRequest.query( + requestId, + name: "test_request", + filters: testFilters, + closeOnEOSE: false, // This is a subscription, not a one-time request + timeoutDuration: Duration(seconds: 30), + ); + + manager.globalState.inFlightRequests[requestId] = RequestState(request); + + // Register the request with both relays + manager.registerRelayRequest( + reqId: requestId, + relayUrl: relay1.url, + filters: testFilters, + ); + manager.registerRelayRequest( + reqId: requestId, + relayUrl: relay2.url, + filters: testFilters, + ); + + // Verify both relay requests are registered + expect(manager.globalState.inFlightRequests[requestId], isNotNull); + expect( manager.globalState.inFlightRequests[requestId]!.requests[relay1.url], - isNotNull); - expect( + isNotNull, + ); + expect( manager.globalState.inFlightRequests[requestId]!.requests[relay2.url], - isNotNull); - - // Create a test event that relay2 will send - final testEvent = Nip01Event( - kind: 1, - pubKey: "test_pubkey", - content: "Test content from relay2", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); - - final List eventsReceived = []; - - // Listen to the request stream to collect events - final streamSubscription = manager - .globalState.inFlightRequests[requestId]!.networkController.stream - .listen((event) { - eventsReceived.add(event); - }); - - // Give a moment for stream setup - await Future.delayed(Duration(milliseconds: 50)); - - // Step 1: relay1 sends a CLOSED message - // This will trigger the bug - the entire request gets removed from inFlightRequests - relay1.sendClosed(requestId, message: "rate limited"); - - // Give a moment for the CLOSED message to be processed - await Future.delayed(Duration(milliseconds: 100)); - - // CORRECT BEHAVIOR: The request should still exist in inFlightRequests - // because relay2 hasn't finished yet. Only relay1's entry should be removed - // from the requests map, not the entire request. - expect(manager.globalState.inFlightRequests[requestId], isNotNull, + isNotNull, + ); + + // Create a test event that relay2 will send + final testEvent = Nip01Event( + kind: 1, + pubKey: "test_pubkey", + content: "Test content from relay2", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + final List eventsReceived = []; + + // Listen to the request stream to collect events + final streamSubscription = manager + .globalState + .inFlightRequests[requestId]! + .networkController + .stream + .listen((event) { + eventsReceived.add(event); + }); + + // Give a moment for stream setup + await Future.delayed(Duration(milliseconds: 50)); + + // Step 1: relay1 sends a CLOSED message + // This will trigger the bug - the entire request gets removed from inFlightRequests + relay1.sendClosed(requestId, message: "rate limited"); + + // Give a moment for the CLOSED message to be processed + await Future.delayed(Duration(milliseconds: 100)); + + // CORRECT BEHAVIOR: The request should still exist in inFlightRequests + // because relay2 hasn't finished yet. Only relay1's entry should be removed + // from the requests map, not the entire request. + expect( + manager.globalState.inFlightRequests[requestId], + isNotNull, reason: - "Request should still exist in inFlightRequests after CLOSED from relay1, since relay2 hasn't finished"); + "Request should still exist in inFlightRequests after CLOSED from relay1, since relay2 hasn't finished", + ); - // The request should still have relay2's entry, but relay1's should be removed or marked as closed - expect( + // The request should still have relay2's entry, but relay1's should be removed or marked as closed + expect( manager.globalState.inFlightRequests[requestId]!.requests[relay2.url], isNotNull, - reason: "Relay2's request entry should still exist"); + reason: "Relay2's request entry should still exist", + ); - // Step 2: relay2 tries to send an event - // This should work because the request entry should still exist - relay2.sendEvent(event: testEvent, subId: requestId); + // Step 2: relay2 tries to send an event + // This should work because the request entry should still exist + relay2.sendEvent(event: testEvent, subId: requestId); - // Give time for event processing - await Future.delayed(Duration(milliseconds: 100)); + // Give time for event processing + await Future.delayed(Duration(milliseconds: 100)); - // CORRECT BEHAVIOR: Events from relay2 should be received - expect(eventsReceived.length, equals(1), + // CORRECT BEHAVIOR: Events from relay2 should be received + expect( + eventsReceived.length, + equals(1), reason: - "Event from relay2 should be received even after relay1 sent CLOSED message"); + "Event from relay2 should be received even after relay1 sent CLOSED message", + ); - expect(eventsReceived.first.content, equals("Test content from relay2"), - reason: "The received event should be the one sent by relay2"); + expect( + eventsReceived.first.content, + equals("Test content from relay2"), + reason: "The received event should be the one sent by relay2", + ); - // Clean up - await streamSubscription.cancel(); - await relay1.stopServer(); - await relay2.stopServer(); - }); + // Clean up + await streamSubscription.cancel(); + await relay1.stopServer(); + await relay2.stopServer(); + }, + ); }); } diff --git a/packages/ndk/test/relays/relay_sets_test.dart b/packages/ndk/test/relays/relay_sets_test.dart index bd87f9e46..56e83b1c6 100644 --- a/packages/ndk/test/relays/relay_sets_test.dart +++ b/packages/ndk/test/relays/relay_sets_test.dart @@ -26,11 +26,12 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map key1TextNotes = {key1: textNote(key1)}; @@ -43,18 +44,24 @@ void main() async { MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: key1TextNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); - Filter filter = - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]); + Filter filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); NdkResponse query = ndk.requests.query(filters: [filter]); @@ -92,10 +99,12 @@ void main() async { relay1.url: ReadWriteMarker.readWrite, relay2.url: ReadWriteMarker.readWrite, }); - Nip65 nip65ForKey3 = - Nip65.fromMap(key3.publicKey, {relay1.url: ReadWriteMarker.readWrite}); - Nip65 nip65ForKey4 = - Nip65.fromMap(key4.publicKey, {relay4.url: ReadWriteMarker.readWrite}); + Nip65 nip65ForKey3 = Nip65.fromMap(key3.publicKey, { + relay1.url: ReadWriteMarker.readWrite, + }); + Nip65 nip65ForKey4 = Nip65.fromMap(key4.publicKey, { + relay4.url: ReadWriteMarker.readWrite, + }); Map nip65s = { key1: nip65ForKey1, @@ -111,19 +120,23 @@ void main() async { // r4 -> k1,k4 await Future.wait([ relay1.startServer( - nip65s: nip65s, - textNotes: {} - ..addAll(key1TextNotes) - ..addAll(key2TextNotes) - ..addAll(key3TextNotes)), + nip65s: nip65s, + textNotes: {} + ..addAll(key1TextNotes) + ..addAll(key2TextNotes) + ..addAll(key3TextNotes), + ), relay2.startServer( - nip65s: nip65s, - textNotes: {} - ..addAll(key1TextNotes) - ..addAll(key2TextNotes)), + nip65s: nip65s, + textNotes: {} + ..addAll(key1TextNotes) + ..addAll(key2TextNotes), + ), relay3.startServer( - nip65s: nip65s, textNotes: {}..addAll(key1TextNotes)), - relay4.startServer(textNotes: key4TextNotes..addAll(key1TextNotes)) + nip65s: nip65s, + textNotes: {}..addAll(key1TextNotes), + ), + relay4.startServer(textNotes: key4TextNotes..addAll(key1TextNotes)), ]); } @@ -164,14 +177,18 @@ void main() async { // ================================================================================================ test('query events from key that writes only on one relay', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); RelaySet relaySet = await ndk.relaySets.calculateRelaySet( name: "test", @@ -181,9 +198,12 @@ void main() async { relayMinCountPerPubKey: 2, ); - NdkResponse query = ndk.requests.query(filters: [ - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key4.publicKey]) - ], relaySet: relaySet); + NdkResponse query = ndk.requests.query( + filters: [ + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key4.publicKey]), + ], + relaySet: relaySet, + ); await for (final event in query.stream.take(4)) { await expectLater(event.sources, [relay4.url]); @@ -224,28 +244,33 @@ void main() async { // ================================================================================================ test( - // skip: 'WiP', - 'query all keys and do not use redundant relays', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], - )); - - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); - - /// query text notes for all keys, should discover where each key keeps its notes (according to nip65) and return all notes - /// only relay 1,2 & 4 should be used, since relay 3 keys are all also kept on relay 1 so should not be needed - RelaySet relaySet = await ndk.relaySets.calculateRelaySet( + // skip: 'WiP', + 'query all keys and do not use redundant relays', + () async { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + /// query text notes for all keys, should discover where each key keeps its notes (according to nip65) and return all notes + /// only relay 1,2 & 4 should be used, since relay 3 keys are all also kept on relay 1 so should not be needed + RelaySet relaySet = await ndk.relaySets.calculateRelaySet( name: "feed", ownerPubKey: "ownerPubKey", pubKeys: [ key1.publicKey, key2.publicKey, key3.publicKey, - key4.publicKey + key4.publicKey, ], direction: RelayDirection.outbox, relayMinCountPerPubKey: 1, @@ -253,42 +278,47 @@ void main() async { if (count % 100 == 0 || (total - count) < 10) { print("[PROGRESS] $stepName: $count/$total"); } - }); - print("BEST ${relaySet.relaysMap.length} RELAYS:"); - relaySet.relaysMap.forEach((url, pubKeyMappings) { - print(" ${relayNames[url]} => has ${pubKeyMappings.length} follows"); - }); - NdkResponse query = ndk.requests.query(filters: [ - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key1.publicKey, - ]), - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key2.publicKey, - ]), - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key3.publicKey, - ]), - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key4.publicKey]) - ], relaySet: relaySet); - - await for (final event in query.stream) { - print(event); - if (event.sources.contains(relay3.url)) { - fail("should not use relay 3 (${relay3.url}) in gossip model"); + }, + ); + print("BEST ${relaySet.relaysMap.length} RELAYS:"); + relaySet.relaysMap.forEach((url, pubKeyMappings) { + print(" ${relayNames[url]} => has ${pubKeyMappings.length} follows"); + }); + NdkResponse query = ndk.requests.query( + filters: [ + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key2.publicKey], + ), + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key3.publicKey], + ), + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey], + ), + ], + relaySet: relaySet, + ); + + await for (final event in query.stream) { + print(event); + if (event.sources.contains(relay3.url)) { + fail("should not use relay 3 (${relay3.url}) in gossip model"); + } } - } - /// todo: how to ALSO check if actually all notes are returned in the stream? - //List expectedAllNotes = [...key1TextNotes.values, ...key2TextNotes.values, ...key3TextNotes.values, ...key4TextNotes.values]; - //expect(query, emitsInAnyOrder(key1TextNotes.values)); - await ndk.destroy(); - }); + /// todo: how to ALSO check if actually all notes are returned in the stream? + //List expectedAllNotes = [...key1TextNotes.values, ...key2TextNotes.values, ...key3TextNotes.values, ...key4TextNotes.values]; + //expect(query, emitsInAnyOrder(key1TextNotes.values)); + await ndk.destroy(); + }, + ); // ================================================================================================ // test(skip: true, 'query all keys and do not use redundant relays (JIT)', @@ -332,27 +362,31 @@ void main() async { // }); test( - "calculate best relays for relayMinCountPerPubKey=1 and check that it doesn't use redundant relays", - () async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], - )); - - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); - - // relayMinCountPerPubKey: 1 - RelaySet relaySet = await ndk.relaySets.calculateRelaySet( + "calculate best relays for relayMinCountPerPubKey=1 and check that it doesn't use redundant relays", + () async { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + // relayMinCountPerPubKey: 1 + RelaySet relaySet = await ndk.relaySets.calculateRelaySet( name: "feed", ownerPubKey: "ownerPubKey", pubKeys: [ key1.publicKey, key2.publicKey, key3.publicKey, - key4.publicKey + key4.publicKey, ], direction: RelayDirection.outbox, relayMinCountPerPubKey: 1, @@ -360,41 +394,47 @@ void main() async { if (count % 100 == 0 || (total - count) < 10) { print("[PROGRESS] $stepName: $count/$total"); } - }); - print("BEST ${relaySet.relaysMap.length} RELAYS:"); - relaySet.relaysMap.forEach((url, pubKeyMappings) { - print(" $url => has ${pubKeyMappings.length} follows"); - }); - - expect(relaySet.urls.contains(relay1.url), true); - expect(relaySet.urls.contains(relay2.url), false); - expect(relaySet.urls.contains(relay3.url), false); - expect(relaySet.urls.contains(relay4.url), true); - expect(relaySet.notCoveredPubkeys.isEmpty, true); - await ndk.destroy(); - }); + }, + ); + print("BEST ${relaySet.relaysMap.length} RELAYS:"); + relaySet.relaysMap.forEach((url, pubKeyMappings) { + print(" $url => has ${pubKeyMappings.length} follows"); + }); - test( - "calculate best relays for relayMinCountPerPubKey=2 and check that it doesn't use redundant relays", - () async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], - )); - - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + expect(relaySet.urls.contains(relay1.url), true); + expect(relaySet.urls.contains(relay2.url), false); + expect(relaySet.urls.contains(relay3.url), false); + expect(relaySet.urls.contains(relay4.url), true); + expect(relaySet.notCoveredPubkeys.isEmpty, true); + await ndk.destroy(); + }, + ); - RelaySet relaySet = await ndk.relaySets.calculateRelaySet( + test( + "calculate best relays for relayMinCountPerPubKey=2 and check that it doesn't use redundant relays", + () async { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url, relay2.url, relay3.url, relay4.url], + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + RelaySet relaySet = await ndk.relaySets.calculateRelaySet( name: "feed", ownerPubKey: "ownerPubKey", pubKeys: [ key1.publicKey, key2.publicKey, key3.publicKey, - key4.publicKey + key4.publicKey, ], direction: RelayDirection.outbox, relayMinCountPerPubKey: 2, @@ -402,18 +442,20 @@ void main() async { if (count % 100 == 0 || (total - count) < 10) { print("[PROGRESS] $stepName: $count/$total"); } - }); - print("BEST ${relaySet.relaysMap.length} RELAYS:"); - relaySet.relaysMap.forEach((url, pubKeyMappings) { - print(" $url => has ${pubKeyMappings.length} follows"); - }); - - expect(relaySet.urls.contains(relay1.url), true); - expect(relaySet.urls.contains(relay2.url), true); - expect(relaySet.urls.contains(relay3.url), false); - expect(relaySet.urls.contains(relay4.url), true); - await ndk.destroy(); - }); + }, + ); + print("BEST ${relaySet.relaysMap.length} RELAYS:"); + relaySet.relaysMap.forEach((url, pubKeyMappings) { + print(" $url => has ${pubKeyMappings.length} follows"); + }); + + expect(relaySet.urls.contains(relay1.url), true); + expect(relaySet.urls.contains(relay2.url), true); + expect(relaySet.urls.contains(relay3.url), false); + expect(relaySet.urls.contains(relay4.url), true); + await ndk.destroy(); + }, + ); }); group("misc", () { // test('nwc info', () async { @@ -542,21 +584,27 @@ void main() async { }); group("Calculate best relays (external REAL)", skip: true, () { -// ================================================================================================ -// REAL EXTERNAL RELAYS FOR SOME NPUBS -// ================================================================================================ - calculateBestRelaysForNpubContactsFeed(String npub, - {String? expectedRelayUrl, - int iterations = 1, - required int relayMinCountPerPubKey}) async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - )); - - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + // ================================================================================================ + // REAL EXTERNAL RELAYS FOR SOME NPUBS + // ================================================================================================ + calculateBestRelaysForNpubContactsFeed( + String npub, { + String? expectedRelayUrl, + int iterations = 1, + required int relayMinCountPerPubKey, + }) async { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); int i = 1; while (i <= iterations) { @@ -564,31 +612,35 @@ void main() async { KeyPair key = KeyPair.justPublicKey(Helpers.decodeBech32(npub)[0]); - ContactList? contactList = - await ndk.follows.getContactList(key.publicKey); + ContactList? contactList = await ndk.follows.getContactList( + key.publicKey, + ); expect(contactList != null, true); String setName = "feed,$relayMinCountPerPubKey,"; RelaySet? bestRelays = await ndk.relaySets.calculateRelaySet( - name: "feed", - ownerPubKey: key.publicKey, - pubKeys: contactList!.contacts, - direction: RelayDirection.outbox, - relayMinCountPerPubKey: relayMinCountPerPubKey, - onProgress: (stepName, count, total) { - if (count % 100 == 0 || (total - count) < 10) { - print("[PROGRESS] $stepName: $count/$total"); - } - }); + name: "feed", + ownerPubKey: key.publicKey, + pubKeys: contactList!.contacts, + direction: RelayDirection.outbox, + relayMinCountPerPubKey: relayMinCountPerPubKey, + onProgress: (stepName, count, total) { + if (count % 100 == 0 || (total - count) < 10) { + print("[PROGRESS] $stepName: $count/$total"); + } + }, + ); bestRelays.name = setName; bestRelays.pubKey = key.publicKey; -// await manager.saveRelaySet(bestRelays); + // await manager.saveRelaySet(bestRelays); print( - "BEST ${bestRelays.relaysMap.length} RELAYS (min $relayMinCountPerPubKey per pubKey):"); + "BEST ${bestRelays.relaysMap.length} RELAYS (min $relayMinCountPerPubKey per pubKey):", + ); bestRelays.relaysMap.forEach((url, pubKeyMappings) { print( - " $url ${pubKeyMappings.length} follows ${pubKeyMappings.length <= 2 ? pubKeyMappings : ""}"); + " $url ${pubKeyMappings.length} follows ${pubKeyMappings.length <= 2 ? pubKeyMappings : ""}", + ); }); if (Helpers.isNotBlank(expectedRelayUrl)) { @@ -596,7 +648,8 @@ void main() async { } final t1 = DateTime.now(); print( - "===== run #$i, time took ${t1.difference(t0).inMilliseconds} ms"); + "===== run #$i, time took ${t1.difference(t0).inMilliseconds} ms", + ); i++; await ndk.destroy(); } @@ -632,76 +685,85 @@ void main() async { // test('Leo feed best relays', () async { await calculateBestRelaysForNpubContactsFeed( - "npub1w9llyw8c3qnn7h27u3msjlet8xyjz5phdycr5rz335r2j5hj5a0qvs3tur", - iterations: 1, - relayMinCountPerPubKey: 2); + "npub1w9llyw8c3qnn7h27u3msjlet8xyjz5phdycr5rz335r2j5hj5a0qvs3tur", + iterations: 1, + relayMinCountPerPubKey: 2, + ); }, timeout: const Timeout.factor(10)); test('Fmar feed best relays', () async { await calculateBestRelaysForNpubContactsFeed( - "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams", - iterations: 1, - relayMinCountPerPubKey: 2); + "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams", + iterations: 1, + relayMinCountPerPubKey: 2, + ); }, timeout: const Timeout.factor(10)); test('mikedilger feed best relays', () async { await calculateBestRelaysForNpubContactsFeed( - "npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c", - iterations: 1, - relayMinCountPerPubKey: 2); + "npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c", + iterations: 1, + relayMinCountPerPubKey: 2, + ); }, timeout: const Timeout.factor(10)); test('Fiatjaf feed best relays', () async { await calculateBestRelaysForNpubContactsFeed( - "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", - iterations: 1, - relayMinCountPerPubKey: 2); + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", + iterations: 1, + relayMinCountPerPubKey: 2, + ); }, timeout: const Timeout.factor(10)); - test('Love is Bitcoin (3k follows) feed best relays', () async { - await calculateBestRelaysForNpubContactsFeed( + test( + 'Love is Bitcoin (3k follows) feed best relays', + () async { + await calculateBestRelaysForNpubContactsFeed( "npub1kwcatqynqmry9d78a8cpe7d882wu3vmrgcmhvdsayhwqjf7mp25qpqf3xx", iterations: 1, - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); + relayMinCountPerPubKey: 2, + ); + }, + timeout: const Timeout.factor(10), + ); }); -// test('testing not timing out on subscriptions', () async { -// RelayManager manager = RelayManager(); -// await manager.init(); -// await manager.connect(); -// KeyPair key = KeyPair.justPublicKey(Helpers.decodeBech32( -// // "npub1cd32tje2tyhcnm3mwen2hwcghs0vfyupcxjd9aff9e64rhgu755qa9wt08" -// "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams" -// )[0]); -// // Map> bestRelays = -// // await manager.calculateBestRelaysForPubKeyMappings( -// // [PubkeyMapping(pubKey: key.publicKey, rwMarker: ReadWriteMarker.readWrite)], -// // relayMinCountPerPubKey: 1 -// // ); -// // print( -// // "BEST ${bestRelays.length} RELAYS (min 1 per pubKey):"); -// // bestRelays.forEach((url, pubKeys) { -// // print(" $url ${pubKeys.length} follows"); -// // }); -// Nip02ContactList? contactList = -// await manager.loadContactList(key.publicKey); -// -// if (contactList != null) { -// print( -// "Have contact list with ${contactList.contacts.length} contacts"); -// Stream query = await manager.subscriptionWithCalculation( -// Filter( -// kinds: [Nip01Event.textNoteKind], -// authors: contactList.contacts), -// relayMinCountPerPubKey: 2); -// // Stream query = await manager.subscription( -// // Filter( -// // kinds: [Nip01Event.textNoteKind], -// // authors: [key.publicKey])); -// // -// await for (final event in query) { -// print(event); -// } -// } -// }, timeout: const Timeout.factor(10)); + // test('testing not timing out on subscriptions', () async { + // RelayManager manager = RelayManager(); + // await manager.init(); + // await manager.connect(); + // KeyPair key = KeyPair.justPublicKey(Helpers.decodeBech32( + // // "npub1cd32tje2tyhcnm3mwen2hwcghs0vfyupcxjd9aff9e64rhgu755qa9wt08" + // "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams" + // )[0]); + // // Map> bestRelays = + // // await manager.calculateBestRelaysForPubKeyMappings( + // // [PubkeyMapping(pubKey: key.publicKey, rwMarker: ReadWriteMarker.readWrite)], + // // relayMinCountPerPubKey: 1 + // // ); + // // print( + // // "BEST ${bestRelays.length} RELAYS (min 1 per pubKey):"); + // // bestRelays.forEach((url, pubKeys) { + // // print(" $url ${pubKeys.length} follows"); + // // }); + // Nip02ContactList? contactList = + // await manager.loadContactList(key.publicKey); + // + // if (contactList != null) { + // print( + // "Have contact list with ${contactList.contacts.length} contacts"); + // Stream query = await manager.subscriptionWithCalculation( + // Filter( + // kinds: [Nip01Event.textNoteKind], + // authors: contactList.contacts), + // relayMinCountPerPubKey: 2); + // // Stream query = await manager.subscription( + // // Filter( + // // kinds: [Nip01Event.textNoteKind], + // // authors: [key.publicKey])); + // // + // await for (final event in query) { + // print(event); + // } + // } + // }, timeout: const Timeout.factor(10)); } diff --git a/packages/ndk/test/relays/requests_test.dart b/packages/ndk/test/relays/requests_test.dart index c3f8790c0..87718f3a7 100644 --- a/packages/ndk/test/relays/requests_test.dart +++ b/packages/ndk/test/relays/requests_test.dart @@ -29,40 +29,66 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map textNotes = { key1: textNote(key1), - key2: textNote(key2) + key2: textNote(key2), }; + Future waitForEventCount( + List events, + int expectedCount, { + Duration timeout = const Duration(seconds: 3), + }) async { + final deadline = DateTime.now().add(timeout); + + while (events.length < expectedCount) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException( + 'Timed out waiting for $expectedCount events, received ${events.length}', + ); + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } + group('Requests', () { test('Request text note with single filter parameter', () async { - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); + MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: textNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); - final filter = - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]); + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); // Using the new single filter parameter final query = ndk.requests.query(filter: filter); await expectLater( - query.stream, emitsInAnyOrder([textNotes.values.first])); + query.stream, + emitsInAnyOrder([textNotes.values.first]), + ); await ndk.destroy(); expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); @@ -70,51 +96,108 @@ void main() async { }); test('Request text note', () async { - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); + MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: textNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); - final filter = - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]); + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); final query = ndk.requests.query(filters: [filter]); await expectLater( - query.stream, emitsInAnyOrder([textNotes.values.first])); + query.stream, + emitsInAnyOrder([textNotes.values.first]), + ); await ndk.destroy(); expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); await relay1.stopServer(); }); + test( + 'Query persists multiple source relays for the same cached event', + () async { + final sharedEvent = textNotes.values.first; + final relay1 = MockRelay(name: "relay 1"); + final relay2 = MockRelay(name: "relay 2"); + await relay1.startServer(textNotes: {key1: sharedEvent}); + await relay2.startServer(textNotes: {key1: sharedEvent}); + + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url, relay2.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); + + final events = await ndk.requests + .query(filter: filter, explicitRelays: [relay1.url, relay2.url]) + .future; + + expect(events, isNotEmpty); + final eventId = events.first.id; + final sources = await cache.loadEventSources(eventId); + expect(sources, containsAll([relay1.url, relay2.url])); + expect(sources.length, equals(2)); + + await ndk.destroy(); + expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); + await relay1.stopServer(); + await relay2.stopServer(); + }, + ); + test('Request multiple filters text note', () async { - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); + MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: textNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); final query = ndk.requests.query( - // explicitRelays: [relay1.url], - filters: [ - Filter( - kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key2.publicKey]) - ]); + // explicitRelays: [relay1.url], + filters: [ + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key2.publicKey]), + ], + ); await expectLater(query.stream, emitsInAnyOrder(textNotes.values)); @@ -127,25 +210,29 @@ void main() async { await relay1.stopServer(); }); test('Request multiple filters text note JIT', skip: true, () async { - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); + MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: textNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); final query = ndk.requests.query( - // explicitRelays: [relay1.url], - filters: [ - Filter( - kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key2.publicKey]) - ]); + // explicitRelays: [relay1.url], + filters: [ + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key2.publicKey]), + ], + ); await expectLater(query.stream, emitsInAnyOrder(textNotes.values)); @@ -159,20 +246,26 @@ void main() async { }); test('Subscription with single filter parameter', () async { - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); + MockRelay relay1 = MockRelay(name: "relay 1"); await relay1.startServer(textNotes: textNotes); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); - final filter = - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]); + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); // Using the new single filter parameter final subscription = ndk.requests.subscription(filter: filter); @@ -181,124 +274,149 @@ void main() async { final streamSubscription = subscription.stream.listen((event) { receivedEvents.add(event); }); - - await Future.delayed(Duration(milliseconds: 200)); - - expect(receivedEvents.length, equals(1)); - expect(receivedEvents[0].content, contains('key1')); - - await streamSubscription.cancel(); - await ndk.requests.closeSubscription(subscription.requestId); - await ndk.destroy(); - expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); - await relay1.stopServer(); + try { + await waitForEventCount(receivedEvents, 1); + + expect(receivedEvents.length, equals(1)); + expect(receivedEvents[0].content, contains('key1')); + } finally { + await streamSubscription.cancel(); + await ndk.requests.closeSubscription(subscription.requestId); + await ndk.destroy(); + expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); + await relay1.stopServer(); + } }); - test('Subscription processes events immediately without stream closing', - () async { - // This test would FAIL with the previous VerifyEventStream implementation - // because events would remain stuck in buffer until stream closes - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); - await relay1.startServer(textNotes: textNotes); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); - - final filter = - Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]); - - // Use subscription instead of query - this creates a long-lived stream - final subscription = ndk.requests.subscription(filters: [filter]); - - final receivedEvents = []; - final streamSubscription = subscription.stream.listen((event) { - receivedEvents.add(event); - }); - - // Wait for initial events to be processed - // Previous implementation would not yield these events from subscription - await Future.delayed(Duration(milliseconds: 200)); - - expect(receivedEvents.length, equals(1), - reason: - 'Subscription should process events immediately without waiting for stream to close'); - expect(receivedEvents[0].content, contains('key1')); - - // Clean up - await streamSubscription.cancel(); - await ndk.requests.closeSubscription(subscription.requestId); - await ndk.destroy(); - expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); - await relay1.stopServer(); - }); - - test('Subscription handles continuous events from non-closing stream', - () async { - // This test simulates a real-world scenario where a subscription - // receives events continuously without the stream ever closing - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 6060); - - // Start with multiple events to test continuous processing - final multipleEvents = { - key1: textNote(key1), - key2: textNote(key2), - key3: textNote(key3), - key4: textNote(key4), - }; - await relay1.startServer(textNotes: multipleEvents); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - )); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); - - final filter = Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key1.publicKey, - key2.publicKey, - key3.publicKey, - key4.publicKey - ]); - - final subscription = ndk.requests.subscription(filters: [filter]); - - final receivedEvents = []; - final streamSubscription = subscription.stream.listen((event) { - receivedEvents.add(event); - }); - - // Wait for events to be processed - // Previous implementation would fail to process events from subscription - // because they would get stuck in the verification buffer - await Future.delayed(Duration(milliseconds: 300)); - - expect(receivedEvents.length, equals(4), - reason: - 'Subscription should process all matching events immediately'); - - // Verify we got events from different authors (showing parallel processing worked) - final uniqueAuthors = receivedEvents.map((e) => e.pubKey).toSet(); - expect(uniqueAuthors.length, greaterThan(1), - reason: 'Should receive events from multiple authors'); + test( + 'Subscription processes events immediately without stream closing', + () async { + // This test would FAIL with the previous VerifyEventStream implementation + // because events would remain stuck in buffer until stream closes + MockRelay relay1 = MockRelay(name: "relay 1"); + await relay1.startServer(textNotes: textNotes); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); + + // Use subscription instead of query - this creates a long-lived stream + final subscription = ndk.requests.subscription(filters: [filter]); + + final receivedEvents = []; + final streamSubscription = subscription.stream.listen((event) { + receivedEvents.add(event); + }); + try { + await waitForEventCount(receivedEvents, 1); + + expect( + receivedEvents.length, + equals(1), + reason: + 'Subscription should process events immediately without waiting for stream to close', + ); + expect(receivedEvents[0].content, contains('key1')); + } finally { + await streamSubscription.cancel(); + await ndk.requests.closeSubscription(subscription.requestId); + await ndk.destroy(); + expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); + await relay1.stopServer(); + } + }, + ); - // Clean up - await streamSubscription.cancel(); - await ndk.requests.closeSubscription(subscription.requestId); - await ndk.destroy(); - expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); - await relay1.stopServer(); - }); + test( + 'Subscription handles continuous events from non-closing stream', + () async { + // This test simulates a real-world scenario where a subscription + // receives events continuously without the stream ever closing + MockRelay relay1 = MockRelay(name: "relay 1"); + + // Start with multiple events to test continuous processing + final multipleEvents = { + key1: textNote(key1), + key2: textNote(key2), + key3: textNote(key3), + key4: textNote(key4), + }; + await relay1.startServer(textNotes: multipleEvents); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + ), + ); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [ + key1.publicKey, + key2.publicKey, + key3.publicKey, + key4.publicKey, + ], + ); + + final subscription = ndk.requests.subscription(filters: [filter]); + + final receivedEvents = []; + final allReceived = Completer(); + final streamSubscription = subscription.stream.listen((event) { + receivedEvents.add(event); + if (receivedEvents.length >= 4 && !allReceived.isCompleted) { + allReceived.complete(); + } + }); + + try { + await allReceived.future.timeout(const Duration(seconds: 3)); + + expect( + receivedEvents.length, + equals(4), + reason: + 'Subscription should process all matching events immediately', + ); + + // Verify we got events from different authors (showing parallel processing worked) + final uniqueAuthors = receivedEvents.map((e) => e.pubKey).toSet(); + expect( + uniqueAuthors.length, + greaterThan(1), + reason: 'Should receive events from multiple authors', + ); + } finally { + await streamSubscription.cancel(); + await ndk.requests.closeSubscription(subscription.requestId); + await ndk.destroy(); + expect(ndk.relays.globalState.inFlightRequests.isEmpty, true); + await relay1.stopServer(); + } + }, + ); }); group('immutable filters', () { @@ -308,10 +426,7 @@ void main() async { final globalState = GlobalState(); final init = Initialization( globalState: globalState, - ndkConfig: NdkConfig( - eventVerifier: eventVerifier, - cache: cache, - ), + ndkConfig: NdkConfig(eventVerifier: eventVerifier, cache: cache), ); // Create a Requests instance @@ -327,14 +442,8 @@ void main() async { ); // Create an initial filter - final originalFilter = Filter( - kinds: [1], - authors: ['author1'], - ); - final originalFilterSub = Filter( - kinds: [1], - authors: ['author1Sub'], - ); + final originalFilter = Filter(kinds: [1], authors: ['author1']); + final originalFilterSub = Filter(kinds: [1], authors: ['author1Sub']); // query requests.query(filters: [originalFilter]); diff --git a/packages/ndk/test/relays/trailing_slash_test.dart b/packages/ndk/test/relays/trailing_slash_test.dart index eff1be4f6..d9ef9997e 100644 --- a/packages/ndk/test/relays/trailing_slash_test.dart +++ b/packages/ndk/test/relays/trailing_slash_test.dart @@ -173,11 +173,13 @@ void main() { }); test('Query without trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final query = ndk.requests.query( filter: Filter(kinds: [1], limit: 1), @@ -193,11 +195,13 @@ void main() { }); test('Query with trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final query = ndk.requests.query( filter: Filter(kinds: [1], limit: 1), @@ -213,11 +217,13 @@ void main() { }); test('Subscription without trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final query = ndk.requests.subscription( filter: Filter(kinds: [1], limit: 1), @@ -233,11 +239,13 @@ void main() { }); test('Subscription with trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final query = ndk.requests.subscription( filter: Filter(kinds: [1], limit: 1), @@ -253,11 +261,13 @@ void main() { }); test('Broadcast without trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final keyPair = Bip340.generatePrivateKey(); ndk.accounts.loginPrivateKey( @@ -287,11 +297,13 @@ void main() { }); test('Broadcast with trailling / and JIT', () async { - final ndk = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + ), + ); final keyPair = Bip340.generatePrivateKey(); ndk.accounts.loginPrivateKey( diff --git a/packages/ndk/test/scenarios/list_metadata_test.dart b/packages/ndk/test/scenarios/list_metadata_test.dart index 9f48cce92..27764c65e 100644 --- a/packages/ndk/test/scenarios/list_metadata_test.dart +++ b/packages/ndk/test/scenarios/list_metadata_test.dart @@ -5,65 +5,61 @@ import '../mocks/mock_event_verifier.dart'; import '../tools/simple_profiler.dart'; void main() async { - group( - "list + metadata (external REAL)", - skip: true, - () { - test('camelus - starter packs', () async { - // ignore: non_constant_identifier_names - final List CAMELUS_RECOMMEDED_STARTER_PACKS = [ - 'c7779fdc1e5d2bbf5edd5f68785bfc4299b3c77d8046957cc79bc4d25ad9d330', // camelus.app - '0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6', // nos.social - ]; + group("list + metadata (external REAL)", skip: true, () { + test('camelus - starter packs', () async { + // ignore: non_constant_identifier_names + final List CAMELUS_RECOMMEDED_STARTER_PACKS = [ + 'c7779fdc1e5d2bbf5edd5f68785bfc4299b3c77d8046957cc79bc4d25ad9d330', // camelus.app + '0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6', // nos.social + ]; - CacheManager cacheManager = MemCacheManager(); + CacheManager cacheManager = MemCacheManager(); - final profiler = SimpleProfiler('list + metadata (external REAL)'); + final profiler = SimpleProfiler('list + metadata (external REAL)'); - final ndk = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: cacheManager, - engine: NdkEngine.JIT, - ), - ); - final recommededSets = - await Future.wait(CAMELUS_RECOMMEDED_STARTER_PACKS.map((rPubkey) { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cacheManager, + engine: NdkEngine.JIT, + ), + ); + final recommededSets = await Future.wait( + CAMELUS_RECOMMEDED_STARTER_PACKS.map((rPubkey) { return ndk.lists.getPublicNip51RelaySets( kind: 30000, publicKey: rPubkey, forceRefresh: false, ); - })); + }), + ); - profiler.checkpoint('got ${recommededSets.length} sets'); + profiler.checkpoint('got ${recommededSets.length} sets'); - final allPubkeys = recommededSets - .expand((set) => set!) - .expand((e) => e.elements) - .map((e) => e.value) - .toSet() - .toList(); + final allPubkeys = recommededSets + .expand((set) => set!) + .expand((e) => e.elements) + .map((e) => e.value) + .toSet() + .toList(); - profiler.checkpoint('got ${allPubkeys.length} pubkeys'); + profiler.checkpoint('got ${allPubkeys.length} pubkeys'); - //? sync - // for (final pubkey in allPubkeys) { - // final metadataR = await ndk.metadata.loadMetadata(pubkey); - // profiler.checkpoint('got metadata for $pubkey, ${metadataR?.name}'); - // } + //? sync + // for (final pubkey in allPubkeys) { + // final metadataR = await ndk.metadata.loadMetadata(pubkey); + // profiler.checkpoint('got metadata for $pubkey, ${metadataR?.name}'); + // } - final metadataFutures = allPubkeys - .map((pubkey) => ndk.metadata.loadMetadata(pubkey).then((metadata) { - profiler.checkpoint( - 'got metadata for $pubkey, ${metadata?.name}'); - return {'pubkey': pubkey, 'metadata': metadata}; - })); + final metadataFutures = allPubkeys.map( + (pubkey) => ndk.metadata.loadMetadata(pubkey).then((metadata) { + profiler.checkpoint('got metadata for $pubkey, ${metadata?.name}'); + return {'pubkey': pubkey, 'metadata': metadata}; + }), + ); - final allMetadata = await Future.wait(metadataFutures); - profiler - .checkpoint('got metadata for all ${allMetadata.length} pubkeys'); - }, timeout: const Timeout.factor(60)); - }, - ); + final allMetadata = await Future.wait(metadataFutures); + profiler.checkpoint('got metadata for all ${allMetadata.length} pubkeys'); + }, timeout: const Timeout.factor(60)); + }); } diff --git a/packages/ndk/test/scenarios/multiple_filters_test.dart b/packages/ndk/test/scenarios/multiple_filters_test.dart index d252a6856..44ebee2e8 100644 --- a/packages/ndk/test/scenarios/multiple_filters_test.dart +++ b/packages/ndk/test/scenarios/multiple_filters_test.dart @@ -8,46 +8,44 @@ import '../mocks/mock_relay.dart'; import '../tools/simple_profiler.dart'; void main() async { - group( - "multiple filters (external REAL)", - skip: true, - () { - late MockRelay relay0; - late MockRelay relay1; - - setUp(() async { - relay0 = MockRelay(name: "relay 0", explicitPort: 5297); - relay1 = MockRelay(name: "relay 1", explicitPort: 5298); - - await relay0.startServer(); - await relay1.startServer(); - }); - - tearDown(() async { - await relay0.stopServer(); - await relay1.stopServer(); - }); - - test('multiple filters JIT query', () async { - // ignore: non_constant_identifier_names - - CacheManager cacheManager = MemCacheManager(); - - final profiler = SimpleProfiler('multiple filters JIT query'); - - final ndk = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: cacheManager, - engine: NdkEngine.JIT, - bootstrapRelays: [relay0.url, relay1.url], - ), - ); + group("multiple filters (external REAL)", skip: true, () { + late MockRelay relay0; + late MockRelay relay1; + + setUp(() async { + relay0 = MockRelay(name: "relay 0", explicitPort: 5297); + relay1 = MockRelay(name: "relay 1", explicitPort: 5298); + + await relay0.startServer(); + await relay1.startServer(); + }); + + tearDown(() async { + await relay0.stopServer(); + await relay1.stopServer(); + }); + + test('multiple filters JIT query', () async { + // ignore: non_constant_identifier_names - final queryResponse = ndk.requests.query(filters: [ + CacheManager cacheManager = MemCacheManager(); + + final profiler = SimpleProfiler('multiple filters JIT query'); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cacheManager, + engine: NdkEngine.JIT, + bootstrapRelays: [relay0.url, relay1.url], + ), + ); + + final queryResponse = ndk.requests.query( + filters: [ Filter( ids: [ - "ad6137b9a3dc4b393a41d745c483837cfd2379e22ec9916c487d6bd6cfe4b3b7" + "ad6137b9a3dc4b393a41d745c483837cfd2379e22ec9916c487d6bd6cfe4b3b7", ], kinds: [9041], ), @@ -55,59 +53,66 @@ void main() async { kinds: [1311, 9735], limit: 200, aTags: [ - "30311:cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5:ec9731a5-b1a0-4296-baf4-0f8355687581" + "30311:cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5:ec9731a5-b1a0-4296-baf4-0f8355687581", ], ), Filter( authors: [ "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", - "46f5797187ff5cf4dddb33828fb4e1296a7fd0ce666a3f24cdd454329e201480" + "46f5797187ff5cf4dddb33828fb4e1296a7fd0ce666a3f24cdd454329e201480", ], kinds: [10000], - ) - ]); - profiler.checkpoint('query req send '); + ), + ], + ); + profiler.checkpoint('query req send '); - queryResponse.stream.listen((event) { + queryResponse.stream.listen( + (event) { profiler.checkpoint('got event ${event.id} of kind ${event.kind}'); - }, onDone: () { + }, + onDone: () { profiler.checkpoint('query done'); profiler.end(); - }); - await queryResponse.future; - }, timeout: const Timeout.factor(60)); - - test('multiple filters JIT sub', () async { - Nip01Event textNote(KeyPair mykey, int kind) { - return Nip01Event( - kind: kind, - pubKey: mykey.publicKey, - content: "some note from key $mykey", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); - } + }, + ); + await queryResponse.future; + }, timeout: const Timeout.factor(60)); + + test('multiple filters JIT sub', () async { + Nip01Event textNote(KeyPair mykey, int kind) { + return Nip01Event( + kind: kind, + pubKey: mykey.publicKey, + content: "some note from key $mykey", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } - KeyPair key1 = Bip340.generatePrivateKey(); - KeyPair key2 = Bip340.generatePrivateKey(); + KeyPair key1 = Bip340.generatePrivateKey(); + KeyPair key2 = Bip340.generatePrivateKey(); - Map key1TextNotes = {key1: textNote(key1, 1)}; + Map key1TextNotes = {key1: textNote(key1, 1)}; - CacheManager cacheManager = MemCacheManager(); + CacheManager cacheManager = MemCacheManager(); - relay0.textNotes = key1TextNotes; + relay0.textNotes = key1TextNotes; - final profiler = SimpleProfiler('multiple filters JIT sub'); + final profiler = SimpleProfiler('multiple filters JIT sub'); - final ndk = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: cacheManager, - engine: NdkEngine.JIT, - bootstrapRelays: [relay1.url], - ), - ); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cacheManager, + engine: NdkEngine.JIT, + bootstrapRelays: [relay1.url], + ), + ); - final subResponse = ndk.requests.subscription(id: "mySubId", filters: [ + final subResponse = ndk.requests.subscription( + id: "mySubId", + filters: [ Filter( ids: [ "ad6137b9a3dc4b393a41d745c483837cfd2379e22ec9916c487d6bd6cfe4b3b7", @@ -119,71 +124,76 @@ void main() async { kinds: [1311, 9735], limit: 200, aTags: [ - "30311:cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5:ec9731a5-b1a0-4296-baf4-0f8355687581" + "30311:cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5:ec9731a5-b1a0-4296-baf4-0f8355687581", ], ), Filter( authors: [ "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", - "46f5797187ff5cf4dddb33828fb4e1296a7fd0ce666a3f24cdd454329e201480" + "46f5797187ff5cf4dddb33828fb4e1296a7fd0ce666a3f24cdd454329e201480", ], kinds: [10000], ), Filter(kinds: [1]), Filter(kinds: [2]), Filter(authors: [key1.publicKey, key2.publicKey], kinds: [3]), - ], explicitRelays: [ - relay0.url - ]); + ], + explicitRelays: [relay0.url], + ); - profiler.checkpoint('query sub send '); + profiler.checkpoint('query sub send '); - subResponse.stream.listen((event) { + subResponse.stream.listen( + (event) { profiler.checkpoint('got event ${event.id} of kind ${event.kind}'); - }, onDone: () { + }, + onDone: () { profiler.checkpoint('query done'); profiler.end(); - }); - - // insert new events - await Future.delayed(const Duration(milliseconds: 1000)); - - relay0.sendEvent( - event: textNote(key1, 1), - keyPair: key1, - subId: "mySubId", - ); - relay0.sendEvent( - event: Nip01Event( - pubKey: key1.publicKey, - kind: 1, - tags: [], - content: "runntime conent"), - keyPair: key1, - subId: "mySubId", - ); - - relay0.sendEvent( - event: Nip01Event( - pubKey: key1.publicKey, - kind: 2, - tags: [], - content: "kind 2 content"), - keyPair: key1, - subId: "mySubId", - ); - relay0.sendEvent( - event: Nip01Event( - pubKey: key1.publicKey, - kind: 3, - tags: [], - content: "kind 3 content"), - keyPair: key1, - subId: "mySubId", - ); - - await Future.delayed(const Duration(seconds: 5)); - }); - }, - ); + }, + ); + + // insert new events + await Future.delayed(const Duration(milliseconds: 1000)); + + relay0.sendEvent( + event: textNote(key1, 1), + keyPair: key1, + subId: "mySubId", + ); + relay0.sendEvent( + event: Nip01Event( + pubKey: key1.publicKey, + kind: 1, + tags: [], + content: "runntime conent", + ), + keyPair: key1, + subId: "mySubId", + ); + + relay0.sendEvent( + event: Nip01Event( + pubKey: key1.publicKey, + kind: 2, + tags: [], + content: "kind 2 content", + ), + keyPair: key1, + subId: "mySubId", + ); + relay0.sendEvent( + event: Nip01Event( + pubKey: key1.publicKey, + kind: 3, + tags: [], + content: "kind 3 content", + ), + keyPair: key1, + subId: "mySubId", + ); + + await Future.delayed(const Duration(seconds: 5)); + }); + }); } diff --git a/packages/ndk/test/scenarios/qs_sign_verify_test.dart b/packages/ndk/test/scenarios/qs_sign_verify_test.dart index 2b5c9794b..5dc0f0810 100644 --- a/packages/ndk/test/scenarios/qs_sign_verify_test.dart +++ b/packages/ndk/test/scenarios/qs_sign_verify_test.dart @@ -8,99 +8,95 @@ import 'package:test/test.dart'; import '../tools/simple_profiler.dart'; void main() async { - group( - "qs_sign + qs_verify", - skip: true, - () { - test('dev - test', () async { - final profiler = SimpleProfiler('QS Sign + Verify'); + group("qs_sign + qs_verify", skip: true, () { + test('dev - test', () async { + final profiler = SimpleProfiler('QS Sign + Verify'); - final LEVEL = 2; + final LEVEL = 2; - final myKeyPair = QsRustEventSigner.generateKeypair(level: LEVEL); + final myKeyPair = QsRustEventSigner.generateKeypair(level: LEVEL); - final eventVerifier = QsRustEventVerifier(level: LEVEL); - final signer = QsRustEventSigner(level: LEVEL, keypair: myKeyPair); + final eventVerifier = QsRustEventVerifier(level: LEVEL); + final signer = QsRustEventSigner(level: LEVEL, keypair: myKeyPair); - final usignedEvent = Nip01Event( - content: "hello", - kind: 1, - pubKey: '', - tags: [], - ); + final usignedEvent = Nip01Event( + content: "hello", + kind: 1, + pubKey: '', + tags: [], + ); - profiler.checkpoint('Created unsigned event'); - final signedEvent = await signer.sign(usignedEvent); + profiler.checkpoint('Created unsigned event'); + final signedEvent = await signer.sign(usignedEvent); - profiler.checkpoint('Event signed with QS signature'); - final isValid = await eventVerifier.verify(signedEvent); + profiler.checkpoint('Event signed with QS signature'); + final isValid = await eventVerifier.verify(signedEvent); - profiler.checkpoint('QS sign + verify completed, valid: $isValid'); + profiler.checkpoint('QS sign + verify completed, valid: $isValid'); - expect(isValid, true); + expect(isValid, true); - print("\n\nLevel: $LEVEL\n"); - print("Signature: ${signedEvent.sig}\n"); - print("Event ID: ${signedEvent.id}\n"); - print("Public Key: ${signedEvent.pubKey}\n\n"); - }); + print("\n\nLevel: $LEVEL\n"); + print("Signature: ${signedEvent.sig}\n"); + print("Event ID: ${signedEvent.id}\n"); + print("Public Key: ${signedEvent.pubKey}\n\n"); + }); - test('dev - bulk speed test', () async { - final profiler = SimpleProfiler('QS Bulk Sign + Verify'); + test('dev - bulk speed test', () async { + final profiler = SimpleProfiler('QS Bulk Sign + Verify'); - final LEVEL = 5; - final MESSAGE_COUNT = 1000; // Configurable via variable + final LEVEL = 5; + final MESSAGE_COUNT = 1000; // Configurable via variable - final myKeyPair = QsRustEventSigner.generateKeypair(level: LEVEL); + final myKeyPair = QsRustEventSigner.generateKeypair(level: LEVEL); - final eventVerifier = QsRustEventVerifier(level: LEVEL); - final signer = QsRustEventSigner(level: LEVEL, keypair: myKeyPair); + final eventVerifier = QsRustEventVerifier(level: LEVEL); + final signer = QsRustEventSigner(level: LEVEL, keypair: myKeyPair); - profiler.checkpoint('Started bulk test with $MESSAGE_COUNT messages'); + profiler.checkpoint('Started bulk test with $MESSAGE_COUNT messages'); - // Create all unsigned events first - final unsignedEvents = List.generate( - MESSAGE_COUNT, - (index) => Nip01Event( - content: "hello message $index", - kind: 1, - pubKey: '', - tags: [], - ), - ); + // Create all unsigned events first + final unsignedEvents = List.generate( + MESSAGE_COUNT, + (index) => Nip01Event( + content: "hello message $index", + kind: 1, + pubKey: '', + tags: [], + ), + ); - profiler.checkpoint('Created $MESSAGE_COUNT unsigned events'); + profiler.checkpoint('Created $MESSAGE_COUNT unsigned events'); - // Sign all events in parallel - final signedEvents = await Future.wait( - unsignedEvents.map((event) => signer.sign(event)), - ); + // Sign all events in parallel + final signedEvents = await Future.wait( + unsignedEvents.map((event) => signer.sign(event)), + ); - profiler.checkpoint('Signed $MESSAGE_COUNT events'); + profiler.checkpoint('Signed $MESSAGE_COUNT events'); - // Verify all events in parallel - final isValidations = await Future.wait( - signedEvents.map((signedEvent) => eventVerifier.verify(signedEvent)), - ); + // Verify all events in parallel + final isValidations = await Future.wait( + signedEvents.map((signedEvent) => eventVerifier.verify(signedEvent)), + ); - profiler.checkpoint('Verified $MESSAGE_COUNT events'); + profiler.checkpoint('Verified $MESSAGE_COUNT events'); - // Check if all verifications passed - bool allValid = isValidations.every((valid) => valid); - int failedCount = isValidations.where((valid) => !valid).length; + // Check if all verifications passed + bool allValid = isValidations.every((valid) => valid); + int failedCount = isValidations.where((valid) => !valid).length; - final checkpointString = - 'QS sign + verify completed: $MESSAGE_COUNT total, $failedCount failed'; - profiler.checkpoint(checkpointString); + final checkpointString = + 'QS sign + verify completed: $MESSAGE_COUNT total, $failedCount failed'; + profiler.checkpoint(checkpointString); - expect(allValid, true); + expect(allValid, true); - profiler.end(); + profiler.end(); - print("Events processed: $MESSAGE_COUNT"); - print("Verification failures: $failedCount"); - print("Used Level: $LEVEL\n\n"); - }); - }, - ); + print("Events processed: $MESSAGE_COUNT"); + print("Verification failures: $failedCount"); + print("Used Level: $LEVEL\n\n"); + }); + }); } diff --git a/packages/ndk/test/shared/bloom_filter/bloom_filter_prehash_test.dart b/packages/ndk/test/shared/bloom_filter/bloom_filter_prehash_test.dart index d9671ba11..88a0162df 100644 --- a/packages/ndk/test/shared/bloom_filter/bloom_filter_prehash_test.dart +++ b/packages/ndk/test/shared/bloom_filter/bloom_filter_prehash_test.dart @@ -9,83 +9,109 @@ import 'package:test/test.dart'; void main() { group('BloomFilterPrehash', () { test('initialization with valid parameters', () { - final filter = - BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: 1000); + final filter = BloomFilterPrehash( + falsePositiveProbability: 0.01, + numItems: 1000, + ); expect(filter.size, greaterThan(0)); expect(filter.numHashFunctions, greaterThan(0)); }); test('throws exception with invalid probability', () { expect( - () => BloomFilterPrehash(falsePositiveProbability: 0, numItems: 1000), - throwsArgumentError); + () => BloomFilterPrehash(falsePositiveProbability: 0, numItems: 1000), + throwsArgumentError, + ); expect( - () => BloomFilterPrehash(falsePositiveProbability: 1, numItems: 1000), - throwsArgumentError); + () => BloomFilterPrehash(falsePositiveProbability: 1, numItems: 1000), + throwsArgumentError, + ); expect( - () => BloomFilterPrehash( - falsePositiveProbability: -0.1, numItems: 1000), - throwsArgumentError); + () => + BloomFilterPrehash(falsePositiveProbability: -0.1, numItems: 1000), + throwsArgumentError, + ); }); test('throws exception with invalid number of items', () { expect( - () => BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: 0), - throwsArgumentError); + () => BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: 0), + throwsArgumentError, + ); expect( - () => - BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: -10), - throwsArgumentError); + () => BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: -10), + throwsArgumentError, + ); }); test('add and contains work correctly', () { - final filter = - BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: 1000); + final filter = BloomFilterPrehash( + falsePositiveProbability: 0.01, + numItems: 1000, + ); // Add some items filter.add( - '341ac9cefc8364e77f570cf43fb90ce82d53628fd7d1567e3d5716db3852d11a'); + '341ac9cefc8364e77f570cf43fb90ce82d53628fd7d1567e3d5716db3852d11a', + ); filter.add( - '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796'); + '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796', + ); filter.add( - '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0'); + '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0', + ); // Check for added items expect( - filter.contains( - '341ac9cefc8364e77f570cf43fb90ce82d53628fd7d1567e3d5716db3852d11a'), - isTrue); + filter.contains( + '341ac9cefc8364e77f570cf43fb90ce82d53628fd7d1567e3d5716db3852d11a', + ), + isTrue, + ); expect( - filter.contains( - '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796'), - isTrue); + filter.contains( + '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796', + ), + isTrue, + ); expect( - filter.contains( - '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0'), - isTrue); + filter.contains( + '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0', + ), + isTrue, + ); // Check for non-added items expect( - filter.contains( - 'cc125c8c1025487681e086c6b3c5b64d6bce14fdc6502c18f3c7472130ea211e'), - isFalse); + filter.contains( + 'cc125c8c1025487681e086c6b3c5b64d6bce14fdc6502c18f3c7472130ea211e', + ), + isFalse, + ); expect( - filter.contains( - '2da7850b3bca47cbe6c08685ea844b33659e5f8b94df469b6005cc012849ef15'), - isFalse); + filter.contains( + '2da7850b3bca47cbe6c08685ea844b33659e5f8b94df469b6005cc012849ef15', + ), + isFalse, + ); }); test('serialization and deserialization', () { - final originalFilter = - BloomFilterPrehash(falsePositiveProbability: 0.01, numItems: 1000); + final originalFilter = BloomFilterPrehash( + falsePositiveProbability: 0.01, + numItems: 1000, + ); // Add some items originalFilter.add( - '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138'); + '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138', + ); originalFilter.add( - '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6'); + '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6', + ); originalFilter.add( - 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3'); + 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3', + ); // Serialize final serialized = originalFilter.serialize(); @@ -93,29 +119,37 @@ void main() { // Deserialize final deserializedFilter = BloomFilterPrehash.fromNumHashFunctionsAndByteArray( - numHashFunctions: originalFilter.numHashFunctions, - byteArray: base64Decode(serialized), - size: originalFilter.size, - ); + numHashFunctions: originalFilter.numHashFunctions, + byteArray: base64Decode(serialized), + size: originalFilter.size, + ); // Check that deserialized filter behaves the same expect( - deserializedFilter.contains( - '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138'), - isTrue); + deserializedFilter.contains( + '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138', + ), + isTrue, + ); expect( - deserializedFilter.contains( - '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6'), - isTrue); + deserializedFilter.contains( + '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6', + ), + isTrue, + ); expect( - deserializedFilter.contains( - 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3'), - isTrue); + deserializedFilter.contains( + 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3', + ), + isTrue, + ); expect( - deserializedFilter.contains( - 'faf136b76938530e5a6702bf6f25f6f52a714bc7387bdebc64ae0aefbf8e5937'), - isFalse); + deserializedFilter.contains( + 'faf136b76938530e5a6702bf6f25f6f52a714bc7387bdebc64ae0aefbf8e5937', + ), + isFalse, + ); }); test('false positive rate is within expected bounds', () { @@ -123,7 +157,9 @@ void main() { final falsePositiveRate = 0.01; final numItems = 1000; final filter = BloomFilterPrehash( - falsePositiveProbability: falsePositiveRate, numItems: numItems); + falsePositiveProbability: falsePositiveRate, + numItems: numItems, + ); // Add numItems different items for (int i = 0; i < numItems; i++) { @@ -169,19 +205,31 @@ void main() { test('fromNumHashFunctionsAndByteArray constructor validation', () { expect( - () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( - numHashFunctions: 0, byteArray: Uint8List(10), size: 10), - throwsArgumentError); + () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( + numHashFunctions: 0, + byteArray: Uint8List(10), + size: 10, + ), + throwsArgumentError, + ); expect( - () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( - numHashFunctions: -1, byteArray: Uint8List(10), size: 10), - throwsArgumentError); + () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( + numHashFunctions: -1, + byteArray: Uint8List(10), + size: 10, + ), + throwsArgumentError, + ); expect( - () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( - numHashFunctions: 5, byteArray: Uint8List(0), size: 10), - throwsArgumentError); + () => BloomFilterPrehash.fromNumHashFunctionsAndByteArray( + numHashFunctions: 5, + byteArray: Uint8List(0), + size: 10, + ), + throwsArgumentError, + ); }); }); } diff --git a/packages/ndk/test/shared/bloom_filter/bloom_filter_test.dart b/packages/ndk/test/shared/bloom_filter/bloom_filter_test.dart index 95af724ab..aac5e8a1d 100644 --- a/packages/ndk/test/shared/bloom_filter/bloom_filter_test.dart +++ b/packages/ndk/test/shared/bloom_filter/bloom_filter_test.dart @@ -7,54 +7,77 @@ import 'package:test/test.dart'; void main() { group('BloomFilter', () { test('initialization with valid parameters', () { - final filter = - BloomFilter(falsePositiveProbability: 0.01, numItems: 1000); + final filter = BloomFilter( + falsePositiveProbability: 0.01, + numItems: 1000, + ); expect(filter.size, greaterThan(0)); expect(filter.numHashFunctions, greaterThan(0)); }); test('throws exception with invalid probability', () { - expect(() => BloomFilter(falsePositiveProbability: 0, numItems: 1000), - throwsArgumentError); - expect(() => BloomFilter(falsePositiveProbability: 1, numItems: 1000), - throwsArgumentError); - expect(() => BloomFilter(falsePositiveProbability: -0.1, numItems: 1000), - throwsArgumentError); + expect( + () => BloomFilter(falsePositiveProbability: 0, numItems: 1000), + throwsArgumentError, + ); + expect( + () => BloomFilter(falsePositiveProbability: 1, numItems: 1000), + throwsArgumentError, + ); + expect( + () => BloomFilter(falsePositiveProbability: -0.1, numItems: 1000), + throwsArgumentError, + ); }); test('throws exception with invalid number of items', () { - expect(() => BloomFilter(falsePositiveProbability: 0.01, numItems: 0), - throwsArgumentError); - expect(() => BloomFilter(falsePositiveProbability: 0.01, numItems: -10), - throwsArgumentError); + expect( + () => BloomFilter(falsePositiveProbability: 0.01, numItems: 0), + throwsArgumentError, + ); + expect( + () => BloomFilter(falsePositiveProbability: 0.01, numItems: -10), + throwsArgumentError, + ); }); test('add and contains work correctly', () { - final filter = - BloomFilter(falsePositiveProbability: 0.01, numItems: 1000); + final filter = BloomFilter( + falsePositiveProbability: 0.01, + numItems: 1000, + ); // Add some items filter.add( - '395f432aee274459be33c6684fad9471181e0a075bcce8e6fda4050bb1955c51'); + '395f432aee274459be33c6684fad9471181e0a075bcce8e6fda4050bb1955c51', + ); filter.add( - '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796'); + '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796', + ); filter.add( - '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0'); + '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0', + ); filter.add('gerneric-value'); // Check for added items expect( - filter.contains( - '395f432aee274459be33c6684fad9471181e0a075bcce8e6fda4050bb1955c51'), - isTrue); + filter.contains( + '395f432aee274459be33c6684fad9471181e0a075bcce8e6fda4050bb1955c51', + ), + isTrue, + ); expect( - filter.contains( - '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796'), - isTrue); + filter.contains( + '9b8b989bcc4c4e618a136965c8f1c82e9cc3db22568a52bdebeb6f2fa0422796', + ), + isTrue, + ); expect( - filter.contains( - '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0'), - isTrue); + filter.contains( + '0f59b606089c756cf02c67012956638a0c0ae78bbf41be0ee3aabceba4803ba0', + ), + isTrue, + ); expect(filter.contains('gerneric-value'), isTrue); // Check for non-added items @@ -63,16 +86,21 @@ void main() { }); test('serialization and deserialization', () { - final originalFilter = - BloomFilter(falsePositiveProbability: 0.01, numItems: 1000); + final originalFilter = BloomFilter( + falsePositiveProbability: 0.01, + numItems: 1000, + ); // Add some items originalFilter.add( - '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138'); + '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138', + ); originalFilter.add( - '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6'); + '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6', + ); originalFilter.add( - 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3'); + 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3', + ); originalFilter.add('generic-value'); // Serialize @@ -87,22 +115,30 @@ void main() { // Check that deserialized filter behaves the same expect( - deserializedFilter.contains( - '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138'), - isTrue); + deserializedFilter.contains( + '301856e2e523686222cfaa317c9d0314dec97ee4387f1bed82ea82d3ad693138', + ), + isTrue, + ); expect( - deserializedFilter.contains( - '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6'), - isTrue); + deserializedFilter.contains( + '625bac679a9b02a5b737e3c915209481bb604609247802acd39c1c2c3a68d7a6', + ), + isTrue, + ); expect( - deserializedFilter.contains( - 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3'), - isTrue); + deserializedFilter.contains( + 'c878f3e84c39f1671dd763c7e96c89926952cdfdf497f075f3441e6590b1a9d3', + ), + isTrue, + ); expect(deserializedFilter.contains('generic-value'), isTrue); expect( - deserializedFilter.contains( - 'faf136b76938530e5a6702bf6f25f6f52a714bc7387bdebc64ae0aefbf8e5937'), - isFalse); + deserializedFilter.contains( + 'faf136b76938530e5a6702bf6f25f6f52a714bc7387bdebc64ae0aefbf8e5937', + ), + isFalse, + ); }); test('false positive rate is within expected bounds', () { @@ -110,7 +146,9 @@ void main() { final falsePositiveRate = 0.01; final numItems = 1000; final filter = BloomFilter( - falsePositiveProbability: falsePositiveRate, numItems: numItems); + falsePositiveProbability: falsePositiveRate, + numItems: numItems, + ); // Add numItems different items for (int i = 0; i < numItems; i++) { @@ -139,8 +177,10 @@ void main() { test('handles large number of items', () { const numberOfItems = 1000000; - final filter = - BloomFilter(falsePositiveProbability: 0.01, numItems: numberOfItems); + final filter = BloomFilter( + falsePositiveProbability: 0.01, + numItems: numberOfItems, + ); // Add many items for (int i = 0; i < numberOfItems; i++) { @@ -155,19 +195,31 @@ void main() { test('fromNumHashFunctionsAndByteArray constructor validation', () { expect( - () => BloomFilter.fromNumHashFunctionsAndByteArray( - numHashFunctions: 0, byteArray: Uint8List(10), size: 10), - throwsArgumentError); + () => BloomFilter.fromNumHashFunctionsAndByteArray( + numHashFunctions: 0, + byteArray: Uint8List(10), + size: 10, + ), + throwsArgumentError, + ); expect( - () => BloomFilter.fromNumHashFunctionsAndByteArray( - numHashFunctions: -1, byteArray: Uint8List(10), size: 10), - throwsArgumentError); + () => BloomFilter.fromNumHashFunctionsAndByteArray( + numHashFunctions: -1, + byteArray: Uint8List(10), + size: 10, + ), + throwsArgumentError, + ); expect( - () => BloomFilter.fromNumHashFunctionsAndByteArray( - numHashFunctions: 5, byteArray: Uint8List(0), size: 10), - throwsArgumentError); + () => BloomFilter.fromNumHashFunctionsAndByteArray( + numHashFunctions: 5, + byteArray: Uint8List(0), + size: 10, + ), + throwsArgumentError, + ); }); }); } diff --git a/packages/ndk/test/shared/event_filters/nip51_mute_event_filter_test.dart b/packages/ndk/test/shared/event_filters/nip51_mute_event_filter_test.dart index e421ac1f4..31943a008 100644 --- a/packages/ndk/test/shared/event_filters/nip51_mute_event_filter_test.dart +++ b/packages/ndk/test/shared/event_filters/nip51_mute_event_filter_test.dart @@ -43,137 +43,215 @@ void main() { }); test('should filter event from muted pubKey', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'mutedPubKey', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kPubkey, + value: 'mutedPubKey', + private: false, + ), + ); filter.muteList = muteList; final event = createEvent(pubKey: 'mutedPubKey'); expect(filter.filter(event), isFalse); }); test('should not filter event from non-muted pubKey', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'mutedPubKey', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kPubkey, + value: 'mutedPubKey', + private: false, + ), + ); filter.muteList = muteList; final event = createEvent(pubKey: 'anotherPubKey'); expect(filter.filter(event), isTrue); }); test('should not filter metadata events from muted pubKey', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'mutedPubKey', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kPubkey, + value: 'mutedPubKey', + private: false, + ), + ); filter.muteList = muteList; final event = createEvent(pubKey: 'mutedPubKey', kind: Metadata.kKind); expect(filter.filter(event), isTrue); }); test('should filter event with muted word in content', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'secret', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'secret', private: false), + ); filter.muteList = muteList; final event = createEvent(content: 'this is a secret message'); expect(filter.filter(event), isFalse); }); - test('should filter event with muted word (case-insensitive) in content', - () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'Secret', private: false)); - filter.muteList = muteList; - final event = createEvent(content: 'this is a sEcReT message'); - expect(filter.filter(event), isFalse); - }); + test( + 'should filter event with muted word (case-insensitive) in content', + () { + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kWord, + value: 'Secret', + private: false, + ), + ); + filter.muteList = muteList; + final event = createEvent(content: 'this is a sEcReT message'); + expect(filter.filter(event), isFalse); + }, + ); test('should not filter event without muted word in content', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'secret', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'secret', private: false), + ); filter.muteList = muteList; final event = createEvent(content: 'this is a public message'); expect(filter.filter(event), isTrue); }); test('should not filter reaction events with muted word in content', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'secret', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'secret', private: false), + ); filter.muteList = muteList; final event = createEvent( - content: 'this is a secret message', kind: Reaction.kKind); + content: 'this is a secret message', + kind: Reaction.kKind, + ); expect(filter.filter(event), isTrue); }); test('should filter event with muted hashtag', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'mutedTag', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'mutedTag', + private: false, + ), + ); filter.muteList = muteList; - final event = createEvent(tags: [ - ['t', 'mutedTag'] - ]); + final event = createEvent( + tags: [ + ['t', 'mutedTag'], + ], + ); expect(filter.filter(event), isFalse); }); test('should filter event with muted hashtag (case-insensitive)', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'MutedTag', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'MutedTag', + private: false, + ), + ); filter.muteList = muteList; - final event = createEvent(tags: [ - ['t', 'mutedtag'] - ]); + final event = createEvent( + tags: [ + ['t', 'mutedtag'], + ], + ); expect(filter.filter(event), isFalse); }); test('should not filter event without muted hashtag', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'mutedTag', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'mutedTag', + private: false, + ), + ); filter.muteList = muteList; - final event = createEvent(tags: [ - ['t', 'anotherTag'] - ]); + final event = createEvent( + tags: [ + ['t', 'anotherTag'], + ], + ); expect(filter.filter(event), isTrue); }); - test('should not filter event with no t-tags even if hashtags are muted', - () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'mutedTag', private: false)); - filter.muteList = muteList; - final event = createEvent(tags: [ - ['p', 'somepubkey'] - ]); - expect(filter.filter(event), isTrue); - }); + test( + 'should not filter event with no t-tags even if hashtags are muted', + () { + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'mutedTag', + private: false, + ), + ); + filter.muteList = muteList; + final event = createEvent( + tags: [ + ['p', 'somepubkey'], + ], + ); + expect(filter.filter(event), isTrue); + }, + ); test('should filter based on multiple criteria (pubKey and word)', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'mutedAuthor', private: false)); - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'danger', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kPubkey, + value: 'mutedAuthor', + private: false, + ), + ); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'danger', private: false), + ); filter.muteList = muteList; - final eventFromMutedAuthor = - createEvent(pubKey: 'mutedAuthor', content: 'safe content'); - expect(filter.filter(eventFromMutedAuthor), isFalse, - reason: "Event from muted author should be filtered"); + final eventFromMutedAuthor = createEvent( + pubKey: 'mutedAuthor', + content: 'safe content', + ); + expect( + filter.filter(eventFromMutedAuthor), + isFalse, + reason: "Event from muted author should be filtered", + ); - final eventWithMutedWord = - createEvent(pubKey: 'safeAuthor', content: 'this is danger'); - expect(filter.filter(eventWithMutedWord), isFalse, - reason: "Event with muted word should be filtered"); + final eventWithMutedWord = createEvent( + pubKey: 'safeAuthor', + content: 'this is danger', + ); + expect( + filter.filter(eventWithMutedWord), + isFalse, + reason: "Event with muted word should be filtered", + ); }); test('TrieTree: should find a word that exists', () { - final trie = - filter.buildTrieTree(["hello".codeUnits, "world".codeUnits], null); + final trie = filter.buildTrieTree([ + "hello".codeUnits, + "world".codeUnits, + ], null); expect(trie.check("hello"), isTrue); expect(trie.check("world"), isTrue); }); test( - 'TrieTree: should find a word that is a prefix of another word if marked as done', - () { - final trie = - filter.buildTrieTree(["hell".codeUnits, "hello".codeUnits], null); - expect(trie.check("hell"), isTrue); - expect(trie.check("hello"), isTrue); - }); + 'TrieTree: should find a word that is a prefix of another word if marked as done', + () { + final trie = filter.buildTrieTree([ + "hell".codeUnits, + "hello".codeUnits, + ], null); + expect(trie.check("hell"), isTrue); + expect(trie.check("hello"), isTrue); + }, + ); test('TrieTree: should handle empty string check', () { final trie = filter.buildTrieTree(["hello".codeUnits], null); @@ -186,48 +264,64 @@ void main() { }); test('hasMutedWord: should correctly identify muted words', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'test', private: false)); - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'filter', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'test', private: false), + ); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'filter', private: false), + ); filter.muteList = muteList; expect(filter.hasMutedWord('this is a test message'), isTrue); expect(filter.hasMutedWord('apply this filter'), isTrue); - expect(filter.hasMutedWord('This Is A Test Message'), - isTrue); // case-insensitivity + expect( + filter.hasMutedWord('This Is A Test Message'), + isTrue, + ); // case-insensitivity }); test('hasMutedWord: should not identify non-muted words', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kWord, value: 'test', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kWord, value: 'test', private: false), + ); filter.muteList = muteList; expect(filter.hasMutedWord('this is a safe message'), isFalse); }); test('isMutedPubKey: should correctly identify muted pubkeys', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'pk1', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kPubkey, value: 'pk1', private: false), + ); filter.muteList = muteList; expect(filter.isMutedPubKey('pk1'), isTrue); }); test('isMutedPubKey: should not identify non-muted pubkeys', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kPubkey, value: 'pk1', private: false)); + muteList.elements.add( + Nip51ListElement(tag: Nip51List.kPubkey, value: 'pk1', private: false), + ); filter.muteList = muteList; expect(filter.isMutedPubKey('pk2'), isFalse); }); test('hasMutedHashtag: should correctly identify muted hashtags', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'nostr', private: false)); - filter.muteList = muteList; - final eventWithMutedTag = createEvent(tags: [ - ['t', 'nostr'] - ]); - final eventWithMutedTagCaps = createEvent(tags: [ - ['t', 'NOSTR'] - ]); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'nostr', + private: false, + ), + ); + filter.muteList = muteList; + final eventWithMutedTag = createEvent( + tags: [ + ['t', 'nostr'], + ], + ); + final eventWithMutedTagCaps = createEvent( + tags: [ + ['t', 'NOSTR'], + ], + ); expect(filter.hasMutedHashtag(eventWithMutedTag), isTrue); // Nip51MuteEventFilter converts muted tags to lowercase upon setting muteList // and event.tTags are also expected to be lowercase or handled as such by contains. @@ -236,12 +330,19 @@ void main() { }); test('hasMutedHashtag: should not identify non-muted hashtags', () { - muteList.elements.add(Nip51ListElement( - tag: Nip51List.kHashtag, value: 'nostr', private: false)); + muteList.elements.add( + Nip51ListElement( + tag: Nip51List.kHashtag, + value: 'nostr', + private: false, + ), + ); filter.muteList = muteList; - final eventWithoutMutedTag = createEvent(tags: [ - ['t', 'bitcoin'] - ]); + final eventWithoutMutedTag = createEvent( + tags: [ + ['t', 'bitcoin'], + ], + ); expect(filter.hasMutedHashtag(eventWithoutMutedTag), isFalse); }); }); diff --git a/packages/ndk/test/shared/event_kind_classification_test.dart b/packages/ndk/test/shared/event_kind_classification_test.dart new file mode 100644 index 000000000..e5c86b481 --- /dev/null +++ b/packages/ndk/test/shared/event_kind_classification_test.dart @@ -0,0 +1,48 @@ +import 'package:ndk/shared/nips/nip01/event_kind_classification.dart'; +import 'package:ndk/shared/nips/nip28/channel_metadata.dart'; +import 'package:ndk/domain_layer/entities/contact_list.dart'; +import 'package:ndk/domain_layer/entities/metadata.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventKindClassification', () { + test('detects singleton replaceable kinds', () { + expect(EventKindClassification.isReplaceableKind(Metadata.kKind), isTrue); + expect( + EventKindClassification.isReplaceableKind(ContactList.kKind), + isTrue, + ); + expect( + EventKindClassification.isReplaceableKind(ChannelMetadata.kKind), + isTrue, + ); + expect( + EventKindClassification.isAddressableKind(Metadata.kKind), + isFalse, + ); + }); + + test('distinguishes regular replaceable from addressable kinds', () { + expect(EventKindClassification.isReplaceableKind(10002), isTrue); + expect(EventKindClassification.isRegularReplaceableKind(10002), isTrue); + expect(EventKindClassification.isAddressableKind(10002), isFalse); + expect( + EventKindClassification.isParameterizedReplaceableKind(10002), + isFalse, + ); + + expect(EventKindClassification.isReplaceableKind(30023), isTrue); + expect( + EventKindClassification.isParameterizedReplaceableKind(30023), + isTrue, + ); + expect(EventKindClassification.isAddressableKind(30023), isTrue); + }); + + test('detects ephemeral kinds separately', () { + expect(EventKindClassification.isEphemeralKind(24133), isTrue); + expect(EventKindClassification.isReplaceableKind(24133), isFalse); + expect(EventKindClassification.isAddressableKind(24133), isFalse); + }); + }); +} diff --git a/packages/ndk/test/shared/helpers/concurrency_limiter_mixin_test.dart b/packages/ndk/test/shared/helpers/concurrency_limiter_mixin_test.dart index 7a92c44c5..ae115909b 100644 --- a/packages/ndk/test/shared/helpers/concurrency_limiter_mixin_test.dart +++ b/packages/ndk/test/shared/helpers/concurrency_limiter_mixin_test.dart @@ -8,10 +8,10 @@ class _Limiter with ConcurrencyLimiterMixin { @override final int maxConcurrentRequests; _Limiter(this.maxConcurrentRequests) - : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0'); + : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0'); } -/// Mimics the way a real signer (NIP-07, Amber, NIP-46) drives the mixin: +/// Mimics the way a real signer (NIP-07, NIP-55, NIP-46) drives the mixin: /// each operation is wrapped in `runThrottled`, and we observe the number /// of operations that are *actually* executing at the same time. class _PeakTrackingSigner with ConcurrencyLimiterMixin { @@ -45,41 +45,45 @@ void main() { expect(limiter.queuedRequests, 0); }); - test('queues operations beyond the limit and runs them in FIFO order', - () async { - final limiter = _Limiter(2); - final gates = List.generate(4, (_) => Completer()); - final completionOrder = []; - - final futures = >[]; - for (var i = 0; i < 4; i++) { - futures.add(limiter.runThrottled(() async { - await gates[i].future; - completionOrder.add(i); - })); - } + test( + 'queues operations beyond the limit and runs them in FIFO order', + () async { + final limiter = _Limiter(2); + final gates = List.generate(4, (_) => Completer()); + final completionOrder = []; + + final futures = >[]; + for (var i = 0; i < 4; i++) { + futures.add( + limiter.runThrottled(() async { + await gates[i].future; + completionOrder.add(i); + }), + ); + } - // Let the event loop process the acquire calls. - await Future.delayed(Duration.zero); - expect(limiter.inFlightRequests, 2); - expect(limiter.queuedRequests, 2); + // Let the event loop process the acquire calls. + await Future.delayed(Duration.zero); + expect(limiter.inFlightRequests, 2); + expect(limiter.queuedRequests, 2); - // Finish #1 first; #2 should pick up the slot, not #3. - gates[1].complete(); - await Future.delayed(Duration.zero); - expect(completionOrder, [1]); - expect(limiter.inFlightRequests, 2); - expect(limiter.queuedRequests, 1); + // Finish #1 first; #2 should pick up the slot, not #3. + gates[1].complete(); + await Future.delayed(Duration.zero); + expect(completionOrder, [1]); + expect(limiter.inFlightRequests, 2); + expect(limiter.queuedRequests, 1); - gates[0].complete(); - gates[2].complete(); - gates[3].complete(); - await Future.wait(futures); + gates[0].complete(); + gates[2].complete(); + gates[3].complete(); + await Future.wait(futures); - expect(completionOrder, [1, 0, 2, 3]); - expect(limiter.inFlightRequests, 0); - expect(limiter.queuedRequests, 0); - }); + expect(completionOrder, [1, 0, 2, 3]); + expect(limiter.inFlightRequests, 0); + expect(limiter.queuedRequests, 0); + }, + ); test('releases the slot even when the task throws', () async { final limiter = _Limiter(1); @@ -98,95 +102,105 @@ void main() { expect(() => _Limiter(-1), throwsA(isA())); }); - test('peak in-flight never exceeds maxConcurrentRequests under load', - () async { - final signer = _PeakTrackingSigner(2); - final completers = List.generate(10, (_) => Completer()); - - final futures = List.generate( - 10, - (i) => signer.doWork(() => completers[i].future), - ); - - // Resolve the gates in shuffled order to mimic out-of-order responses - // from a remote signer (e.g. bunker, browser extension). - final order = [3, 0, 7, 1, 9, 4, 2, 8, 5, 6]; - for (final i in order) { - completers[i].complete(); - // Yield so the mixin can release the slot and start the next waiter - // before we resolve the following gate. - await Future.delayed(Duration.zero); - } - - await Future.wait(futures); - - expect(signer.peakInFlight, 2, - reason: 'no more than 2 operations should ever run in parallel'); - expect(signer.inFlightRequests, 0); - expect(signer.queuedRequests, 0); - }); - - test('a queued task that detects cancellation can skip its work entirely', - () async { - // Mirrors the pattern each remote signer uses: the lambda passed to - // runThrottled checks shared state when it gets a slot and bails out - // before touching the network if the caller no longer wants the call. - final limiter = _Limiter(1); - final cancelled = {}; - final executed = []; - - final blocker = Completer(); - final inFlight = limiter.runThrottled(() async { - executed.add(0); - await blocker.future; - }); - - await Future.delayed(Duration.zero); - - final queued = limiter.runThrottled(() async { - if (cancelled.contains(1)) { - throw StateError('cancelled before execution'); + test( + 'peak in-flight never exceeds maxConcurrentRequests under load', + () async { + final signer = _PeakTrackingSigner(2); + final completers = List.generate(10, (_) => Completer()); + + final futures = List.generate( + 10, + (i) => signer.doWork(() => completers[i].future), + ); + + // Resolve the gates in shuffled order to mimic out-of-order responses + // from a remote signer (e.g. bunker, browser extension). + final order = [3, 0, 7, 1, 9, 4, 2, 8, 5, 6]; + for (final i in order) { + completers[i].complete(); + // Yield so the mixin can release the slot and start the next waiter + // before we resolve the following gate. + await Future.delayed(Duration.zero); } - executed.add(1); - }); - await Future.delayed(Duration.zero); - cancelled.add(1); - blocker.complete(); + await Future.wait(futures); + + expect( + signer.peakInFlight, + 2, + reason: 'no more than 2 operations should ever run in parallel', + ); + expect(signer.inFlightRequests, 0); + expect(signer.queuedRequests, 0); + }, + ); + + test( + 'a queued task that detects cancellation can skip its work entirely', + () async { + // Mirrors the pattern each remote signer uses: the lambda passed to + // runThrottled checks shared state when it gets a slot and bails out + // before touching the network if the caller no longer wants the call. + final limiter = _Limiter(1); + final cancelled = {}; + final executed = []; + + final blocker = Completer(); + final inFlight = limiter.runThrottled(() async { + executed.add(0); + await blocker.future; + }); - await expectLater(queued, throwsA(isA())); - await inFlight; + await Future.delayed(Duration.zero); - expect(executed, [0], - reason: 'cancelled task must not run when its slot frees'); - expect(limiter.inFlightRequests, 0); - }); + final queued = limiter.runThrottled(() async { + if (cancelled.contains(1)) { + throw StateError('cancelled before execution'); + } + executed.add(1); + }); - test('cancelAllQueued rejects pending waiters but spares in-flight tasks', - () async { - final limiter = _Limiter(1); - final running = Completer(); - final inFlight = limiter.runThrottled(() async { - await running.future; - return 'done'; - }); + await Future.delayed(Duration.zero); + cancelled.add(1); + blocker.complete(); + + await expectLater(queued, throwsA(isA())); + await inFlight; + + expect(executed, [ + 0, + ], reason: 'cancelled task must not run when its slot frees'); + expect(limiter.inFlightRequests, 0); + }, + ); + + test( + 'cancelAllQueued rejects pending waiters but spares in-flight tasks', + () async { + final limiter = _Limiter(1); + final running = Completer(); + final inFlight = limiter.runThrottled(() async { + await running.future; + return 'done'; + }); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); - final queued1 = limiter.runThrottled(() async => 'never'); - final queued2 = limiter.runThrottled(() async => 'never'); + final queued1 = limiter.runThrottled(() async => 'never'); + final queued2 = limiter.runThrottled(() async => 'never'); - await Future.delayed(Duration.zero); - expect(limiter.queuedRequests, 2); + await Future.delayed(Duration.zero); + expect(limiter.queuedRequests, 2); - limiter.cancelAllQueued(); + limiter.cancelAllQueued(); - await expectLater(queued1, throwsA(isA())); - await expectLater(queued2, throwsA(isA())); - expect(limiter.queuedRequests, 0); + await expectLater(queued1, throwsA(isA())); + await expectLater(queued2, throwsA(isA())); + expect(limiter.queuedRequests, 0); - running.complete(); - expect(await inFlight, 'done'); - }); + running.complete(); + expect(await inFlight, 'done'); + }, + ); }); } diff --git a/packages/ndk/test/shared/helpers/relay_helper_test.dart b/packages/ndk/test/shared/helpers/relay_helper_test.dart index 42c3de03a..6f890282a 100644 --- a/packages/ndk/test/shared/helpers/relay_helper_test.dart +++ b/packages/ndk/test/shared/helpers/relay_helper_test.dart @@ -5,8 +5,10 @@ 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', () { @@ -18,13 +20,17 @@ void main() { }); test('accepts URL with port', () { - expect(cleanRelayUrl('wss://relay.example.com:8080'), - 'wss://relay.example.com:8080'); + expect( + cleanRelayUrl('wss://relay.example.com:8080'), + 'wss://relay.example.com:8080', + ); }); test('accepts URL with subdomain', () { - expect(cleanRelayUrl('wss://nostr.relay.example.com'), - 'wss://nostr.relay.example.com'); + expect( + cleanRelayUrl('wss://nostr.relay.example.com'), + 'wss://nostr.relay.example.com', + ); }); test('accepts IP address', () { @@ -33,7 +39,9 @@ void main() { test('accepts IP address with port', () { expect( - cleanRelayUrl('wss://192.168.1.1:8080'), 'wss://192.168.1.1:8080'); + cleanRelayUrl('wss://192.168.1.1:8080'), + 'wss://192.168.1.1:8080', + ); }); test('accepts localhost', () { @@ -52,12 +60,16 @@ void main() { test('trims whitespace', () { expect( - cleanRelayUrl(' wss://relay.damus.io '), 'wss://relay.damus.io'); + cleanRelayUrl(' wss://relay.damus.io '), + 'wss://relay.damus.io', + ); }); test('decodes percent-encoded URL', () { - expect(cleanRelayUrl('wss://relay.example%2Ecom'), - 'wss://relay.example.com'); + expect( + cleanRelayUrl('wss://relay.example%2Ecom'), + 'wss://relay.example.com', + ); }); test('fixes triple slash URL', () { @@ -114,36 +126,22 @@ void main() { }); test('filters out invalid URLs', () { - final urls = [ - 'wss://relay.damus.io', - 'invalid-url', - 'wss://nos.lol', - ]; + final urls = ['wss://relay.damus.io', 'invalid-url', 'wss://nos.lol']; expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); }); test('cleans valid URLs', () { - final urls = [ - 'wss://relay.damus.io/', - ' wss://nos.lol ', - ]; + final urls = ['wss://relay.damus.io/', ' wss://nos.lol ']; expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); }); test('returns empty list when all URLs are invalid', () { - final urls = [ - 'invalid', - 'http://example.com', - '', - ]; + final urls = ['invalid', 'http://example.com', '']; expect(cleanRelayUrls(urls), []); }); test('fixes triple slash URLs in list', () { - final urls = [ - 'wss:///relay.damus.io', - 'wss://nos.lol', - ]; + final urls = ['wss:///relay.damus.io', 'wss://nos.lol']; expect(cleanRelayUrls(urls), ['wss://relay.damus.io', 'wss://nos.lol']); }); @@ -153,8 +151,11 @@ void main() { 'wss://nos.lol:443', 'WS://LOCALHOST:80', ]; - expect(cleanRelayUrls(urls), - ['wss://relay.damus.io', 'wss://nos.lol', 'ws://localhost']); + expect(cleanRelayUrls(urls), [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'ws://localhost', + ]); }); }); diff --git a/packages/ndk/test/shared/helpers/url_normalizer_test.dart b/packages/ndk/test/shared/helpers/url_normalizer_test.dart index c1988b80b..f4c00becd 100644 --- a/packages/ndk/test/shared/helpers/url_normalizer_test.dart +++ b/packages/ndk/test/shared/helpers/url_normalizer_test.dart @@ -28,90 +28,135 @@ void main() { }); test('preserves non-default ports', () { - expect(cleanRelayUrl('wss://relay.damus.io:8080'), - 'wss://relay.damus.io:8080'); + expect( + cleanRelayUrl('wss://relay.damus.io:8080'), + 'wss://relay.damus.io:8080', + ); expect(cleanRelayUrl('ws://localhost:8080'), 'ws://localhost:8080'); expect( - cleanRelayUrl('wss://relay.damus.io:80'), 'wss://relay.damus.io:80'); + cleanRelayUrl('wss://relay.damus.io:80'), + 'wss://relay.damus.io:80', + ); expect(cleanRelayUrl('ws://localhost:443'), 'ws://localhost:443'); }); test('preserves query string', () { - expect(cleanRelayUrl('wss://relay.damus.io/path?query=value'), - 'wss://relay.damus.io/path?query=value'); + expect( + cleanRelayUrl('wss://relay.damus.io/path?query=value'), + 'wss://relay.damus.io/path?query=value', + ); }); test('preserves fragment', () { - expect(cleanRelayUrl('wss://relay.damus.io/path#section'), - 'wss://relay.damus.io/path#section'); + expect( + cleanRelayUrl('wss://relay.damus.io/path#section'), + 'wss://relay.damus.io/path#section', + ); }); test('preserves query and fragment together', () { - expect(cleanRelayUrl('wss://relay.damus.io/path?query=value#section'), - 'wss://relay.damus.io/path?query=value#section'); + expect( + cleanRelayUrl('wss://relay.damus.io/path?query=value#section'), + 'wss://relay.damus.io/path?query=value#section', + ); }); test('normalizes host while preserving query and fragment', () { - expect(cleanRelayUrl('wss://RELAY.DAMUS.IO/path?query=value#section'), - 'wss://relay.damus.io/path?query=value#section'); + expect( + cleanRelayUrl('wss://RELAY.DAMUS.IO/path?query=value#section'), + 'wss://relay.damus.io/path?query=value#section', + ); }); test('normalizes percent-encoding in query string (RFC 3986)', () { // %41 = 'A' (unreserved) should be decoded - expect(cleanRelayUrl('wss://relay.damus.io/path?key%41=value'), - 'wss://relay.damus.io/path?keyA=value'); + expect( + cleanRelayUrl('wss://relay.damus.io/path?key%41=value'), + 'wss://relay.damus.io/path?keyA=value', + ); // %2f = '/' (reserved) should stay encoded but uppercase - expect(cleanRelayUrl('wss://relay.damus.io/path?key=%2fvalue'), - 'wss://relay.damus.io/path?key=%2Fvalue'); + expect( + cleanRelayUrl('wss://relay.damus.io/path?key=%2fvalue'), + 'wss://relay.damus.io/path?key=%2Fvalue', + ); }); test('normalizes percent-encoding in fragment (RFC 3986)', () { // %7E = '~' (unreserved) should be decoded - expect(cleanRelayUrl('wss://relay.damus.io/path#section%7E'), - 'wss://relay.damus.io/path#section~'); + expect( + cleanRelayUrl('wss://relay.damus.io/path#section%7E'), + 'wss://relay.damus.io/path#section~', + ); // %23 = '#' (reserved) should stay encoded but uppercase - expect(cleanRelayUrl('wss://relay.damus.io/path#section%2f'), - 'wss://relay.damus.io/path#section%2F'); + expect( + cleanRelayUrl('wss://relay.damus.io/path#section%2f'), + 'wss://relay.damus.io/path#section%2F', + ); }); test('removes dot segments from path (RFC 3986 Section 5.2.4)', () { - expect(cleanRelayUrl('wss://relay.damus.io/a/./b'), - 'wss://relay.damus.io/a/b'); - expect(cleanRelayUrl('wss://relay.damus.io/a/../b'), - 'wss://relay.damus.io/b'); - expect(cleanRelayUrl('wss://relay.damus.io/a/b/c/../d'), - 'wss://relay.damus.io/a/b/d'); - expect(cleanRelayUrl('wss://relay.damus.io/a/b/../../../c'), - 'wss://relay.damus.io/c'); + expect( + cleanRelayUrl('wss://relay.damus.io/a/./b'), + 'wss://relay.damus.io/a/b', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/a/../b'), + 'wss://relay.damus.io/b', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/a/b/c/../d'), + 'wss://relay.damus.io/a/b/d', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/a/b/../../../c'), + 'wss://relay.damus.io/c', + ); }); test( - 'decodes percent-encoded unreserved characters (RFC 3986 Section 6.2.2.2)', - () { - // %41 = 'A', %7E = '~', %2D = '-' - expect(cleanRelayUrl('wss://relay.damus.io/path%41'), - 'wss://relay.damus.io/pathA'); - expect(cleanRelayUrl('wss://relay.damus.io/path%7E'), - 'wss://relay.damus.io/path~'); - expect(cleanRelayUrl('wss://relay.damus.io/path%2D'), - 'wss://relay.damus.io/path-'); - }); + 'decodes percent-encoded unreserved characters (RFC 3986 Section 6.2.2.2)', + () { + // %41 = 'A', %7E = '~', %2D = '-' + expect( + cleanRelayUrl('wss://relay.damus.io/path%41'), + 'wss://relay.damus.io/pathA', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/path%7E'), + 'wss://relay.damus.io/path~', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/path%2D'), + 'wss://relay.damus.io/path-', + ); + }, + ); - test('uppercases hex digits in percent-encoding (RFC 3986 Section 6.2.2.2)', - () { - // %2f (slash) should become %2F - expect(cleanRelayUrl('wss://relay.damus.io/path%2fto'), - 'wss://relay.damus.io/path%2Fto'); - expect(cleanRelayUrl('wss://relay.damus.io/path%2a'), - 'wss://relay.damus.io/path%2A'); - }); + test( + 'uppercases hex digits in percent-encoding (RFC 3986 Section 6.2.2.2)', + () { + // %2f (slash) should become %2F + expect( + cleanRelayUrl('wss://relay.damus.io/path%2fto'), + 'wss://relay.damus.io/path%2Fto', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/path%2a'), + 'wss://relay.damus.io/path%2A', + ); + }, + ); test('keeps reserved characters percent-encoded (RFC 3986)', () { // Reserved characters should stay encoded: / ? # [ ] @ ! $ & ' ( ) * + , ; = - expect(cleanRelayUrl('wss://relay.damus.io/path%2F'), - 'wss://relay.damus.io/path%2F'); - expect(cleanRelayUrl('wss://relay.damus.io/path%3F'), - 'wss://relay.damus.io/path%3F'); + expect( + cleanRelayUrl('wss://relay.damus.io/path%2F'), + 'wss://relay.damus.io/path%2F', + ); + expect( + cleanRelayUrl('wss://relay.damus.io/path%3F'), + 'wss://relay.damus.io/path%3F', + ); }); }); } diff --git a/packages/ndk/test/shared/logger_test.dart b/packages/ndk/test/shared/logger_test.dart index 4d8b54c08..1e0b59de7 100644 --- a/packages/ndk/test/shared/logger_test.dart +++ b/packages/ndk/test/shared/logger_test.dart @@ -162,10 +162,7 @@ void main() { final testOutput = _TestLogOutput(testOutputs); // Create logger with outputs in constructor - final logger = NdkLogger( - level: LogLevel.info, - outputs: [testOutput], - ); + final logger = NdkLogger(level: LogLevel.info, outputs: [testOutput]); // Log a message logger.i(() => 'Test message'); @@ -176,37 +173,40 @@ void main() { expect(testOutputs.first.level, LogLevel.info); // Test with null outputs (should default to empty list) - final loggerWithoutOutputs = NdkLogger( - level: LogLevel.debug, - ); + final loggerWithoutOutputs = NdkLogger(level: LogLevel.debug); // Should not throw when logging without outputs expect( - () => loggerWithoutOutputs.d(() => 'Debug message'), returnsNormally); + () => loggerWithoutOutputs.d(() => 'Debug message'), + returnsNormally, + ); }); test('Logger lazy closure is only evaluated when enabled', () { var evaluated = false; final testOutputs = []; final testOutput = _TestLogOutput(testOutputs); - final logger = NdkLogger( - level: LogLevel.info, - outputs: [testOutput], - ); + final logger = NdkLogger(level: LogLevel.info, outputs: [testOutput]); logger.d(() { evaluated = true; return 'Should not log'; }); - expect(evaluated, false, - reason: 'Closure should not be evaluated when level is filtered'); + expect( + evaluated, + false, + reason: 'Closure should not be evaluated when level is filtered', + ); logger.i(() { evaluated = true; return 'Should log'; }); - expect(evaluated, true, - reason: 'Closure should be evaluated when level is enabled'); + expect( + evaluated, + true, + reason: 'Closure should be evaluated when level is enabled', + ); expect(testOutputs.length, 1); expect(testOutputs.first.message, 'Should log'); expect(testOutputs.first.level, LogLevel.info); diff --git a/packages/ndk/test/signers/bip340_event_signer_test.dart b/packages/ndk/test/signers/bip340_event_signer_test.dart index 5624cb739..3f438d5f0 100644 --- a/packages/ndk/test/signers/bip340_event_signer_test.dart +++ b/packages/ndk/test/signers/bip340_event_signer_test.dart @@ -115,8 +115,10 @@ void main() { expect(encrypted, isNotNull); expect(encrypted, isNot(equals(message))); - final decrypted = - await otherSigner.decrypt(encrypted!, keyPair.publicKey); + final decrypted = await otherSigner.decrypt( + encrypted!, + keyPair.publicKey, + ); expect(decrypted, equals(message)); await otherSigner.dispose(); @@ -211,24 +213,22 @@ void main() { await signer.dispose(); }); - test('creates signer with only private key (derives public key)', - () async { - final keyPair = Bip340.generatePrivateKey(); - final signer = factory.create( - privateKey: keyPair.privateKey, - ); + test( + 'creates signer with only private key (derives public key)', + () async { + final keyPair = Bip340.generatePrivateKey(); + final signer = factory.create(privateKey: keyPair.privateKey); - expect(signer.getPublicKey(), equals(keyPair.publicKey)); - expect(signer.canSign(), isTrue); + expect(signer.getPublicKey(), equals(keyPair.publicKey)); + expect(signer.canSign(), isTrue); - await signer.dispose(); - }); + await signer.dispose(); + }, + ); test('creates read-only signer with only public key', () async { final keyPair = Bip340.generatePrivateKey(); - final signer = factory.create( - publicKey: keyPair.publicKey, - ); + final signer = factory.create(publicKey: keyPair.publicKey); expect(signer.getPublicKey(), equals(keyPair.publicKey)); expect(signer.canSign(), isFalse); @@ -237,10 +237,7 @@ void main() { }); test('throws ArgumentError when neither key is provided', () { - expect( - () => factory.create(), - throwsArgumentError, - ); + expect(() => factory.create(), throwsArgumentError); }); test('derived public key can sign valid events', () async { @@ -266,23 +263,25 @@ void main() { await signer.dispose(); }); - test('uses provided public key even if derivation would differ', - () async { - final keyPair1 = Bip340.generatePrivateKey(); - final keyPair2 = Bip340.generatePrivateKey(); + test( + 'uses provided public key even if derivation would differ', + () async { + final keyPair1 = Bip340.generatePrivateKey(); + final keyPair2 = Bip340.generatePrivateKey(); - // Intentionally mismatched keys - final signer = factory.create( - privateKey: keyPair1.privateKey, - publicKey: keyPair2.publicKey, // Different public key - ); + // Intentionally mismatched keys + final signer = factory.create( + privateKey: keyPair1.privateKey, + publicKey: keyPair2.publicKey, // Different public key + ); - // Should use the provided public key, not derived - expect(signer.getPublicKey(), equals(keyPair2.publicKey)); - expect(signer.getPublicKey(), isNot(equals(keyPair1.publicKey))); + // Should use the provided public key, not derived + expect(signer.getPublicKey(), equals(keyPair2.publicKey)); + expect(signer.getPublicKey(), isNot(equals(keyPair1.publicKey))); - await signer.dispose(); - }); + await signer.dispose(); + }, + ); }); group('createWithNewKeyPair', () { diff --git a/packages/ndk/test/signers/nip46_event_signer_test.dart b/packages/ndk/test/signers/nip46_event_signer_test.dart index 142e80eb4..4f25674d4 100644 --- a/packages/ndk/test/signers/nip46_event_signer_test.dart +++ b/packages/ndk/test/signers/nip46_event_signer_test.dart @@ -13,15 +13,19 @@ void main() { late BunkerConnection connection; late Ndk ndk; late MockRelay mockRelay; + var signerInitialized = false; + var ndkInitialized = false; + var relayInitialized = false; setUp(() async { + signerInitialized = false; + ndkInitialized = false; + relayInitialized = false; + // Start the mock relay with NIP-46 support - mockRelay = MockRelay( - name: 'nip46-test-relay', - signEvents: true, - explicitPort: 4046, // Use a specific port for NIP-46 tests - ); + mockRelay = MockRelay(name: 'nip46-test-relay', signEvents: true); await mockRelay.startServer(); + relayInitialized = true; ndk = Ndk( NdkConfig( @@ -31,6 +35,8 @@ void main() { logLevel: Logger.logLevels.trace, ), ); + ndkInitialized = true; + await ndk.relays.seedRelaysConnected; connection = BunkerConnection( privateKey: @@ -41,16 +47,24 @@ void main() { ); signer = Nip46EventSigner( - connection: connection, - requests: ndk.requests, - broadcast: ndk.broadcast, - eventSignerFactory: eventSignerFactory); + connection: connection, + requests: ndk.requests, + broadcast: ndk.broadcast, + eventSignerFactory: eventSignerFactory, + ); + signerInitialized = true; }); tearDown(() async { - signer.dispose(); - await ndk.destroy(); - await mockRelay.stopServer(); + if (signerInitialized) { + signer.dispose(); + } + if (ndkInitialized) { + await ndk.destroy(); + } + if (relayInitialized) { + await mockRelay.stopServer(); + } }); test('canSign should return true', () { @@ -80,7 +94,7 @@ void main() { pubKey: MockRelay.remoteSignerPublicKey, kind: 1, tags: [ - ['t', 'test'] + ['t', 'test'], ], content: 'requested content', createdAt: 1234567890, @@ -114,7 +128,8 @@ void main() { test('login with bunker URL should connect successfully', () async { // Create bunker URL with mock relay's remote signer - final bunkerUrl = 'bunker://${MockRelay.remoteSignerPublicKey}' + final bunkerUrl = + 'bunker://${MockRelay.remoteSignerPublicKey}' '?relay=${mockRelay.url}' '&secret=test-secret-123'; @@ -134,8 +149,10 @@ void main() { ); expect(bunkerConnection, isNotNull); - expect(bunkerConnection!.remotePubkey, - equals(MockRelay.remoteSignerPublicKey)); + expect( + bunkerConnection!.remotePubkey, + equals(MockRelay.remoteSignerPublicKey), + ); expect(bunkerConnection.relays, contains(mockRelay.url)); // Create a signer with the connection and test signing @@ -163,7 +180,8 @@ void main() { test('loginWithBunkerUrl should set up account correctly', () async { // Create bunker URL with mock relay's remote signer - final bunkerUrl = 'bunker://${MockRelay.remoteSignerPublicKey}' + final bunkerUrl = + 'bunker://${MockRelay.remoteSignerPublicKey}' '?relay=${mockRelay.url}' '&secret=bunker-url-test-secret'; diff --git a/packages/ndk/test/timeouts/network_speed_test.dart b/packages/ndk/test/timeouts/network_speed_test.dart index d1a1dd4f3..a0656db45 100644 --- a/packages/ndk/test/timeouts/network_speed_test.dart +++ b/packages/ndk/test/timeouts/network_speed_test.dart @@ -56,19 +56,17 @@ void main() { final stopwatch = Stopwatch()..start(); final response = ndk.requests.query( - filters: [ - Filter( - authors: [key1.publicKey], - kinds: [Nip01Event.kTextNodeKind], - ) - ], - timeout: Duration(milliseconds: timoutMiliseconds), - timeoutCallback: () { - timeoutTriggered = true; - }, - timeoutCallbackUserFacing: () { - timeoutUserTriggered = true; - }); + filters: [ + Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]), + ], + timeout: Duration(milliseconds: timoutMiliseconds), + timeoutCallback: () { + timeoutTriggered = true; + }, + timeoutCallbackUserFacing: () { + timeoutUserTriggered = true; + }, + ); // wait for completion final responseData = await response.future; @@ -85,7 +83,8 @@ void main() { // ignore: avoid_print print( - 'low level - network faster then timeout Query took: ${stopwatch.elapsedMilliseconds}ms'); + 'low level - network faster then timeout Query took: ${stopwatch.elapsedMilliseconds}ms', + ); }); test('query - one dead seed relay', () async { @@ -96,7 +95,7 @@ void main() { bootstrapRelays: [ relay1.url, relay2.url, - 'wss://dead-relay.example.com' + 'wss://dead-relay.example.com', ], logLevel: Logger.logLevels.all, ); @@ -109,19 +108,17 @@ void main() { final stopwatch = Stopwatch()..start(); final response = ndk.requests.query( - filters: [ - Filter( - authors: [key1.publicKey], - kinds: [Nip01Event.kTextNodeKind], - ) - ], - timeout: Duration(milliseconds: timoutMiliseconds), - timeoutCallback: () { - timeoutTriggered = true; - }, - timeoutCallbackUserFacing: () { - timeoutUserTriggered = true; - }); + filters: [ + Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]), + ], + timeout: Duration(milliseconds: timoutMiliseconds), + timeoutCallback: () { + timeoutTriggered = true; + }, + timeoutCallbackUserFacing: () { + timeoutUserTriggered = true; + }, + ); // wait for completion final responseData = await response.future; @@ -159,9 +156,7 @@ void main() { Map key1TextNotes = {key1: textNote(key1)}; - Map nip65s = { - key1: nip65ForKey1, - }; + Map nip65s = {key1: nip65ForKey1}; const timoutMiliseconds = 5000; @@ -195,8 +190,9 @@ void main() { // Start the stopwatch final stopwatch = Stopwatch()..start(); - final response = - ndk.userRelayLists.getSingleUserRelayList(key1.publicKey); + final response = ndk.userRelayLists.getSingleUserRelayList( + key1.publicKey, + ); // wait for completion await response; @@ -211,7 +207,8 @@ void main() { // ignore: avoid_print print( - 'high level - network faster then timeout Query took: ${stopwatch.elapsedMilliseconds}ms'); + 'high level - network faster then timeout Query took: ${stopwatch.elapsedMilliseconds}ms', + ); }); test('query - nip65 - no data', () async { @@ -229,8 +226,9 @@ void main() { // Start the stopwatch final stopwatch = Stopwatch()..start(); - final response = - ndk.userRelayLists.getSingleUserRelayList("notExistingPubkey"); + final response = ndk.userRelayLists.getSingleUserRelayList( + "notExistingPubkey", + ); // wait for completion await response; @@ -289,20 +287,20 @@ void main() { // Start the stopwatch final stopwatch = Stopwatch()..start(); - final response1 = - ndk.userRelayLists.getSingleUserRelayList(key1.publicKey); + final response1 = ndk.userRelayLists.getSingleUserRelayList( + key1.publicKey, + ); // wait for completion final response1Data = await response1; expect(response1Data, isNotNull); - final response2 = ndk.requests.query(filters: [ - Filter( - authors: [key1.publicKey], - kinds: [Nip01Event.kTextNodeKind], - ) - ]); + final response2 = ndk.requests.query( + filters: [ + Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]), + ], + ); final responseData = await response2.future; expect(responseData, isNotEmpty); @@ -327,10 +325,12 @@ void main() { ), ); - final events = await ndk.requests.query( - filter: Filter(kinds: [1]), - explicitRelays: ['ws://127.0.0.1:59999'], - ).future; + final events = await ndk.requests + .query( + filter: Filter(kinds: [1]), + explicitRelays: ['ws://127.0.0.1:59999'], + ) + .future; expect(events, isEmpty); }); diff --git a/packages/ndk/test/timeouts/query_timeout_test.dart b/packages/ndk/test/timeouts/query_timeout_test.dart index 878855ae8..b9c13b9a0 100644 --- a/packages/ndk/test/timeouts/query_timeout_test.dart +++ b/packages/ndk/test/timeouts/query_timeout_test.dart @@ -9,26 +9,30 @@ import '../mocks/mock_relay.dart'; void main() { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key $key2}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key $key2}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } group('Timeout - query', () { KeyPair key1 = Bip340.generatePrivateKey(); - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 8201); + late MockRelay relay1; + late Ndk ndk; Map key1TextNotes = {key1: textNote(key1)}; // startup and teardown setUp(() async { + relay1 = MockRelay(name: "relay 1"); await relay1.startServer(); relay1.textNotes = key1TextNotes; }); tearDown(() async { - await relay1.server!.close(force: true); + await ndk.destroy(); + await relay1.stopServer(); }); test('timeout does not trigger on normal request', () async { @@ -39,23 +43,24 @@ void main() { bootstrapRelays: [relay1.url], ); - final ndk = Ndk(config); + ndk = Ndk(config); bool timeoutTriggered = false; bool timeoutUserTriggered = false; final response = ndk.requests.query( - filters: [ - Filter(authors: [key1.publicKey]) - ], - // short to fail fast - timeout: Duration(seconds: 1), - timeoutCallback: () { - timeoutTriggered = true; - }, - timeoutCallbackUserFacing: () { - timeoutUserTriggered = true; - }); + filters: [ + Filter(authors: [key1.publicKey]), + ], + // Allow enough headroom to remain stable under parallel suite load. + timeout: Duration(seconds: 4), + timeoutCallback: () { + timeoutTriggered = true; + }, + timeoutCallbackUserFacing: () { + timeoutUserTriggered = true; + }, + ); // wait for completion await response.future; @@ -72,55 +77,17 @@ void main() { bootstrapRelays: ["invalid"], ); - final ndk = Ndk(config); - - bool timeoutTriggered = false; - bool timeoutUserTriggered = false; - - final response = ndk.requests.query( - filters: [ - Filter(authors: ["unknown"]) - ], - // short to fail fast - timeout: Duration(seconds: 1), - timeoutCallback: () { - timeoutTriggered = true; - }, - timeoutCallbackUserFacing: () { - timeoutUserTriggered = true; - }); - - // wait for completion - await response.future; - - expect(timeoutUserTriggered, isTrue); - expect(timeoutTriggered, isTrue); - }); - - test('timeout triggers default ndk values within expected time window', - () async { - const Duration myTimeout = Duration(seconds: 1); - NdkConfig config = NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: ["invalid"], - defaultQueryTimeout: myTimeout, - ); - - final ndk = Ndk(config); + ndk = Ndk(config); bool timeoutTriggered = false; bool timeoutUserTriggered = false; - // Start the stopwatch - final stopwatch = Stopwatch()..start(); - final response = ndk.requests.query( filters: [ - Filter(authors: ["unknown"]) + Filter(authors: ["unknown"]), ], // short to fail fast + timeout: Duration(seconds: 1), timeoutCallback: () { timeoutTriggered = true; }, @@ -129,25 +96,70 @@ void main() { }, ); - // Wait for completion + // wait for completion await response.future; - // Stop the stopwatch - stopwatch.stop(); - - // Get the elapsed time - final elapsedMilliseconds = stopwatch.elapsedMilliseconds; - - // Assert that timeout callbacks were triggered expect(timeoutUserTriggered, isTrue); expect(timeoutTriggered, isTrue); - - // Assert that the timeout occurred within the expected time window - // Adjust these values based on your expected timeout duration - expect(elapsedMilliseconds, - greaterThanOrEqualTo(myTimeout.inMilliseconds - 1000)); // lower bound - expect(elapsedMilliseconds, - lessThanOrEqualTo(myTimeout.inMilliseconds + 1000)); // upper bound }); + + test( + 'timeout triggers default ndk values within expected time window', + () async { + const Duration myTimeout = Duration(seconds: 1); + NdkConfig config = NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: ["invalid"], + defaultQueryTimeout: myTimeout, + ); + + ndk = Ndk(config); + + bool timeoutTriggered = false; + bool timeoutUserTriggered = false; + + // Start the stopwatch + final stopwatch = Stopwatch()..start(); + + final response = ndk.requests.query( + filters: [ + Filter(authors: ["unknown"]), + ], + // short to fail fast + timeoutCallback: () { + timeoutTriggered = true; + }, + timeoutCallbackUserFacing: () { + timeoutUserTriggered = true; + }, + ); + + // Wait for completion + await response.future; + + // Stop the stopwatch + stopwatch.stop(); + + // Get the elapsed time + final elapsedMilliseconds = stopwatch.elapsedMilliseconds; + + // Assert that timeout callbacks were triggered + expect(timeoutUserTriggered, isTrue); + expect(timeoutTriggered, isTrue); + + // Assert that the timeout occurred within the expected time window + // Adjust these values based on your expected timeout duration + expect( + elapsedMilliseconds, + greaterThanOrEqualTo(myTimeout.inMilliseconds - 1000), + ); // lower bound + expect( + elapsedMilliseconds, + lessThanOrEqualTo(myTimeout.inMilliseconds + 1000), + ); // upper bound + }, + ); }); } diff --git a/packages/ndk/test/tools/simple_profiler.dart b/packages/ndk/test/tools/simple_profiler.dart index 15e99ddbf..95cfad3bb 100644 --- a/packages/ndk/test/tools/simple_profiler.dart +++ b/packages/ndk/test/tools/simple_profiler.dart @@ -5,8 +5,8 @@ class SimpleProfiler { DateTime _lastCheckpoint; SimpleProfiler(this.name) - : startTime = DateTime.now(), - _lastCheckpoint = DateTime.now() { + : startTime = DateTime.now(), + _lastCheckpoint = DateTime.now() { // ignore: avoid_print print('Starting $name at $startTime'); } @@ -17,9 +17,11 @@ class SimpleProfiler { final checkpointDuration = now.difference(_lastCheckpoint); // ignore: avoid_print - print('$name - $description:' - '\n\tTotal: ${totalDuration.inMilliseconds}ms' - '\n\tSince last checkpoint: ${checkpointDuration.inMilliseconds}ms'); + print( + '$name - $description:' + '\n\tTotal: ${totalDuration.inMilliseconds}ms' + '\n\tSince last checkpoint: ${checkpointDuration.inMilliseconds}ms', + ); _lastCheckpoint = now; } @@ -30,9 +32,11 @@ class SimpleProfiler { final checkpointDuration = now.difference(_lastCheckpoint); // ignore: avoid_print - print('Ended $name:' - '\n\tTotal time: ${totalDuration.inMilliseconds}ms' - '\n\tSince last checkpoint: ${checkpointDuration.inMilliseconds}ms'); + print( + 'Ended $name:' + '\n\tTotal time: ${totalDuration.inMilliseconds}ms' + '\n\tSince last checkpoint: ${checkpointDuration.inMilliseconds}ms', + ); } } diff --git a/packages/ndk/test/usecases/accounts_test.dart b/packages/ndk/test/usecases/accounts_test.dart index 31f8a720f..215849247 100644 --- a/packages/ndk/test/usecases/accounts_test.dart +++ b/packages/ndk/test/usecases/accounts_test.dart @@ -39,18 +39,24 @@ void main() async { expect(ndk.accounts.getLoggedAccount()!.pubkey, key0.publicKey); expect(ndk.accounts.hasAccount(key0.publicKey), true); expect( - () => ndk.accounts.sign(Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "")), - throwsA(isA())); + () => ndk.accounts.sign( + Nip01Event( + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ), + ), + throwsA(isA()), + ); }); test('loginPrivateKey', () { expect(ndk.accounts.isNotLoggedIn, true); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); expect(ndk.accounts.getLoggedAccount()!.pubkey, key0.publicKey); expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); @@ -61,8 +67,11 @@ void main() async { test('loginExternalSigner', () { expect(ndk.accounts.isNotLoggedIn, true); ndk.accounts.loginExternalSigner( - signer: Bip340EventSigner( - privateKey: key0.privateKey, publicKey: key0.publicKey)); + signer: Bip340EventSigner( + privateKey: key0.privateKey, + publicKey: key0.publicKey, + ), + ); expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); ndk.accounts.logout(); @@ -72,8 +81,11 @@ void main() async { test('remove account', () { expect(ndk.accounts.isNotLoggedIn, true); ndk.accounts.loginExternalSigner( - signer: Bip340EventSigner( - privateKey: key0.privateKey, publicKey: key0.publicKey)); + signer: Bip340EventSigner( + privateKey: key0.privateKey, + publicKey: key0.publicKey, + ), + ); expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); ndk.accounts.removeAccount(pubkey: key0.publicKey); @@ -82,22 +94,33 @@ void main() async { test('do not allow duplicated login', () { expect(ndk.accounts.isNotLoggedIn, true); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); expect(ndk.accounts.getLoggedAccount()!.pubkey, key0.publicKey); expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); expect( - () => ndk.accounts.loginPrivateKey( - pubkey: key0.publicKey, privkey: key0.privateKey!), - throwsA(isA())); - expect(() => ndk.accounts.loginPublicKey(pubkey: key0.publicKey), - throwsA(isA())); + () => ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ), + throwsA(isA()), + ); + expect( + () => ndk.accounts.loginPublicKey(pubkey: key0.publicKey), + throwsA(isA()), + ); expect( - () => ndk.accounts.loginExternalSigner( - signer: Bip340EventSigner( - privateKey: key0.privateKey, publicKey: key0.publicKey)), - throwsA(isA())); + () => ndk.accounts.loginExternalSigner( + signer: Bip340EventSigner( + privateKey: key0.privateKey, + publicKey: key0.publicKey, + ), + ), + throwsA(isA()), + ); expect(ndk.accounts.getLoggedAccount()!.pubkey, key0.publicKey); ndk.accounts.logout(); expect(ndk.accounts.isNotLoggedIn, true); @@ -105,15 +128,19 @@ void main() async { test('switchAccount', () { expect(ndk.accounts.isNotLoggedIn, true); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); expect(ndk.accounts.hasAccount(key0.publicKey), true); expect(ndk.accounts.getLoggedAccount()!.pubkey, key0.publicKey); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); expect(ndk.accounts.hasAccount(key1.publicKey), true); expect(ndk.accounts.getLoggedAccount()!.pubkey, key1.publicKey); @@ -135,8 +162,10 @@ void main() async { expect(ndk.accounts.isLoggedIn, true); expect(ndk.accounts.canSign, true); - expect(() => ndk.accounts.switchAccount(pubkey: key0.publicKey), - throwsA(isA())); + expect( + () => ndk.accounts.switchAccount(pubkey: key0.publicKey), + throwsA(isA()), + ); ndk.accounts.logout(); expect(ndk.accounts.isNotLoggedIn, true); @@ -178,10 +207,7 @@ void main() async { final stream = ndk.accounts.authStateChanges; await ndk.destroy(); - await expectLater( - stream, - emitsDone, - ); + await expectLater(stream, emitsDone); }); }); } diff --git a/packages/ndk/test/usecases/broadcast_delivery_policy_test.dart b/packages/ndk/test/usecases/broadcast_delivery_policy_test.dart new file mode 100644 index 000000000..5da102d8b --- /dev/null +++ b/packages/ndk/test/usecases/broadcast_delivery_policy_test.dart @@ -0,0 +1,354 @@ +import 'package:ndk/domain_layer/entities/broadcast_state.dart'; +import 'package:ndk/domain_layer/entities/event_cache_records.dart'; +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:ndk/domain_layer/usecases/broadcast/delivery_policy.dart'; +import 'package:test/test.dart'; + +void main() { + group('DeliveryPolicy', () { + test('uses latest-state-only policy for replaceable events', () { + final event = Nip01Event( + id: 'replaceable-1', + pubKey: 'pubkey', + createdAt: 1700000000, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ], + content: 'article', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect(policy.kind, DeliveryPolicyKind.latestStateOnly); + expect(policy.retainsOnlyLatest, isTrue); + }); + + test( + 'uses latest-state-only policy for regular replaceable events too', + () { + final event = Nip01Event( + id: 'replaceable-2', + pubKey: 'pubkey', + createdAt: 1700000000, + kind: 10002, + tags: const [], + content: 'relay list', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect(policy.kind, DeliveryPolicyKind.latestStateOnly); + expect(policy.retainsOnlyLatest, isTrue); + }, + ); + + test('uses do-not-retry policy for ephemeral events', () { + final event = Nip01Event( + id: 'ephemeral-1', + pubKey: 'pubkey', + createdAt: 1700000001, + kind: 24133, + tags: const [], + content: 'ephemeral', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect(policy.kind, DeliveryPolicyKind.doNotRetry); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'rate-limited: retry later', + ), + ), + RelayDeliveryState.permanentFailure, + ); + }); + + test('classifies auth-required separately from permanent failures', () { + final event = Nip01Event( + id: 'event-1', + pubKey: 'pubkey', + createdAt: 1700000002, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'text', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'auth-required: please authenticate', + ), + ), + RelayDeliveryState.authRequired, + ); + // Uppercase / leading whitespace must still be classified as auth-required. + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: ' AUTH-REQUIRED: please authenticate', + ), + ), + RelayDeliveryState.authRequired, + ); + expect(policy.shouldRetryState(RelayDeliveryState.authRequired), isTrue); + expect( + policy.retryDelayFor( + state: RelayDeliveryState.authRequired, + attemptCount: 1, + ), + const Duration(minutes: 1), + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'invalid signature', + ), + ), + RelayDeliveryState.permanentFailure, + ); + expect( + policy.shouldRetryState(RelayDeliveryState.permanentFailure), + isFalse, + ); + expect( + policy.retryDelayFor( + state: RelayDeliveryState.permanentFailure, + attemptCount: 1, + ), + Duration.zero, + ); + }); + + test('treats a duplicate response as acked', () { + final event = Nip01Event( + id: 'event-dup', + pubKey: 'pubkey', + createdAt: 1700000002, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'text', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + // A relay answering `duplicate:` already holds the event, so even with + // OK=false the delivery to that relay is effectively done. + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'duplicate: already have this event', + ), + ), + RelayDeliveryState.acked, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'DUPLICATE: already have this event', + ), + ), + RelayDeliveryState.acked, + ); + }); + + test('treats policy violations as permanent failures', () { + final event = Nip01Event( + id: 'event-2', + pubKey: 'pubkey', + createdAt: 1700000002, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'text', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'policy violation: forbidden kind', + ), + ), + RelayDeliveryState.permanentFailure, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'restricted: not allowed to write', + ), + ), + RelayDeliveryState.permanentFailure, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'invalid: event creation date is too far off', + ), + ), + RelayDeliveryState.permanentFailure, + ); + }); + + test('keeps rate-limited and error prefixes retryable', () { + final event = Nip01Event( + id: 'event-3', + pubKey: 'pubkey', + createdAt: 1700000003, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'text', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'rate-limited: slow down', + ), + ), + RelayDeliveryState.transientFailure, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'error: backend unavailable', + ), + ), + RelayDeliveryState.transientFailure, + ); + }); + + test('treats common raw strfry validation failures as permanent', () { + final event = Nip01Event( + id: 'event-4', + pubKey: 'pubkey', + createdAt: 1700000004, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'text', + sig: 'sig', + ); + + final policy = DeliveryPolicy.forEvent(event); + + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'bad signature', + ), + ), + RelayDeliveryState.permanentFailure, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'too many tags: 1200', + ), + ), + RelayDeliveryState.permanentFailure, + ); + expect( + policy.resolveNextState( + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'event too large: 999999', + ), + ), + RelayDeliveryState.permanentFailure, + ); + }); + + test('uses faster backoff for deletion events', () { + final deletionEvent = Nip01Event( + id: 'delete-1', + pubKey: 'pubkey', + createdAt: 1700000003, + kind: 5, + tags: const [ + ['e', 'target'], + ], + content: 'delete', + sig: 'sig', + ); + final noteEvent = Nip01Event( + id: 'note-1', + pubKey: 'pubkey', + createdAt: 1700000004, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'note', + sig: 'sig', + ); + + final deletionPolicy = DeliveryPolicy.forEvent(deletionEvent); + final notePolicy = DeliveryPolicy.forEvent(noteEvent); + + expect( + deletionPolicy.retryDelayFor( + state: RelayDeliveryState.transientFailure, + attemptCount: 1, + ), + lessThan( + notePolicy.retryDelayFor( + state: RelayDeliveryState.transientFailure, + attemptCount: 1, + ), + ), + ); + }); + }); +} diff --git a/packages/ndk/test/usecases/broadcast_jit_test.dart b/packages/ndk/test/usecases/broadcast_jit_test.dart index 019fca079..2fa744345 100644 --- a/packages/ndk/test/usecases/broadcast_jit_test.dart +++ b/packages/ndk/test/usecases/broadcast_jit_test.dart @@ -17,12 +17,15 @@ void main() async { setUp(() async { relay0 = MockRelay(name: "relay 0", explicitPort: 5085); - await relay0.startServer(nip65s: { - key0: Nip65( + await relay0.startServer( + nip65s: { + key0: Nip65( pubKey: key0.publicKey, relays: {relay0.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) - }); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + }, + ); final cache = MemCacheManager(); final NdkConfig config = NdkConfig( @@ -46,145 +49,225 @@ void main() async { }); test('broadcast 2 events', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: key0.privateKey!); + event: event, + privateKey: key0.privateKey!, + ); await ndk.broadcast .broadcast(nostrEvent: signedEvent) .broadcastDoneFuture; - List result = await ndk.requests.query( - filters: [ - Filter(authors: [key0.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + List result = await ndk.requests + .query( + filters: [ + Filter( + authors: [key0.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(result.length, 1); final event2 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "my content"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "my content", + ); final signedEvent2 = Nip01Utils.signWithPrivateKey( - event: event2, privateKey: key0.privateKey!); + event: event2, + privateKey: key0.privateKey!, + ); await ndk.broadcast .broadcast(nostrEvent: signedEvent2) .broadcastDoneFuture; - result = await ndk.requests.query( - filters: [ - Filter(authors: [key0.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + result = await ndk.requests + .query( + filters: [ + Filter( + authors: [key0.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(result.length, 2); }); test('broadcast deletion', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, privateKey: key0.privateKey!); - NdkBroadcastResponse response = - ndk.broadcast.broadcast(nostrEvent: signedEvent); + event: event, + privateKey: key0.privateKey!, + ); + NdkBroadcastResponse response = ndk.broadcast.broadcast( + nostrEvent: signedEvent, + ); await response.broadcastDoneFuture; - List list = await ndk.requests.query(filters: [ - Filter(authors: [signedEvent.pubKey], kinds: [Nip01Event.kTextNodeKind]) - ]).future; + List list = await ndk.requests + .query( + filters: [ + Filter( + authors: [signedEvent.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(list.first, signedEvent); response = ndk.broadcast.broadcastDeletion(eventId: signedEvent.id); await response.broadcastDoneFuture; - list = await ndk.requests.query(filters: [ - Filter(authors: [signedEvent.pubKey], kinds: [Nip01Event.kTextNodeKind]) - ]).future; + list = await ndk.requests + .query( + filters: [ + Filter( + authors: [signedEvent.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(list, isEmpty); }); test('broadcast deletion', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event1 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "1"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "1", + ); Nip01Event event2 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "2"); - NdkBroadcastResponse response1 = - ndk.broadcast.broadcast(nostrEvent: event1); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "2", + ); + NdkBroadcastResponse response1 = ndk.broadcast.broadcast( + nostrEvent: event1, + ); await response1.broadcastDoneFuture; - NdkBroadcastResponse response2 = - ndk.broadcast.broadcast(nostrEvent: event2); + NdkBroadcastResponse response2 = ndk.broadcast.broadcast( + nostrEvent: event2, + ); await response2.broadcastDoneFuture; - List list = await ndk.requests.query(filters: [ - Filter(authors: [event1.pubKey], kinds: [Nip01Event.kTextNodeKind]) - ]).future; + List list = await ndk.requests + .query( + filters: [ + Filter( + authors: [event1.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; - response1 = - ndk.broadcast.broadcastDeletion(eventIds: [event1.id, event2.id]); + response1 = ndk.broadcast.broadcastDeletion( + eventIds: [event1.id, event2.id], + ); await response1.broadcastDoneFuture; - list = await ndk.requests.query(filters: [ - Filter(authors: [event1.pubKey], kinds: [Nip01Event.kTextNodeKind]) - ]).future; + list = await ndk.requests + .query( + filters: [ + Filter( + authors: [event1.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(list, isEmpty); }); - test('broadcast deletion with empty eventIds throws ArgumentError', - () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); - expect( - () => ndk.broadcast.broadcastDeletion(eventIds: []), - throwsA(isA()), - ); - }); + test( + 'broadcast deletion with empty eventIds throws ArgumentError', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); + expect( + () => ndk.broadcast.broadcastDeletion(eventIds: []), + throwsA(isA()), + ); + }, + ); test('broadcast reaction', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); - NdkBroadcastResponse response = - ndk.broadcast.broadcast(nostrEvent: event); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); + NdkBroadcastResponse response = ndk.broadcast.broadcast( + nostrEvent: event, + ); await response.broadcastDoneFuture; - List list = await ndk.requests.query(filters: [ - Filter(authors: [event.pubKey], kinds: [Nip01Event.kTextNodeKind]) - ]).future; + List list = await ndk.requests + .query( + filters: [ + Filter( + authors: [event.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(list.first, event); final reaction = "♡"; - response = ndk.broadcast - .broadcastReaction(eventId: event.id, reaction: reaction); + response = ndk.broadcast.broadcastReaction( + eventId: event.id, + reaction: reaction, + ); await response.broadcastDoneFuture; - list = await ndk.requests.query(filters: [ - Filter(authors: [event.pubKey], kinds: [Reaction.kKind]) - ]).future; + list = await ndk.requests + .query( + filters: [ + Filter(authors: [event.pubKey], kinds: [Reaction.kKind]), + ], + ) + .future; expect(list.first.content, reaction); }); }); @@ -202,12 +285,15 @@ void main() async { relay1 = MockRelay(name: "relay 1", explicitPort: 5086); relay2 = MockRelay(name: "relay 2", explicitPort: 5087); relay3 = MockRelay(name: "relay 3", explicitPort: 5088); - await relay1.startServer(nip65s: { - key1: Nip65( + await relay1.startServer( + nip65s: { + key1: Nip65( pubKey: key1.publicKey, relays: {relay1.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) - }); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + }, + ); await relay2.startServer(); await relay3.startServer(); @@ -225,30 +311,39 @@ void main() async { ndk = Ndk(config); // own - await cache.saveUserRelayList(UserRelayList.fromNip65( - Nip65( + await cache.saveUserRelayList( + UserRelayList.fromNip65( + Nip65( pubKey: key1.publicKey, relays: {relay1.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000), - )); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ), + ); // other - await cache.saveUserRelayList(UserRelayList.fromNip65( - Nip65( + await cache.saveUserRelayList( + UserRelayList.fromNip65( + Nip65( pubKey: keyOther.publicKey, relays: {relay2.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000), - )); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ), + ); - await cache.saveUserRelayList(UserRelayList.fromNip65( - Nip65( + await cache.saveUserRelayList( + UserRelayList.fromNip65( + Nip65( pubKey: key1.publicKey, relays: { relay1.url: ReadWriteMarker.readWrite, relay3.url: ReadWriteMarker.readWrite, // Add new relay }, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000), - )); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ), + ); await ndk.relays.seedRelaysConnected; }); @@ -261,85 +356,113 @@ void main() async { }); test('broadcast JIT - specific', () async { - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key1.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "hi there"); - await ndk.broadcast.broadcast( - nostrEvent: event, - specificRelays: [relay1.url, relay2.url]).broadcastDoneFuture; - - List result = await ndk.requests.query( - explicitRelays: [relay2.url], - filters: [ - Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + pubKey: key1.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "hi there", + ); + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay1.url, relay2.url], + ) + .broadcastDoneFuture; + + List result = await ndk.requests + .query( + explicitRelays: [relay2.url], + filters: [ + Filter( + authors: [key1.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(result.length, 1); }); test('broadcast JIT - other read', () async { - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key1.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [ - ["p", keyOther.publicKey] - ], - content: "hi other"); + pubKey: key1.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [ + ["p", keyOther.publicKey], + ], + content: "hi other", + ); - await ndk.broadcast - .broadcast( - nostrEvent: event, - ) - .broadcastDoneFuture; + await ndk.broadcast.broadcast(nostrEvent: event).broadcastDoneFuture; - List result = await ndk.requests.query( - name: "other read", - explicitRelays: [relay2.url], - filters: [ - Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + List result = await ndk.requests + .query( + name: "other read", + explicitRelays: [relay2.url], + filters: [ + Filter( + authors: [key1.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(result.length, 1); }); test('broadcast JIT - connect relay during broadcast', () async { - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); // verify relay3 is not initially connected expect(ndk.relays.isRelayConnected(relay3.url), false); Nip01Event event = Nip01Event( - pubKey: key1.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test connection during broadcast"); + pubKey: key1.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test connection during broadcast", + ); // broadcast the event - this should trigger connection to relay3 await ndk.broadcast.broadcast(nostrEvent: event).broadcastDoneFuture; // verify the event was broadcast to both relays - List resultRelay1 = await ndk.requests.query( - explicitRelays: [relay1.url], - filters: [ - Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + List resultRelay1 = await ndk.requests + .query( + explicitRelays: [relay1.url], + filters: [ + Filter( + authors: [key1.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(resultRelay1.length, 1); - List resultRelay3 = await ndk.requests.query( - explicitRelays: [relay3.url], - filters: [ - Filter(authors: [key1.publicKey], kinds: [Nip01Event.kTextNodeKind]) - ], - ).future; + List resultRelay3 = await ndk.requests + .query( + explicitRelays: [relay3.url], + filters: [ + Filter( + authors: [key1.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ], + ) + .future; expect(resultRelay3.length, 1); }); }); diff --git a/packages/ndk/test/usecases/broadcast_test.dart b/packages/ndk/test/usecases/broadcast_test.dart index 706864c03..b7d1c591d 100644 --- a/packages/ndk/test/usecases/broadcast_test.dart +++ b/packages/ndk/test/usecases/broadcast_test.dart @@ -17,12 +17,15 @@ void main() async { setUp(() async { relay0 = MockRelay(name: "relay 0", explicitPort: 5102); - await relay0.startServer(nip65s: { - key0: Nip65( + await relay0.startServer( + nip65s: { + key0: Nip65( pubKey: key0.publicKey, relays: {relay0.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) - }); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + }, + ); final cache = MemCacheManager(); final NdkConfig config = NdkConfig( @@ -35,10 +38,15 @@ void main() async { ndk = Ndk(config); - cache.saveUserRelayList(UserRelayList.fromNip65(Nip65( - pubKey: key0.publicKey, - relays: {relay0.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000))); + cache.saveUserRelayList( + UserRelayList.fromNip65( + Nip65( + pubKey: key0.publicKey, + relays: {relay0.url: ReadWriteMarker.readWrite}, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ), + ); await ndk.relays.seedRelaysConnected; }); @@ -48,13 +56,16 @@ void main() async { }); test('broadcast 2 events', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, @@ -75,10 +86,11 @@ void main() async { expect(result.length, 1); final event2 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "my content"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "my content", + ); final signedEvent2 = Nip01Utils.signWithPrivateKey( event: event2, @@ -100,20 +112,24 @@ void main() async { }); test('broadcast deletion', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, privateKey: key0.privateKey!, ); - NdkBroadcastResponse response = - ndk.broadcast.broadcast(nostrEvent: signedEvent); + NdkBroadcastResponse response = ndk.broadcast.broadcast( + nostrEvent: signedEvent, + ); await response.broadcastDoneFuture; List list = await ndk.requests @@ -141,45 +157,53 @@ void main() async { }); test('broadcast deletion 2', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event1 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "1"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "1", + ); final signedEvent1 = Nip01Utils.signWithPrivateKey( event: event1, privateKey: key0.privateKey!, ); Nip01Event event2 = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "2"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "2", + ); final signedEvent2 = Nip01Utils.signWithPrivateKey( event: event2, privateKey: key0.privateKey!, ); - NdkBroadcastResponse response1 = - ndk.broadcast.broadcast(nostrEvent: signedEvent1); + NdkBroadcastResponse response1 = ndk.broadcast.broadcast( + nostrEvent: signedEvent1, + ); await response1.broadcastDoneFuture; - NdkBroadcastResponse response2 = - ndk.broadcast.broadcast(nostrEvent: signedEvent2); + NdkBroadcastResponse response2 = ndk.broadcast.broadcast( + nostrEvent: signedEvent2, + ); await response2.broadcastDoneFuture; List list = await ndk.requests .query( - filter: Filter( - authors: [signedEvent1.pubKey], - kinds: [Nip01Event.kTextNodeKind], - )) + filter: Filter( + authors: [signedEvent1.pubKey], + kinds: [Nip01Event.kTextNodeKind], + ), + ) .future; - response1 = ndk.broadcast - .broadcastDeletion(eventIds: [signedEvent1.id, signedEvent2.id]); + response1 = ndk.broadcast.broadcastDeletion( + eventIds: [signedEvent1.id, signedEvent2.id], + ); await response1.broadcastDoneFuture; list = await ndk.requests @@ -194,21 +218,25 @@ void main() async { }); test('broadcast reaction', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: ""); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, privateKey: key0.privateKey!, ); - NdkBroadcastResponse response = - ndk.broadcast.broadcast(nostrEvent: signedEvent); + NdkBroadcastResponse response = ndk.broadcast.broadcast( + nostrEvent: signedEvent, + ); await response.broadcastDoneFuture; List list = await ndk.requests @@ -222,8 +250,10 @@ void main() async { expect(list.first, signedEvent); final reaction = "♡"; - response = ndk.broadcast - .broadcastReaction(eventId: signedEvent.id, reaction: reaction); + response = ndk.broadcast.broadcastReaction( + eventId: signedEvent.id, + reaction: reaction, + ); await response.broadcastDoneFuture; list = await ndk.requests @@ -237,40 +267,49 @@ void main() async { expect(list.first.content, reaction); }); - test('broadcast deletion with empty eventIds throws ArgumentError', - () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); - expect( - () => ndk.broadcast.broadcastDeletion(eventIds: []), - throwsA(isA()), - ); - }); + test( + 'broadcast deletion with empty eventIds throws ArgumentError', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); + expect( + () => ndk.broadcast.broadcastDeletion(eventIds: []), + throwsA(isA()), + ); + }, + ); test('broadcast respects timeout parameter', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); // Create a slow relay that won't respond in time MockRelay slowRelay = MockRelay(name: "slow relay", explicitPort: 5103); await slowRelay.startServer( nip65s: { key0: Nip65( - pubKey: key0.publicKey, - relays: {slowRelay.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) + pubKey: key0.publicKey, + relays: {slowRelay.url: ReadWriteMarker.readWrite}, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), }, - delayResponse: - const Duration(seconds: 2), // Add delay to simulate slow relay + delayResponse: const Duration( + seconds: 2, + ), // Add delay to simulate slow relay ); try { // Create and broadcast an event with a short timeout Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "testing timeout"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "testing timeout", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, @@ -281,10 +320,11 @@ void main() async { final customTimeout = const Duration(milliseconds: 500); NdkBroadcastResponse response = ndk.broadcast.broadcast( - nostrEvent: signedEvent, - timeout: customTimeout, - specificRelays: [slowRelay.url, relay0.url], - considerDonePercent: 1); + nostrEvent: signedEvent, + timeout: customTimeout, + specificRelays: [slowRelay.url, relay0.url], + considerDonePercent: 1, + ); await response.broadcastDoneFuture; final endTime = DateTime.now(); @@ -292,9 +332,9 @@ void main() async { // Verify that the broadcast completed within the timeout period (with some margin) final duration = endTime.difference(startTime); expect( - duration, - lessThanOrEqualTo( - customTimeout + const Duration(milliseconds: 600))); + duration, + lessThanOrEqualTo(customTimeout + const Duration(milliseconds: 600)), + ); // Verify the event was published to at least one relay (the fast one) List result = await ndk.requests @@ -313,25 +353,31 @@ void main() async { }); test('broadcast respects considerDonePercent parameter', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 5104); MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 5106); - await relay1.startServer(nip65s: { - key0: Nip65( + await relay1.startServer( + nip65s: { + key0: Nip65( pubKey: key0.publicKey, relays: {relay1.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) - }); + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + }, + ); await relay2.startServer( nip65s: { key0: Nip65( - pubKey: key0.publicKey, - relays: {relay2.url: ReadWriteMarker.readWrite}, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000) + pubKey: key0.publicKey, + relays: {relay2.url: ReadWriteMarker.readWrite}, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), }, delayResponse: const Duration(seconds: 2), // Add delay to the second ); @@ -340,10 +386,11 @@ void main() async { // Create and broadcast an event with considerDonePercent set to 66% // This means it should complete after 2 of the 3 relays receive the event Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "testing considerDonePercent"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "testing considerDonePercent", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, @@ -353,11 +400,13 @@ void main() async { final startTime = DateTime.now(); NdkBroadcastResponse response = ndk.broadcast.broadcast( - nostrEvent: signedEvent, - considerDonePercent: 0.66, // 66% = 2 out of 3 relays - timeout: const Duration( - seconds: 5), // Long timeout to ensure it's not timing out - specificRelays: [relay0.url, relay1.url, relay2.url]); + nostrEvent: signedEvent, + considerDonePercent: 0.66, // 66% = 2 out of 3 relays + timeout: const Duration( + seconds: 5, + ), // Long timeout to ensure it's not timing out + specificRelays: [relay0.url, relay1.url, relay2.url], + ); await response.broadcastDoneFuture; final endTime = DateTime.now(); @@ -378,8 +427,9 @@ void main() async { expect(successRate.length / 3, closeTo(0.66, 0.01)); // Verify the event was published to at least the two fast relays - await Future.delayed(const Duration( - milliseconds: 100)); // Small delay to ensure events are indexed + await Future.delayed( + const Duration(milliseconds: 100), + ); // Small delay to ensure events are indexed } finally { await relay1.stopServer(); await relay2.stopServer(); @@ -387,14 +437,17 @@ void main() async { }); test('broadcast saves event to cache by default', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Nip01Event event = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test cache save"); + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test cache save", + ); final signedEvent = Nip01Utils.signWithPrivateKey( event: event, @@ -412,30 +465,35 @@ void main() async { expect(cachedEvent.content, "test cache save"); }); - test('broadcast does not save to cache when saveToCache is false', - () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + test( + 'broadcast does not save to cache when saveToCache is false', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); - Nip01Event event = Nip01Event( + Nip01Event event = Nip01Event( pubKey: key0.publicKey, kind: Nip01Event.kTextNodeKind, tags: [], - content: "test no cache save"); + content: "test no cache save", + ); - final signedEvent = Nip01Utils.signWithPrivateKey( - event: event, - privateKey: key0.privateKey!, - ); + final signedEvent = Nip01Utils.signWithPrivateKey( + event: event, + privateKey: key0.privateKey!, + ); - await ndk.broadcast - .broadcast(nostrEvent: signedEvent, saveToCache: false) - .broadcastDoneFuture; + await ndk.broadcast + .broadcast(nostrEvent: signedEvent, saveToCache: false) + .broadcastDoneFuture; - // Verify event is NOT saved in cache - final cachedEvent = await ndk.config.cache.loadEvent(signedEvent.id); - expect(cachedEvent, isNull); - }); + // Verify event is NOT saved in cache + final cachedEvent = await ndk.config.cache.loadEvent(signedEvent.id); + expect(cachedEvent, isNull); + }, + ); test('broadcast to offline relay returns fast', () async { // Create a relay URL that is not running (offline) @@ -472,85 +530,99 @@ void main() async { // NIP-09 Compliance Tests - test('broadcastDeletion with event generates e and k tags (NIP-09)', - () async { - ndk.accounts.loginPrivateKey( - pubkey: key0.publicKey, - privkey: key0.privateKey!, - ); + test( + 'broadcastDeletion with event generates e and k tags (NIP-09)', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); - Nip01Event textNote = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test event for NIP-09 deletion", - ); + Nip01Event textNote = Nip01Event( + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test event for NIP-09 deletion", + ); - await ndk.broadcast - .broadcastDeletion(event: textNote) - .broadcastDoneFuture; + await ndk.broadcast + .broadcastDeletion(event: textNote) + .broadcastDoneFuture; - List deletionEvents = await ndk.config.cache - .loadEvents(kinds: [5], pubKeys: [key0.publicKey]); + List deletionEvents = await ndk.config.cache.loadEvents( + kinds: [5], + pubKeys: [key0.publicKey], + ); - expect(deletionEvents.length, 1); - final deletionEvent = deletionEvents.first; + expect(deletionEvents.length, 1); + final deletionEvent = deletionEvents.first; - final eTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'e'); - expect(eTags.length, 1); - expect(eTags.first[1], textNote.id); + final eTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'e', + ); + expect(eTags.length, 1); + expect(eTags.first[1], textNote.id); - final kTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'k'); - expect(kTags.length, 1); - expect(kTags.first[1], Nip01Event.kTextNodeKind.toString()); - }); + final kTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'k', + ); + expect(kTags.length, 1); + expect(kTags.first[1], Nip01Event.kTextNodeKind.toString()); + }, + ); test( - 'broadcastDeletion with eventAndAllVersions generates e, a, and k tags', - () async { - ndk.accounts.loginPrivateKey( - pubkey: key0.publicKey, - privkey: key0.privateKey!, - ); + 'broadcastDeletion with eventAndAllVersions generates e, a, and k tags', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); - const articleKind = 30023; - Nip01Event article = Nip01Event( - pubKey: key0.publicKey, - kind: articleKind, - tags: [ - ["d", "my-article-identifier"] - ], - content: "article content", - ); + const articleKind = 30023; + Nip01Event article = Nip01Event( + pubKey: key0.publicKey, + kind: articleKind, + tags: [ + ["d", "my-article-identifier"], + ], + content: "article content", + ); - await ndk.broadcast - .broadcastDeletion(eventAndAllVersions: article) - .broadcastDoneFuture; + await ndk.broadcast + .broadcastDeletion(eventAndAllVersions: article) + .broadcastDoneFuture; - List deletionEvents = await ndk.config.cache - .loadEvents(kinds: [5], pubKeys: [key0.publicKey]); + List deletionEvents = await ndk.config.cache.loadEvents( + kinds: [5], + pubKeys: [key0.publicKey], + ); - expect(deletionEvents.length, 1); - final deletionEvent = deletionEvents.first; + expect(deletionEvents.length, 1); + final deletionEvent = deletionEvents.first; - final eTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'e'); - expect(eTags.length, 1); - expect(eTags.first[1], article.id); - - final aTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'a'); - expect(aTags.length, 1); - expect(aTags.first[1], - "$articleKind:${key0.publicKey}:my-article-identifier"); - - final kTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'k'); - expect(kTags.length, 1); - expect(kTags.first[1], articleKind.toString()); - }); + final eTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'e', + ); + expect(eTags.length, 1); + expect(eTags.first[1], article.id); + + final aTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'a', + ); + expect(aTags.length, 1); + expect( + aTags.first[1], + "$articleKind:${key0.publicKey}:my-article-identifier", + ); + + final kTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'k', + ); + expect(kTags.length, 1); + expect(kTags.first[1], articleKind.toString()); + }, + ); test('broadcastDeletion with eventId generates only e tag', () async { ndk.accounts.loginPrivateKey( @@ -562,139 +634,150 @@ void main() async { .broadcastDeletion(eventId: "abc123") .broadcastDoneFuture; - List deletionEvents = await ndk.config.cache - .loadEvents(kinds: [5], pubKeys: [key0.publicKey]); + List deletionEvents = await ndk.config.cache.loadEvents( + kinds: [5], + pubKeys: [key0.publicKey], + ); expect(deletionEvents.length, 1); final deletionEvent = deletionEvents.first; - final eTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'e'); + final eTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'e', + ); expect(eTags.length, 1); expect(eTags.first[1], "abc123"); - final kTags = - deletionEvent.tags.where((t) => t.length >= 2 && t[0] == 'k'); + final kTags = deletionEvent.tags.where( + (t) => t.length >= 2 && t[0] == 'k', + ); expect(kTags.length, 0); }); test( - 'broadcastDeletion with eventAndAllVersions removes all versions from cache', - () async { - ndk.accounts.loginPrivateKey( - pubkey: key0.publicKey, - privkey: key0.privateKey!, - ); - - const articleKind = 30023; - final dTag = "my-article-${DateTime.now().millisecondsSinceEpoch}"; - - // Create multiple versions of the same replaceable event - Nip01Event version1 = Nip01Event( - pubKey: key0.publicKey, - kind: articleKind, - tags: [ - ["d", dTag] - ], - content: "version 1", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100, - ); - - Nip01Event version2 = Nip01Event( - pubKey: key0.publicKey, - kind: articleKind, - tags: [ - ["d", dTag] - ], - content: "version 2", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 50, - ); + 'broadcastDeletion with eventAndAllVersions removes all versions from cache', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); - Nip01Event version3 = Nip01Event( - pubKey: key0.publicKey, - kind: articleKind, - tags: [ - ["d", dTag] - ], - content: "version 3", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); - - // Save all versions to cache - await ndk.config.cache.saveEvent(version1); - await ndk.config.cache.saveEvent(version2); - await ndk.config.cache.saveEvent(version3); - - // Verify all versions are in cache - List cachedBefore = await ndk.config.cache.loadEvents( - kinds: [articleKind], - pubKeys: [key0.publicKey], - tags: { - 'd': [dTag] - }, - ); - expect(cachedBefore.length, 3); + const articleKind = 30023; + final dTag = "my-article-${DateTime.now().millisecondsSinceEpoch}"; - // Delete using eventAndAllVersions - await ndk.broadcast - .broadcastDeletion(eventAndAllVersions: version3) - .broadcastDoneFuture; + // Create multiple versions of the same replaceable event + Nip01Event version1 = Nip01Event( + pubKey: key0.publicKey, + kind: articleKind, + tags: [ + ["d", dTag], + ], + content: "version 1", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100, + ); - // Wait for cache removal (fire and forget async operation) - await Future.delayed(const Duration(milliseconds: 100)); + Nip01Event version2 = Nip01Event( + pubKey: key0.publicKey, + kind: articleKind, + tags: [ + ["d", dTag], + ], + content: "version 2", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 50, + ); - // Verify ALL versions are removed from cache - List cachedAfter = await ndk.config.cache.loadEvents( - kinds: [articleKind], - pubKeys: [key0.publicKey], - tags: { - 'd': [dTag] - }, - ); - expect(cachedAfter.length, 0); - }); + Nip01Event version3 = Nip01Event( + pubKey: key0.publicKey, + kind: articleKind, + tags: [ + ["d", dTag], + ], + content: "version 3", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); - test('broadcastDeletion with multiple events generates correct tags', - () async { - ndk.accounts.loginPrivateKey( - pubkey: key0.publicKey, - privkey: key0.privateKey!, - ); + // Save all versions to cache + await ndk.config.cache.saveEvent(version1); + await ndk.config.cache.saveEvent(version2); + await ndk.config.cache.saveEvent(version3); + + // Verify only the canonical current version is visible in cache + List cachedBefore = await ndk.config.cache.loadEvents( + kinds: [articleKind], + pubKeys: [key0.publicKey], + tags: { + 'd': [dTag], + }, + ); + expect(cachedBefore.length, 1); + expect(cachedBefore.first.content, 'version 3'); + + // Delete using eventAndAllVersions + await ndk.broadcast + .broadcastDeletion(eventAndAllVersions: version3) + .broadcastDoneFuture; + + // Wait for cache removal (fire and forget async operation) + await Future.delayed(const Duration(milliseconds: 100)); + + // Verify ALL versions are removed from cache + List cachedAfter = await ndk.config.cache.loadEvents( + kinds: [articleKind], + pubKeys: [key0.publicKey], + tags: { + 'd': [dTag], + }, + ); + expect(cachedAfter.length, 0); + }, + ); - Nip01Event textNote = Nip01Event( - pubKey: key0.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "text note", - ); + test( + 'broadcastDeletion with multiple events generates correct tags', + () async { + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); - Nip01Event repost = Nip01Event( - pubKey: key0.publicKey, - kind: 6, - tags: [], - content: "repost", - ); + Nip01Event textNote = Nip01Event( + pubKey: key0.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "text note", + ); - await ndk.broadcast - .broadcastDeletion(events: [textNote, repost]).broadcastDoneFuture; + Nip01Event repost = Nip01Event( + pubKey: key0.publicKey, + kind: 6, + tags: [], + content: "repost", + ); - List deletionEvents = await ndk.config.cache - .loadEvents(kinds: [5], pubKeys: [key0.publicKey]); + await ndk.broadcast + .broadcastDeletion(events: [textNote, repost]) + .broadcastDoneFuture; - expect(deletionEvents.length, 1); - final deletionEvent = deletionEvents.first; + List deletionEvents = await ndk.config.cache.loadEvents( + kinds: [5], + pubKeys: [key0.publicKey], + ); - final eTags = deletionEvent.tags - .where((t) => t.length >= 2 && t[0] == 'e') - .toList(); - expect(eTags.length, 2); - expect(eTags.map((t) => t[1]).toSet(), {textNote.id, repost.id}); - - final kTags = deletionEvent.tags - .where((t) => t.length >= 2 && t[0] == 'k') - .toList(); - expect(kTags.length, 2); - expect(kTags.map((t) => t[1]).toSet(), {'1', '6'}); - }); + expect(deletionEvents.length, 1); + final deletionEvent = deletionEvents.first; + + final eTags = deletionEvent.tags + .where((t) => t.length >= 2 && t[0] == 'e') + .toList(); + expect(eTags.length, 2); + expect(eTags.map((t) => t[1]).toSet(), {textNote.id, repost.id}); + + final kTags = deletionEvent.tags + .where((t) => t.length >= 2 && t[0] == 'k') + .toList(); + expect(kTags.length, 2); + expect(kTags.map((t) => t[1]).toSet(), {'1', '6'}); + }, + ); }); } diff --git a/packages/ndk/test/usecases/cache_eviction_scheduler_test.dart b/packages/ndk/test/usecases/cache_eviction_scheduler_test.dart new file mode 100644 index 000000000..58753f7c7 --- /dev/null +++ b/packages/ndk/test/usecases/cache_eviction_scheduler_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:ndk/data_layer/repositories/cache_manager/mem_cache_manager.dart'; +import 'package:ndk/domain_layer/entities/cache_eviction.dart'; +import 'package:ndk/domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart'; +import 'package:test/test.dart'; + +void main() { + group('CacheEvictionScheduler', () { + test('runs once after startup delay and then periodically', () async { + final cacheManager = _RecordingMemCacheManager(); + final scheduler = CacheEvictionScheduler( + cacheManager: cacheManager, + policy: const EvictionPolicy(), + startupDelay: const Duration(milliseconds: 20), + interval: const Duration(milliseconds: 30), + runOnStartup: true, + ); + + scheduler.start(); + addTearDown(() => scheduler.stop()); + + await _waitUntil(() => cacheManager.evictCallCount >= 1); + await _waitUntil(() => cacheManager.evictCallCount >= 2); + }); + + test('can disable startup run and keep only periodic scheduling', () async { + final cacheManager = _RecordingMemCacheManager(); + final scheduler = CacheEvictionScheduler( + cacheManager: cacheManager, + policy: const EvictionPolicy(), + startupDelay: Duration.zero, + interval: const Duration(milliseconds: 25), + runOnStartup: false, + ); + + scheduler.start(); + addTearDown(() => scheduler.stop()); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(cacheManager.evictCallCount, 0); + + await _waitUntil(() => cacheManager.evictCallCount >= 1); + expect(cacheManager.evictCallCount, 1); + }); + + test('does not overlap long-running eviction executions', () async { + final blocker = Completer(); + final cacheManager = _RecordingMemCacheManager( + onEvict: () => blocker.future, + ); + final scheduler = CacheEvictionScheduler( + cacheManager: cacheManager, + policy: const EvictionPolicy(), + startupDelay: Duration.zero, + interval: const Duration(milliseconds: 15), + runOnStartup: true, + ); + + scheduler.start(); + + await _waitUntil(() => cacheManager.evictCallCount >= 1); + await Future.delayed(const Duration(milliseconds: 60)); + expect(cacheManager.evictCallCount, 1); + + blocker.complete(); + await _waitUntil(() => cacheManager.evictCallCount >= 2); + + await scheduler.stop(); + }); + }); +} + +class _RecordingMemCacheManager extends MemCacheManager { + _RecordingMemCacheManager({this.onEvict}); + + int evictCallCount = 0; + final Future Function()? onEvict; + + @override + Future evict(EvictionPolicy policy) async { + evictCallCount += 1; + await onEvict?.call(); + return EvictionResult.empty; + } +} + +Future _waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 2), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('condition not met within $timeout'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} diff --git a/packages/ndk/test/usecases/cache_read_test.dart b/packages/ndk/test/usecases/cache_read_test.dart index e36ccaf2f..05deba6c4 100644 --- a/packages/ndk/test/usecases/cache_read_test.dart +++ b/packages/ndk/test/usecases/cache_read_test.dart @@ -26,10 +26,7 @@ void main() async { final NdkRequest myNdkRequest = NdkRequest.query( "id", filters: [ - Filter( - authors: ['pubKey1', 'pubKey2'], - kinds: [1], - ) + Filter(authors: ['pubKey1', 'pubKey2'], kinds: [1]), ], timeoutDuration: Duration(seconds: 5), ); @@ -51,7 +48,7 @@ void main() async { ); await response.then((data) { - expect(data, equals(myEvens)); + expect(data, unorderedEquals(myEvens)); }); //await expectLater(myRequestState.stream, emitsInAnyOrder(myEvens)); @@ -62,14 +59,16 @@ void main() async { }); test('cache read - some missing - BAD TEST', skip: true, () async { - final NdkRequest myNdkRequest = NdkRequest.query("id", - filters: [ - Filter( - authors: ['pubKey1', 'pubKey2', 'notInCachePubKey'], - kinds: [1], - ) - ], - timeoutDuration: Duration(seconds: 5)); + final NdkRequest myNdkRequest = NdkRequest.query( + "id", + filters: [ + Filter( + authors: ['pubKey1', 'pubKey2', 'notInCachePubKey'], + kinds: [1], + ), + ], + timeoutDuration: Duration(seconds: 5), + ); final RequestState myRequestState = RequestState(myNdkRequest); final CacheRead myUsecase = CacheRead(myCacheManager); @@ -98,30 +97,35 @@ void main() async { } }); - test('cache read - author removal based on limit - remove - BAD TEST', - skip: true, () async { - final CacheRead myUsecase = CacheRead(myCacheManager); + test( + 'cache read - author removal based on limit - remove - BAD TEST', + skip: true, + () async { + final CacheRead myUsecase = CacheRead(myCacheManager); - // Test with limit - final NdkRequest myNdkRequestWithLimit = NdkRequest.query("author-remove", + // Test with limit + final NdkRequest myNdkRequestWithLimit = NdkRequest.query( + "author-remove", filters: [ - Filter( - authors: ['pubKey1', 'pubKey2'], - kinds: [1], - limit: 2, - ) + Filter(authors: ['pubKey1', 'pubKey2'], kinds: [1], limit: 2), ], - timeoutDuration: Duration(seconds: 5)); - final RequestState myRequestStateWithLimit = - RequestState(myNdkRequestWithLimit); - - await myUsecase.resolveUnresolvedFilters( - requestState: myRequestStateWithLimit, - outController: myRequestStateWithLimit.controller, - ); - - expect(myRequestStateWithLimit.unresolvedFilters[0].authors, equals([])); - }); + timeoutDuration: Duration(seconds: 5), + ); + final RequestState myRequestStateWithLimit = RequestState( + myNdkRequestWithLimit, + ); + + await myUsecase.resolveUnresolvedFilters( + requestState: myRequestStateWithLimit, + outController: myRequestStateWithLimit.controller, + ); + + expect( + myRequestStateWithLimit.unresolvedFilters[0].authors, + equals([]), + ); + }, + ); test('cache read - not all in cache - BAD TEST', skip: true, () async { final CacheRead myUsecase = CacheRead(myCacheManager); @@ -135,16 +139,17 @@ void main() async { 'pubKey1', 'pubKey2', 'notInCachePubKey1', - 'notInCachePubKey2' + 'notInCachePubKey2', ], kinds: [1], limit: 200, // some high limit - ) + ), ], timeoutDuration: Duration(seconds: 5), ); - final RequestState myRequestStateWithLimit = - RequestState(myNdkRequestWithLimit); + final RequestState myRequestStateWithLimit = RequestState( + myNdkRequestWithLimit, + ); await myUsecase.resolveUnresolvedFilters( requestState: myRequestStateWithLimit, @@ -152,13 +157,14 @@ void main() async { ); expect( - myRequestStateWithLimit.unresolvedFilters[0].authors, - equals([ - 'pubKey1', - 'pubKey2', - 'notInCachePubKey1', - 'notInCachePubKey2' - ])); + myRequestStateWithLimit.unresolvedFilters[0].authors, + equals([ + 'pubKey1', + 'pubKey2', + 'notInCachePubKey1', + 'notInCachePubKey2', + ]), + ); }); test('cache read - id filter with one missing', () async { @@ -166,47 +172,43 @@ void main() async { final CacheRead myUsecase = CacheRead(myCacheManager); final eventId0 = Nip01Event( - pubKey: "pubKey0", - kind: 1, - tags: [], - content: "content0", - id: "id0", - sig: null, - validSig: null); + pubKey: "pubKey0", + kind: 1, + tags: [], + content: "content0", + id: "id0", + sig: null, + validSig: null, + ); final eventId1 = Nip01Event( - pubKey: "pubKey1", - kind: 1, - tags: [], - content: "content1", - id: "id1", - sig: null, - validSig: null); + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1", + id: "id1", + sig: null, + validSig: null, + ); final eventId2 = Nip01Event( - pubKey: "pubKey2", - kind: 1, - tags: [], - content: "content2", - id: "id2", - sig: null, - validSig: null); - - final List idEvents = [ - eventId0, - eventId1, - eventId2, - ]; + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2", + id: "id2", + sig: null, + validSig: null, + ); + + final List idEvents = [eventId0, eventId1, eventId2]; await myCacheManager.saveEvents(idEvents); final NdkRequest myNdkRequest = NdkRequest.query( "id-filter", filters: [ - Filter( - ids: ['id0', 'id1', 'id2', 'id3'], - kinds: [1], - ) + Filter(ids: ['id0', 'id1', 'id2', 'id3'], kinds: [1]), ], timeoutDuration: Duration(seconds: 5), ); @@ -225,7 +227,9 @@ void main() async { final foundEvents = await response; expect(foundEvents.length, equals(3)); expect( - foundEvents.map((e) => e.id).toSet(), equals({'id0', 'id1', 'id2'})); + foundEvents.map((e) => e.id).toSet(), + equals({'id0', 'id1', 'id2'}), + ); expect(myRequestState.unresolvedFilters[0].ids, equals(['id3'])); }); @@ -235,47 +239,43 @@ void main() async { final CacheRead myUsecase = CacheRead(myCacheManager); final eventId0 = Nip01Event( - pubKey: "pubKey0", - kind: 1, - tags: [], - content: "content0", - id: "id0", - sig: null, - validSig: null); + pubKey: "pubKey0", + kind: 1, + tags: [], + content: "content0", + id: "id0", + sig: null, + validSig: null, + ); final eventId1 = Nip01Event( - pubKey: "pubKey1", - kind: 1, - tags: [], - content: "content1", - id: "id1", - sig: null, - validSig: null); + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1", + id: "id1", + sig: null, + validSig: null, + ); final eventId2 = Nip01Event( - pubKey: "pubKey2", - kind: 1, - tags: [], - content: "content2", - id: "id2", - sig: null, - validSig: null); - - final List idEvents = [ - eventId0, - eventId1, - eventId2, - ]; + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2", + id: "id2", + sig: null, + validSig: null, + ); + + final List idEvents = [eventId0, eventId1, eventId2]; await myCacheManager.saveEvents(idEvents); final NdkRequest myNdkRequest = NdkRequest.query( "id-filter", filters: [ - Filter( - ids: ['id0', 'id1', 'id2'], - kinds: [1], - ) + Filter(ids: ['id0', 'id1', 'id2'], kinds: [1]), ], timeoutDuration: Duration(seconds: 5), ); @@ -294,19 +294,21 @@ void main() async { final foundEvents = await response; expect(foundEvents.length, equals(3)); expect( - foundEvents.map((e) => e.id).toSet(), equals({'id0', 'id1', 'id2'})); + foundEvents.map((e) => e.id).toSet(), + equals({'id0', 'id1', 'id2'}), + ); expect(myRequestState.unresolvedFilters, equals([])); }); test('cache read - has events for all authors', () async { // ...but we cannot remove them from the filter because only replaceable events have 1 event per pubKey+kind, normal events can have many per pubKey+kind - final filter = Filter( - authors: ['pubKey1', 'pubKey2'], - kinds: [1], + final filter = Filter(authors: ['pubKey1', 'pubKey2'], kinds: [1]); + final NdkRequest myNdkRequest = NdkRequest.query( + "id", + filters: [filter], + timeoutDuration: Duration(seconds: 5), ); - final NdkRequest myNdkRequest = NdkRequest.query("id", - filters: [filter], timeoutDuration: Duration(seconds: 5)); final RequestState myRequestState = RequestState(myNdkRequest); final CacheRead myUsecase = CacheRead(myCacheManager); @@ -325,7 +327,7 @@ void main() async { ); await response.then((data) { - expect(data, equals(myEvens)); + expect(data, unorderedEquals(myEvens)); }); /// expect in any order diff --git a/packages/ndk/test/usecases/cache_write_test.dart b/packages/ndk/test/usecases/cache_write_test.dart index 79e342faa..86f222adc 100644 --- a/packages/ndk/test/usecases/cache_write_test.dart +++ b/packages/ndk/test/usecases/cache_write_test.dart @@ -55,8 +55,10 @@ void main() async { expect(loadedEvent, isNotNull); } - final duplicatedEvents = - await myCacheManager.loadEvents(kinds: [1], pubKeys: ['duplicate']); + final duplicatedEvents = await myCacheManager.loadEvents( + kinds: [1], + pubKeys: ['duplicate'], + ); expect(duplicatedEvents.length, equals(1)); }); diff --git a/packages/ndk/test/usecases/concurrency_check/concurrency_check_test.dart b/packages/ndk/test/usecases/concurrency_check/concurrency_check_test.dart index 8cf6ad1e5..b060cf594 100644 --- a/packages/ndk/test/usecases/concurrency_check/concurrency_check_test.dart +++ b/packages/ndk/test/usecases/concurrency_check/concurrency_check_test.dart @@ -22,11 +22,12 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map key1TextNotes = {key1: textNote(key1)}; @@ -34,10 +35,10 @@ void main() async { Map key3TextNotes = {key3: textNote(key3)}; Map key4TextNotes = {key4: textNote(key4)}; - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 4340); - MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 4341); - MockRelay relay3 = MockRelay(name: "relay 3", explicitPort: 4342); - MockRelay relay4 = MockRelay(name: "relay 4", explicitPort: 4343); + MockRelay relay1 = MockRelay(name: "relay 1"); + MockRelay relay2 = MockRelay(name: "relay 2"); + MockRelay relay3 = MockRelay(name: "relay 3"); + MockRelay relay4 = MockRelay(name: "relay 4"); final myRelayUrls = [relay1.url, relay2.url, relay3.url, relay4.url]; @@ -55,39 +56,47 @@ void main() async { await relay4.stopServer(); }); - test('test if events get replayed on concurrency requests', - timeout: const Timeout(Duration(seconds: 3)), () async { - final ndkJit = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.JIT, - bootstrapRelays: myRelayUrls, - ), - ); - ndkJit.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + test( + 'test if events get replayed on concurrency requests', + timeout: const Timeout(Duration(seconds: 10)), + () async { + final ndkJit = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + bootstrapRelays: myRelayUrls, + ), + ); + ndkJit.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + await ndkJit.relays.seedRelaysConnected; - final myfilter = Filter( + final myfilter = Filter( kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey]); + authors: [key1.publicKey], + ); - final response0 = ndkJit.requests.query( - filters: [myfilter], - desiredCoverage: 1, - ); - await Future.delayed(Duration(milliseconds: 1)); - final response1 = ndkJit.requests.query( - filters: [myfilter], - desiredCoverage: 1, - ); + final response0 = ndkJit.requests.query( + filters: [myfilter], + desiredCoverage: 1, + ); + await Future.delayed(Duration(milliseconds: 1)); + final response1 = ndkJit.requests.query( + filters: [myfilter], + desiredCoverage: 1, + ); - await expectLater( - response0.stream, emitsInAnyOrder(key1TextNotes.values)); + final result0 = await response0.future; + final result1 = await response1.future; - await expectLater( - response1.stream, emitsInAnyOrder(key1TextNotes.values)); - await ndkJit.destroy(); - }); + expect(result0, hasLength(1)); + expect(result0.map((event) => event.id), [key1TextNotes[key1]!.id]); + expect(result1.map((event) => event.id), [key1TextNotes[key1]!.id]); + await ndkJit.destroy(); + }, + ); }); } diff --git a/packages/ndk/test/usecases/connectivity/connectivity_test.dart b/packages/ndk/test/usecases/connectivity/connectivity_test.dart index 79ca14317..d38381e9f 100644 --- a/packages/ndk/test/usecases/connectivity/connectivity_test.dart +++ b/packages/ndk/test/usecases/connectivity/connectivity_test.dart @@ -43,8 +43,9 @@ void main() async { test('state updates are received', () async { final completer = Completer>(); - final subscription = - ndk.connectivity.relayConnectivityChanges.listen((event) { + final subscription = ndk.connectivity.relayConnectivityChanges.listen(( + event, + ) { // When we detect a change where one relay is disconnected, complete the completer if (event[relay0.url]?.isConnected == true && event[relay1.url]?.isConnected == true) { @@ -52,11 +53,12 @@ void main() async { } }); - ndk.requests.query(filters: [ - Filter(kinds: [1]) - ], explicitRelays: [ - relay1.url - ]); + ndk.requests.query( + filters: [ + Filter(kinds: [1]), + ], + explicitRelays: [relay1.url], + ); // Wait for the disconnection event with a timeout final result = await completer.future.timeout( @@ -72,48 +74,46 @@ void main() async { }); test('try reconnect', () async { - ndk.requests.query(filters: [ - Filter(kinds: [1]) - ], explicitRelays: [ - relay1.url - ]); - - // Ensure connected - await Future.delayed(Duration(milliseconds: 500)); - expect( - ndk.connectivity.relayConnectivityChanges, - emitsInAnyOrder([ - predicate>((event) { - expect(event[relay0.url]?.isConnected, true); - expect(event[relay1.url]?.isConnected, true); - return true; - }), - ])); - - // Disconnect relay 0 - await ndk.relays.globalState.relays[relay1.url]?.close(); - - expect( - ndk.connectivity.relayConnectivityChanges, - emitsInAnyOrder([ - predicate>((event) { - expect(event[relay0.url]?.isConnected, true); - expect(event[relay1.url]?.isConnected, false); - return true; - }), - ])); - - // Reconnect relay 0 + ndk.requests.query( + filters: [ + Filter(kinds: [1]), + ], + explicitRelays: [relay1.url], + ); + + await _waitForRelayConnectionState(ndk, relay1.url, true); + expect(ndk.relays.globalState.relays[relay0.url]?.isConnected, true); + expect(ndk.relays.globalState.relays[relay1.url]?.isConnected, true); + + await ndk.relays.resetTransport(relay1.url); + + await _waitForRelayConnectionState(ndk, relay1.url, false); + expect(ndk.relays.globalState.relays[relay0.url]?.isConnected, true); + expect(ndk.relays.globalState.relays[relay1.url]?.isConnected, false); + await ndk.connectivity.tryReconnect(); - expect( - ndk.connectivity.relayConnectivityChanges, - emitsInAnyOrder([ - predicate>((event) { - expect(event[relay0.url]?.isConnected, true); - expect(event[relay1.url]?.isConnected, true); - return true; - }), - ])); + await _waitForRelayConnectionState(ndk, relay1.url, true); + expect(ndk.relays.globalState.relays[relay0.url]?.isConnected, true); + expect(ndk.relays.globalState.relays[relay1.url]?.isConnected, true); }); }); } + +Future _waitForRelayConnectionState( + Ndk ndk, + String relayUrl, + bool expectedState, +) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + + while (DateTime.now().isBefore(deadline)) { + if (ndk.relays.globalState.relays[relayUrl]?.isConnected == expectedState) { + return; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + + throw TimeoutException( + 'Relay $relayUrl did not reach expected connection state $expectedState', + ); +} diff --git a/packages/ndk/test/usecases/decrypted_event_payloads/decrypted_event_payloads_test.dart b/packages/ndk/test/usecases/decrypted_event_payloads/decrypted_event_payloads_test.dart new file mode 100644 index 000000000..35d8c8f0f --- /dev/null +++ b/packages/ndk/test/usecases/decrypted_event_payloads/decrypted_event_payloads_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + group('DecryptedEventPayloads', () { + late CacheManager cacheManager; + late DecryptedEventPayloads usecase; + late Nip01Event event; + + setUp(() { + cacheManager = MemCacheManager(); + usecase = DecryptedEventPayloads(cacheManager: cacheManager); + event = Nip01Event( + pubKey: 'author-pubkey', + kind: 4, + tags: const [], + content: 'ciphertext', + ); + }); + + test('decrypts on miss and persists plaintext sidecar', () async { + final plaintext = await usecase.loadOrDecrypt( + event: event, + viewerPubKey: 'viewer-pubkey', + scheme: DecryptedPayloadScheme.nip44, + decrypt: () async => 'hello plaintext', + ); + + expect(plaintext, 'hello plaintext'); + + final stored = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-pubkey', + ); + + expect(stored, isNotNull); + expect(stored!.status, DecryptedPayloadStatus.ready); + expect(stored.plaintextContent, 'hello plaintext'); + expect(stored.scheme, DecryptedPayloadScheme.nip44); + expect(stored.sourceEventPubKey, event.pubKey); + expect(stored.sourceEventKind, event.kind); + }); + + test('returns cached plaintext without calling decrypt again', () async { + await cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-pubkey', + scheme: DecryptedPayloadScheme.nip44, + status: DecryptedPayloadStatus.ready, + plaintextContent: 'cached plaintext', + createdAt: 1, + updatedAt: 1, + decryptedAt: 1, + sourceEventPubKey: event.pubKey, + sourceEventKind: event.kind, + ), + ); + + var decryptCalls = 0; + final plaintext = await usecase.loadOrDecrypt( + event: event, + viewerPubKey: 'viewer-pubkey', + scheme: DecryptedPayloadScheme.nip44, + decrypt: () async { + decryptCalls += 1; + return 'should not happen'; + }, + ); + + expect(plaintext, 'cached plaintext'); + expect(decryptCalls, 0); + }); + + test('persists classified decrypt failures and rethrows', () async { + await expectLater( + () => usecase.loadOrDecrypt( + event: event, + viewerPubKey: 'viewer-pubkey', + scheme: DecryptedPayloadScheme.nip44, + decrypt: () async => throw StateError('boom'), + classifyFailure: (_, __) => DecryptedPayloadStatus.permanentFailure, + ), + throwsA(isA()), + ); + + final stored = await cacheManager.loadDecryptedEventPayloadRecord( + eventId: event.id, + viewerPubKey: 'viewer-pubkey', + ); + expect(stored, isNotNull); + expect(stored!.status, DecryptedPayloadStatus.permanentFailure); + expect(stored.failureReason, contains('boom')); + expect(stored.plaintextContent, isNull); + }); + + test( + 'coalesces concurrent decrypts for the same event and viewer', + () async { + var decryptCalls = 0; + + final futures = List.generate( + 2, + (_) => usecase.loadOrDecrypt( + event: event, + viewerPubKey: 'viewer-pubkey', + scheme: DecryptedPayloadScheme.nip44, + decrypt: () async { + decryptCalls += 1; + await Future.delayed(const Duration(milliseconds: 50)); + return 'shared plaintext'; + }, + ), + ); + + final results = await Future.wait(futures); + + expect(results, ['shared plaintext', 'shared plaintext']); + expect(decryptCalls, 1); + }, + ); + }); +} diff --git a/packages/ndk/test/usecases/delivery_inspection_test.dart b/packages/ndk/test/usecases/delivery_inspection_test.dart new file mode 100644 index 000000000..946a6d723 --- /dev/null +++ b/packages/ndk/test/usecases/delivery_inspection_test.dart @@ -0,0 +1,117 @@ +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + group('delivery inspection', () { + test( + 'loads pending local-first deliveries with relay-specific outcomes', + () async { + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: Bip340EventVerifier(), + bootstrapRelays: const [], + ), + ); + + final pendingEvent = Nip01Event( + pubKey: 'pubkey-pending', + createdAt: 1_700_000_000, + kind: 1, + tags: const [], + content: 'pending note', + ); + final deliveredEvent = Nip01Event( + pubKey: 'pubkey-delivered', + createdAt: 1_700_000_001, + kind: 1, + tags: const [], + content: 'delivered note', + ); + + await cache.saveEvents([pendingEvent, deliveredEvent]); + + await cache.saveEventDeliveryRecords([ + EventDeliveryRecord( + eventId: pendingEvent.id, + status: EventDeliveryStatus.partiallyDelivered, + createdAt: pendingEvent.createdAt, + updatedAt: pendingEvent.createdAt + 10, + ), + EventDeliveryRecord( + eventId: deliveredEvent.id, + status: EventDeliveryStatus.delivered, + createdAt: deliveredEvent.createdAt, + updatedAt: deliveredEvent.createdAt + 10, + completedAt: deliveredEvent.createdAt + 10, + ), + ]); + + await cache.saveRelayDeliveryTargets([ + RelayDeliveryTarget( + eventId: pendingEvent.id, + relayUrl: 'wss://relay-a.example.com', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.acked, + attemptCount: 1, + lastOkMessage: 'ok', + ), + RelayDeliveryTarget( + eventId: pendingEvent.id, + relayUrl: 'wss://relay-b.example.com', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.transientFailure, + attemptCount: 2, + lastError: 'rate-limited: retry later', + nextRetryAt: pendingEvent.createdAt + 30, + ), + RelayDeliveryTarget( + eventId: deliveredEvent.id, + relayUrl: 'wss://relay-c.example.com', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.acked, + attemptCount: 1, + lastOkMessage: 'ok', + ), + ]); + + final pending = await ndk.broadcast.loadPendingDeliveries(); + + expect(pending, hasLength(1)); + expect(pending.single.event?.id, pendingEvent.id); + expect( + pending.single.record.status, + EventDeliveryStatus.partiallyDelivered, + ); + expect(pending.single.isPendingDelivery, isTrue); + expect(pending.single.isOnlyLocal, isTrue); + expect( + pending.single.relayTargets.map((target) => target.relayUrl).toList(), + ['wss://relay-a.example.com', 'wss://relay-b.example.com'], + ); + expect( + pending.single.relayTargets + .firstWhere( + (target) => target.relayUrl == 'wss://relay-b.example.com', + ) + .lastError, + 'rate-limited: retry later', + ); + + final delivered = await ndk.broadcast.loadDeliveries( + pendingOnly: false, + status: EventDeliveryStatus.delivered, + ); + expect(delivered, hasLength(1)); + expect(delivered.single.event?.id, deliveredEvent.id); + + final byId = await ndk.broadcast.loadEventDelivery(pendingEvent.id); + expect(byId, isNotNull); + expect(byId!.relayTargets, hasLength(2)); + + await ndk.destroy(); + }, + ); + }); +} diff --git a/packages/ndk/test/usecases/dms/dms_test.dart b/packages/ndk/test/usecases/dms/dms_test.dart new file mode 100644 index 000000000..d3465ccfb --- /dev/null +++ b/packages/ndk/test/usecases/dms/dms_test.dart @@ -0,0 +1,416 @@ +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_event_verifier.dart'; +import '../../mocks/mock_relay.dart'; + +/// Tests for the NIP-17 [Dms] usecase. +/// +/// These are integration tests: a real [Ndk] instance talks to an in-process +/// [MockRelay]. Gift wrap / seal cryptography is exercised end-to-end, so the +/// happy-path tests prove the full send -> store -> load -> decrypt cycle. +void main() async { + group('dms', () { + late MockRelay relay; + late Ndk ndk; + + // Sender (the logged-in user in most tests) + late KeyPair alice; + // Peer + late KeyPair bob; + + setUp(() async { + alice = Bip340.generatePrivateKey(); + bob = Bip340.generatePrivateKey(); + + relay = MockRelay(name: 'dm relay', explicitPort: 5201); + await relay.startServer(); + + final cache = MemCacheManager(); + final config = NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay.url], + ignoreRelays: [], + ); + ndk = Ndk(config); + await ndk.relays.seedRelaysConnected; + + // Alice logs in + ndk.accounts.loginPrivateKey( + pubkey: alice.publicKey, + privkey: alice.privateKey!, + ); + }); + + tearDown(() async { + await ndk.destroy(); + await relay.stopServer(); + }); + + /// Publishes a NIP-17 DM relay list (kind 10050) for [pubKey] containing + /// [urls], signed with [privKey], directly into the relay's storage. + Future publishDmRelayList( + KeyPair owner, { + required List urls, + }) async { + final event = Nip01Event( + pubKey: owner.publicKey, + kind: Nip51List.kDmRelays, + content: '', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + tags: [ + for (final url in urls) ['relay', url], + ], + ); + final signed = Nip01Utils.signWithPrivateKey( + event: event, + privateKey: owner.privateKey!, + ); + await ndk.config.cache.saveEvent(signed); + } + + test('requires a logged-in account to send', () async { + ndk.accounts.logout(); + expect( + () => + ndk.dms.sendMessage(recipientPubKey: bob.publicKey, content: 'hi'), + throwsA(isA()), + ); + }); + + test('sendMessage throws when sender has no DM relays', () async { + // Alice logged in but has no kind 10050 anywhere + await expectLater( + ndk.dms.sendMessage(recipientPubKey: bob.publicKey, content: 'hi'), + throwsA(isA()), + ); + }); + + test('sendMessage throws when recipient has no DM relays', () async { + await publishDmRelayList(alice, urls: [relay.url]); + + expect( + ndk.dms.sendMessage(recipientPubKey: bob.publicKey, content: 'hi'), + throwsA(isA()), + ); + }); + + test( + 'sendMessage broadcasts a wrapped copy to each side\'s DM relays', + () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'hello bob', + ); + + // The mock relay should have received two distinct gift wrap events + // (one for the recipient, one for the sender). + final giftWraps = relay.receivedEvents + .where((e) => e.kind == GiftWrap.kGiftWrapEventkind) + .toList(); + expect(giftWraps.length, greaterThanOrEqualTo(2)); + + final recipients = giftWraps + .map((e) => e.pTags) + .expand((p) => p) + .toSet(); + expect(recipients, containsAll([alice.publicKey, bob.publicKey])); + }, + ); + + test( + 'sendMessage with additional tags keeps the p tag on the rumor', + () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'with subject', + additionalTags: const [ + ['subject', 'greeting'], + ], + ); + + // Login as bob and load the conversation to verify the rumor carries the + // extra tag through the gift wrap. + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: bob.publicKey, + privkey: bob.privateKey!, + ); + await publishDmRelayList(bob, urls: [relay.url]); + + final conversations = await ndk.dms.loadConversations( + timeout: const Duration(seconds: 10), + ); + expect(conversations, isNotEmpty); + + final aliceConv = conversations.firstWhere( + (c) => c.peerPubKey == alice.publicKey, + ); + expect(aliceConv.messages, isNotEmpty); + final rumor = aliceConv.messages.first.rumor; + expect( + rumor.tags.any( + (t) => t.length >= 2 && t[0] == 'subject' && t[1] == 'greeting', + ), + isTrue, + ); + }, + ); + + test( + 'sender view keeps outgoing DMs with additional p tags for mentions', + () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + final mention = Bip340.generatePrivateKey(); + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'hello with mention', + additionalTags: [ + ['p', mention.publicKey], + ], + ); + + final messages = await ndk.dms.loadConversation( + peerPubKey: bob.publicKey, + timeout: const Duration(seconds: 10), + ); + + expect(messages, isNotEmpty); + expect(messages.single.content, 'hello with mention'); + expect(messages.single.isOutgoing, isTrue); + expect(messages.single.peerPubKey, bob.publicKey); + }, + ); + + test('loadConversations throws when not logged in', () async { + ndk.accounts.logout(); + expect(ndk.dms.loadConversations(), throwsA(isA())); + }); + + test('loadConversations throws when user has no DM relays', () async { + await expectLater(ndk.dms.loadConversations(), throwsA(isA())); + }); + + test('loadConversations returns empty when no messages exist', () async { + await publishDmRelayList(alice, urls: [relay.url]); + + final conversations = await ndk.dms.loadConversations( + timeout: const Duration(seconds: 10), + ); + expect(conversations, isEmpty); + }); + + test('loadConversation returns empty list for unknown peer', () async { + await publishDmRelayList(alice, urls: [relay.url]); + + final messages = await ndk.dms.loadConversation( + peerPubKey: bob.publicKey, + timeout: const Duration(seconds: 10), + ); + expect(messages, isEmpty); + }); + + test( + 'send then load round-trip groups messages by peer and marks direction', + () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + // Alice sends two messages to bob. createdAt is second-precision, so we + // wait > 1s between sends to guarantee a strict ordering. + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'message 1', + ); + await Future.delayed(const Duration(seconds: 2)); + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'message 2', + ); + + // Alice can load her own conversations from her DM relays + final aliceConversations = await ndk.dms.loadConversations( + timeout: const Duration(seconds: 10), + ); + expect(aliceConversations.length, 1); + final aliceConv = aliceConversations.first; + expect(aliceConv.peerPubKey, bob.publicKey); + expect(aliceConv.messages.length, 2); + // Alice authored these, so they are outgoing for her. + for (final m in aliceConv.messages) { + expect(m.isOutgoing, isTrue); + } + // messages are sorted ascending by createdAt; second message is newest + expect(aliceConv.latestMessage.content, 'message 2'); + + // Bob logs in and sees the conversation as incoming. + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: bob.publicKey, + privkey: bob.privateKey!, + ); + + final bobConversation = await ndk.dms.loadConversation( + peerPubKey: alice.publicKey, + timeout: const Duration(seconds: 10), + ); + expect(bobConversation.length, 2); + for (final m in bobConversation) { + expect(m.isOutgoing, isFalse); + expect(m.peerPubKey, alice.publicKey); + } + final contents = bobConversation.map((m) => m.content).toSet(); + expect(contents, containsAll(['message 1', 'message 2'])); + }, + ); + + test('conversations are sorted by latest message descending', () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + final carol = Bip340.generatePrivateKey(); + await publishDmRelayList(carol, urls: [relay.url]); + + // Send to bob first, then carol. createdAt is second-precision so we + // space the sends by > 1s to guarantee carol's conversation is newer. + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'to bob', + ); + await Future.delayed(const Duration(seconds: 2)); + await ndk.dms.sendMessage( + recipientPubKey: carol.publicKey, + content: 'to carol', + ); + + final conversations = await ndk.dms.loadConversations( + timeout: const Duration(seconds: 10), + ); + expect(conversations.length, 2); + // carol's conversation is newer -> first + expect(conversations.first.peerPubKey, carol.publicKey); + expect(conversations.last.peerPubKey, bob.publicKey); + }); + + test('loadConversationsSnapshot reads only from cache', () async { + await publishDmRelayList(alice, urls: [relay.url]); + + // Nothing in cache -> empty + var snapshot = await ndk.dms.loadConversationsSnapshot(); + expect(snapshot, isEmpty); + + // Populate cache by sending + loading + await publishDmRelayList(bob, urls: [relay.url]); + await ndk.dms.sendMessage( + recipientPubKey: bob.publicKey, + content: 'cached msg', + ); + await ndk.dms.loadConversations(timeout: const Duration(seconds: 10)); + + snapshot = await ndk.dms.loadConversationsSnapshot(); + // After a network load, the gift wraps are cached and decryptable via + // the decrypted payload sidecar cache. + final bobConv = snapshot.where((c) => c.peerPubKey == bob.publicKey); + expect(bobConv, isNotEmpty); + expect(bobConv.first.messages.first.content, 'cached msg'); + }); + + test('loadConversationSnapshot reads only from cache', () async { + await publishDmRelayList(alice, urls: [relay.url]); + + var messages = await ndk.dms.loadConversationSnapshot( + peerPubKey: bob.publicKey, + ); + expect(messages, isEmpty); + }); + + test('parseWrappedMessage returns null for non-DM rumor kind', () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + // Build a gift wrap around a kind:1 event (not kind 14) addressed to + // alice so she can decrypt it. + final rumor = await ndk.giftWrap.createRumor( + content: 'not a dm', + kind: 1, + tags: [ + ['p', bob.publicKey], + ], + ); + final wrap = await ndk.giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + + final parsed = await ndk.dms.parseWrappedMessage(wrappedEvent: wrap); + expect(parsed, isNull); + }); + + test('parseWrappedMessage parses a valid incoming DM', () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + // Bob authors a DM rumor and wraps it for alice. + final rumor = await ndk.giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'hi alice', + kind: Dms.kMessageKind, + tags: [ + ['p', alice.publicKey], + ], + ); + final wrap = await ndk.giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + + final parsed = await ndk.dms.parseWrappedMessage(wrappedEvent: wrap); + expect(parsed, isNotNull); + expect(parsed!.content, 'hi alice'); + expect(parsed.peerPubKey, bob.publicKey); + expect(parsed.isOutgoing, isFalse); + }); + + test( + 'parseWrappedMessage resolves outgoing direction for author', + () async { + await publishDmRelayList(alice, urls: [relay.url]); + await publishDmRelayList(bob, urls: [relay.url]); + + // Alice authors a DM rumor and wraps it for herself (sender copy). + final rumor = await ndk.giftWrap.createRumor( + content: 'self copy', + kind: Dms.kMessageKind, + tags: [ + ['p', bob.publicKey], + ], + ); + final wrap = await ndk.giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + + final parsed = await ndk.dms.parseWrappedMessage(wrappedEvent: wrap); + expect(parsed, isNotNull); + expect(parsed!.isOutgoing, isTrue); + expect(parsed.peerPubKey, bob.publicKey); + }, + ); + + test('kMessageKind is the NIP-17 text message kind (14)', () { + expect(Dms.kMessageKind, 14); + }); + }); +} diff --git a/packages/ndk/test/usecases/duplicate_request_test.dart b/packages/ndk/test/usecases/duplicate_request_test.dart index e140ea29e..f817142b4 100644 --- a/packages/ndk/test/usecases/duplicate_request_test.dart +++ b/packages/ndk/test/usecases/duplicate_request_test.dart @@ -12,30 +12,35 @@ void main() { final key = Bip340.generatePrivateKey(); // Create a mock relay that responds quickly - final relay = MockRelay( - name: "relay duplicate test", - explicitPort: 5105, - ); + final relay = MockRelay(name: "relay duplicate test", explicitPort: 5105); await relay.startServer(); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - // Short query timeout - if bug exists, duplicate will wait this long - defaultQueryTimeout: const Duration(seconds: 5), - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + // Short query timeout - if bug exists, duplicate will wait this long + defaultQueryTimeout: const Duration(seconds: 5), + ), + ); - final stopwatch = Stopwatch()..start(); + final current = ndk.relays.getRelayConnectivity(relay.url); + if (current?.isConnected != true) { + await ndk.connectivity.relayConnectivityChanges + .firstWhere((relays) => relays[relay.url]?.isConnected == true) + .timeout(const Duration(seconds: 10)); + } - // Create two identical requests at the same time (same filter) - // One will be treated as a duplicate + final stopwatch = Stopwatch()..start(); final filter = Filter( kinds: [Nip01Event.kTextNodeKind], authors: [key.publicKey], ); + // Create two identical requests at the same time (same filter) + // One will be treated as a duplicate final futures = await Future.wait([ ndk.requests.query(filter: filter, explicitRelays: [relay.url]).future, ndk.requests.query(filter: filter, explicitRelays: [relay.url]).future, @@ -48,7 +53,8 @@ void main() { expect( stopwatch.elapsedMilliseconds, lessThan(2000), - reason: 'Both requests should complete quickly when relay responds. ' + reason: + 'Both requests should complete quickly when relay responds. ' 'Elapsed: ${stopwatch.elapsedMilliseconds}ms. ' 'If this fails, the duplicate request timeout fix is needed.', ); diff --git a/packages/ndk/test/usecases/ephemeral_cache_test.dart b/packages/ndk/test/usecases/ephemeral_cache_test.dart new file mode 100644 index 000000000..737af0489 --- /dev/null +++ b/packages/ndk/test/usecases/ephemeral_cache_test.dart @@ -0,0 +1,416 @@ +import 'dart:async'; + +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:test/test.dart'; + +import '../mocks/mock_event_verifier.dart'; +import '../mocks/mock_relay.dart'; + +/// Tests for the ephemeral-event cache policy. +/// +/// Ephemeral events (NIP-01 kinds 20000-29999) are non-persistent by +/// definition. NDK enforces an asymmetric cache policy: +/// +/// - **Write (inbound from relays):** disabled. Events received via queries or +/// subscriptions flow through to the caller but are never persisted to cache. +/// This prevents unbounded memory growth when processing high-volume +/// ephemeral traffic (e.g. NIP-46 bunker sessions). +/// +/// - **Read:** enabled. Locally-stored ephemeral events (own broadcasts and +/// pending-delivery queue) remain discoverable by subsequent queries — +/// local-first. +/// +/// - **Broadcast / pending delivery:** unchanged. The user's own ephemeral +/// events ARE saved to cache so the retry mechanism can redeliver them when +/// connectivity is restored. +void main() { + const ephemeralKind = 21133; // NIP-46 request + + group('ephemeral cache policy', () { + test('inbound ephemeral event via subscription is NOT written to cache ' + 'even when cacheWrite is true', () async { + final mockRelay = MockRelay(name: 'ephemeral-cache-test'); + await mockRelay.startServer(); + addTearDown(() => mockRelay.stopServer()); + + final authorKey = Bip340.generatePrivateKey(); + final subscriberKey = Bip340.generatePrivateKey(); + final cache = MemCacheManager(); + + final subscriberNdk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final broadcasterNdk = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final receivedEvents = []; + final completer = Completer(); + + // Subscribe with cacheWrite explicitly true — subscriptions default to + // cacheWrite: false in the Requests facade, so we must opt in. + final subscription = subscriberNdk.requests.subscription( + filter: Filter( + kinds: [ephemeralKind], + pTags: [subscriberKey.publicKey], + ), + cacheWrite: true, + ); + + subscription.stream.listen((event) { + receivedEvents.add(event); + if (!completer.isCompleted) { + completer.complete(); + } + }); + + await _waitForSubscriptionCount(mockRelay, 1); + + // Broadcast a signed ephemeral event from the other client. + final ephemeralEvent = Nip01Event( + pubKey: authorKey.publicKey, + kind: ephemeralKind, + tags: [ + ['p', subscriberKey.publicKey], + ], + content: 'inbound ephemeral — should not be cached', + ); + final signedEvent = Nip01Utils.signWithPrivateKey( + event: ephemeralEvent, + privateKey: authorKey.privateKey!, + ); + + await broadcasterNdk.broadcast + .broadcast(nostrEvent: signedEvent, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + await completer.future.timeout(const Duration(seconds: 5)); + + // The subscriber received the event live. + expect(receivedEvents, hasLength(1)); + expect(receivedEvents.first.kind, equals(ephemeralKind)); + + // Give cache write a moment to settle. + await Future.delayed(const Duration(milliseconds: 200)); + + // The cache must NOT contain the ephemeral event. + final cached = cache.events.values.where((e) => e.kind == ephemeralKind); + expect( + cached, + isEmpty, + reason: + 'Inbound ephemeral events must not be persisted to cache even when ' + 'cacheWrite is true.', + ); + + await subscriberNdk.destroy(); + await broadcasterNdk.destroy(); + }); + + test('locally-stored ephemeral event IS returned by a query ' + '(cache read stays enabled)', () async { + final mockRelay = MockRelay(name: 'ephemeral-read-test'); + await mockRelay.startServer(); + addTearDown(() => mockRelay.stopServer()); + + final authorKey = Bip340.generatePrivateKey(); + final cache = MemCacheManager(); + + final ndk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + // Manually save an ephemeral event to cache — simulates a broadcast or + // pending-delivery event that the local-first path stored. + final ephemeralEvent = Nip01Event( + pubKey: authorKey.publicKey, + kind: ephemeralKind, + tags: const [], + content: 'locally stored ephemeral', + createdAt: Nip01Event.secondsSinceEpoch(), + ); + await cache.saveEvent(ephemeralEvent); + + // Query with cacheRead: true, cacheWrite: false. + // The relay has no matching stored events (ephemerals are not stored by + // relays), so the only possible source is the cache. + final response = ndk.requests.query( + filter: Filter(kinds: [ephemeralKind], authors: [authorKey.publicKey]), + cacheRead: true, + cacheWrite: false, + explicitRelays: [mockRelay.url], + timeout: const Duration(seconds: 3), + ); + + final results = await response.future; + + // The locally-stored ephemeral event must be returned. + expect( + results.map((e) => e.id), + contains(ephemeralEvent.id), + reason: + 'Cache read must stay enabled for ephemeral kinds so that ' + 'locally-stored events (broadcasts, pending deliveries) remain ' + 'discoverable by queries.', + ); + + await ndk.destroy(); + }); + + test('broadcasting an ephemeral event purges it from cache once delivery ' + 'is terminal (ephemeral cache is transient)', () async { + final mockRelay = MockRelay(name: 'ephemeral-broadcast-test'); + await mockRelay.startServer(); + addTearDown(() => mockRelay.stopServer()); + + final authorKey = Bip340.generatePrivateKey(); + final cache = MemCacheManager(); + + final ndk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final ephemeralEvent = Nip01Event( + pubKey: authorKey.publicKey, + kind: ephemeralKind, + tags: const [], + content: 'outgoing ephemeral — transient in cache', + ); + + // saveToCache persists the event immediately so the local-first path can + // deliver (and retry) it while the outcome is still pending. + await ndk.broadcast + .broadcast( + nostrEvent: ephemeralEvent, + specificRelays: [mockRelay.url], + saveToCache: true, + ) + .broadcastDoneFuture; + + // Give the async purge a moment to settle. + await Future.delayed(const Duration(milliseconds: 200)); + + // Ephemeral events use a doNotRetry delivery policy: once a relay has + // responded (here: acked -> delivered) there is nothing left to retry. + // The local-first cache copy and its delivery record are dropped + // immediately instead of lingering until a background eviction pass. + final cached = cache.events.values.where((e) => e.kind == ephemeralKind); + expect( + cached, + isEmpty, + reason: + 'Delivered ephemeral events must be purged; relays do not persist ' + 'them and doNotRetry means nothing will re-broadcast them.', + ); + expect(await cache.loadEventDeliveryRecord(ephemeralEvent.id), isNull); + + await ndk.destroy(); + }); + + test('non-ephemeral events ARE cached normally on inbound query ' + '(regression guard)', () async { + final key = Bip340.generatePrivateKey(); + + final textNote = Nip01Event( + kind: Nip01Event.kTextNodeKind, + pubKey: key.publicKey, + content: 'regular text note — must be cached', + tags: [], + createdAt: Nip01Event.secondsSinceEpoch(), + ); + + final mockRelay = MockRelay(name: 'non-ephemeral-test'); + await mockRelay.startServer(textNotes: {key: textNote}); + addTearDown(() => mockRelay.stopServer()); + + final cache = MemCacheManager(); + + final ndk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final response = ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key.publicKey], + ), + cacheRead: true, + cacheWrite: true, + ); + + final results = await response.future; + expect(results.map((e) => e.id), contains(textNote.id)); + + await Future.delayed(const Duration(milliseconds: 200)); + + // Non-ephemeral event must be in cache. + expect( + cache.events[textNote.id], + isNotNull, + reason: 'Non-ephemeral events must be cached normally.', + ); + + await ndk.destroy(); + }); + + test('mixed ephemeral + non-ephemeral kinds: only ephemeral skipped ' + '(per-event filtering)', () async { + final key = Bip340.generatePrivateKey(); + + final mockRelay = MockRelay(name: 'mixed-kinds-test'); + await mockRelay.startServer(); + addTearDown(() => mockRelay.stopServer()); + + final cache = MemCacheManager(); + + final subscriberNdk = Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final broadcasterNdk = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + // Subscribe to BOTH text notes and ephemeral events. + final textNoteReceived = Completer(); + final ephemeralReceived = Completer(); + + final subscription = subscriberNdk.requests.subscription( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind, ephemeralKind], + authors: [key.publicKey], + ), + cacheWrite: true, + ); + + Nip01Event? receivedTextNote; + Nip01Event? receivedEphemeral; + + subscription.stream.listen((event) { + if (event.kind == Nip01Event.kTextNodeKind && + !textNoteReceived.isCompleted) { + receivedTextNote = event; + textNoteReceived.complete(); + } + if (event.kind == ephemeralKind && !ephemeralReceived.isCompleted) { + receivedEphemeral = event; + ephemeralReceived.complete(); + } + }); + + await _waitForSubscriptionCount(mockRelay, 1); + + // Broadcast a non-ephemeral text note from a separate NDK instance. + final textNote = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + kind: Nip01Event.kTextNodeKind, + pubKey: key.publicKey, + content: 'text note in a mixed request', + tags: [], + createdAt: Nip01Event.secondsSinceEpoch(), + ), + privateKey: key.privateKey!, + ); + await broadcasterNdk.broadcast + .broadcast(nostrEvent: textNote, specificRelays: [mockRelay.url]) + .broadcastDoneFuture; + + await textNoteReceived.future.timeout(const Duration(seconds: 5)); + + // Broadcast an ephemeral event from the same separate instance. + final ephemeralEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: key.publicKey, + kind: ephemeralKind, + tags: const [], + content: 'ephemeral in a mixed request', + ), + privateKey: key.privateKey!, + ); + await broadcasterNdk.broadcast + .broadcast( + nostrEvent: ephemeralEvent, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + await ephemeralReceived.future.timeout(const Duration(seconds: 5)); + + await Future.delayed(const Duration(milliseconds: 300)); + + // Both events were received by the subscriber. + expect(receivedTextNote, isNotNull); + expect(receivedEphemeral, isNotNull); + + // Text note (non-ephemeral) must be cached. + expect( + cache.events[receivedTextNote!.id], + isNotNull, + reason: 'Non-ephemeral events in a mixed-kinds request must be cached.', + ); + + // Ephemeral event must NOT be cached. + expect( + cache.events[receivedEphemeral!.id], + isNull, + reason: 'Ephemeral events in a mixed-kinds request must not be cached.', + ); + + await subscriberNdk.destroy(); + await broadcasterNdk.destroy(); + }); + }); +} + +/// Polls the mock relay until [expectedCount] active subscriptions exist. +Future _waitForSubscriptionCount( + MockRelay relay, + int expectedCount, { + Duration timeout = const Duration(seconds: 3), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (relay.activeSubscriptionCount >= expectedCount) { + return; + } + await Future.delayed(const Duration(milliseconds: 25)); + } + throw TimeoutException( + 'MockRelay did not reach $expectedCount active subscriptions.', + ); +} diff --git a/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_integration_test.dart b/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_integration_test.dart index 904d529d5..4c8e77122 100644 --- a/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_integration_test.dart +++ b/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_integration_test.dart @@ -17,292 +17,330 @@ void main() async { createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); return Nip01Utils.signWithPrivateKey( - event: event, privateKey: key.privateKey!); + event: event, + privateKey: key.privateKey!, + ); } Map textNotes = {key1: textNote(key1)}; group('FetchedRanges integration', () { - test('query automatically records fetched ranges after EOSE', - timeout: const Timeout(Duration(seconds: 5)), () async { - // Setup mock relay - MockRelay relay1 = MockRelay( - name: "relay fetched ranges test", - explicitPort: 4200, - signEvents: false, - ); - await relay1.startServer(textNotes: textNotes); - - // Setup NDK - final cache = MemCacheManager(); - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: cache, - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - fetchedRangesEnabled: true, - ), - ); - - await ndk.relays.seedRelaysConnected; - - // Define filter with time bounds - final since = DateTime.now() - .subtract(const Duration(days: 1)) - .millisecondsSinceEpoch ~/ - 1000; - final until = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - final filter = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - since: since, - until: until, - ); - - // Make query - final response = ndk.requests.query(filter: filter); - - // Wait for response to complete - await response.future; - - // Small delay to ensure fetched ranges are recorded - await Future.delayed(const Duration(milliseconds: 100)); - - // Check fetched ranges were recorded - final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter); - - expect(fetchedRanges.isNotEmpty, isTrue, - reason: 'FetchedRanges should be recorded after query'); - expect(fetchedRanges.containsKey(relay1.url), isTrue, - reason: 'FetchedRanges should contain the relay URL'); - - final relayFetchedRanges = fetchedRanges[relay1.url]!; - expect(relayFetchedRanges.ranges.isNotEmpty, isTrue, - reason: 'Should have at least one range'); - expect(relayFetchedRanges.ranges.first.since, equals(since), - reason: 'Range since should match filter since'); - expect(relayFetchedRanges.ranges.first.until, equals(until), - reason: 'Range until should match filter until'); - - await relay1.stopServer(); - await ndk.destroy(); - }); - - test('multiple queries merge fetched ranges', - timeout: const Timeout(Duration(seconds: 5)), () async { - MockRelay relay1 = MockRelay( - name: "relay merge test", - explicitPort: 4201, - signEvents: false, - ); - await relay1.startServer(textNotes: textNotes); - - final cache = MemCacheManager(); - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: cache, - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - fetchedRangesEnabled: true, - ), - ); - - await ndk.relays.seedRelaysConnected; - - // First query: 100-200 - final filter1 = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - since: 100, - until: 200, - ); - - final response1 = ndk.requests.query(filter: filter1); - await response1.future; - await Future.delayed(const Duration(milliseconds: 100)); - - // Second query: 201-300 (adjacent) - final filter2 = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - since: 201, - until: 300, - ); - - final response2 = ndk.requests.query(filter: filter2); - await response2.future; - await Future.delayed(const Duration(milliseconds: 100)); - - // Check fetched ranges were merged - final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter1); - - expect(fetchedRanges.containsKey(relay1.url), isTrue); - - final relayFetchedRanges = fetchedRanges[relay1.url]!; - // Should have merged into 1 range (100-300) - expect(relayFetchedRanges.ranges.length, equals(1), - reason: 'Adjacent ranges should be merged'); - expect(relayFetchedRanges.ranges.first.since, equals(100)); - expect(relayFetchedRanges.ranges.first.until, equals(300)); - - await relay1.stopServer(); - await ndk.destroy(); - }); - - test('findGaps returns correct gaps after query', - timeout: const Timeout(Duration(seconds: 5)), () async { - MockRelay relay1 = MockRelay( - name: "relay gaps test", - explicitPort: 4202, - signEvents: false, - ); - await relay1.startServer(textNotes: textNotes); - - final cache = MemCacheManager(); - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: cache, - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - fetchedRangesEnabled: true, - ), - ); - - await ndk.relays.seedRelaysConnected; - - // Query for 200-300 - final filter = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - since: 200, - until: 300, - ); - - final response = ndk.requests.query(filter: filter); - await response.future; - await Future.delayed(const Duration(milliseconds: 100)); - - // Find gaps for 100-500 - final gaps = await ndk.fetchedRanges.findGaps( - filter: filter, - since: 100, - until: 500, - ); - - // Should have 2 gaps: 100-199 and 301-500 - expect(gaps.length, equals(2), reason: 'Should have 2 gaps'); - expect(gaps[0].since, equals(100)); - expect(gaps[0].until, equals(199)); - expect(gaps[1].since, equals(301)); - expect(gaps[1].until, equals(500)); - - await relay1.stopServer(); - await ndk.destroy(); - }); - - test('getOptimizedFilters returns filters for gaps only', - timeout: const Timeout(Duration(seconds: 5)), () async { - MockRelay relay1 = MockRelay( - name: "relay optimized test", - explicitPort: 4203, - signEvents: false, - ); - await relay1.startServer(textNotes: textNotes); - - final cache = MemCacheManager(); - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: cache, - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay1.url], - fetchedRangesEnabled: true, - ), - ); - - await ndk.relays.seedRelaysConnected; - - // Query for 200-300 - final filter = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - since: 200, - until: 300, - ); - - final response = ndk.requests.query(filter: filter); - await response.future; - await Future.delayed(const Duration(milliseconds: 100)); - - // Get optimized filters for 100-500 - final optimized = await ndk.fetchedRanges.getOptimizedFilters( - filter: filter, - since: 100, - until: 500, - ); - - expect(optimized.containsKey(relay1.url), isTrue); - - final filters = optimized[relay1.url]!; - expect(filters.length, equals(2), reason: 'Should have 2 gap filters'); - - // First gap filter: 100-199 - expect(filters[0].since, equals(100)); - expect(filters[0].until, equals(199)); - expect(filters[0].kinds, equals([Nip01Event.kTextNodeKind])); - expect(filters[0].authors, equals([key1.publicKey])); - - // Second gap filter: 301-500 - expect(filters[1].since, equals(301)); - expect(filters[1].until, equals(500)); - - await relay1.stopServer(); - await ndk.destroy(); - }); + test( + 'query automatically records fetched ranges after EOSE', + timeout: const Timeout(Duration(seconds: 5)), + () async { + // Setup mock relay + MockRelay relay1 = MockRelay( + name: "relay fetched ranges test", + explicitPort: 4200, + signEvents: false, + ); + await relay1.startServer(textNotes: textNotes); + + // Setup NDK + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + fetchedRangesEnabled: true, + ), + ); + + await ndk.relays.seedRelaysConnected; + + // Define filter with time bounds + final since = + DateTime.now() + .subtract(const Duration(days: 1)) + .millisecondsSinceEpoch ~/ + 1000; + final until = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + since: since, + until: until, + ); + + // Make query + final response = ndk.requests.query(filter: filter); + + // Wait for response to complete + await response.future; + + // Small delay to ensure fetched ranges are recorded + await Future.delayed(const Duration(milliseconds: 100)); + + // Check fetched ranges were recorded + final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter); + + expect( + fetchedRanges.isNotEmpty, + isTrue, + reason: 'FetchedRanges should be recorded after query', + ); + expect( + fetchedRanges.containsKey(relay1.url), + isTrue, + reason: 'FetchedRanges should contain the relay URL', + ); + + final relayFetchedRanges = fetchedRanges[relay1.url]!; + expect( + relayFetchedRanges.ranges.isNotEmpty, + isTrue, + reason: 'Should have at least one range', + ); + expect( + relayFetchedRanges.ranges.first.since, + equals(since), + reason: 'Range since should match filter since', + ); + expect( + relayFetchedRanges.ranges.first.until, + equals(until), + reason: 'Range until should match filter until', + ); + + await relay1.stopServer(); + await ndk.destroy(); + }, + ); test( - 'should NOT record fetched ranges when relay requires auth and client cannot authenticate', - timeout: const Timeout(Duration(seconds: 5)), () async { - MockRelay relay1 = MockRelay( - name: "relay auth required test", - explicitPort: 4210, - signEvents: false, - requireAuthForRequests: true, - ); - await relay1.startServer(textNotes: textNotes); - - final ndk = Ndk( - NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay1.url], - fetchedRangesEnabled: true, - ), - ); - - await ndk.relays.seedRelaysConnected; - - final filter = Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], - ); - - final response = ndk.requests.query(filter: filter); - await response.future; - await Future.delayed(const Duration(milliseconds: 100)); - - final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter); - - expect(fetchedRanges.isEmpty, isTrue, + 'multiple queries merge fetched ranges', + timeout: const Timeout(Duration(seconds: 5)), + () async { + MockRelay relay1 = MockRelay( + name: "relay merge test", + explicitPort: 4201, + signEvents: false, + ); + await relay1.startServer(textNotes: textNotes); + + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + fetchedRangesEnabled: true, + ), + ); + + await ndk.relays.seedRelaysConnected; + + // First query: 100-200 + final filter1 = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + since: 100, + until: 200, + ); + + final response1 = ndk.requests.query(filter: filter1); + await response1.future; + await Future.delayed(const Duration(milliseconds: 100)); + + // Second query: 201-300 (adjacent) + final filter2 = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + since: 201, + until: 300, + ); + + final response2 = ndk.requests.query(filter: filter2); + await response2.future; + await Future.delayed(const Duration(milliseconds: 100)); + + // Check fetched ranges were merged + final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter1); + + expect(fetchedRanges.containsKey(relay1.url), isTrue); + + final relayFetchedRanges = fetchedRanges[relay1.url]!; + // Should have merged into 1 range (100-300) + expect( + relayFetchedRanges.ranges.length, + equals(1), + reason: 'Adjacent ranges should be merged', + ); + expect(relayFetchedRanges.ranges.first.since, equals(100)); + expect(relayFetchedRanges.ranges.first.until, equals(300)); + + await relay1.stopServer(); + await ndk.destroy(); + }, + ); + + test( + 'findGaps returns correct gaps after query', + timeout: const Timeout(Duration(seconds: 5)), + () async { + MockRelay relay1 = MockRelay( + name: "relay gaps test", + explicitPort: 4202, + signEvents: false, + ); + await relay1.startServer(textNotes: textNotes); + + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + fetchedRangesEnabled: true, + ), + ); + + await ndk.relays.seedRelaysConnected; + + // Query for 200-300 + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + since: 200, + until: 300, + ); + + final response = ndk.requests.query(filter: filter); + await response.future; + await Future.delayed(const Duration(milliseconds: 100)); + + // Find gaps for 100-500 + final gaps = await ndk.fetchedRanges.findGaps( + filter: filter, + since: 100, + until: 500, + ); + + // Should have 2 gaps: 100-199 and 301-500 + expect(gaps.length, equals(2), reason: 'Should have 2 gaps'); + expect(gaps[0].since, equals(100)); + expect(gaps[0].until, equals(199)); + expect(gaps[1].since, equals(301)); + expect(gaps[1].until, equals(500)); + + await relay1.stopServer(); + await ndk.destroy(); + }, + ); + + test( + 'getOptimizedFilters returns filters for gaps only', + timeout: const Timeout(Duration(seconds: 5)), + () async { + MockRelay relay1 = MockRelay( + name: "relay optimized test", + explicitPort: 4203, + signEvents: false, + ); + await relay1.startServer(textNotes: textNotes); + + final cache = MemCacheManager(); + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: cache, + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay1.url], + fetchedRangesEnabled: true, + ), + ); + + await ndk.relays.seedRelaysConnected; + + // Query for 200-300 + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + since: 200, + until: 300, + ); + + final response = ndk.requests.query(filter: filter); + await response.future; + await Future.delayed(const Duration(milliseconds: 100)); + + // Get optimized filters for 100-500 + final optimized = await ndk.fetchedRanges.getOptimizedFilters( + filter: filter, + since: 100, + until: 500, + ); + + expect(optimized.containsKey(relay1.url), isTrue); + + final filters = optimized[relay1.url]!; + expect(filters.length, equals(2), reason: 'Should have 2 gap filters'); + + // First gap filter: 100-199 + expect(filters[0].since, equals(100)); + expect(filters[0].until, equals(199)); + expect(filters[0].kinds, equals([Nip01Event.kTextNodeKind])); + expect(filters[0].authors, equals([key1.publicKey])); + + // Second gap filter: 301-500 + expect(filters[1].since, equals(301)); + expect(filters[1].until, equals(500)); + + await relay1.stopServer(); + await ndk.destroy(); + }, + ); + + test( + 'should NOT record fetched ranges when relay requires auth and client cannot authenticate', + timeout: const Timeout(Duration(seconds: 5)), + () async { + MockRelay relay1 = MockRelay( + name: "relay auth required test", + explicitPort: 4210, + signEvents: false, + requireAuthForRequests: true, + ); + await relay1.startServer(textNotes: textNotes); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay1.url], + fetchedRangesEnabled: true, + ), + ); + + await ndk.relays.seedRelaysConnected; + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); + + final response = ndk.requests.query(filter: filter); + await response.future; + await Future.delayed(const Duration(milliseconds: 100)); + + final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter); + + expect( + fetchedRanges.isEmpty, + isTrue, reason: - 'FetchedRanges should NOT be recorded when relay returns CLOSED auth-required'); + 'FetchedRanges should NOT be recorded when relay returns CLOSED auth-required', + ); - await relay1.stopServer(); - await ndk.destroy(); - }); + await relay1.stopServer(); + await ndk.destroy(); + }, + ); }); } diff --git a/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_test.dart b/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_test.dart index 46748ffba..acb4304a1 100644 --- a/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_test.dart +++ b/packages/ndk/test/usecases/fetched_ranges/fetched_ranges_test.dart @@ -258,56 +258,60 @@ void main() { expect(optimized, isEmpty); }); - test('returns full range as gap when cache is empty for specified relay', - () async { - final filter = Filter(kinds: [1]); - - // Don't add any ranges - cache is empty - final optimized = await fetchedRanges.getOptimizedFilters( - filter: filter, - since: 100, - until: 500, - relayUrls: ['wss://example.com'], - ); - - expect(optimized.containsKey('wss://example.com'), isTrue); - final filters = optimized['wss://example.com']!; - expect(filters.length, 1); - expect(filters[0].since, 100); - expect(filters[0].until, 500); - expect(filters[0].kinds, [1]); - }); - - test('respects relayUrls parameter and only checks specified relays', - () async { - final filter = Filter(kinds: [1]); - - await fetchedRanges.addRange( - filter: filter, - relayUrl: 'wss://relay1.example.com', - since: 200, - until: 300, - ); - - await fetchedRanges.addRange( - filter: filter, - relayUrl: 'wss://relay2.example.com', - since: 150, - until: 350, - ); - - // Only check relay1 - final optimized = await fetchedRanges.getOptimizedFilters( - filter: filter, - since: 100, - until: 500, - relayUrls: ['wss://relay1.example.com'], - ); - - expect(optimized.length, 1); - expect(optimized.containsKey('wss://relay1.example.com'), isTrue); - expect(optimized.containsKey('wss://relay2.example.com'), isFalse); - }); + test( + 'returns full range as gap when cache is empty for specified relay', + () async { + final filter = Filter(kinds: [1]); + + // Don't add any ranges - cache is empty + final optimized = await fetchedRanges.getOptimizedFilters( + filter: filter, + since: 100, + until: 500, + relayUrls: ['wss://example.com'], + ); + + expect(optimized.containsKey('wss://example.com'), isTrue); + final filters = optimized['wss://example.com']!; + expect(filters.length, 1); + expect(filters[0].since, 100); + expect(filters[0].until, 500); + expect(filters[0].kinds, [1]); + }, + ); + + test( + 'respects relayUrls parameter and only checks specified relays', + () async { + final filter = Filter(kinds: [1]); + + await fetchedRanges.addRange( + filter: filter, + relayUrl: 'wss://relay1.example.com', + since: 200, + until: 300, + ); + + await fetchedRanges.addRange( + filter: filter, + relayUrl: 'wss://relay2.example.com', + since: 150, + until: 350, + ); + + // Only check relay1 + final optimized = await fetchedRanges.getOptimizedFilters( + filter: filter, + since: 100, + until: 500, + relayUrls: ['wss://relay1.example.com'], + ); + + expect(optimized.length, 1); + expect(optimized.containsKey('wss://relay1.example.com'), isTrue); + expect(optimized.containsKey('wss://relay2.example.com'), isFalse); + }, + ); }); group('FetchedRanges.reachedOldest', () { diff --git a/packages/ndk/test/usecases/files/blossom_test.dart b/packages/ndk/test/usecases/files/blossom_test.dart index 4d15859a1..cb12be6d9 100644 --- a/packages/ndk/test/usecases/files/blossom_test.dart +++ b/packages/ndk/test/usecases/files/blossom_test.dart @@ -41,8 +41,10 @@ void main() { engine: NdkEngine.JIT, ), ); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); client = ndk.blossom; }); @@ -126,9 +128,13 @@ void main() { /// expect not to throw expect( - getResponse, completion('http://localhost:${server.port}/$sha256')); - expect(getResponseAuth, - completion('http://localhost:${server.port}/$sha256')); + getResponse, + completion('http://localhost:${server.port}/$sha256'), + ); + expect( + getResponseAuth, + completion('http://localhost:${server.port}/$sha256'), + ); final getResponseVoid = client.checkBlob( sha256: "nonexistent_sha256", @@ -266,80 +272,93 @@ void main() { strategy: UploadStrategy.firstSuccess, ); // Assert results by server URL instead of relying on order - final dead = uploadResponse - .firstWhere((r) => r.serverUrl == 'http://dead.example.com'); + final dead = uploadResponse.firstWhere( + (r) => r.serverUrl == 'http://dead.example.com', + ); final server1Result = uploadResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$secondaryServerPort'); + (r) => r.serverUrl == 'http://localhost:$secondaryServerPort', + ); expect(dead.success, false); expect(server1Result.success, true); final sha256 = server1Result.descriptor!.sha256; - final deadServer = client.getBlob(sha256: sha256, serverUrls: [ - 'http://dead.example.com', - ]); + final deadServer = client.getBlob( + sha256: sha256, + serverUrls: ['http://dead.example.com'], + ); expect(deadServer, throwsException); - final server1 = await client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server2.port}', - ]); + final server1 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server2.port}'], + ); expect(utf8.decode(server1.data), equals('strategy test')); - final served2 = client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server.port}', - ]); + final served2 = client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server.port}'], + ); expect(served2, throwsException); }); - test('Upload to first successful server only - mirrorAfterSuccess', - () async { - final myData = "strategy test mirrorAfterSuccess"; - final testData = Uint8List.fromList(utf8.encode(myData)); - - // Upload blob - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: [ - 'http://dead.example.com', - 'http://localhost:${server2.port}', - 'http://localhost:${server.port}', - ], - strategy: UploadStrategy.mirrorAfterSuccess, - ); - // Assert results by server URL instead of relying on order - final dead = uploadResponse - .firstWhere((r) => r.serverUrl == 'http://dead.example.com'); - final server1Result = uploadResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$secondaryServerPort'); - final server2Result = uploadResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$primaryServerPort'); + test( + 'Upload to first successful server only - mirrorAfterSuccess', + () async { + final myData = "strategy test mirrorAfterSuccess"; + final testData = Uint8List.fromList(utf8.encode(myData)); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: [ + 'http://dead.example.com', + 'http://localhost:${server2.port}', + 'http://localhost:${server.port}', + ], + strategy: UploadStrategy.mirrorAfterSuccess, + ); + // Assert results by server URL instead of relying on order + final dead = uploadResponse.firstWhere( + (r) => r.serverUrl == 'http://dead.example.com', + ); + final server1Result = uploadResponse.firstWhere( + (r) => r.serverUrl == 'http://localhost:$secondaryServerPort', + ); + final server2Result = uploadResponse.firstWhere( + (r) => r.serverUrl == 'http://localhost:$primaryServerPort', + ); - expect(dead.success, false); - expect(server1Result.success, true); - expect(server2Result.success, true); + expect(dead.success, false); + expect(server1Result.success, true); + expect(server2Result.success, true); - final sha256 = server1Result.descriptor!.sha256; + final sha256 = server1Result.descriptor!.sha256; - final deadServer = client.getBlob(sha256: sha256, serverUrls: [ - 'http://dead.example.com', - ]); - expect(deadServer, throwsException); + final deadServer = client.getBlob( + sha256: sha256, + serverUrls: ['http://dead.example.com'], + ); + expect(deadServer, throwsException); - final server1 = await client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server2.port}', - ]); + final server1 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server2.port}'], + ); - expect(utf8.decode(server1.data), equals(myData)); + expect(utf8.decode(server1.data), equals(myData)); - final served2 = await client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server.port}', - ]); + final served2 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server.port}'], + ); - expect(utf8.decode(served2.data), equals(myData)); - }); + expect(utf8.decode(served2.data), equals(myData)); + }, + ); test('Upload to first successful server only - allSimultaneous', () async { final myData = "strategy test allSimultaneous"; @@ -356,12 +375,15 @@ void main() { strategy: UploadStrategy.allSimultaneous, ); // Assert results by server URL instead of relying on order - final dead = uploadResponse - .firstWhere((r) => r.serverUrl == 'http://dead.example.com'); + final dead = uploadResponse.firstWhere( + (r) => r.serverUrl == 'http://dead.example.com', + ); final server1Result = uploadResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$secondaryServerPort'); + (r) => r.serverUrl == 'http://localhost:$secondaryServerPort', + ); final server2Result = uploadResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$primaryServerPort'); + (r) => r.serverUrl == 'http://localhost:$primaryServerPort', + ); expect(dead.success, false); expect(server1Result.success, true); @@ -369,20 +391,23 @@ void main() { final sha256 = server1Result.descriptor!.sha256; - final deadServer = client.getBlob(sha256: sha256, serverUrls: [ - 'http://dead.example.com', - ]); + final deadServer = client.getBlob( + sha256: sha256, + serverUrls: ['http://dead.example.com'], + ); expect(deadServer, throwsException); - final server1 = await client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server2.port}', - ]); + final server1 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server2.port}'], + ); expect(utf8.decode(server1.data), equals(myData)); - final served2 = await client.getBlob(sha256: sha256, serverUrls: [ - 'http://localhost:${server.port}', - ]); + final served2 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:${server.port}'], + ); expect(utf8.decode(served2.data), equals(myData)); }); @@ -393,80 +418,85 @@ void main() { client: HttpRequestDS(http.Client()), fileIO: createFileIO(), ); - test('getBlobStream should properly stream large files with range requests', - () async { - // First upload a test file to the mock server - final testData = Uint8List.fromList( - List.generate(5 * 1024 * 1024, (i) => i % 256)); // 5MB test file - - // Upload the test file - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: ['http://localhost:${server.port}'], - ); + test( + 'getBlobStream should properly stream large files with range requests', + () async { + // First upload a test file to the mock server + final testData = Uint8List.fromList( + List.generate(5 * 1024 * 1024, (i) => i % 256), + ); // 5MB test file + + // Upload the test file + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:${server.port}'], + ); - expect(uploadResponse.first.success, true); - final sha256 = uploadResponse.first.descriptor!.sha256; + expect(uploadResponse.first.success, true); + final sha256 = uploadResponse.first.descriptor!.sha256; - // Now test the streaming download - final stream = await blossomRepo.getBlobStream( - sha256: sha256, - serverUrls: ['http://localhost:${server.port}'], - chunkSize: 1024 * 1024, // 1MB chunks - ); + // Now test the streaming download + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: ['http://localhost:${server.port}'], + chunkSize: 1024 * 1024, // 1MB chunks + ); - // Collect all chunks and verify the data - final chunks = []; - int totalSize = 0; + // Collect all chunks and verify the data + final chunks = []; + int totalSize = 0; - await for (final response in stream) { - chunks.add(response.data); - totalSize += response.data.length; + await for (final response in stream) { + chunks.add(response.data); + totalSize += response.data.length; - // Verify chunk metadata - expect(response.mimeType, isNotNull); - expect(response.contentLength, equals(testData.length)); - //expect(response.contentRange, matches(RegExp(r'bytes \d+-\d+/\d+'))); - } + // Verify chunk metadata + expect(response.mimeType, isNotNull); + expect(response.contentLength, equals(testData.length)); + //expect(response.contentRange, matches(RegExp(r'bytes \d+-\d+/\d+'))); + } - // Combine chunks and verify the complete file - final resultData = Uint8List(totalSize); - int offset = 0; - for (final chunk in chunks) { - resultData.setRange(offset, offset + chunk.length, chunk); - offset += chunk.length; - } + // Combine chunks and verify the complete file + final resultData = Uint8List(totalSize); + int offset = 0; + for (final chunk in chunks) { + resultData.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } - expect(resultData, equals(testData)); - expect(totalSize, equals(testData.length)); - }); + expect(resultData, equals(testData)); + expect(totalSize, equals(testData.length)); + }, + ); test( - 'getBlobStream should fallback to regular download if range requests not supported', - () async { - // Upload a smaller test file - final testData = Uint8List.fromList( - List.generate(1024, (i) => i % 256)); // 1KB test file - - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: ['http://localhost:${server.port}'], - ); + 'getBlobStream should fallback to regular download if range requests not supported', + () async { + // Upload a smaller test file + final testData = Uint8List.fromList( + List.generate(1024, (i) => i % 256), + ); // 1KB test file + + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:${server.port}'], + ); - expect(uploadResponse.first.success, true); - final sha256 = uploadResponse.first.descriptor!.sha256; + expect(uploadResponse.first.success, true); + final sha256 = uploadResponse.first.descriptor!.sha256; - // Test the streaming download - final stream = await blossomRepo.getBlobStream( - sha256: sha256, - serverUrls: ['http://localhost:${server.port}'], - ); + // Test the streaming download + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: ['http://localhost:${server.port}'], + ); - // Should receive exactly one chunk with the complete file - final chunks = await stream.toList(); - expect(chunks.length, equals(1)); - expect(chunks.first.data, equals(testData)); - }); + // Should receive exactly one chunk with the complete file + final chunks = await stream.toList(); + expect(chunks.length, equals(1)); + expect(chunks.first.data, equals(testData)); + }, + ); test('getBlobStream should handle server errors gracefully', () async { expect( @@ -479,39 +509,42 @@ void main() { }); test( - 'getBlobStream should try multiple servers until finding one that works', - () async { - final testData = Uint8List.fromList( - List.generate(2 * 1024 * 1024, (i) => i % 256)); // 2MB test file - - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: ['http://localhost:${server.port}'], - ); + 'getBlobStream should try multiple servers until finding one that works', + () async { + final testData = Uint8List.fromList( + List.generate(2 * 1024 * 1024, (i) => i % 256), + ); // 2MB test file + + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:${server.port}'], + ); - final sha256 = uploadResponse.first.descriptor!.sha256; + final sha256 = uploadResponse.first.descriptor!.sha256; - // Test with multiple servers, including non-existent ones - final stream = await blossomRepo.getBlobStream( - sha256: sha256, - serverUrls: [ - 'http://nonexistent-server:${server.port}', - 'http://localhost:${server.port}', - 'http://another-nonexistent:${server.port}', - ], - ); + // Test with multiple servers, including non-existent ones + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: [ + 'http://nonexistent-server:${server.port}', + 'http://localhost:${server.port}', + 'http://another-nonexistent:${server.port}', + ], + ); - final receivedData = await stream - .map((response) => response.data) - .expand((chunk) => chunk) - .toList(); + final receivedData = await stream + .map((response) => response.data) + .expand((chunk) => chunk) + .toList(); - expect(Uint8List.fromList(receivedData), equals(testData)); - }); + expect(Uint8List.fromList(receivedData), equals(testData)); + }, + ); test('getBlobStream with auth', () async { final testData = Uint8List.fromList( - List.generate(2 * 1024 * 1024, (i) => i % 256)); // 2MB test file + List.generate(2 * 1024 * 1024, (i) => i % 256), + ); // 2MB test file final uploadResponse = await client.uploadBlob( data: testData, @@ -542,45 +575,48 @@ void main() { }); group("mirror", () { - test('mirrorToServers should mirror blob from one server to others', - () async { - final myData = "mirror test data"; - final testData = Uint8List.fromList(utf8.encode(myData)); - - // Upload blob to first server only - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: ['http://localhost:$primaryServerPort'], - ); - expect(uploadResponse.first.success, true); + test( + 'mirrorToServers should mirror blob from one server to others', + () async { + final myData = "mirror test data"; + final testData = Uint8List.fromList(utf8.encode(myData)); + + // Upload blob to first server only + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:$primaryServerPort'], + ); + expect(uploadResponse.first.success, true); - final sha256 = uploadResponse.first.descriptor!.sha256; - final blossomUrl = - Uri.parse('http://localhost:$primaryServerPort/$sha256'); + final sha256 = uploadResponse.first.descriptor!.sha256; + final blossomUrl = Uri.parse( + 'http://localhost:$primaryServerPort/$sha256', + ); - // Mirror to the second server - final mirrorResponse = await client.mirrorToServers( - blossomUrl: blossomUrl, - targetServerUrls: ['http://localhost:$secondaryServerPort'], - ); + // Mirror to the second server + final mirrorResponse = await client.mirrorToServers( + blossomUrl: blossomUrl, + targetServerUrls: ['http://localhost:$secondaryServerPort'], + ); - expect(mirrorResponse.length, equals(1)); - expect(mirrorResponse.first.success, true); - expect(mirrorResponse.first.descriptor?.sha256, equals(sha256)); + expect(mirrorResponse.length, equals(1)); + expect(mirrorResponse.first.success, true); + expect(mirrorResponse.first.descriptor?.sha256, equals(sha256)); - // Verify both servers now have the blob - final fromServer1 = await client.getBlob( - sha256: sha256, - serverUrls: ['http://localhost:$primaryServerPort'], - ); - final fromServer2 = await client.getBlob( - sha256: sha256, - serverUrls: ['http://localhost:$secondaryServerPort'], - ); + // Verify both servers now have the blob + final fromServer1 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:$primaryServerPort'], + ); + final fromServer2 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:$secondaryServerPort'], + ); - expect(utf8.decode(fromServer1.data), equals(myData)); - expect(utf8.decode(fromServer2.data), equals(myData)); - }); + expect(utf8.decode(fromServer1.data), equals(myData)); + expect(utf8.decode(fromServer2.data), equals(myData)); + }, + ); test('mirrorToServers accepts Created response status', () async { final mirrorServer = MockBlossomServer( @@ -600,8 +636,9 @@ void main() { expect(uploadResponse.first.success, true); final sha256 = uploadResponse.first.descriptor!.sha256; - final blossomUrl = - Uri.parse('http://localhost:$primaryServerPort/$sha256'); + final blossomUrl = Uri.parse( + 'http://localhost:$primaryServerPort/$sha256', + ); final mirrorResponse = await client.mirrorToServers( blossomUrl: blossomUrl, @@ -612,42 +649,46 @@ void main() { expect(mirrorResponse.first.descriptor?.sha256, equals(sha256)); }); - test('mirrorToServers should mirror to multiple servers simultaneously', - () async { - final myData = "multi mirror test"; - final testData = Uint8List.fromList(utf8.encode(myData)); - - // Upload to first server - final uploadResponse = await client.uploadBlob( - data: testData, - serverUrls: ['http://localhost:$primaryServerPort'], - ); - expect(uploadResponse.first.success, true); + test( + 'mirrorToServers should mirror to multiple servers simultaneously', + () async { + final myData = "multi mirror test"; + final testData = Uint8List.fromList(utf8.encode(myData)); + + // Upload to first server + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:$primaryServerPort'], + ); + expect(uploadResponse.first.success, true); - final sha256 = uploadResponse.first.descriptor!.sha256; - final blossomUrl = - Uri.parse('http://localhost:$primaryServerPort/$sha256'); + final sha256 = uploadResponse.first.descriptor!.sha256; + final blossomUrl = Uri.parse( + 'http://localhost:$primaryServerPort/$sha256', + ); - // Mirror to second server (we only have 2 servers in tests, but this demonstrates the capability) - final mirrorResponse = await client.mirrorToServers( - blossomUrl: blossomUrl, - targetServerUrls: ['http://localhost:$secondaryServerPort'], - ); + // Mirror to second server (we only have 2 servers in tests, but this demonstrates the capability) + final mirrorResponse = await client.mirrorToServers( + blossomUrl: blossomUrl, + targetServerUrls: ['http://localhost:$secondaryServerPort'], + ); - expect(mirrorResponse.every((r) => r.success), true); + expect(mirrorResponse.every((r) => r.success), true); - // Verify all servers have the blob - final fromServer2 = await client.getBlob( - sha256: sha256, - serverUrls: ['http://localhost:$secondaryServerPort'], - ); + // Verify all servers have the blob + final fromServer2 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:$secondaryServerPort'], + ); - expect(utf8.decode(fromServer2.data), equals(myData)); - }); + expect(utf8.decode(fromServer2.data), equals(myData)); + }, + ); test('mirrorToServers should throw error if URL has no SHA256', () async { - final invalidUrl = - Uri.parse('http://localhost:$primaryServerPort/invalid-url'); + final invalidUrl = Uri.parse( + 'http://localhost:$primaryServerPort/invalid-url', + ); expect( () => client.mirrorToServers( @@ -670,8 +711,9 @@ void main() { expect(uploadResponse.first.success, true); final sha256 = uploadResponse.first.descriptor!.sha256; - final blossomUrl = - Uri.parse('http://localhost:$primaryServerPort/$sha256'); + final blossomUrl = Uri.parse( + 'http://localhost:$primaryServerPort/$sha256', + ); // Try to mirror to both a working server and a dead one final mirrorResponse = await client.mirrorToServers( @@ -686,9 +728,11 @@ void main() { // Find results by server URL final workingServer = mirrorResponse.firstWhere( - (r) => r.serverUrl == 'http://localhost:$secondaryServerPort'); - final deadServer = mirrorResponse - .firstWhere((r) => r.serverUrl == 'http://dead.example.com'); + (r) => r.serverUrl == 'http://localhost:$secondaryServerPort', + ); + final deadServer = mirrorResponse.firstWhere( + (r) => r.serverUrl == 'http://dead.example.com', + ); expect(workingServer.success, true); expect(deadServer.success, false); @@ -705,11 +749,12 @@ void main() { group("report", () { test('report', () async { final reportRsp = await client.report( - serverUrl: 'http://localhost:${server.port}', - sha256: "some_sha256", - eventId: "some_event_id", - reportMsg: "some_report_msg", - reportType: "malware"); + serverUrl: 'http://localhost:${server.port}', + sha256: "some_sha256", + eventId: "some_event_id", + reportMsg: "some_report_msg", + reportType: "malware", + ); expect(reportRsp, equals(200)); }); @@ -792,63 +837,69 @@ void main() { } expect(uploadResults.any((result) => result.success), true); - expect(uploadResults.firstWhere((result) => result.success).descriptor, - isNotNull); + expect( + uploadResults.firstWhere((result) => result.success).descriptor, + isNotNull, + ); }); - test('uploadBlobFromFile emits phase-aware BlobUploadProgress stream', - () async { - final testFile = File('${tempDir.path}/test_progress_stream.txt'); - await testFile.writeAsString('Progress stream validation payload'); + test( + 'uploadBlobFromFile emits phase-aware BlobUploadProgress stream', + () async { + final testFile = File('${tempDir.path}/test_progress_stream.txt'); + await testFile.writeAsString('Progress stream validation payload'); + + final events = []; + + await for (final progress in client.uploadBlobFromFile( + filePath: testFile.path, + serverUrls: ['http://localhost:$primaryServerPort'], + )) { + events.add(progress); + } - final events = []; + expect(events, isNotEmpty); + expect(events.first.phase, equals(UploadPhase.hashing)); + expect(events.any((e) => e.phase == UploadPhase.uploading), isTrue); + expect(events.any((e) => e.phase == UploadPhase.mirroring), isTrue); - await for (final progress in client.uploadBlobFromFile( - filePath: testFile.path, - serverUrls: ['http://localhost:$primaryServerPort'], - )) { - events.add(progress); - } + final firstUploadingIndex = events.indexWhere( + (e) => e.phase == UploadPhase.uploading, + ); + final firstMirroringIndex = events.indexWhere( + (e) => e.phase == UploadPhase.mirroring, + ); - expect(events, isNotEmpty); - expect(events.first.phase, equals(UploadPhase.hashing)); - expect(events.any((e) => e.phase == UploadPhase.uploading), isTrue); - expect(events.any((e) => e.phase == UploadPhase.mirroring), isTrue); - - final firstUploadingIndex = - events.indexWhere((e) => e.phase == UploadPhase.uploading); - final firstMirroringIndex = - events.indexWhere((e) => e.phase == UploadPhase.mirroring); - - expect(firstUploadingIndex, greaterThan(0)); - expect(firstMirroringIndex, greaterThan(firstUploadingIndex)); - - // Once upload starts, hashing should not appear again. - final hasHashingAfterUpload = events - .skip(firstUploadingIndex) - .any((e) => e.phase == UploadPhase.hashing); - expect(hasHashingAfterUpload, isFalse); - - // Overall percentage should map to phase bands. - for (final event in events) { - expect(event.percentage, inInclusiveRange(0, 100)); - - switch (event.phase) { - case UploadPhase.hashing: - expect(event.percentage, inInclusiveRange(0, 33)); - case UploadPhase.uploading: - expect(event.percentage, inInclusiveRange(33, 66)); - case UploadPhase.mirroring: - expect(event.percentage, inInclusiveRange(66, 100)); + expect(firstUploadingIndex, greaterThan(0)); + expect(firstMirroringIndex, greaterThan(firstUploadingIndex)); + + // Once upload starts, hashing should not appear again. + final hasHashingAfterUpload = events + .skip(firstUploadingIndex) + .any((e) => e.phase == UploadPhase.hashing); + expect(hasHashingAfterUpload, isFalse); + + // Overall percentage should map to phase bands. + for (final event in events) { + expect(event.percentage, inInclusiveRange(0, 100)); + + switch (event.phase) { + case UploadPhase.hashing: + expect(event.percentage, inInclusiveRange(0, 33)); + case UploadPhase.uploading: + expect(event.percentage, inInclusiveRange(33, 66)); + case UploadPhase.mirroring: + expect(event.percentage, inInclusiveRange(66, 100)); + } } - } - final lastEvent = events.last; - expect(lastEvent.isComplete, isTrue); - expect(lastEvent.phase, equals(UploadPhase.mirroring)); - expect(lastEvent.percentage, closeTo(100, 0.000001)); - expect(lastEvent.completedUploads.any((u) => u.success), isTrue); - }); + final lastEvent = events.last; + expect(lastEvent.isComplete, isTrue); + expect(lastEvent.phase, equals(UploadPhase.mirroring)); + expect(lastEvent.percentage, closeTo(100, 0.000001)); + expect(lastEvent.completedUploads.any((u) => u.success), isTrue); + }, + ); test('downloadBlobToFile should download a blob to disk', () async { // First, upload some test data @@ -881,7 +932,8 @@ void main() { // Create a test file with binary content final uploadFile = File('${tempDir.path}/test_binary_upload.bin'); final testData = Uint8List.fromList( - List.generate(1024, (i) => i % 256)); // 1KB of test data + List.generate(1024, (i) => i % 256), + ); // 1KB of test data await uploadFile.writeAsBytes(testData); // Upload the file @@ -938,89 +990,96 @@ void main() { expect(downloadedContent, equals(testContent)); }); - test('uploadBlobFromFile with multiple servers - mirrorAfterSuccess', - () async { - final testFile = File('${tempDir.path}/test_mirror.txt'); - final testContent = 'Mirror upload test'; - await testFile.writeAsString(testContent); - - // Upload with mirror strategy - List uploadResults = const []; - await for (final progress in client.uploadBlobFromFile( - filePath: testFile.path, - serverUrls: [ - 'http://localhost:$primaryServerPort', - 'http://localhost:$secondaryServerPort', - ], - strategy: UploadStrategy.mirrorAfterSuccess, - )) { - if (progress.completedUploads.isNotEmpty) { - uploadResults = progress.completedUploads; + test( + 'uploadBlobFromFile with multiple servers - mirrorAfterSuccess', + () async { + final testFile = File('${tempDir.path}/test_mirror.txt'); + final testContent = 'Mirror upload test'; + await testFile.writeAsString(testContent); + + // Upload with mirror strategy + List uploadResults = const []; + await for (final progress in client.uploadBlobFromFile( + filePath: testFile.path, + serverUrls: [ + 'http://localhost:$primaryServerPort', + 'http://localhost:$secondaryServerPort', + ], + strategy: UploadStrategy.mirrorAfterSuccess, + )) { + if (progress.completedUploads.isNotEmpty) { + uploadResults = progress.completedUploads; + } } - } - // Should have uploaded to both servers - expect(uploadResults.length, equals(2)); - expect(uploadResults.every((r) => r.success), true); + // Should have uploaded to both servers + expect(uploadResults.length, equals(2)); + expect(uploadResults.every((r) => r.success), true); - final sha256 = uploadResults.first.descriptor!.sha256; + final sha256 = uploadResults.first.descriptor!.sha256; - // Verify both servers have the file - final fromServer1 = await client.getBlob( - sha256: sha256, - serverUrls: ['http://localhost:$primaryServerPort'], - ); - final fromServer2 = await client.getBlob( - sha256: sha256, - serverUrls: ['http://localhost:$secondaryServerPort'], - ); + // Verify both servers have the file + final fromServer1 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:$primaryServerPort'], + ); + final fromServer2 = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:$secondaryServerPort'], + ); - expect(utf8.decode(fromServer1.data), equals(testContent)); - expect(utf8.decode(fromServer2.data), equals(testContent)); - }); + expect(utf8.decode(fromServer1.data), equals(testContent)); + expect(utf8.decode(fromServer2.data), equals(testContent)); + }, + ); test( - 'uploadBlobFromFile mirrorAfterSuccess reports mirrorsTotal and mirrorsCompleted progression', - () async { - final testFile = File('${tempDir.path}/test_mirror_progress.txt'); - await testFile.writeAsString('Mirror progress test'); - - final events = []; - await for (final progress in client.uploadBlobFromFile( - filePath: testFile.path, - serverUrls: [ - 'http://localhost:$primaryServerPort', - 'http://localhost:$secondaryServerPort', - ], - strategy: UploadStrategy.mirrorAfterSuccess, - )) { - events.add(progress); - } - - final mirrorEvents = events - .where((e) => - e.phase == UploadPhase.mirroring && - (e.mirrorsTotal > 0 || e.mirrorsCompleted > 0)) - .toList(); + 'uploadBlobFromFile mirrorAfterSuccess reports mirrorsTotal and mirrorsCompleted progression', + () async { + final testFile = File('${tempDir.path}/test_mirror_progress.txt'); + await testFile.writeAsString('Mirror progress test'); + + final events = []; + await for (final progress in client.uploadBlobFromFile( + filePath: testFile.path, + serverUrls: [ + 'http://localhost:$primaryServerPort', + 'http://localhost:$secondaryServerPort', + ], + strategy: UploadStrategy.mirrorAfterSuccess, + )) { + events.add(progress); + } - expect(mirrorEvents, isNotEmpty); + final mirrorEvents = events + .where( + (e) => + e.phase == UploadPhase.mirroring && + (e.mirrorsTotal > 0 || e.mirrorsCompleted > 0), + ) + .toList(); - // With 2 servers and first success strategy, only 1 mirror should be needed. - expect(mirrorEvents.every((e) => e.mirrorsTotal == 1), isTrue); + expect(mirrorEvents, isNotEmpty); - // Should emit start of mirroring and completion of mirroring. - expect(mirrorEvents.any((e) => e.mirrorsCompleted == 0), isTrue); - expect(mirrorEvents.any((e) => e.mirrorsCompleted == e.mirrorsTotal), - isTrue); + // With 2 servers and first success strategy, only 1 mirror should be needed. + expect(mirrorEvents.every((e) => e.mirrorsTotal == 1), isTrue); - // Progression should be monotonic. - for (var i = 1; i < mirrorEvents.length; i++) { + // Should emit start of mirroring and completion of mirroring. + expect(mirrorEvents.any((e) => e.mirrorsCompleted == 0), isTrue); expect( - mirrorEvents[i].mirrorsCompleted, - greaterThanOrEqualTo(mirrorEvents[i - 1].mirrorsCompleted), + mirrorEvents.any((e) => e.mirrorsCompleted == e.mirrorsTotal), + isTrue, ); - } - }); + + // Progression should be monotonic. + for (var i = 1; i < mirrorEvents.length; i++) { + expect( + mirrorEvents[i].mirrorsCompleted, + greaterThanOrEqualTo(mirrorEvents[i - 1].mirrorsCompleted), + ); + } + }, + ); test('downloadBlobToFile should handle server fallback', () async { // Upload to one server @@ -1051,24 +1110,27 @@ void main() { }); test( - 'uploadBlob should work without logged in account using temporary signer', - () async { - final ndkNoLogin = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - ), - ); + 'uploadBlob should work without logged in account using temporary signer', + () async { + final ndkNoLogin = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + ), + ); - final testData = Uint8List.fromList(utf8.encode('Temporary signer test')); + final testData = Uint8List.fromList( + utf8.encode('Temporary signer test'), + ); - final uploadResponse = await ndkNoLogin.blossom.uploadBlob( - data: testData, - serverUrls: ['http://localhost:${server.port}'], - ); + final uploadResponse = await ndkNoLogin.blossom.uploadBlob( + data: testData, + serverUrls: ['http://localhost:${server.port}'], + ); - expect(uploadResponse.first.success, true); - expect(uploadResponse.first.descriptor, isNotNull); - }); + expect(uploadResponse.first.success, true); + expect(uploadResponse.first.descriptor, isNotNull); + }, + ); }); } diff --git a/packages/ndk/test/usecases/files/files_test.dart b/packages/ndk/test/usecases/files/files_test.dart index a68751ec0..b0ee13d41 100644 --- a/packages/ndk/test/usecases/files/files_test.dart +++ b/packages/ndk/test/usecases/files/files_test.dart @@ -31,8 +31,10 @@ void main() { engine: NdkEngine.JIT, ), ); - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); client = ndk.files; }); @@ -95,10 +97,7 @@ void main() { // download final getResponse = client.download( url: 'http://localhost:3010/$sha256', - serverUrls: [ - 'https://localhost:3011', - 'http://localhost:3010', - ], + serverUrls: ['https://localhost:3011', 'http://localhost:3010'], ); expect(getResponse, throwsA(isA())); }); @@ -125,15 +124,14 @@ void main() { // check final response = client.checkUrl( url: 'http://localhost:3010/${uploadResponse.first.descriptor!.sha256}', - serverUrls: [ - 'https://localhost:3011', - 'http://localhost:3010', - ], + serverUrls: ['https://localhost:3011', 'http://localhost:3010'], ); expect( - response, - completion( - 'http://localhost:3010/${uploadResponse.first.descriptor!.sha256}')); + response, + completion( + 'http://localhost:3010/${uploadResponse.first.descriptor!.sha256}', + ), + ); }); }); @@ -214,7 +212,8 @@ void main() { // Create a test file with binary content final uploadFile = File('${tempDir.path}/test_binary_upload.bin'); final testData = Uint8List.fromList( - List.generate(2048, (i) => i % 256)); // 2KB of test data + List.generate(2048, (i) => i % 256), + ); // 2KB of test data await uploadFile.writeAsBytes(testData); // Upload the file @@ -280,10 +279,7 @@ void main() { List uploadResults = const []; await for (final progress in client.uploadFromFile( filePath: testFile.path, - serverUrls: [ - 'http://localhost:3010', - 'http://localhost:3011', - ], + serverUrls: ['http://localhost:3010', 'http://localhost:3011'], strategy: UploadStrategy.mirrorAfterSuccess, )) { if (progress.completedUploads.isNotEmpty) { @@ -327,10 +323,7 @@ void main() { await client.downloadToFile( url: 'http://localhost:3010/$sha256', outputPath: downloadFile.path, - serverUrls: [ - 'http://dead.example.com', - 'http://localhost:3010', - ], + serverUrls: ['http://dead.example.com', 'http://localhost:3010'], ); expect(await downloadFile.exists(), true); diff --git a/packages/ndk/test/usecases/follows/follows_test.dart b/packages/ndk/test/usecases/follows/follows_test.dart index e4c911730..16b466d14 100644 --- a/packages/ndk/test/usecases/follows/follows_test.dart +++ b/packages/ndk/test/usecases/follows/follows_test.dart @@ -11,14 +11,7 @@ void main() async { KeyPair key0 = Bip340.generatePrivateKey(); final ContactList network0ContactList = ContactList( pubKey: key0.publicKey, - contacts: [ - 'old0', - 'old1', - 'old2', - 'old3', - 'old4', - 'old5', - ], + contacts: ['old0', 'old1', 'old2', 'old3', 'old4', 'old5'], ); network0ContactList.createdAt = 100; final ContactList cache0ContactList = ContactList( @@ -37,18 +30,12 @@ void main() async { KeyPair key1 = Bip340.generatePrivateKey(); final ContactList network1ContactList = ContactList( pubKey: key1.publicKey, - contacts: [ - 'contact0', - 'contact1', - 'contact2', - ], + contacts: ['contact0', 'contact1', 'contact2'], ); final ContactList cache1ContactList = ContactList( pubKey: key1.publicKey, - contacts: [ - 'old0', - ], + contacts: ['old0'], ); cache1ContactList.createdAt = 100; @@ -57,10 +44,12 @@ void main() async { setUp(() async { relay0 = MockRelay(name: "relay 0", explicitPort: 5095); - await relay0.startServer(contactLists: { - key0.publicKey: network0ContactList.toEvent(), - key1.publicKey: network1ContactList.toEvent(), - }); + await relay0.startServer( + contactLists: { + key0.publicKey: network0ContactList.toEvent(), + key1.publicKey: network1ContactList.toEvent(), + }, + ); final cache = MemCacheManager(); final NdkConfig config = NdkConfig( @@ -75,7 +64,7 @@ void main() async { await ndk.relays.seedRelaysConnected; - cache.saveContactList(cache0ContactList); + await cache.saveEvent(cache0ContactList.toEvent()); //cache.saveContactList(cache1ContactList); }); @@ -93,7 +82,13 @@ void main() async { final rcvContactList = await ndk.follows.getContactList(key0.publicKey); // cache - expect(rcvContactList, equals(cache0ContactList)); + expect(rcvContactList, isNotNull); + expect(rcvContactList!.pubKey, equals(cache0ContactList.pubKey)); + expect(rcvContactList.contacts, equals(cache0ContactList.contacts)); + expect( + rcvContactList.followedTags, + equals(cache0ContactList.followedTags), + ); }); test('getContactList- network', () async { @@ -107,13 +102,13 @@ void main() async { }); test('add/remove contact', () async { - var list = await ndk.follows.getContactList( - key0.publicKey, - ); + var list = await ndk.follows.getContactList(key0.publicKey); expect(list!.contacts.contains(key1.publicKey), false); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); // overwrite same list await ndk.follows.broadcastSetContactList(list); @@ -121,99 +116,111 @@ void main() async { // add key1 await ndk.follows.broadcastAddContact(key1.publicKey); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.contacts.contains(key1.publicKey), true); // remove key1 await ndk.follows.broadcastRemoveContact(key1.publicKey); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.contacts.contains(key1.publicKey), false); }); test('add/remove followed tag', () async { - var list = await ndk.follows.getContactList( - key0.publicKey, - ); + var list = await ndk.follows.getContactList(key0.publicKey); final tag = "myTag"; expect(list!.followedTags.contains(tag), false); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); // add tag await ndk.follows.broadcastAddFollowedTag(tag); + list = await ndk.follows.getContactList(key0.publicKey); + expect(list!.followedTags.contains(tag), true); + list = await ndk.follows.getContactList( key0.publicKey, + forceRefresh: true, ); expect(list!.followedTags.contains(tag), true); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); - expect(list!.followedTags.contains(tag), true); - // remove key1 await ndk.follows.broadcastRemoveFollowedTag(tag); + list = await ndk.follows.getContactList(key0.publicKey); + expect(list!.followedTags.contains(tag), false); list = await ndk.follows.getContactList( key0.publicKey, + forceRefresh: true, ); expect(list!.followedTags.contains(tag), false); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); - expect(list!.followedTags.contains(tag), false); }); test('add/remove followed community', () async { - var list = await ndk.follows.getContactList( - key0.publicKey, - ); + var list = await ndk.follows.getContactList(key0.publicKey); final community = "myCommunity"; expect(list!.followedCommunities.contains(community), false); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); // add community await ndk.follows.broadcastAddFollowedCommunity(community); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.followedCommunities.contains(community), true); // remove community await ndk.follows.broadcastRemoveFollowedCommunity(community); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.followedCommunities.contains(community), false); }); test('add/remove followed event', () async { - var list = await ndk.follows.getContactList( - key0.publicKey, - ); + var list = await ndk.follows.getContactList(key0.publicKey); final event = "myEvent"; expect(list!.followedEvents.contains(event), false); - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); // add community await ndk.follows.broadcastAddFollowedEvent(event); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.followedEvents.contains(event), true); // remove community await ndk.follows.broadcastRemoveFollowedEvent(event); - list = - await ndk.follows.getContactList(key0.publicKey, forceRefresh: true); + list = await ndk.follows.getContactList( + key0.publicKey, + forceRefresh: true, + ); expect(list!.followedEvents.contains(event), false); }); }); diff --git a/packages/ndk/test/usecases/gift_wrap/gift_wrap_cache_test.dart b/packages/ndk/test/usecases/gift_wrap/gift_wrap_cache_test.dart new file mode 100644 index 000000000..4070c51e7 --- /dev/null +++ b/packages/ndk/test/usecases/gift_wrap/gift_wrap_cache_test.dart @@ -0,0 +1,368 @@ +import 'dart:convert'; + +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_event_verifier.dart'; + +/// Tests for [GiftWrap.tryFromGiftWrapFromCache]. +/// +/// The method reads only from the decrypted-payload sidecar cache (no network, +/// no decryption). Each branch is exercised: +/// - non gift-wrap kind -> throws +/// - missing seal sidecar -> null +/// - missing rumor sidecar -> null +/// - invalid seal signature (when verifySignature) -> throws +/// - verifySignature:false skips verification +/// - happy path returns the cached rumor +/// - viewer pubkey comes from the signer (logged-in or custom) +void main() { + group('GiftWrap.tryFromGiftWrapFromCache', () { + late Ndk ndk; + late CacheManager cache; + late GiftWrap giftWrap; + + late KeyPair alice; // recipient / viewer + late KeyPair bob; // sender + + setUp(() { + alice = Bip340.generatePrivateKey(); + bob = Bip340.generatePrivateKey(); + + ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [], + ), + ); + cache = ndk.config.cache; + + giftWrap = GiftWrap( + accounts: ndk.accounts, + eventVerifier: MockEventVerifier(), + eventSignerFactory: ndk.config.eventSignerFactory, + decryptedEventPayloads: ndk.decryptedEventPayloads, + ); + + ndk.accounts.loginPrivateKey( + pubkey: alice.publicKey, + privkey: alice.privateKey!, + ); + }); + + tearDown(() => ndk.destroy()); + + int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + /// Builds a real, signed kind:13 seal (from bob -> alice) plus a gift wrap + /// around it addressed to alice. The seal has a stable id used to key the + /// rumor sidecar. Login state is restored to alice on return. + Future<({Nip01Event seal, Nip01Event giftWrap})> buildArtifacts({ + String content = 'hello', + int kind = 1, + }) async { + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: bob.publicKey, + privkey: bob.privateKey!, + ); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: content, + kind: kind, + tags: const [], + ); + final seal = await giftWrap.sealRumor( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + final wrap = await giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: alice.publicKey, + privkey: alice.privateKey!, + ); + return (seal: seal, giftWrap: wrap); + } + + DecryptedEventPayloadRecord sealSidecar( + Nip01Event wrap, + Nip01Event seal, + String viewer, + ) => DecryptedEventPayloadRecord( + eventId: wrap.id, + viewerPubKey: viewer, + scheme: DecryptedPayloadScheme.giftWrap, + status: DecryptedPayloadStatus.ready, + plaintextContent: Nip01EventModel.fromEntity(seal).toJsonString(), + createdAt: now(), + updatedAt: now(), + ); + + DecryptedEventPayloadRecord rumorSidecar( + Nip01Event seal, + Nip01Event rumor, + String viewer, + ) => DecryptedEventPayloadRecord( + eventId: seal.id, + viewerPubKey: viewer, + scheme: DecryptedPayloadScheme.seal, + status: DecryptedPayloadStatus.ready, + plaintextContent: Nip01EventModel.fromEntity(rumor).toJsonString(), + createdAt: now(), + updatedAt: now(), + ); + + test('throws when event is not a gift wrap kind', () async { + final notAWrap = Nip01Event( + pubKey: alice.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'nope', + ); + expect( + () => giftWrap.tryFromGiftWrapFromCache(giftWrap: notAWrap), + throwsA(isA()), + ); + }); + + test('returns null when no seal sidecar is cached', () async { + final artifacts = await buildArtifacts(); + // cache is empty -> nothing to read + final result = await giftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + ); + expect(result, isNull); + }); + + test( + 'returns null when seal sidecar exists but rumor sidecar is missing', + () async { + final artifacts = await buildArtifacts(); + // store only the seal plaintext + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, alice.publicKey), + ); + + final result = await giftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + ); + expect(result, isNull); + }, + ); + + test('returns the cached rumor when both sidecars are present', () async { + final artifacts = await buildArtifacts(content: 'cached body'); + // The rumor that was wrapped. + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'cached body', + kind: 1, + tags: const [], + ); + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, alice.publicKey), + ); + await cache.saveDecryptedEventPayloadRecord( + rumorSidecar(artifacts.seal, rumor, alice.publicKey), + ); + + final result = await giftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + ); + expect(result, isNotNull); + expect(result!.content, 'cached body'); + expect(result.kind, 1); + expect(result.pubKey, bob.publicKey); + }); + + test('throws when seal signature verification fails', () async { + final artifacts = await buildArtifacts(content: 'tampered'); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'tampered', + kind: 1, + tags: const [], + ); + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, alice.publicKey), + ); + await cache.saveDecryptedEventPayloadRecord( + rumorSidecar(artifacts.seal, rumor, alice.publicKey), + ); + + // GiftWrap whose verifier always rejects signatures. + final strictGiftWrap = GiftWrap( + accounts: ndk.accounts, + eventVerifier: MockEventVerifier(result: false), + eventSignerFactory: ndk.config.eventSignerFactory, + decryptedEventPayloads: ndk.decryptedEventPayloads, + ); + + expect( + () => strictGiftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + ), + throwsA(isA()), + ); + }); + + test( + 'skips signature verification when verifySignature is false', + () async { + final artifacts = await buildArtifacts(content: 'skip verify'); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'skip verify', + kind: 1, + tags: const [], + ); + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, alice.publicKey), + ); + await cache.saveDecryptedEventPayloadRecord( + rumorSidecar(artifacts.seal, rumor, alice.publicKey), + ); + + // Verifier rejects everything, but verification is bypassed. + final lenientGiftWrap = GiftWrap( + accounts: ndk.accounts, + eventVerifier: MockEventVerifier(result: false), + eventSignerFactory: ndk.config.eventSignerFactory, + decryptedEventPayloads: ndk.decryptedEventPayloads, + ); + + final result = await lenientGiftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + verifySignature: false, + ); + expect(result, isNotNull); + expect(result!.content, 'skip verify'); + }, + ); + + test('uses the custom signer pubkey as the cache viewer', () async { + final artifacts = await buildArtifacts(content: 'custom viewer'); + + // A third party viewer (carol) with her own keypair. + final carol = Bip340.generatePrivateKey(); + final carolSigner = Bip340EventSigner( + privateKey: carol.privateKey, + publicKey: carol.publicKey, + ); + + // Author the rumor as bob so it is independent of carol/alice. + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: bob.publicKey, + privkey: bob.privateKey!, + ); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'custom viewer', + kind: 1, + tags: const [], + ); + // Alice stays logged out here; the custom signer is the only source of + // the viewer pubkey. + ndk.accounts.logout(); + + // Sidecars keyed under carol's pubkey. + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, carol.publicKey), + ); + await cache.saveDecryptedEventPayloadRecord( + rumorSidecar(artifacts.seal, rumor, carol.publicKey), + ); + + final result = await giftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + customSigner: carolSigner, + ); + expect(result, isNotNull); + expect(result!.content, 'custom viewer'); + + // Sanity: without the signer (no logged-in account) it throws rather + // than silently returning null. + expect( + () => giftWrap.tryFromGiftWrapFromCache(giftWrap: artifacts.giftWrap), + throwsA(anyOf(isA(), isA())), + ); + }); + + test('sidecars are viewer-scoped: wrong viewer gets no seal', () async { + final artifacts = await buildArtifacts(); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'scoped', + kind: 1, + tags: const [], + ); + // Store under bob's pubkey, not alice's (the current viewer). + await cache.saveDecryptedEventPayloadRecord( + sealSidecar(artifacts.giftWrap, artifacts.seal, bob.publicKey), + ); + await cache.saveDecryptedEventPayloadRecord( + rumorSidecar(artifacts.seal, rumor, bob.publicKey), + ); + + final result = await giftWrap.tryFromGiftWrapFromCache( + giftWrap: artifacts.giftWrap, + ); + expect(result, isNull); + }); + + test('happy path mirrors a real fromGiftWrap decryption', () async { + // Full end-to-end: bob wraps for alice, alice decrypts with fromGiftWrap + // (which populates sidecars), then the cache-only read returns the same + // rumor without any further decryption. + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: bob.publicKey, + privkey: bob.privateKey!, + ); + final rumor = await giftWrap.createRumor( + customPubkey: bob.publicKey, + content: 'round trip', + kind: 14, + tags: const [ + ['p', 'unused'], + ], + ); + final wrap = await giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: alice.publicKey, + ); + ndk.accounts.logout(); + ndk.accounts.loginPrivateKey( + pubkey: alice.publicKey, + privkey: alice.privateKey!, + ); + + final decrypted = await giftWrap.fromGiftWrap(giftWrap: wrap); + + final fromCache = await giftWrap.tryFromGiftWrapFromCache(giftWrap: wrap); + expect(fromCache, isNotNull); + expect(fromCache!.id, decrypted.id); + expect(fromCache.content, decrypted.content); + expect(fromCache.kind, decrypted.kind); + + // The cached JSON round-trips to the same event id. + final rec = await cache.loadDecryptedEventPayloadRecord( + eventId: wrap.id, + viewerPubKey: alice.publicKey, + ); + expect(rec, isNotNull); + expect( + jsonDecode(rec!.plaintextContent!)['kind'], + GiftWrap.kSealEventKind, + ); + }); + }); +} diff --git a/packages/ndk/test/usecases/gift_wrap/gift_wrap_external_test.dart b/packages/ndk/test/usecases/gift_wrap/gift_wrap_external_test.dart index 6bffaa14f..7213b4d1b 100644 --- a/packages/ndk/test/usecases/gift_wrap/gift_wrap_external_test.dart +++ b/packages/ndk/test/usecases/gift_wrap/gift_wrap_external_test.dart @@ -24,15 +24,13 @@ void main() async { final completer = Completer(); final ndk = Ndk( NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: relays), + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: relays, + ), ); - ndk.accounts.loginPrivateKey( - privkey: myPrivKey, - pubkey: myPubkey, - ); + ndk.accounts.loginPrivateKey(privkey: myPrivKey, pubkey: myPubkey); final subscription = ndk.requests.subscription( filters: [ @@ -42,8 +40,9 @@ void main() async { subscription.stream.listen((giftWrap) async { try { - final messageEvent = - await ndk.giftWrap.fromGiftWrap(giftWrap: giftWrap); + final messageEvent = await ndk.giftWrap.fromGiftWrap( + giftWrap: giftWrap, + ); log(messageEvent.content); expect(messageEvent.content.length, greaterThan(1)); diff --git a/packages/ndk/test/usecases/gift_wrap/gift_wrap_test.dart b/packages/ndk/test/usecases/gift_wrap/gift_wrap_test.dart index 15a4bc129..6c9be3910 100644 --- a/packages/ndk/test/usecases/gift_wrap/gift_wrap_test.dart +++ b/packages/ndk/test/usecases/gift_wrap/gift_wrap_test.dart @@ -23,59 +23,67 @@ void main() { ); // Login with test key - ndk.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); // Create the service giftWrapService = GiftWrap( accounts: ndk.accounts, eventVerifier: MockEventVerifier(), eventSignerFactory: ndk.config.eventSignerFactory, + decryptedEventPayloads: ndk.decryptedEventPayloads, ); }); group('GiftWrapService', () { - test('Full gift wrap and unwrap cycle should preserve the original event', - () async { - // Create an original rumor event - final originalRumor = await giftWrapService.createRumor( - content: 'Test message for gift wrap', - kind: 1, - tags: [], - ); - - // Wrap the rumor in a gift wrap - final giftWrap = await giftWrapService.toGiftWrap( - rumor: originalRumor, - recipientPubkey: key2.publicKey, - ); - - // Verify the gift wrap has the correct structure - expect(giftWrap.kind, equals(GiftWrap.kGiftWrapEventkind)); - expect( + test( + 'Full gift wrap and unwrap cycle should preserve the original event', + () async { + // Create an original rumor event + final originalRumor = await giftWrapService.createRumor( + content: 'Test message for gift wrap', + kind: 1, + tags: [], + ); + + // Wrap the rumor in a gift wrap + final giftWrap = await giftWrapService.toGiftWrap( + rumor: originalRumor, + recipientPubkey: key2.publicKey, + ); + + // Verify the gift wrap has the correct structure + expect(giftWrap.kind, equals(GiftWrap.kGiftWrapEventkind)); + expect( giftWrap.tags.any((tag) => tag[0] == 'p' && tag[1] == key2.publicKey), - isTrue); - - // login as the recipient to unwrap - ndk.accounts - .loginPrivateKey(pubkey: key2.publicKey, privkey: key2.privateKey!); - - // Unwrap the gift wrap - final unwrappedRumor = await giftWrapService.fromGiftWrap( - giftWrap: giftWrap, - ); - - // Verify the unwrapped rumor matches the original - expect(unwrappedRumor.content, equals(originalRumor.content)); - expect(unwrappedRumor.kind, equals(originalRumor.kind)); - expect(unwrappedRumor.pubKey, equals(originalRumor.pubKey)); - - // Compare tags - expect(unwrappedRumor.tags.length, equals(originalRumor.tags.length)); - for (int i = 0; i < originalRumor.tags.length; i++) { - expect(unwrappedRumor.tags[i], equals(originalRumor.tags[i])); - } - }); + isTrue, + ); + + // login as the recipient to unwrap + ndk.accounts.loginPrivateKey( + pubkey: key2.publicKey, + privkey: key2.privateKey!, + ); + + // Unwrap the gift wrap + final unwrappedRumor = await giftWrapService.fromGiftWrap( + giftWrap: giftWrap, + ); + + // Verify the unwrapped rumor matches the original + expect(unwrappedRumor.content, equals(originalRumor.content)); + expect(unwrappedRumor.kind, equals(originalRumor.kind)); + expect(unwrappedRumor.pubKey, equals(originalRumor.pubKey)); + + // Compare tags + expect(unwrappedRumor.tags.length, equals(originalRumor.tags.length)); + for (int i = 0; i < originalRumor.tags.length; i++) { + expect(unwrappedRumor.tags[i], equals(originalRumor.tags[i])); + } + }, + ); test('Can create a gift wrap with additional tags', () async { // Create a rumor @@ -83,7 +91,7 @@ void main() { content: 'Test message with additional tags', kind: 1, tags: [ - ['p', key2.publicKey] + ['p', key2.publicKey], ], ); @@ -108,97 +116,110 @@ void main() { // Verify additional tags were included expect( - giftWrap.tags.any((tag) => tag[0] == 'p' && tag[1] == key2.publicKey), - isTrue); - expect(giftWrap.tags.any((tag) => tag[0] == 'pow' && tag[1] == '25'), - isTrue); - expect( - giftWrap.tags - .any((tag) => tag[0] == 'client' && tag[1] == 'test_client'), - isTrue); - }); - - test( - 'NIP-17 DM gift wrap and seal timestamps should be randomized in the 2 days before the rumor createdAt', - () async { - const twoDaysInSeconds = 172800; - - final rumor = Nip01Event( - pubKey: key1.publicKey, - content: 'Test NIP-17 direct message', - kind: 14, - tags: [ - ['p', key2.publicKey], - ], + giftWrap.tags.any((tag) => tag[0] == 'p' && tag[1] == key2.publicKey), + isTrue, ); - final baseCreatedAt = rumor.createdAt; - - final giftWrap = await giftWrapService.toGiftWrap( - rumor: rumor, - recipientPubkey: key2.publicKey, + expect( + giftWrap.tags.any((tag) => tag[0] == 'pow' && tag[1] == '25'), + isTrue, ); - - expect(giftWrap.createdAt, - greaterThanOrEqualTo(baseCreatedAt - twoDaysInSeconds)); - expect(giftWrap.createdAt, lessThan(baseCreatedAt)); - - ndk.accounts - .loginPrivateKey(pubkey: key2.publicKey, privkey: key2.privateKey!); - - final seal = await giftWrapService.unwrapEvent(wrappedEvent: giftWrap); - - expect(seal.createdAt, - greaterThanOrEqualTo(baseCreatedAt - twoDaysInSeconds)); - expect(seal.createdAt, lessThan(baseCreatedAt)); - expect(seal.createdAt, isNot(equals(giftWrap.createdAt))); - - final unwrappedRumor = await giftWrapService.fromGiftWrap( - giftWrap: giftWrap, + expect( + giftWrap.tags.any( + (tag) => tag[0] == 'client' && tag[1] == 'test_client', + ), + isTrue, ); - expect(unwrappedRumor.createdAt, equals(rumor.createdAt)); }); test( - 'Can use custom signer instead of logged-in account for wrap and unwrap', - () async { - // Create a custom signer with key3 (different from logged-in key1) - final customSigner = Bip340EventSigner( - privateKey: key3.privateKey, - publicKey: key3.publicKey, - ); - - // Create a rumor with custom pubkey matching the custom signer - final originalRumor = await giftWrapService.createRumor( - customPubkey: key3.publicKey, - content: 'Test message with custom signer', - kind: 1, - tags: [], - ); - - // Wrap the rumor using the custom signer (not the logged-in account) - final giftWrap = await giftWrapService.toGiftWrap( - rumor: originalRumor, - recipientPubkey: key2.publicKey, - customSigner: customSigner, - ); - - // Create a signer for the recipient to unwrap - final recipientSigner = Bip340EventSigner( - privateKey: key2.privateKey, - publicKey: key2.publicKey, - ); - - // Unwrap using the custom signer (without switching logged-in account) - final unwrappedRumor = await giftWrapService.fromGiftWrap( - giftWrap: giftWrap, - customSigner: recipientSigner, - ); + 'NIP-17 DM gift wrap and seal timestamps should be randomized in the 2 days before the rumor createdAt', + () async { + const twoDaysInSeconds = 172800; + + final rumor = Nip01Event( + pubKey: key1.publicKey, + content: 'Test NIP-17 direct message', + kind: 14, + tags: [ + ['p', key2.publicKey], + ], + ); + final baseCreatedAt = rumor.createdAt; + + final giftWrap = await giftWrapService.toGiftWrap( + rumor: rumor, + recipientPubkey: key2.publicKey, + ); + + expect( + giftWrap.createdAt, + greaterThanOrEqualTo(baseCreatedAt - twoDaysInSeconds), + ); + expect(giftWrap.createdAt, lessThan(baseCreatedAt)); + + ndk.accounts.loginPrivateKey( + pubkey: key2.publicKey, + privkey: key2.privateKey!, + ); + + final seal = await giftWrapService.unwrapEvent(wrappedEvent: giftWrap); + + expect( + seal.createdAt, + greaterThanOrEqualTo(baseCreatedAt - twoDaysInSeconds), + ); + expect(seal.createdAt, lessThan(baseCreatedAt)); + expect(seal.createdAt, isNot(equals(giftWrap.createdAt))); + + final unwrappedRumor = await giftWrapService.fromGiftWrap( + giftWrap: giftWrap, + ); + expect(unwrappedRumor.createdAt, equals(rumor.createdAt)); + }, + ); - // Verify the unwrapped rumor matches the original - expect(unwrappedRumor.content, equals(originalRumor.content)); - expect(unwrappedRumor.kind, equals(originalRumor.kind)); - expect(unwrappedRumor.pubKey, equals(key3.publicKey)); - }); + test( + 'Can use custom signer instead of logged-in account for wrap and unwrap', + () async { + // Create a custom signer with key3 (different from logged-in key1) + final customSigner = Bip340EventSigner( + privateKey: key3.privateKey, + publicKey: key3.publicKey, + ); + + // Create a rumor with custom pubkey matching the custom signer + final originalRumor = await giftWrapService.createRumor( + customPubkey: key3.publicKey, + content: 'Test message with custom signer', + kind: 1, + tags: [], + ); + + // Wrap the rumor using the custom signer (not the logged-in account) + final giftWrap = await giftWrapService.toGiftWrap( + rumor: originalRumor, + recipientPubkey: key2.publicKey, + customSigner: customSigner, + ); + + // Create a signer for the recipient to unwrap + final recipientSigner = Bip340EventSigner( + privateKey: key2.privateKey, + publicKey: key2.publicKey, + ); + + // Unwrap using the custom signer (without switching logged-in account) + final unwrappedRumor = await giftWrapService.fromGiftWrap( + giftWrap: giftWrap, + customSigner: recipientSigner, + ); + + // Verify the unwrapped rumor matches the original + expect(unwrappedRumor.content, equals(originalRumor.content)); + expect(unwrappedRumor.kind, equals(originalRumor.kind)); + expect(unwrappedRumor.pubKey, equals(key3.publicKey)); + }, + ); test('Custom signer seal uses correct pubkey', () async { // Create a custom signer @@ -241,8 +262,10 @@ void main() { ); // Login as recipient to unwrap - ndk.accounts - .loginPrivateKey(pubkey: key2.publicKey, privkey: key2.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key2.publicKey, + privkey: key2.privateKey!, + ); // Unwrap the gift wrap to get the seal final seal = await giftWrapService.unwrapEvent(wrappedEvent: giftWrap); diff --git a/packages/ndk/test/usecases/jit_engine/feed_test.dart b/packages/ndk/test/usecases/jit_engine/feed_test.dart index 307a091bd..25c4fa770 100644 --- a/packages/ndk/test/usecases/jit_engine/feed_test.dart +++ b/packages/ndk/test/usecases/jit_engine/feed_test.dart @@ -9,162 +9,187 @@ import 'dart:developer' as developer; import '../../mocks/mock_event_verifier.dart'; void main() async { - group( - "Calculate best relays (external REAL)", - skip: true, - () { - calculateBestRelaysForNpubContactsFeed(String npub, - {int relayMinCountPerPubKey = 2}) async { - CacheManager cacheManager = MemCacheManager(); - - final ndk = Ndk(NdkConfig( + group("Calculate best relays (external REAL)", skip: true, () { + calculateBestRelaysForNpubContactsFeed( + String npub, { + int relayMinCountPerPubKey = 2, + }) async { + CacheManager cacheManager = MemCacheManager(); + + final ndk = Ndk( + NdkConfig( eventVerifier: MockEventVerifier(), cache: cacheManager, engine: NdkEngine.JIT, - )); + ), + ); - // wait for the relays to connect - await Future.delayed(const Duration(seconds: 2)); + // wait for the relays to connect + await Future.delayed(const Duration(seconds: 2)); - final t0 = DateTime.now(); + final t0 = DateTime.now(); - final key = KeyPair.justPublicKey(Helpers.decodeBech32(npub)[0]); + final key = KeyPair.justPublicKey(Helpers.decodeBech32(npub)[0]); - final contactsResponse = ndk.requests.query(name: "contacts", filters: [ + final contactsResponse = ndk.requests.query( + name: "contacts", + filters: [ Filter(authors: [key.publicKey], kinds: [ContactList.kKind]), - ]); + ], + ); - var responseList = await contactsResponse.stream.toList(); + var responseList = await contactsResponse.stream.toList(); - List contactLists = - responseList.map((event) => ContactList.fromEvent(event)).toList(); + List contactLists = responseList + .map((event) => ContactList.fromEvent(event)) + .toList(); - cacheManager.saveContactLists(contactLists); + await cacheManager.saveEvents( + contactLists.map((contactList) => contactList.toEvent()).toList(), + ); - ContactList? myContactList = - await cacheManager.loadContactList(key.publicKey); - expect(myContactList, isNotNull); + ContactList? myContactList = await cacheManager.loadContactList( + key.publicKey, + ); + expect(myContactList, isNotNull); - // get nip65 data + // get nip65 data - NdkResponse nip65Response = ndk.requests.query(name: "nip65", filters: [ + NdkResponse nip65Response = ndk.requests.query( + name: "nip65", + filters: [ Filter(authors: myContactList!.contacts, kinds: [Nip65.kKind]), - ]); - - var nip65events = await nip65Response.stream.toList(); - await cacheManager.saveEvents(nip65events); - - //UserRelayList.fromNip65(Nip65.fromEvent(nip65events)) - final List nip65List = - nip65events.map((event) => Nip65.fromEvent(event)).toList(); - final List userRelayLists = - nip65List.map((nip65) => UserRelayList.fromNip65(nip65)).toList(); - - await cacheManager.saveUserRelayLists(userRelayLists); - - await cacheManager.loadEvents( - pubKeys: myContactList.contacts, - kinds: [Nip65.kKind], + ], + ); + + var nip65events = await nip65Response.stream.toList(); + await cacheManager.saveEvents(nip65events); + + //UserRelayList.fromNip65(Nip65.fromEvent(nip65events)) + final List nip65List = nip65events + .map((event) => Nip65.fromEvent(event)) + .toList(); + final List userRelayLists = nip65List + .map((nip65) => UserRelayList.fromNip65(nip65)) + .toList(); + + await cacheManager.saveUserRelayLists(userRelayLists); + + await cacheManager.loadEvents( + pubKeys: myContactList.contacts, + kinds: [Nip65.kKind], + ); + + developer.log('##################################################'); + + ndk.requests.query( + name: "feed-test", + filters: [ + Filter( + authors: myContactList.contacts, + kinds: [Nip01Event.kTextNodeKind], + since: + (DateTime.now().millisecondsSinceEpoch ~/ 1000) - 60 * 60 * 1, + ), + ], + desiredCoverage: relayMinCountPerPubKey, + ); + + await Future.delayed(const Duration(seconds: 5)); + + NdkResponse feedResponse2 = ndk.requests.subscription( + id: "feed-test2", + filters: [ + Filter( + limit: 100, + authors: myContactList.contacts, + kinds: [Nip01Event.kTextNodeKind], + since: + (DateTime.now().millisecondsSinceEpoch ~/ 1000) - 60 * 60 * 4, + ), + ], + desiredCoverage: relayMinCountPerPubKey, + ); + + List events = []; + developer.log('waiting for events...'); + //final feedData = await feedResponse2.future; + //events.addAll(feedData); + + feedResponse2.stream.listen((event) { + events.add(event); + }); + await Future.delayed(const Duration(seconds: 5)); + + final t1 = DateTime.now(); + + for (final relay in ndk.relays.globalState.relays.values) { + log( + "Relay: ${relay.url} - ${relay.specificEngineData.assignedPubkeys.length} pubkeys", ); - - developer.log('##################################################'); - - ndk.requests.query( - name: "feed-test", - filters: [ - Filter( - authors: myContactList.contacts, - kinds: [Nip01Event.kTextNodeKind], - since: - (DateTime.now().millisecondsSinceEpoch ~/ 1000) - 60 * 60 * 1, - ), - ], - desiredCoverage: relayMinCountPerPubKey, - ); - - await Future.delayed(const Duration(seconds: 5)); - - NdkResponse feedResponse2 = ndk.requests.subscription( - id: "feed-test2", - filters: [ - Filter( - limit: 100, - authors: myContactList.contacts, - kinds: [Nip01Event.kTextNodeKind], - since: - (DateTime.now().millisecondsSinceEpoch ~/ 1000) - 60 * 60 * 4, - ), - ], - desiredCoverage: relayMinCountPerPubKey, - ); - - List events = []; - developer.log('waiting for events...'); - //final feedData = await feedResponse2.future; - //events.addAll(feedData); - - feedResponse2.stream.listen((event) { - events.add(event); - }); - await Future.delayed(const Duration(seconds: 5)); - - final t1 = DateTime.now(); - - for (final relay in ndk.relays.globalState.relays.values) { - log( - "Relay: ${relay.url} - ${relay.specificEngineData.assignedPubkeys.length} pubkeys", - ); - } - - log("BEST ${ndk.relays.globalState.relays.length} RELAYS (min $relayMinCountPerPubKey per pubKey):"); - log("CONTACTS ${myContactList.contacts.length} WITH ${nip65events.length} nip65 events"); - log("${events.length} events"); - - log("===== time took ${t1.difference(t0).inMilliseconds} ms"); - - //developer.log("FEED: ${events.toString()}"); - - developer.log('##################################################'); - // developer.log( - // "Relay: {relay.url} - {relay.assignedPubkeys.length} - {relay.relayUsefulness}"); - // for (var relay in relayJitManager.connectedRelays) { - // developer.log( - // "Relay: ${relay.url} - ${relay.assignedPubkeys.length} - ${relay.relayUsefulness.toString()}"); - // } - // developer - // .log('relays count: ${relayJitManager.connectedRelays.length}'); - // developer.log('##################################################'); } - test('Leo feed best relays', () async { + log( + "BEST ${ndk.relays.globalState.relays.length} RELAYS (min $relayMinCountPerPubKey per pubKey):", + ); + log( + "CONTACTS ${myContactList.contacts.length} WITH ${nip65events.length} nip65 events", + ); + log("${events.length} events"); + + log("===== time took ${t1.difference(t0).inMilliseconds} ms"); + + //developer.log("FEED: ${events.toString()}"); + + developer.log('##################################################'); + // developer.log( + // "Relay: {relay.url} - {relay.assignedPubkeys.length} - {relay.relayUsefulness}"); + // for (var relay in relayJitManager.connectedRelays) { + // developer.log( + // "Relay: ${relay.url} - ${relay.assignedPubkeys.length} - ${relay.relayUsefulness.toString()}"); + // } + // developer + // .log('relays count: ${relayJitManager.connectedRelays.length}'); + // developer.log('##################################################'); + } + + test('Leo feed best relays', () async { + await calculateBestRelaysForNpubContactsFeed( + "npub1w9llyw8c3qnn7h27u3msjlet8xyjz5phdycr5rz335r2j5hj5a0qvs3tur", + relayMinCountPerPubKey: 2, + ); + }, timeout: const Timeout.factor(10)); + + test('Fmar feed best relays', () async { + await calculateBestRelaysForNpubContactsFeed( + "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams", + relayMinCountPerPubKey: 2, + ); + }, timeout: const Timeout.factor(10)); + + test('mikedilger feed best relays', () async { + await calculateBestRelaysForNpubContactsFeed( + "npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c", + relayMinCountPerPubKey: 2, + ); + }, timeout: const Timeout.factor(10)); + + test('Fiatjaf feed best relays', () async { + await calculateBestRelaysForNpubContactsFeed( + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", + relayMinCountPerPubKey: 2, + ); + }, timeout: const Timeout.factor(10)); + + test( + 'Love is Bitcoin (3k follows) feed best relays', + () async { await calculateBestRelaysForNpubContactsFeed( - "npub1w9llyw8c3qnn7h27u3msjlet8xyjz5phdycr5rz335r2j5hj5a0qvs3tur", - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); - - test('Fmar feed best relays', () async { - await calculateBestRelaysForNpubContactsFeed( - "npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams", - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); - - test('mikedilger feed best relays', () async { - await calculateBestRelaysForNpubContactsFeed( - "npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c", - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); - - test('Fiatjaf feed best relays', () async { - await calculateBestRelaysForNpubContactsFeed( - "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); - - test('Love is Bitcoin (3k follows) feed best relays', () async { - await calculateBestRelaysForNpubContactsFeed( - "npub1kwcatqynqmry9d78a8cpe7d882wu3vmrgcmhvdsayhwqjf7mp25qpqf3xx", - relayMinCountPerPubKey: 2); - }, timeout: const Timeout.factor(10)); - }, - ); + "npub1kwcatqynqmry9d78a8cpe7d882wu3vmrgcmhvdsayhwqjf7mp25qpqf3xx", + relayMinCountPerPubKey: 2, + ); + }, + timeout: const Timeout.factor(10), + ); + }); } diff --git a/packages/ndk/test/usecases/jit_engine/relay_jit_connection_test.dart b/packages/ndk/test/usecases/jit_engine/relay_jit_connection_test.dart index 8d64b74eb..8041d3d8e 100644 --- a/packages/ndk/test/usecases/jit_engine/relay_jit_connection_test.dart +++ b/packages/ndk/test/usecases/jit_engine/relay_jit_connection_test.dart @@ -29,11 +29,12 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map key1TextNotes = {key1: textNote(key1)}; @@ -57,10 +58,12 @@ void main() async { relay21.url: ReadWriteMarker.readWrite, relay22.url: ReadWriteMarker.readWrite, }); - Nip65 nip65ForKey3 = - Nip65.fromMap(key3.publicKey, {relay21.url: ReadWriteMarker.readWrite}); - Nip65 nip65ForKey4 = - Nip65.fromMap(key4.publicKey, {relay24.url: ReadWriteMarker.readWrite}); + Nip65 nip65ForKey3 = Nip65.fromMap(key3.publicKey, { + relay21.url: ReadWriteMarker.readWrite, + }); + Nip65 nip65ForKey4 = Nip65.fromMap(key4.publicKey, { + relay24.url: ReadWriteMarker.readWrite, + }); Map nip65s = { key1: nip65ForKey1, @@ -76,19 +79,23 @@ void main() async { // r4 -> k1,k4 await Future.wait([ relay21.startServer( - nip65s: nip65s, - textNotes: {} - ..addAll(key1TextNotes) - ..addAll(key2TextNotes) - ..addAll(key3TextNotes)), + nip65s: nip65s, + textNotes: {} + ..addAll(key1TextNotes) + ..addAll(key2TextNotes) + ..addAll(key3TextNotes), + ), relay22.startServer( - nip65s: nip65s, - textNotes: {} - ..addAll(key1TextNotes) - ..addAll(key2TextNotes)), + nip65s: nip65s, + textNotes: {} + ..addAll(key1TextNotes) + ..addAll(key2TextNotes), + ), relay23.startServer( - nip65s: nip65s, textNotes: {}..addAll(key1TextNotes)), - relay24.startServer(textNotes: key4TextNotes..addAll(key1TextNotes)) + nip65s: nip65s, + textNotes: {}..addAll(key1TextNotes), + ), + relay24.startServer(textNotes: key4TextNotes..addAll(key1TextNotes)), ]); } @@ -124,16 +131,18 @@ void main() async { bootstrapRelays: [relay21.url], ); - RequestState myRequest = RequestState(NdkRequest.query( - "debug-get-events", - filters: [ - Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key4.publicKey], - ), - ], - timeoutDuration: Duration(seconds: 5), - )); + RequestState myRequest = RequestState( + NdkRequest.query( + "debug-get-events", + filters: [ + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey], + ), + ], + timeoutDuration: Duration(seconds: 5), + ), + ); myRequest.stream.listen((event) { expectAsync1((event) { @@ -148,56 +157,63 @@ void main() async { await stopServers(); }); - test('query with inbox/outbox', - timeout: const Timeout(Duration(seconds: 5)), () async { - await startServers(); - - CacheManager cacheManager = MemCacheManager(); - - // save nip65 data - await cacheManager - .saveEvents(nip65s.values.map((e) => e.toEvent()).toList()); - - await cacheManager.saveUserRelayLists( - nip65s.values.map((e) => UserRelayList.fromNip65(e)).toList(), - ); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: cacheManager, - engine: NdkEngine.JIT, - bootstrapRelays: [], // dont connect to anything - )); - - final response = ndk.requests.query( - name: "qInOut", - filters: [ - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key1.publicKey, - key2.publicKey, - key3.publicKey, - key4.publicKey, - ]), - ], - desiredCoverage: 1, - ); - // List responses = []; - // response.stream.listen((event) { - // responses.add(event); - // }); - - final responses = await response.stream.toList(); - - expect(responses.length, 4); - // expect that all responses are there - expect(responses.contains(key1TextNotes[key1]), true); - expect(responses.contains(key2TextNotes[key2]), true); - expect(responses.contains(key3TextNotes[key3]), true); - expect(responses.contains(key4TextNotes[key4]), true); - - await stopServers(); - }); + test( + 'query with inbox/outbox', + timeout: const Timeout(Duration(seconds: 5)), + () async { + await startServers(); + + CacheManager cacheManager = MemCacheManager(); + + // save nip65 data + await cacheManager.saveEvents( + nip65s.values.map((e) => e.toEvent()).toList(), + ); + + await cacheManager.saveUserRelayLists( + nip65s.values.map((e) => UserRelayList.fromNip65(e)).toList(), + ); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: cacheManager, + engine: NdkEngine.JIT, + bootstrapRelays: [], // dont connect to anything + ), + ); + + final response = ndk.requests.query( + name: "qInOut", + filters: [ + Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [ + key1.publicKey, + key2.publicKey, + key3.publicKey, + key4.publicKey, + ], + ), + ], + desiredCoverage: 1, + ); + // List responses = []; + // response.stream.listen((event) { + // responses.add(event); + // }); + + final responses = await response.stream.toList(); + + expect(responses.length, 4); + // expect that all responses are there + expect(responses.contains(key1TextNotes[key1]), true); + expect(responses.contains(key2TextNotes[key2]), true); + expect(responses.contains(key3TextNotes[key3]), true); + expect(responses.contains(key4TextNotes[key4]), true); + + await stopServers(); + }, + ); }); } diff --git a/packages/ndk/test/usecases/lists/lists_test.dart b/packages/ndk/test/usecases/lists/lists_test.dart index 7e8124de0..6ccb14a03 100644 --- a/packages/ndk/test/usecases/lists/lists_test.dart +++ b/packages/ndk/test/usecases/lists/lists_test.dart @@ -34,23 +34,31 @@ void main() async { ); bookmarkListKey0 = Nip51List( - pubKey: key0.publicKey, - kind: Nip51List.kBookmarks, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - elements: [ - Nip51ListElement( - tag: Nip51List.kPubkey, value: key1.publicKey, private: false) - ]); + pubKey: key0.publicKey, + kind: Nip51List.kBookmarks, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + elements: [ + Nip51ListElement( + tag: Nip51List.kPubkey, + value: key1.publicKey, + private: false, + ), + ], + ); favoriteRelaysKey1 = Nip51Set( - pubKey: key1.publicKey, - kind: Nip51List.kRelaySet, - name: "my favorite relays", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - elements: [ - Nip51ListElement( - tag: Nip51List.kRelay, value: "wss://bla.com", private: true) - ]); + pubKey: key1.publicKey, + kind: Nip51List.kRelaySet, + name: "my favorite relays", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + elements: [ + Nip51ListElement( + tag: Nip51List.kRelay, + value: "wss://bla.com", + private: true, + ), + ], + ); relay0 = MockRelay(name: "relay 0"); Nip01Event event0 = await bookmarkListKey0.toEvent(signer0); @@ -59,8 +67,9 @@ void main() async { final signedEvent0 = await signer0.sign(event0); final signedEvent1 = await signer1.sign(event1); - await relay0 - .startServer(textNotes: {key0: signedEvent0, key1: signedEvent1}); + await relay0.startServer( + textNotes: {key0: signedEvent0, key1: signedEvent1}, + ); final cache = MemCacheManager(); final NdkConfig config = NdkConfig( @@ -88,8 +97,10 @@ void main() async { expect(publicList, isNotNull); expect(publicList!.kind, Nip51List.kBookmarks); expect(publicList.pubKey, key0.publicKey); - expect(publicList.elements.any((e) => e.value == 'publicListPubkey'), - isTrue); + expect( + publicList.elements.any((e) => e.value == 'publicListPubkey'), + isTrue, + ); }); tearDown(() async { @@ -98,28 +109,36 @@ void main() async { test('lists get bookmarks', () async { ndk.accounts.loginExternalSigner(signer: signer0); - Nip51List? bookmarks = - await ndk.lists.getSingleNip51List(Nip51List.kBookmarks); + Nip51List? bookmarks = await ndk.lists.getSingleNip51List( + Nip51List.kBookmarks, + ); expect(bookmarkListKey0.kind, bookmarks!.kind); expect(bookmarkListKey0.elements.length, bookmarks.elements.length); - expect(bookmarkListKey0.elements.first.value, - bookmarks.elements.first.value); + expect( + bookmarkListKey0.elements.first.value, + bookmarks.elements.first.value, + ); }); test('lists get favorite relays', () async { ndk.accounts.loginExternalSigner(signer: signer1); - Nip51Set? relays = - await ndk.lists.getSingleNip51RelaySet(favoriteRelaysKey1.name); + Nip51Set? relays = await ndk.lists.getSingleNip51RelaySet( + favoriteRelaysKey1.name, + ); expect(favoriteRelaysKey1.kind, relays!.kind); expect(favoriteRelaysKey1.elements.length, relays.elements.length); expect( - favoriteRelaysKey1.elements.first.value, relays.elements.first.value); + favoriteRelaysKey1.elements.first.value, + relays.elements.first.value, + ); }); test('lists get bookmarks with forceRefresh', () async { ndk.accounts.loginExternalSigner(signer: signer0); - Nip51List? bookmarks = await ndk.lists - .getSingleNip51List(Nip51List.kBookmarks, forceRefresh: true); + Nip51List? bookmarks = await ndk.lists.getSingleNip51List( + Nip51List.kBookmarks, + forceRefresh: true, + ); expect(bookmarks, isNotNull); expect(bookmarkListKey0.kind, bookmarks!.kind); }); @@ -135,8 +154,10 @@ void main() async { }); test('lists get public sets', () async { - final sets = ndk.lists - .getPublicSets(kind: Nip51List.kRelaySet, publicKey: key1.publicKey); + final sets = ndk.lists.getPublicSets( + kind: Nip51List.kRelaySet, + publicKey: key1.publicKey, + ); final result = await sets.first; expect(result, isNotNull); expect(result?.first.name, favoriteRelaysKey1.name); @@ -145,18 +166,20 @@ void main() async { test('addElementToList creates new list if not exists', () async { ndk.accounts.loginExternalSigner(signer: signer0); final list = await ndk.lists.addElementToList( - kind: Nip51List.kBookmarks, - tag: Nip51List.kPubkey, - value: 'newPubkey123'); + kind: Nip51List.kBookmarks, + tag: Nip51List.kPubkey, + value: 'newPubkey123', + ); expect(list.elements.any((e) => e.value == 'newPubkey123'), isTrue); }); test('removeElementFromList removes element', () async { ndk.accounts.loginExternalSigner(signer: signer0); final list = await ndk.lists.removeElementFromList( - kind: Nip51List.kBookmarks, - tag: Nip51List.kPubkey, - value: key1.publicKey); + kind: Nip51List.kBookmarks, + tag: Nip51List.kPubkey, + value: key1.publicKey, + ); expect(list, isNotNull); expect(list!.pubKeys, isNot(contains(key1.publicKey))); }); @@ -164,15 +187,18 @@ void main() async { test('addElementToSet creates new set if not exists', () async { ndk.accounts.loginExternalSigner(signer: signer1); - final setCheck = await ndk.lists - .getSetByName(name: 'test-set', kind: Nip51List.kRelaySet); + final setCheck = await ndk.lists.getSetByName( + name: 'test-set', + kind: Nip51List.kRelaySet, + ); expect(setCheck, isNull); final set = await ndk.lists.addElementToSet( - name: 'test-set', - tag: Nip51List.kRelay, - value: 'wss://test.com', - kind: Nip51List.kRelaySet); + name: 'test-set', + tag: Nip51List.kRelay, + value: 'wss://test.com', + kind: Nip51List.kRelaySet, + ); expect(set, isNotNull); expect(set!.elements.any((e) => e.value == 'wss://test.com'), isTrue); }); @@ -180,10 +206,11 @@ void main() async { test('removeElementFromSet removes element', () async { ndk.accounts.loginExternalSigner(signer: signer1); final set = await ndk.lists.removeElementFromSet( - name: favoriteRelaysKey1.name, - tag: Nip51List.kRelay, - value: 'wss://bla.com', - kind: Nip51List.kRelaySet); + name: favoriteRelaysKey1.name, + tag: Nip51List.kRelay, + value: 'wss://bla.com', + kind: Nip51List.kRelaySet, + ); final fetchedSet = await ndk.lists.getSetByName( name: favoriteRelaysKey1.name, @@ -193,32 +220,39 @@ void main() async { expect(fetchedSet, isNotNull); expect(set!.elements.any((e) => e.value == 'wss://bla.com'), isFalse); expect( - fetchedSet!.elements.any((e) => e.value == 'wss://bla.com'), isFalse); + fetchedSet!.elements.any((e) => e.value == 'wss://bla.com'), + isFalse, + ); }); test('setCompleteSet replaces existing set', () async { ndk.accounts.loginExternalSigner(signer: signer1); final newSet = Nip51Set( - pubKey: key1.publicKey, - kind: Nip51List.kRelaySet, - name: "replacement-set", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - elements: [ - Nip51ListElement( - tag: Nip51List.kRelay, - value: "wss://newrelay.com", - private: false) - ]); + pubKey: key1.publicKey, + kind: Nip51List.kRelaySet, + name: "replacement-set", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + elements: [ + Nip51ListElement( + tag: Nip51List.kRelay, + value: "wss://newrelay.com", + private: false, + ), + ], + ); ndk.lists.addElementToSet( - name: "replacement-set", - tag: Nip51List.kRelay, - value: "wss://oldrelay.com", - kind: Nip51List.kRelaySet); + name: "replacement-set", + tag: Nip51List.kRelay, + value: "wss://oldrelay.com", + kind: Nip51List.kRelaySet, + ); - final result = await ndk.lists - .setCompleteSet(set: newSet, kind: Nip51List.kRelaySet); + final result = await ndk.lists.setCompleteSet( + set: newSet, + kind: Nip51List.kRelaySet, + ); expect(result.name, "replacement-set"); expect(result.elements.length, 1); }); @@ -247,46 +281,60 @@ void main() async { test('addElementToList throws without signer', () async { expect( - () async => await ndk.lists.addElementToList( - kind: Nip51List.kBookmarks, - tag: Nip51List.kPubkey, - value: 'test'), - throwsException); + () async => await ndk.lists.addElementToList( + kind: Nip51List.kBookmarks, + tag: Nip51List.kPubkey, + value: 'test', + ), + throwsException, + ); }); test('removeElementFromList throws without signer', () async { expect( - () async => await ndk.lists.removeElementFromList( - kind: Nip51List.kBookmarks, - tag: Nip51List.kPubkey, - value: 'test'), - throwsException); + () async => await ndk.lists.removeElementFromList( + kind: Nip51List.kBookmarks, + tag: Nip51List.kPubkey, + value: 'test', + ), + throwsException, + ); }); test('removeElementFromSet throws without signer', () async { expect( - () async => await ndk.lists.removeElementFromSet( - name: 'test-set', - value: 'wss://test.com', - tag: Nip51List.kRelay, - kind: Nip51List.kRelaySet), - throwsException); + () async => await ndk.lists.removeElementFromSet( + name: 'test-set', + value: 'wss://test.com', + tag: Nip51List.kRelay, + kind: Nip51List.kRelaySet, + ), + throwsException, + ); }); test('deleteSet throws without signer', () async { expect( - () async => await ndk.lists - .deleteSet(name: 'test-set', kind: Nip51List.kRelaySet), - throwsException); + () async => await ndk.lists.deleteSet( + name: 'test-set', + kind: Nip51List.kRelaySet, + ), + throwsException, + ); }); - test('getSetByName throws when not logged in and no custom signer', - () async { - expect( - () async => await ndk.lists - .getSetByName(name: 'test', kind: Nip51List.kRelaySet), - throwsException); - }); + test( + 'getSetByName throws when not logged in and no custom signer', + () async { + expect( + () async => await ndk.lists.getSetByName( + name: 'test', + kind: Nip51List.kRelaySet, + ), + throwsException, + ); + }, + ); test('addElementToList creates new list when none exists', () async { ndk.accounts.loginExternalSigner(signer: signer0); @@ -353,14 +401,15 @@ void main() async { pubKey: key1.publicKey, kind: Nip51List.kRelaySet, name: setName, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - + createdAt: + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100, // 100 seconds ago elements: [ Nip51ListElement( tag: Nip51List.kRelay, value: "wss://old.com", private: false, - ) + ), ], ); @@ -375,7 +424,7 @@ void main() async { tag: Nip51List.kRelay, value: "wss://new.com", private: false, - ) + ), ], ); @@ -414,17 +463,20 @@ void main() async { tag: Nip51List.kRelay, value: "wss://encrypted-relay.com", private: true, - ) + ), ], ); String content = ""; - List privateElements = - nip04Set.elements.where((element) => element.private).toList(); + List privateElements = nip04Set.elements + .where((element) => element.private) + .toList(); if (privateElements.isNotEmpty) { - String json = jsonEncode(privateElements - .map((element) => [element.tag, element.value]) - .toList()); + String json = jsonEncode( + privateElements + .map((element) => [element.tag, element.value]) + .toList(), + ); content = json; } @@ -453,9 +505,7 @@ void main() async { ); // Create a new event with NIP-04 encrypted content - final nip04Event = eventPlaintext.copyWith( - content: nip04Content, - ); + final nip04Event = eventPlaintext.copyWith(content: nip04Content); final signedNip04Event = await signer1.sign(nip04Event); @@ -470,58 +520,66 @@ void main() async { }); test( - 'fromEvent preserves metadata (title/description/image) alongside private elements', - () async { - // Regression test for: when a set has encrypted private elements AND - // public metadata tags, parsing with a full signer must retain both. - // Previously, parseSetTags was called only on the decrypted content - // (which never contains title/description/image), so metadata was silently - // dropped for any set that had private content. - final original = Nip51Set( - pubKey: key1.publicKey, - kind: Nip51List.kRelaySet, - name: "metadata-set", - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - title: "My Relay Set", - description: "A set with both private relays and metadata", - image: "https://example.com/image.png", - elements: [ - Nip51ListElement( - tag: Nip51List.kRelay, - value: "wss://private-relay.com", - private: true, + 'fromEvent preserves metadata (title/description/image) alongside private elements', + () async { + // Regression test for: when a set has encrypted private elements AND + // public metadata tags, parsing with a full signer must retain both. + // Previously, parseSetTags was called only on the decrypted content + // (which never contains title/description/image), so metadata was silently + // dropped for any set that had private content. + final original = Nip51Set( + pubKey: key1.publicKey, + kind: Nip51List.kRelaySet, + name: "metadata-set", + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + title: "My Relay Set", + description: "A set with both private relays and metadata", + image: "https://example.com/image.png", + elements: [ + Nip51ListElement( + tag: Nip51List.kRelay, + value: "wss://private-relay.com", + private: true, + ), + Nip51ListElement( + tag: Nip51List.kRelay, + value: "wss://public-relay.com", + private: false, + ), + ], + ); + + // Serialize: private relay goes into encrypted content, metadata stays + // as public tags on the event. + final event = await original.toEvent(signer1); + final signedEvent = await signer1.sign(event); + + // Deserialize with a full signer (can decrypt). + final parsed = await Nip51Set.fromEvent(signedEvent, signer1); + + expect(parsed, isNotNull); + // Metadata must survive even though private content was decrypted. + expect(parsed!.title, "My Relay Set"); + expect( + parsed.description, + "A set with both private relays and metadata", + ); + expect(parsed.image, "https://example.com/image.png"); + // Both private and public elements must be present. + expect(parsed.elements.length, 2); + expect( + parsed.elements.any( + (e) => e.value == "wss://private-relay.com" && e.private, ), - Nip51ListElement( - tag: Nip51List.kRelay, - value: "wss://public-relay.com", - private: false, + isTrue, + ); + expect( + parsed.elements.any( + (e) => e.value == "wss://public-relay.com" && !e.private, ), - ], - ); - - // Serialize: private relay goes into encrypted content, metadata stays - // as public tags on the event. - final event = await original.toEvent(signer1); - final signedEvent = await signer1.sign(event); - - // Deserialize with a full signer (can decrypt). - final parsed = await Nip51Set.fromEvent(signedEvent, signer1); - - expect(parsed, isNotNull); - // Metadata must survive even though private content was decrypted. - expect(parsed!.title, "My Relay Set"); - expect(parsed.description, "A set with both private relays and metadata"); - expect(parsed.image, "https://example.com/image.png"); - // Both private and public elements must be present. - expect(parsed.elements.length, 2); - expect( - parsed.elements - .any((e) => e.value == "wss://private-relay.com" && e.private), - isTrue); - expect( - parsed.elements - .any((e) => e.value == "wss://public-relay.com" && !e.private), - isTrue); - }); + isTrue, + ); + }, + ); }); } diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 68e0de415..7bd90bb84 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -38,8 +38,9 @@ void main() { }; // Mock the client.get method - when(client.get(Uri.parse(link), headers: {"Accept": "application/json"})) - .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); + when( + client.get(Uri.parse(link), headers: {"Accept": "application/json"}), + ).thenAnswer((_) async => http.Response(jsonEncode(response), 200)); var lnurlResponse = await lnurl.getLnurlResponse(link); expect(lnurlResponse, isNotNull); @@ -52,6 +53,9 @@ void main() { final Lnurl lnurl = Lnurl(transport: transport); final link = 'https://invalid.com'; + when( + client.get(Uri.parse(link), headers: {"Accept": "application/json"}), + ).thenAnswer((_) async => http.Response('not found', 404)); var lnurlResponse = await lnurl.getLnurlResponse(link); expect(lnurlResponse, isNull); @@ -59,7 +63,8 @@ void main() { test('getAmountFromBolt11 returns correct amount for valid input', () { final amount = Lnurl.getAmountFromBolt11( - 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs'); // Replace with a valid Bolt11 string + 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs', + ); // Replace with a valid Bolt11 string expect(amount, 1500); // Replace with the expected amount }); diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart index 31c776521..0ff1e6a18 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart @@ -27,24 +27,14 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Client]. @@ -56,46 +46,30 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> post( @@ -105,28 +79,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> put( @@ -136,28 +105,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> patch( @@ -167,28 +131,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> delete( @@ -198,49 +157,36 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => + _i3.Future read(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future); + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); @override _i3.Future<_i6.Uint8List> readBytes( @@ -248,37 +194,27 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), - ) as _i3.Future<_i6.Uint8List>); + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); } diff --git a/packages/ndk/test/usecases/local_first/local_first_subscription_test.dart b/packages/ndk/test/usecases/local_first/local_first_subscription_test.dart new file mode 100644 index 000000000..c55d0016a --- /dev/null +++ b/packages/ndk/test/usecases/local_first/local_first_subscription_test.dart @@ -0,0 +1,356 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip09/deletion.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_event_verifier.dart'; +import '../../mocks/mock_relay.dart'; + +void main() { + group('local first subscription', () { + late Directory tempDir; + late Ndk ndk; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'local_first_subscription', + ); + }); + + tearDown(() async { + await ndk.destroy(); + await tempDir.delete(recursive: true); + }); + + test( + 'cache-backed subscription emits cached event first and later continues with live relay updates', + () async { + final relay = MockRelay(name: 'subscription-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final cachedEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'cached event', + createdAt: 1_700_000_080, + ), + privateKey: remoteAuthor.privateKey!, + ); + final liveEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'live event', + createdAt: 1_700_000_081, + ), + privateKey: remoteAuthor.privateKey!, + ); + await relay.startServer(textNotes: {remoteAuthor: cachedEvent}); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + await ndk.relays.seedRelaysConnected; + + final initiallyCached = await ndk.requests + .query( + filter: Filter(ids: [cachedEvent.id]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 4), + ) + .future; + expect(initiallyCached.map((e) => e.id), contains(cachedEvent.id)); + + final receivedEvents = []; + final cachedSeen = Completer(); + final liveSeen = Completer(); + final subscription = ndk.requests.subscription( + filter: Filter( + authors: [remoteAuthor.publicKey], + kinds: [Nip01Event.kTextNodeKind], + ), + cacheRead: true, + cacheWrite: true, + explicitRelays: [relay.url], + ); + final sub = subscription.stream.listen((event) { + receivedEvents.add(event); + if (event.id == cachedEvent.id && !cachedSeen.isCompleted) { + cachedSeen.complete(); + } + if (event.id == liveEvent.id && !liveSeen.isCompleted) { + liveSeen.complete(); + } + }); + addTearDown(sub.cancel); + + await cachedSeen.future.timeout(const Duration(seconds: 2)); + + await _publishFixtureEventToRelay(relay.url, liveEvent); + await liveSeen.future.timeout(const Duration(seconds: 2)); + + expect(receivedEvents.map((e) => e.id), contains(cachedEvent.id)); + expect(receivedEvents.map((e) => e.id), contains(liveEvent.id)); + + await relay.stopServer(); + }, + ); + + test( + 'cache-backed subscription does not emit tombstoned foreign events or resurrect them later', + () async { + final relay = MockRelay(name: 'subscription-delete-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final targetEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'deleted target', + createdAt: 1_700_000_090, + ), + privateKey: remoteAuthor.privateKey!, + ); + final deletionEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Deletion.kKind, + tags: [ + ['e', targetEvent.id], + ['k', targetEvent.kind.toString()], + ], + content: 'delete target', + createdAt: 1_700_000_091, + ), + privateKey: remoteAuthor.privateKey!, + ); + await relay.startServer(textNotes: {remoteAuthor: targetEvent}); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + await ndk.relays.seedRelaysConnected; + + final cachedTarget = await ndk.requests + .query( + filter: Filter(ids: [targetEvent.id]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(cachedTarget.map((e) => e.id), contains(targetEvent.id)); + + await _publishFixtureEventToRelay(relay.url, deletionEvent); + final syncedDeletion = await ndk.requests + .query( + filter: Filter(ids: [deletionEvent.id], kinds: [Deletion.kKind]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(syncedDeletion.map((e) => e.id), contains(deletionEvent.id)); + + final emittedEvents = []; + final subscription = ndk.requests.subscription( + filter: Filter(ids: [targetEvent.id]), + cacheRead: true, + cacheWrite: true, + explicitRelays: [relay.url], + ); + final sub = subscription.stream.listen(emittedEvents.add); + addTearDown(sub.cancel); + + await Future.delayed(const Duration(milliseconds: 500)); + expect(emittedEvents, isEmpty); + + await _publishFixtureEventToRelay(relay.url, targetEvent); + await Future.delayed(const Duration(milliseconds: 500)); + + expect( + emittedEvents.map((e) => e.id), + isNot(contains(targetEvent.id)), + reason: + 'A tombstoned foreign target should stay suppressed on the public reactive stream even if replayed later.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'cache-backed subscription emits cached replaceable winner then newer live replacement but not stale late event', + () async { + final relay = MockRelay(name: 'subscription-replaceable-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final writerNdk = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [relay.url], + ), + ); + addTearDown(writerNdk.destroy); + final cachedVersion = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Metadata.kKind, + tags: const [], + content: jsonEncode({'name': 'version-1'}), + createdAt: 1_700_000_100, + ), + privateKey: remoteAuthor.privateKey!, + ); + final newerVersion = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Metadata.kKind, + tags: const [], + content: jsonEncode({'name': 'version-2'}), + createdAt: 1_700_000_101, + ), + privateKey: remoteAuthor.privateKey!, + ); + final staleVersion = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Metadata.kKind, + tags: const [], + content: jsonEncode({'name': 'version-0'}), + createdAt: 1_700_000_099, + ), + privateKey: remoteAuthor.privateKey!, + ); + await relay.startServer( + metadatas: {remoteAuthor.publicKey: cachedVersion}, + ); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + await ndk.relays.seedRelaysConnected; + + final cachedLoad = await ndk.requests + .query( + filter: Filter( + authors: [remoteAuthor.publicKey], + kinds: [Metadata.kKind], + ), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(cachedLoad.map((e) => e.id), contains(cachedVersion.id)); + await Future.delayed(const Duration(milliseconds: 200)); + + final receivedContents = []; + final cachedSeen = Completer(); + final newerSeen = Completer(); + final subscription = ndk.requests.subscription( + filter: Filter( + authors: [remoteAuthor.publicKey], + kinds: [Metadata.kKind], + ), + cacheRead: true, + cacheWrite: true, + explicitRelays: [relay.url], + ); + final sub = subscription.stream.listen((event) { + receivedContents.add(event.content); + if (event.id == cachedVersion.id && !cachedSeen.isCompleted) { + cachedSeen.complete(); + } + if (event.id == newerVersion.id && !newerSeen.isCompleted) { + newerSeen.complete(); + } + }); + addTearDown(sub.cancel); + + await cachedSeen.future.timeout(const Duration(seconds: 2)); + await Future.delayed(const Duration(milliseconds: 200)); + + await writerNdk.broadcast + .broadcast(nostrEvent: newerVersion, specificRelays: [relay.url]) + .broadcastDoneFuture; + await newerSeen.future.timeout(const Duration(seconds: 2)); + + await writerNdk.broadcast + .broadcast(nostrEvent: staleVersion, specificRelays: [relay.url]) + .broadcastDoneFuture; + await Future.delayed(const Duration(milliseconds: 500)); + + expect(receivedContents, contains(jsonEncode({'name': 'version-1'}))); + expect(receivedContents, contains(jsonEncode({'name': 'version-2'}))); + expect( + receivedContents, + isNot(contains(jsonEncode({'name': 'version-0'}))), + reason: + 'A stale replaceable event that arrives after a newer winner should not be emitted on the local-first reactive stream.', + ); + + await relay.stopServer(); + }, + ); + }); +} + +Future _createNdk( + String databasePath, { + List bootstrapRelays = const [], +}) async { + final cache = await SembastCacheManager.create(databasePath: databasePath); + return Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: bootstrapRelays, + ignoreRelays: const [], + ), + ); +} + +Future _publishFixtureEventToRelay( + String relayUrl, + Nip01Event event, +) async { + final socket = await WebSocket.connect(relayUrl); + final completer = Completer(); + + late final StreamSubscription subscription; + subscription = socket.listen((message) { + if (message is! String) { + return; + } + + final decoded = jsonDecode(message); + if (decoded is! List || decoded.isEmpty || decoded[0] != 'OK') { + return; + } + if (decoded.length < 4 || decoded[1] != event.id) { + return; + } + + if (decoded[2] == true) { + completer.complete(); + } else { + completer.completeError( + StateError('Fixture relay rejected event ${event.id}: ${decoded[3]}'), + ); + } + }, onError: completer.completeError); + + socket.add( + jsonEncode([ + 'EVENT', + { + 'id': event.id, + 'pubkey': event.pubKey, + 'created_at': event.createdAt, + 'kind': event.kind, + 'tags': event.tags, + 'content': event.content, + 'sig': event.sig, + }, + ]), + ); + + await completer.future.timeout(const Duration(seconds: 2)); + await subscription.cancel(); + await socket.close(); +} diff --git a/packages/ndk/test/usecases/local_first/local_first_test.dart b/packages/ndk/test/usecases/local_first/local_first_test.dart new file mode 100644 index 000000000..c2319b1e9 --- /dev/null +++ b/packages/ndk/test/usecases/local_first/local_first_test.dart @@ -0,0 +1,1285 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:ndk/shared/nips/nip09/deletion.dart'; +import 'package:ndk/shared/nips/nip25/reactions.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_event_verifier.dart'; +import '../../mocks/mock_relay.dart'; + +void main() { + group('local first', () { + late Directory tempDir; + late KeyPair authorKey; + late Ndk ndk; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('local_first_public_api'); + authorKey = Bip340.generatePrivateKey(); + }); + + tearDown(() async { + await ndk.destroy(); + await tempDir.delete(recursive: true); + }); + + test( + 'offline publish is locally readable and eventually reaches relay when relay comes online', + () async { + final relay = MockRelay(name: 'local-first-relay'); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'offline-first public api note', + createdAt: 1_700_000_000, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .broadcastDoneFuture; + + final localWhileOffline = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + + expect(localWhileOffline.map((e) => e.id), contains(event.id)); + + await relay.startServer(); + await _waitForRelayConnected( + ndk: ndk, + relayUrl: relay.url, + timeout: const Duration(seconds: 20), + ); + + final relayAfterReconnect = await _waitForRelayEvents( + relay: relay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 15), + ); + + expect( + relayAfterReconnect.map((e) => e.id), + contains(event.id), + reason: + 'Local-first should auto-deliver the unpublished event after the relay becomes reachable.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'partial success keeps local visibility and should later deliver only to relays that were offline', + () async { + final relayOnline = MockRelay(name: 'relay-online'); + final relayOffline = MockRelay(name: 'relay-offline'); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relayOnline.url, relayOffline.url], + ); + + await relayOnline.startServer(); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'partial success note', + createdAt: 1_700_000_010, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relayOnline.url, relayOffline.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + + final localVisible = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localVisible.map((e) => e.id), contains(event.id)); + + final onlineRelayEvents = await _waitForRelayEvents( + relay: relayOnline, + filter: Filter(ids: [event.id]), + ); + expect(onlineRelayEvents.map((e) => e.id), contains(event.id)); + + await relayOffline.startServer(); + + final offlineRelayEventually = await _waitForRelayEvents( + relay: relayOffline, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 8), + ); + + expect( + offlineRelayEventually.map((e) => e.id), + contains(event.id), + reason: + 'After one relay succeeded and another was offline, local-first delivery should later flush only the missing relay.', + ); + + await relayOnline.stopServer(); + await relayOffline.stopServer(); + }, + ); + + test( + 'offline reaction to a cached root is locally visible and should later reach the relay', + () async { + final relay = MockRelay(name: 'reaction-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final rootEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'root event', + createdAt: 1_700_000_020, + ), + privateKey: remoteAuthor.privateKey!, + ); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + await relay.startServer(textNotes: {remoteAuthor: rootEvent}); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final cachedRoot = await ndk.requests + .query( + filter: Filter(ids: [rootEvent.id]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(cachedRoot.map((e) => e.id), contains(rootEvent.id)); + + await relay.stopServer(); + + await ndk.broadcast + .broadcastReaction( + eventId: rootEvent.id, + customRelays: [relay.url], + reaction: '♡', + ) + .broadcastDoneFuture; + + final localReaction = await ndk.requests + .query( + filter: Filter( + authors: [authorKey.publicKey], + kinds: [Reaction.kKind], + ), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + + expect(localReaction.map((e) => e.content), contains('♡')); + + await relay.startServer(textNotes: {remoteAuthor: rootEvent}); + + final relayReaction = await _waitForRelayEvents( + relay: relay, + filter: Filter( + authors: [authorKey.publicKey], + kinds: [Reaction.kKind], + ), + timeout: const Duration(seconds: 8), + ); + + expect( + relayReaction.map((e) => e.content), + contains('♡'), + reason: + 'A reaction created while offline should be immediately visible locally and later delivered when the relay becomes reachable again.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'offline replaceable supersession shows latest locally and should later deliver only the newest version', + () async { + final relay = MockRelay(name: 'replaceable-relay'); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final version1 = Nip01Event( + pubKey: authorKey.publicKey, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ], + content: 'version 1', + createdAt: 1_700_000_030, + ); + + final version2 = Nip01Event( + pubKey: authorKey.publicKey, + kind: 30023, + tags: const [ + ['d', 'article-1'], + ], + content: 'version 2', + createdAt: 1_700_000_031, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: version1, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + await ndk.broadcast + .broadcast( + nostrEvent: version2, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + + final localCurrent = await ndk.requests + .query( + filter: Filter( + authors: [authorKey.publicKey], + kinds: [30023], + tags: { + 'd': ['article-1'], + }, + ), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + + expect(localCurrent.length, 1); + expect(localCurrent.single.content, 'version 2'); + + await relay.startServer(); + await _waitForRelayConnected( + ndk: ndk, + relayUrl: relay.url, + timeout: const Duration(seconds: 20), + ); + + final relayCurrent = await _waitForRelayEvents( + relay: relay, + filter: Filter( + authors: [authorKey.publicKey], + kinds: [30023], + tags: { + 'd': ['article-1'], + }, + ), + timeout: const Duration(seconds: 8), + ); + + expect( + relayCurrent.map((e) => e.content), + contains('version 2'), + reason: + 'When multiple offline versions of a replaceable event exist, local-first delivery should later flush only the newest visible version.', + ); + expect( + relayCurrent.where((e) => e.content == 'version 1'), + isEmpty, + reason: + 'Superseded replaceable versions should not later be delivered after the current one replaced them locally.', + ); + expect( + relay.receivedEvents.where((e) => e.content == 'version 1'), + isEmpty, + reason: + 'Superseded replaceable versions should be skipped by local-first flush instead of being sent and relying on relay-side conflict resolution.', + ); + expect( + relay.receivedEvents.where((e) => e.content == 'version 2').length, + 1, + reason: + 'Only the current replaceable version should be delivered during delayed flush.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'offline publish survives ndk restart and is later delivered after relay comes online', + () async { + final relay = MockRelay(name: 'restart-relay'); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'restart persistence note', + createdAt: 1_700_000_035, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + + final localBeforeRestart = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localBeforeRestart.map((e) => e.id), contains(event.id)); + + await ndk.destroy(); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final localAfterRestart = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localAfterRestart.map((e) => e.id), contains(event.id)); + + await relay.startServer(); + await _waitForRelayConnected( + ndk: ndk, + relayUrl: relay.url, + timeout: const Duration(seconds: 20), + ); + + final relayAfterReconnect = await _waitForRelayEvents( + relay: relay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 8), + ); + + expect( + relayAfterReconnect.map((e) => e.id), + contains(event.id), + reason: + 'A locally queued event should survive process restart and still be delivered once the relay becomes reachable.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'incoming deletion hides a previously cached foreign event from app queries', + () async { + final relay = MockRelay(name: 'incoming-deletion-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final rootEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'event later deleted', + createdAt: 1_700_000_050, + ), + privateKey: remoteAuthor.privateKey!, + ); + final deletionEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Deletion.kKind, + tags: [ + ['e', rootEvent.id], + ['k', rootEvent.kind.toString()], + ], + content: 'delete root event', + createdAt: 1_700_000_051, + ), + privateKey: remoteAuthor.privateKey!, + ); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + await relay.startServer(textNotes: {remoteAuthor: rootEvent}); + + final cachedRoot = await ndk.requests + .query( + filter: Filter(ids: [rootEvent.id]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(cachedRoot.map((e) => e.id), contains(rootEvent.id)); + + await _publishFixtureEventToRelay(relay.url, deletionEvent); + + final fetchedDeletion = await ndk.requests + .query( + filter: Filter( + authors: [remoteAuthor.publicKey], + kinds: [Deletion.kKind], + ids: [deletionEvent.id], + ), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(fetchedDeletion.map((e) => e.id), contains(deletionEvent.id)); + + final localAfterDeletion = await ndk.requests + .query( + filter: Filter(ids: [rootEvent.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + + expect( + localAfterDeletion.map((e) => e.id), + isNot(contains(rootEvent.id)), + reason: + 'After a valid remote deletion is seen, app-level local-first reads should stop returning the deleted foreign event.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'deletion received before target should keep the later foreign target suppressed locally', + () async { + final relay = MockRelay(name: 'deletion-first-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final rootEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'event that should stay tombstoned', + createdAt: 1_700_000_060, + ), + privateKey: remoteAuthor.privateKey!, + ); + final deletionEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Deletion.kKind, + tags: [ + ['e', rootEvent.id], + ['k', rootEvent.kind.toString()], + ], + content: 'delete before target arrives', + createdAt: 1_700_000_061, + ), + privateKey: remoteAuthor.privateKey!, + ); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + await relay.startServer(); + + await _publishFixtureEventToRelay(relay.url, deletionEvent); + + final fetchedDeletion = await ndk.requests + .query( + filter: Filter( + authors: [remoteAuthor.publicKey], + kinds: [Deletion.kKind], + ids: [deletionEvent.id], + ), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect(fetchedDeletion.map((e) => e.id), contains(deletionEvent.id)); + + await _publishFixtureEventToRelay(relay.url, rootEvent); + + final targetFetch = await ndk.requests + .query( + filter: Filter(ids: [rootEvent.id]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 1), + ) + .future; + expect( + targetFetch.map((e) => e.id), + isNot(contains(rootEvent.id)), + reason: + 'Once a tombstone is known locally, a later fetch of that foreign target should already be suppressed at the public query layer.', + ); + + final localAfterLateTarget = await ndk.requests + .query( + filter: Filter(ids: [rootEvent.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + + expect( + localAfterLateTarget.map((e) => e.id), + isNot(contains(rootEvent.id)), + reason: + 'If the deletion arrived first, later sync of the deleted foreign target should remain suppressed instead of resurrecting locally.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'relay refresh replaces stale cached metadata with the newest remote version', + () async { + final relay = MockRelay(name: 'metadata-convergence-relay'); + final remoteAuthor = Bip340.generatePrivateKey(); + final oldMetadataEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Metadata.kKind, + tags: const [], + content: jsonEncode({'name': 'old-name'}), + createdAt: 1_700_000_070, + ), + privateKey: remoteAuthor.privateKey!, + ); + final newMetadataEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: remoteAuthor.publicKey, + kind: Metadata.kKind, + tags: const [], + content: jsonEncode({'name': 'new-name'}), + createdAt: 1_700_000_071, + ), + privateKey: remoteAuthor.privateKey!, + ); + ndk = await _createNdk(tempDir.path, bootstrapRelays: [relay.url]); + + await relay.startServer( + metadatas: {remoteAuthor.publicKey: oldMetadataEvent}, + ); + + final cachedOldMetadata = await ndk.metadata.loadMetadata( + remoteAuthor.publicKey, + forceRefresh: true, + idleTimeout: const Duration(seconds: 1), + ); + expect(cachedOldMetadata?.name, 'old-name'); + + await relay.stopServer(); + await relay.startServer( + metadatas: {remoteAuthor.publicKey: newMetadataEvent}, + ); + + final refreshedMetadata = await ndk.metadata.loadMetadata( + remoteAuthor.publicKey, + forceRefresh: true, + idleTimeout: const Duration(seconds: 1), + ); + expect( + refreshedMetadata?.name, + 'new-name', + reason: + 'A newer replaceable metadata event from the relay should overwrite the stale cached materialized view.', + ); + + final localCurrentMetadata = await ndk.metadata.loadMetadata( + remoteAuthor.publicKey, + ); + expect(localCurrentMetadata?.name, 'new-name'); + + await relay.stopServer(); + }, + ); + + test( + 'connected relay rejecting first should keep local visibility and later succeed via periodic retry', + () async { + final relay = MockRelay( + name: 'retry-relay', + rejectFirstEventPublishes: 1, + rejectEventMessage: 'rate-limited: retry later', + ); + await relay.startServer(); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'connected retry note', + createdAt: 1_700_000_040, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 500), + ) + .broadcastDoneFuture; + + final localVisible = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localVisible.map((e) => e.id), contains(event.id)); + + expect( + relay.receivedEvents.where((e) => e.id == event.id).length, + 1, + reason: + 'The first connected publish attempt should reach the relay and be rejected without disconnecting.', + ); + expect( + relay.matchingEvents(Filter(ids: [event.id])), + isEmpty, + reason: + 'Rejected events should not be visible on the relay before the retry succeeds.', + ); + + final eventuallyStored = await _waitForRelayEvents( + relay: relay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 8), + ); + + expect( + eventuallyStored.map((e) => e.id), + contains(event.id), + reason: + 'A connected relay that first rejects with a transient error should later receive the event through the periodic due-retry path.', + ); + expect( + relay.receivedEvents.where((e) => e.id == event.id).length, + 2, + reason: + 'The retry path should produce a second publish attempt after the initial transient rejection.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'connected relay returning permanent failure should keep local visibility without periodic retry spam', + () async { + final relay = MockRelay( + name: 'permanent-failure-relay', + rejectFirstEventPublishes: 99, + rejectEventMessage: 'policy violation: forbidden kind', + ); + await relay.startServer(); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + + ndk.accounts.loginPrivateKey( + pubkey: authorKey.publicKey, + privkey: authorKey.privateKey!, + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'permanent failure note', + createdAt: 1_700_000_041, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 500), + ) + .broadcastDoneFuture; + + final localVisible = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localVisible.map((e) => e.id), contains(event.id)); + + expect( + relay.receivedEvents.where((e) => e.id == event.id).length, + 1, + reason: + 'The initial publish attempt should still reach the connected relay before being rejected permanently.', + ); + + await Future.delayed(const Duration(seconds: 4)); + + expect( + relay.matchingEvents(Filter(ids: [event.id])), + isEmpty, + reason: + 'A permanently rejected event should never appear on the relay if the relay keeps refusing it.', + ); + expect( + relay.receivedEvents.where((e) => e.id == event.id).length, + 1, + reason: + 'Permanent failures should not be retried by the periodic retry pump.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'network-backed interactive signer queues locally while signer transport relay is offline and publishes once that relay connects', + () async { + final publishRelay = MockRelay(name: 'signer-publish-relay'); + final signerRelay = MockRelay(name: 'signer-transport-relay'); + final signer = _ControllableInteractiveSigner( + keyPair: authorKey, + available: false, + requiresSignerNetwork: true, + transportRelayUrls: () => [signerRelay.url], + ); + + await publishRelay.startServer(); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [publishRelay.url, signerRelay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + ndk.accounts.loginExternalSigner(signer: signer); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'network signer offline then online', + createdAt: 1_700_000_080, + ); + + try { + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [publishRelay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + } catch (_) {} + + final localWhileSignerOffline = await _waitForCachedEvents( + ndk: ndk, + filter: Filter(ids: [event.id]), + ); + expect(localWhileSignerOffline.map((e) => e.id), contains(event.id)); + + await Future.delayed(const Duration(seconds: 2)); + expect( + publishRelay.receivedEvents.where((e) => e.id == event.id), + isEmpty, + reason: + 'While the network-backed signer transport relay is unavailable, local-first should keep the event queued without publishing to target relays.', + ); + + signer.available = true; + await signerRelay.startServer(); + await ndk.connectivity.tryReconnect(); + await _waitForRelayConnected( + ndk: ndk, + relayUrl: signerRelay.url, + timeout: const Duration(seconds: 10), + ); + + final relayAfterSignerTransportOnline = await _waitForRelayEvents( + relay: publishRelay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 10), + ); + + expect( + relayAfterSignerTransportOnline.map((e) => e.id), + contains(event.id), + reason: + 'When the signer transport relay comes online, local-first should retry signing and then immediately publish to connected delivery relays.', + ); + + await publishRelay.stopServer(); + await signerRelay.stopServer(); + }, + ); + + test( + 'interactive signer queue survives restart and later signs plus publishes when signer becomes available', + () async { + final relay = MockRelay(name: 'interactive-restart-relay'); + final failingSigner = _ControllableInteractiveSigner( + keyPair: authorKey, + available: false, + requiresSignerNetwork: false, + ); + + await relay.startServer(); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + ndk.accounts.loginExternalSigner(signer: failingSigner); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'interactive signer restart persistence', + createdAt: 1_700_000_081, + ); + + try { + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + } catch (_) {} + + final localBeforeRestart = await _waitForCachedEvents( + ndk: ndk, + filter: Filter(ids: [event.id]), + ); + expect(localBeforeRestart.map((e) => e.id), contains(event.id)); + expect( + relay.receivedEvents.where((e) => e.id == event.id), + isEmpty, + reason: + 'If interactive signing cannot complete yet, the event should remain local without being published.', + ); + + await ndk.destroy(); + + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [relay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + ndk.accounts.loginExternalSigner( + signer: _ControllableInteractiveSigner( + keyPair: authorKey, + available: true, + requiresSignerNetwork: false, + ), + ); + + final localAfterRestart = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect(localAfterRestart.map((e) => e.id), contains(event.id)); + + final relayAfterRestart = await _waitForRelayEvents( + relay: relay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 10), + ); + + expect( + relayAfterRestart.map((e) => e.id), + contains(event.id), + reason: + 'Unsigned interactive-signing work should survive restart and later complete once a compatible signer becomes available again.', + ); + + await relay.stopServer(); + }, + ); + + test( + 'successful network signing while publish relay is offline should still deliver later when the target relay reconnects', + () async { + final publishRelay = MockRelay(name: 'signed-then-publish-later-relay'); + final signerRelay = MockRelay(name: 'signer-online-relay'); + final signer = _ControllableInteractiveSigner( + keyPair: authorKey, + available: true, + requiresSignerNetwork: true, + transportRelayUrls: () => [signerRelay.url], + ); + + await signerRelay.startServer(); + ndk = await _createNdk( + tempDir.path, + bootstrapRelays: [publishRelay.url, signerRelay.url], + pendingDeliveryRetryInterval: const Duration(seconds: 1), + ); + ndk.accounts.loginExternalSigner(signer: signer); + + await _waitForRelayConnected( + ndk: ndk, + relayUrl: signerRelay.url, + timeout: const Duration(seconds: 10), + ); + + final event = Nip01Event( + pubKey: authorKey.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'signed now publish later', + createdAt: 1_700_000_082, + ); + + await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [publishRelay.url], + timeout: const Duration(milliseconds: 300), + ) + .broadcastDoneFuture; + + final localSignedWhilePublishOffline = await ndk.requests + .query( + filter: Filter(ids: [event.id]), + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + expect( + localSignedWhilePublishOffline.map((e) => e.id), + contains(event.id), + ); + + await Future.delayed(const Duration(seconds: 2)); + expect( + publishRelay.receivedEvents.where((e) => e.id == event.id), + isEmpty, + reason: + 'Even if signing succeeds immediately, target relay delivery should stay queued while the publish relay is still offline.', + ); + + await publishRelay.startServer(); + await _waitForRelayConnected( + ndk: ndk, + relayUrl: publishRelay.url, + timeout: const Duration(seconds: 15), + ); + + final relayAfterPublishReconnect = await _waitForRelayEvents( + relay: publishRelay, + filter: Filter(ids: [event.id]), + timeout: const Duration(seconds: 10), + ); + + expect( + relayAfterPublishReconnect.map((e) => e.id), + contains(event.id), + reason: + 'After signing succeeded while the publish relay was offline, the queued signed event should later be delivered once that relay reconnects.', + ); + + await publishRelay.stopServer(); + await signerRelay.stopServer(); + }, + ); + }); +} + +Future _createNdk( + String databasePath, { + List bootstrapRelays = const [], + Duration? pendingDeliveryRetryInterval, +}) async { + final cache = await SembastCacheManager.create(databasePath: databasePath); + return Ndk( + NdkConfig( + cache: cache, + eventVerifier: MockEventVerifier(), + bootstrapRelays: bootstrapRelays, + ignoreRelays: const [], + logLevel: LogLevel.all, + pendingDeliveryRetryInterval: + pendingDeliveryRetryInterval ?? const Duration(seconds: 15), + cashuUserSeedphrase: CashuUserSeedphrase( + seedPhrase: CashuSeed.generateSeedPhrase(), + ), + ), + ); +} + +Future _publishFixtureEventToRelay( + String relayUrl, + Nip01Event event, +) async { + final socket = await WebSocket.connect(relayUrl); + final completer = Completer(); + + late final StreamSubscription subscription; + subscription = socket.listen((message) { + if (message is! String) { + return; + } + + final decoded = jsonDecode(message); + if (decoded is! List || decoded.isEmpty || decoded[0] != 'OK') { + return; + } + if (decoded.length < 4 || decoded[1] != event.id) { + return; + } + + if (decoded[2] == true) { + completer.complete(); + } else { + completer.completeError( + StateError('Fixture relay rejected event ${event.id}: ${decoded[3]}'), + ); + } + }, onError: completer.completeError); + + socket.add( + jsonEncode([ + 'EVENT', + { + 'id': event.id, + 'pubkey': event.pubKey, + 'created_at': event.createdAt, + 'kind': event.kind, + 'tags': event.tags, + 'content': event.content, + 'sig': event.sig, + }, + ]), + ); + + await completer.future.timeout(const Duration(seconds: 2)); + await subscription.cancel(); + await socket.close(); +} + +Future> _waitForRelayEvents({ + required MockRelay relay, + required Filter filter, + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final result = relay.matchingEvents(filter); + if (result.isNotEmpty) { + return result; + } + await Future.delayed(const Duration(milliseconds: 250)); + } + + return relay.matchingEvents(filter); +} + +Future> _waitForCachedEvents({ + required Ndk ndk, + required Filter filter, + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final result = await ndk.requests + .query( + filter: filter, + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; + if (result.isNotEmpty) { + return result; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + + return ndk.requests + .query( + filter: filter, + cacheRead: true, + cacheWrite: false, + timeout: const Duration(milliseconds: 300), + ) + .future; +} + +Future _waitForRelayConnected({ + required Ndk ndk, + required String relayUrl, + Duration timeout = const Duration(seconds: 10), +}) async { + final current = ndk.relays.getRelayConnectivity(relayUrl); + if (current?.isConnected == true) { + return; + } + + await ndk.connectivity.relayConnectivityChanges + .firstWhere((relays) => relays[relayUrl]?.isConnected == true) + .timeout(timeout); +} + +class _ControllableInteractiveSigner implements EventSigner { + final KeyPair keyPair; + @override + final bool requiresSignerNetwork; + final Iterable Function() _transportRelayUrlsProvider; + bool available; + final Bip340EventSigner _innerSigner; + + _ControllableInteractiveSigner({ + required this.keyPair, + required this.available, + required this.requiresSignerNetwork, + Iterable Function()? transportRelayUrls, + }) : _transportRelayUrlsProvider = + transportRelayUrls ?? (() => const []), + _innerSigner = Bip340EventSigner( + privateKey: keyPair.privateKey!, + publicKey: keyPair.publicKey, + ); + + @override + bool get requiresInteractiveSigning => true; + + @override + Iterable get signerTransportRelayUrls => + _transportRelayUrlsProvider(); + + @override + bool canSign() => true; + + @override + bool cancelRequest(String requestId) => false; + + @override + Future decrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future decryptNip44({ + required String ciphertext, + required String senderPubKey, + }) async => null; + + @override + Future dispose() async {} + + @override + Future encrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future encryptNip44({ + required String plaintext, + required String recipientPubKey, + }) async => null; + + @override + String getPublicKey() => keyPair.publicKey; + + @override + List get pendingRequests => const []; + + @override + Stream> get pendingRequestsStream => + const Stream.empty(); + + @override + Future sign(Nip01Event event) async { + if (!available) { + throw Exception( + requiresSignerNetwork + ? 'temporary network signer offline' + : 'temporary interactive signer unavailable', + ); + } + + return _innerSigner.sign(event); + } +} diff --git a/packages/ndk/test/usecases/metadatas/metadata_entity_test.dart b/packages/ndk/test/usecases/metadatas/metadata_entity_test.dart index 75dcf3798..e7c6a9be3 100644 --- a/packages/ndk/test/usecases/metadatas/metadata_entity_test.dart +++ b/packages/ndk/test/usecases/metadatas/metadata_entity_test.dart @@ -1,7 +1,8 @@ +import 'dart:convert'; + import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; import 'package:test/test.dart'; -import 'dart:convert'; void main() { group('Metadata', () { @@ -21,7 +22,7 @@ void main() { 'custom_field': 'custom_value', }, 'tags': [ - ['i', 'github:alice', 'proof'] + ['i', 'github:alice', 'proof'], ], 'refreshedTimestamp': 1234567890, 'sources': ['relay1', 'relay2'], @@ -63,7 +64,7 @@ void main() { updatedAt: 1234567890, refreshedTimestamp: 9876543210, tags: [ - ['i', 'github:alice', 'proof'] + ['i', 'github:alice', 'proof'], ], content: {'custom_field': 'custom_value'}, ); @@ -97,7 +98,7 @@ void main() { updatedAt: 1234567890, refreshedTimestamp: 9876543210, tags: [ - ['i', 'github:alice', 'proof'] + ['i', 'github:alice', 'proof'], ], content: {'custom_field': 'custom_value'}, ); @@ -156,24 +157,27 @@ void main() { test('cleanNip05', () { expect(Metadata(nip05: '_@example.com').cleanNip05, '@example.com'); expect( - Metadata(nip05: 'John@EXAMPLE.COM').cleanNip05, 'john@example.com'); + Metadata(nip05: 'John@EXAMPLE.COM').cleanNip05, + 'john@example.com', + ); expect(Metadata(nip05: null).cleanNip05, null); }); test('getName', () { expect( - Metadata(displayName: 'John Doe', name: 'John', pubKey: 'testPubKey') - .getName(), - 'John Doe'); + Metadata( + displayName: 'John Doe', + name: 'John', + pubKey: 'testPubKey', + ).getName(), + 'John Doe', + ); expect(Metadata(name: 'John', pubKey: 'testPubKey').getName(), 'John'); expect(Metadata(pubKey: 'testPubKey').getName(), 'testPubKey'); }); test('matchesSearch', () { - final metadata = Metadata( - displayName: 'John Doe', - name: 'JohnnyD', - ); + final metadata = Metadata(displayName: 'John Doe', name: 'JohnnyD'); expect(metadata.matchesSearch('John'), true); expect(metadata.matchesSearch('Doe'), true); @@ -346,13 +350,16 @@ void main() { }); test('modifying known fields keeps custom fields intact', () { - final metadata = Metadata.fromEvent(Nip01Event( - pubKey: 'testPubKey', - content: '{"name":"John","custom_field":"custom_value","another":456}', - kind: Metadata.kKind, - tags: [], - createdAt: 1234567890, - )); + final metadata = Metadata.fromEvent( + Nip01Event( + pubKey: 'testPubKey', + content: + '{"name":"John","custom_field":"custom_value","another":456}', + kind: Metadata.kKind, + tags: [], + createdAt: 1234567890, + ), + ); // Modify a known field metadata.name = 'Jane'; @@ -496,16 +503,14 @@ void main() { pubKey: keypair.publicKey, kind: 0, tags: [ - ["i", "badge"] + ["i", "badge"], ], content: '{"name":"Alice","badges":["A","B"]}', ); final signedMetadataEvent = await signer.sign(metadataEvent); - final metadata = Metadata.fromEvent(signedMetadataEvent); - - await cache.saveMetadata(metadata); + await cache.saveEvent(signedMetadataEvent); final savedMetadata = await cache.loadMetadata(keypair.publicKey); diff --git a/packages/ndk/test/usecases/metadatas/metadatas_test.dart b/packages/ndk/test/usecases/metadatas/metadatas_test.dart index 60b680c00..5ebbb6c19 100644 --- a/packages/ndk/test/usecases/metadatas/metadatas_test.dart +++ b/packages/ndk/test/usecases/metadatas/metadatas_test.dart @@ -9,20 +9,28 @@ import '../../mocks/mock_relay.dart'; void main() async { group('metadatas', () { KeyPair key0 = Bip340.generatePrivateKey(); - final Metadata network0Metadata = - Metadata(pubKey: key0.publicKey, name: "network0"); + final Metadata network0Metadata = Metadata( + pubKey: key0.publicKey, + name: "network0", + ); network0Metadata.updatedAt = 100; - final Metadata cache0Metadata = - Metadata(pubKey: key0.publicKey, name: "cache0"); + final Metadata cache0Metadata = Metadata( + pubKey: key0.publicKey, + name: "cache0", + ); //? network last KeyPair key1 = Bip340.generatePrivateKey(); - final Metadata network1Metadata = - Metadata(pubKey: key1.publicKey, name: "network1"); - - final Metadata cache1Metadata = - Metadata(pubKey: key1.publicKey, name: "cache1"); + final Metadata network1Metadata = Metadata( + pubKey: key1.publicKey, + name: "network1", + ); + + final Metadata cache1Metadata = Metadata( + pubKey: key1.publicKey, + name: "cache1", + ); cache1Metadata.updatedAt = 100; late MockRelay relay0; @@ -30,10 +38,12 @@ void main() async { setUp(() async { relay0 = MockRelay(name: "relay 0", explicitPort: 5096); - await relay0.startServer(metadatas: { - key0.publicKey: network0Metadata.toEvent(), - key1.publicKey: network1Metadata.toEvent(), - }); + await relay0.startServer( + metadatas: { + key0.publicKey: network0Metadata.toEvent(), + key1.publicKey: network1Metadata.toEvent(), + }, + ); final cache = MemCacheManager(); final NdkConfig config = NdkConfig( @@ -45,7 +55,7 @@ void main() async { ndk = Ndk(config); await ndk.relays.seedRelaysConnected; - cache.saveMetadata(cache0Metadata); + await cache.saveEvent(cache0Metadata.toEvent()); //cache.saveContactList(cache1ContactList); }); @@ -63,7 +73,9 @@ void main() async { final rcvMetadata = await ndk.metadata.loadMetadata(key0.publicKey); // cache - expect(rcvMetadata, equals(cache0Metadata)); + expect(rcvMetadata, isNotNull); + expect(rcvMetadata!.pubKey, equals(cache0Metadata.pubKey)); + expect(rcvMetadata.name, equals(cache0Metadata.name)); }); test('getMetadata- network', () async { @@ -77,20 +89,26 @@ void main() async { }); test('getMetadatas - network', () async { - final rcvMetadatas = await ndk.metadata - .loadMetadatas([key0.publicKey, key1.publicKey], null); + final rcvMetadatas = await ndk.metadata.loadMetadatas([ + key0.publicKey, + key1.publicKey, + ], null); expect(rcvMetadatas.length, 2); }); test('broadcast metadata', () async { - ndk.accounts - .loginPrivateKey(pubkey: key0.publicKey, privkey: key0.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key0.publicKey, + privkey: key0.privateKey!, + ); Metadata metadata = Metadata(pubKey: key0.publicKey); await ndk.metadata.broadcastMetadata(metadata); - Metadata? result = - await ndk.metadata.loadMetadata(key0.publicKey, forceRefresh: true); + Metadata? result = await ndk.metadata.loadMetadata( + key0.publicKey, + forceRefresh: true, + ); expect(result!.pubKey, metadata.pubKey); // NIP-01 replacement uses created_at (1s resolution); on a tie the @@ -100,8 +118,10 @@ void main() async { metadata.name = "my name"; await ndk.metadata.broadcastMetadata(metadata); - result = - await ndk.metadata.loadMetadata(key0.publicKey, forceRefresh: true); + result = await ndk.metadata.loadMetadata( + key0.publicKey, + forceRefresh: true, + ); expect(result!.name, metadata.name); }); }); diff --git a/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_jit_test.dart b/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_jit_test.dart index fad203b28..926f68e88 100644 --- a/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_jit_test.dart +++ b/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_jit_test.dart @@ -25,11 +25,12 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map key1TextNotes = {key1: textNote(key1)}; @@ -45,30 +46,32 @@ void main() async { final myRelayUrls = [relay1.url, relay2.url, relay3.url, relay4.url]; final myFilters = [ + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), Filter( kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], + authors: [key1.publicKey, key2.publicKey], ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey, key3.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key4.publicKey, key3.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey, key2.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key4.publicKey, key2.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey, key2.publicKey, key3.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey, key3.publicKey]), - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key1.publicKey, - key2.publicKey, - key3.publicKey, - key4.publicKey - ]), + kinds: [Nip01Event.kTextNodeKind], + authors: [ + key1.publicKey, + key2.publicKey, + key3.publicKey, + key4.publicKey, + ], + ), ]; setUp(() async { @@ -94,8 +97,10 @@ void main() async { bootstrapRelays: myRelayUrls, ), ); - ndkJit.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + ndkJit.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); await testNdk( myNdk: ndkJit, diff --git a/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_lists_test.dart b/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_lists_test.dart index d2b8e40a9..7377655de 100644 --- a/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_lists_test.dart +++ b/packages/ndk/test/usecases/ndk_repeated/ndk_repeated_lists_test.dart @@ -23,11 +23,12 @@ void main() async { Nip01Event textNote(KeyPair key2) { return Nip01Event( - kind: Nip01Event.kTextNodeKind, - pubKey: key2.publicKey, - content: "some note from key ${keyNames[key2]}", - tags: [], - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + kind: Nip01Event.kTextNodeKind, + pubKey: key2.publicKey, + content: "some note from key ${keyNames[key2]}", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); } Map key1TextNotes = {key1: textNote(key1)}; @@ -43,30 +44,32 @@ void main() async { final myRelayUrls = [relay1.url, relay2.url, relay3.url, relay4.url]; final myFilters = [ + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key1.publicKey]), Filter( kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey], + authors: [key1.publicKey, key2.publicKey], ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey, key3.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key4.publicKey, key3.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key4.publicKey, key2.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key4.publicKey, key2.publicKey]), + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey, key2.publicKey, key3.publicKey], + ), Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key1.publicKey, key2.publicKey, key3.publicKey]), - Filter(kinds: [ - Nip01Event.kTextNodeKind - ], authors: [ - key1.publicKey, - key2.publicKey, - key3.publicKey, - key4.publicKey - ]), + kinds: [Nip01Event.kTextNodeKind], + authors: [ + key1.publicKey, + key2.publicKey, + key3.publicKey, + key4.publicKey, + ], + ), ]; setUp(() async { @@ -83,28 +86,33 @@ void main() async { await relay4.stopServer(); }); - test('Lists Engine', timeout: const Timeout(Duration(seconds: 3)), - () async { - final ndkLists = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: myRelayUrls, - ), - ); - ndkLists.accounts - .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + test( + 'Lists Engine', + timeout: const Timeout(Duration(seconds: 3)), + () async { + final ndkLists = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: myRelayUrls, + ), + ); + ndkLists.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); - await testNdk( - myNdk: ndkLists, - coverage: 1, - myFilters: myFilters, - key1TextNotes: key1TextNotes, - key2TextNotes: key2TextNotes, - key3TextNotes: key3TextNotes, - key4TextNotes: key4TextNotes, - ); - }); + await testNdk( + myNdk: ndkLists, + coverage: 1, + myFilters: myFilters, + key1TextNotes: key1TextNotes, + key2TextNotes: key2TextNotes, + key3TextNotes: key3TextNotes, + key4TextNotes: key4TextNotes, + ); + }, + ); }); } diff --git a/packages/ndk/test/usecases/ndk_repeated/test_repeated.dart b/packages/ndk/test/usecases/ndk_repeated/test_repeated.dart index fafece344..f7e84dc8b 100644 --- a/packages/ndk/test/usecases/ndk_repeated/test_repeated.dart +++ b/packages/ndk/test/usecases/ndk_repeated/test_repeated.dart @@ -12,71 +12,67 @@ Future testNdk({ required Map key4TextNotes, }) async { NdkResponse response0 = myNdk.requests.query( - filters: [ - myFilters[0], - ], + filters: [myFilters[0]], desiredCoverage: coverage, ); await expectLater(response0.stream, emitsInAnyOrder(key1TextNotes.values)); NdkResponse response1 = myNdk.requests.query( - filters: [ - myFilters[1], - ], + filters: [myFilters[1]], desiredCoverage: coverage, ); - await expectLater(response1.stream, - emitsInAnyOrder([...key1TextNotes.values, ...key2TextNotes.values])); + await expectLater( + response1.stream, + emitsInAnyOrder([...key1TextNotes.values, ...key2TextNotes.values]), + ); NdkResponse response2 = myNdk.requests.query( - filters: [ - myFilters[2], - ], + filters: [myFilters[2]], desiredCoverage: coverage, ); - await expectLater(response2.stream, - emitsInAnyOrder([...key3TextNotes.values, ...key4TextNotes.values])); + await expectLater( + response2.stream, + emitsInAnyOrder([...key3TextNotes.values, ...key4TextNotes.values]), + ); NdkResponse response3 = myNdk.requests.query( - filters: [ - myFilters[3], - ], + filters: [myFilters[3]], desiredCoverage: coverage, ); - await expectLater(response3.stream, - emitsInAnyOrder([...key2TextNotes.values, ...key4TextNotes.values])); + await expectLater( + response3.stream, + emitsInAnyOrder([...key2TextNotes.values, ...key4TextNotes.values]), + ); NdkResponse response4 = myNdk.requests.query( - filters: [ - myFilters[4], - ], + filters: [myFilters[4]], desiredCoverage: coverage, ); await expectLater( - response4.stream, - emitsInAnyOrder([ - ...key1TextNotes.values, - ...key2TextNotes.values, - ...key3TextNotes.values, - ])); + response4.stream, + emitsInAnyOrder([ + ...key1TextNotes.values, + ...key2TextNotes.values, + ...key3TextNotes.values, + ]), + ); NdkResponse response5 = myNdk.requests.query( - filters: [ - myFilters[5], - ], + filters: [myFilters[5]], desiredCoverage: coverage, ); await expectLater( - response5.stream, - emitsInAnyOrder([ - ...key1TextNotes.values, - ...key2TextNotes.values, - ...key3TextNotes.values, - ...key4TextNotes.values, - ])); + response5.stream, + emitsInAnyOrder([ + ...key1TextNotes.values, + ...key2TextNotes.values, + ...key3TextNotes.values, + ...key4TextNotes.values, + ]), + ); } 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 3a31fa66f..3a456153a 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network.test.mocks.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network.test.mocks.dart @@ -25,24 +25,14 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: subtype_of_sealed_class class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Client]. @@ -54,46 +44,30 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> post( @@ -103,28 +77,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> put( @@ -134,28 +103,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> patch( @@ -165,28 +129,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> delete( @@ -196,49 +155,36 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => + _i3.Future read(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future); + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); @override _i3.Future<_i6.Uint8List> readBytes( @@ -246,37 +192,27 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), - ) as _i3.Future<_i6.Uint8List>); + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); } diff --git a/packages/ndk/test/usecases/nip05/nip05_network_test.dart b/packages/ndk/test/usecases/nip05/nip05_network_test.dart index 3f978e590..9a1626d9c 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network_test.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network_test.dart @@ -42,13 +42,18 @@ void main() { ); expect(nip05Usecase.check(nip05: '', pubkey: ''), throwsException); - expect(nip05Usecase.check(nip05: 'test@example.com', pubkey: ''), - throwsException); expect( - nip05Usecase.check(nip05: '', pubkey: 'testPubkey'), throwsException); + nip05Usecase.check(nip05: 'test@example.com', pubkey: ''), + throwsException, + ); + expect( + nip05Usecase.check(nip05: '', pubkey: 'testPubkey'), + throwsException, + ); expect( - nip05Usecase.check(nip05: 'test@example.com', pubkey: 'testPubkey'), - isA>()); + nip05Usecase.check(nip05: 'test@example.com', pubkey: 'testPubkey'), + isA>(), + ); }); test('returns Nip05 if the http call completes successfully', () async { final client = MockClient(requestHandler); @@ -64,9 +69,12 @@ void main() { await Future.delayed(const Duration(milliseconds: 10)); expect( - await nip05Usecase.check( - nip05: 'username@example.com', pubkey: 'pubkey'), - isA()); + await nip05Usecase.check( + nip05: 'username@example.com', + pubkey: 'pubkey', + ), + isA(), + ); }); test('return false if the http call completes with an error', () async { @@ -81,7 +89,9 @@ void main() { ); var result = await nip05Usecase.check( - nip05: 'username@url.test', pubkey: 'pubkey'); + nip05: 'username@url.test', + pubkey: 'pubkey', + ); expect(result.valid, false); }); @@ -97,7 +107,9 @@ void main() { ); var result = await nip05Usecase.check( - nip05: 'username@url.test', pubkey: 'non-existing-pubkey'); + nip05: 'username@url.test', + pubkey: 'non-existing-pubkey', + ); expect(result.valid, false); }); @@ -114,7 +126,9 @@ void main() { ); var result = await nip05Usecase.check( - nip05: 'username@url.test', pubkey: 'pubkey'); + nip05: 'username@url.test', + pubkey: 'pubkey', + ); expect(result.valid, true); }); @@ -131,7 +145,9 @@ void main() { ); var result = await nip05Usecase.check( - nip05: 'domain@domain.test', pubkey: 'pubkey'); + nip05: 'domain@domain.test', + pubkey: 'pubkey', + ); expect(result.valid, true); }); @@ -147,14 +163,22 @@ void main() { nip05Repository: nip05Repos, ); - final check1 = - nip05Usecase.check(nip05: 'username@url.test', pubkey: 'pubkey'); + final check1 = nip05Usecase.check( + nip05: 'username@url.test', + pubkey: 'pubkey', + ); final check2 = nip05Usecase.check( - nip05: 'somethingDiffrent@url.test', pubkey: 'pubkey'); - final check3 = - nip05Usecase.check(nip05: 'username@url.test', pubkey: 'pubkey'); - final check4 = - nip05Usecase.check(nip05: 'username@url.test', pubkey: 'pubkey'); + nip05: 'somethingDiffrent@url.test', + pubkey: 'pubkey', + ); + final check3 = nip05Usecase.check( + nip05: 'username@url.test', + pubkey: 'pubkey', + ); + final check4 = nip05Usecase.check( + nip05: 'username@url.test', + pubkey: 'pubkey', + ); final results = await Future.wait([check1, check2, check3, check4]); @@ -163,70 +187,92 @@ void main() { expect(results.first.hashCode, equals(results.last.hashCode)); }); - test('returns true when updatedAt is older than the given duration', - () async { - final client = MockClient(requestHandler); - - final cache = MemCacheManager(); - final nip05Repos = Nip05HttpRepositoryImpl(httpDS: HttpRequestDS(client)); - // Create a Nip05 object with an old updatedAt timestamp - final oldTimestamp = (DateTime.now() - .subtract(Duration(seconds: NIP_05_VALID_DURATION.inSeconds - 1)) - .millisecondsSinceEpoch ~/ - 1000); - - final oldNip05 = Nip05( - pubKey: 'test_pubkey', - nip05: 'test_nip05', - valid: true, - networkFetchTime: oldTimestamp, - ); - - await cache.saveNip05(oldNip05); - - Nip05Usecase nip05Usecase = Nip05Usecase( - database: cache, - nip05Repository: nip05Repos, - ); - - final result = - await nip05Usecase.check(nip05: 'test_nip05', pubkey: 'test_pubkey'); - - expect(result.valid, - true); // Should return true since the object is older than 5 days - }); + test( + 'returns true when updatedAt is older than the given duration', + () async { + final client = MockClient(requestHandler); + + final cache = MemCacheManager(); + final nip05Repos = Nip05HttpRepositoryImpl( + httpDS: HttpRequestDS(client), + ); + // Create a Nip05 object with an old updatedAt timestamp + final oldTimestamp = + (DateTime.now() + .subtract( + Duration(seconds: NIP_05_VALID_DURATION.inSeconds - 1), + ) + .millisecondsSinceEpoch ~/ + 1000); + + final oldNip05 = Nip05( + pubKey: 'test_pubkey', + nip05: 'test_nip05', + valid: true, + networkFetchTime: oldTimestamp, + ); - test('returns false when updatedAt is more recent than the given duration', - () async { - final client = MockClient(requestHandler); + await cache.saveNip05(oldNip05); - final cache = MemCacheManager(); - final nip05Repos = Nip05HttpRepositoryImpl(httpDS: HttpRequestDS(client)); - Nip05Usecase nip05Usecase = Nip05Usecase( - database: cache, - nip05Repository: nip05Repos, - ); + Nip05Usecase nip05Usecase = Nip05Usecase( + database: cache, + nip05Repository: nip05Repos, + ); - // Create a Nip05 object with a recent updatedAt timestamp - final recentTimestamp = (DateTime.now() - .subtract( - Duration(seconds: NIP_05_VALID_DURATION.inSeconds + 200)) - .millisecondsSinceEpoch ~/ - 1000); - final oldNip05 = Nip05( + final result = await nip05Usecase.check( + nip05: 'test_nip05', + pubkey: 'test_pubkey', + ); + + expect( + result.valid, + true, + ); // Should return true since the object is older than 5 days + }, + ); + + test( + 'returns false when updatedAt is more recent than the given duration', + () async { + final client = MockClient(requestHandler); + + final cache = MemCacheManager(); + final nip05Repos = Nip05HttpRepositoryImpl( + httpDS: HttpRequestDS(client), + ); + Nip05Usecase nip05Usecase = Nip05Usecase( + database: cache, + nip05Repository: nip05Repos, + ); + + // Create a Nip05 object with a recent updatedAt timestamp + final recentTimestamp = + (DateTime.now() + .subtract( + Duration(seconds: NIP_05_VALID_DURATION.inSeconds + 200), + ) + .millisecondsSinceEpoch ~/ + 1000); + final oldNip05 = Nip05( pubKey: 'test_pubkey', nip05: 'test_nip05', valid: true, - networkFetchTime: recentTimestamp); + networkFetchTime: recentTimestamp, + ); - await cache.saveNip05(oldNip05); + await cache.saveNip05(oldNip05); - // Test with a duration of 5 days - final result = - await nip05Usecase.check(nip05: 'test_nip05', pubkey: 'test_pubkey'); - expect(result.valid, - false); // Should return false since the object is more recent than 5 days - }); + // Test with a duration of 5 days + final result = await nip05Usecase.check( + nip05: 'test_nip05', + pubkey: 'test_pubkey', + ); + expect( + result.valid, + false, + ); // Should return false since the object is more recent than 5 days + }, + ); test('returns false when updatedAt is exactly the duration ago', () async { final client = MockClient(requestHandler); @@ -239,7 +285,8 @@ void main() { ); // Create a Nip05 object with an updatedAt timestamp exactly equal to the duration - final exactTimestamp = (DateTime.now() + final exactTimestamp = + (DateTime.now() .subtract(Duration(seconds: NIP_05_VALID_DURATION.inSeconds)) .millisecondsSinceEpoch ~/ 1000); @@ -252,11 +299,15 @@ void main() { await cache.saveNip05(oldNip05); - final result = - await nip05Usecase.check(nip05: 'test_nip05', pubkey: 'test_pubkey'); + final result = await nip05Usecase.check( + nip05: 'test_nip05', + pubkey: 'test_pubkey', + ); - expect(result.valid, - false); // Should return false since it's exactly at the limit + expect( + result.valid, + false, + ); // Should return false since it's exactly at the limit }); test('test if data is saved even when network fails', () async { @@ -270,7 +321,9 @@ void main() { ); await nip05Usecase.check( - nip05: 'test_nip05_cache', pubkey: 'test_pubkey_cache'); + nip05: 'test_nip05_cache', + pubkey: 'test_pubkey_cache', + ); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -292,7 +345,9 @@ void main() { ); final result = await nip05Usecase.check( - nip05: 'username@example.com', pubkey: 'pubkey'); + nip05: 'username@example.com', + pubkey: 'pubkey', + ); expect(result.relays, equals(['relay1', 'relay2'])); }); @@ -316,27 +371,31 @@ void main() { expect(found.data.relays, equals(['relay1', 'relay2'])); }); - test('resolve() returns Nip05NotFound when user is absent from nostr.json', - () async { - // Server replies 200 with an empty `names` map: the file exists but - // the requested user is not in it. - Future notFoundHandler(http.Request request) async { - return http.Response('{"names": {}}', 200); - } + test( + 'resolve() returns Nip05NotFound when user is absent from nostr.json', + () async { + // Server replies 200 with an empty `names` map: the file exists but + // the requested user is not in it. + Future notFoundHandler(http.Request request) async { + return http.Response('{"names": {}}', 200); + } - final client = MockClient(notFoundHandler); + final client = MockClient(notFoundHandler); - final cache = MemCacheManager(); - final nip05Repos = Nip05HttpRepositoryImpl(httpDS: HttpRequestDS(client)); - Nip05Usecase nip05Usecase = Nip05Usecase( - database: cache, - nip05Repository: nip05Repos, - ); + final cache = MemCacheManager(); + final nip05Repos = Nip05HttpRepositoryImpl( + httpDS: HttpRequestDS(client), + ); + Nip05Usecase nip05Usecase = Nip05Usecase( + database: cache, + nip05Repository: nip05Repos, + ); - final result = await nip05Usecase.resolve('ghost@example.com'); + final result = await nip05Usecase.resolve('ghost@example.com'); - expect(result, isA()); - }); + expect(result, isA()); + }, + ); test('resolve() returns Nip05NotFound on HTTP 404', () async { Future notFoundHandler(http.Request request) async { @@ -437,9 +496,11 @@ void main() { ); // Save an expired nip05 in cache - final expiredTimestamp = (DateTime.now() + final expiredTimestamp = + (DateTime.now() .subtract( - Duration(seconds: NIP_05_VALID_DURATION.inSeconds + 100)) + Duration(seconds: NIP_05_VALID_DURATION.inSeconds + 100), + ) .millisecondsSinceEpoch ~/ 1000); final expiredNip05 = Nip05( 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 8b14136e5..7c101b2b7 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart @@ -27,24 +27,14 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Client]. @@ -56,46 +46,30 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> post( @@ -105,28 +79,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> put( @@ -136,28 +105,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> patch( @@ -167,28 +131,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> delete( @@ -198,49 +157,36 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => + _i3.Future read(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future); + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); @override _i3.Future<_i6.Uint8List> readBytes( @@ -248,37 +194,27 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), - ) as _i3.Future<_i6.Uint8List>); + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); } diff --git a/packages/ndk/test/usecases/nip09_addressable_deletion_test.dart b/packages/ndk/test/usecases/nip09_addressable_deletion_test.dart new file mode 100644 index 000000000..952261ee2 --- /dev/null +++ b/packages/ndk/test/usecases/nip09_addressable_deletion_test.dart @@ -0,0 +1,161 @@ +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/event_eviction_planner.dart'; +import 'package:ndk/shared/nips/nip09/deletion.dart'; +import 'package:test/test.dart'; + +void main() { + // NIP-09 deletions can target addressable/replaceable events by coordinate + // (`a` tag = `kind:pubkey:d-tag`) instead of by event id (`e` tag). These + // tests exercise that path, which is currently unhandled. + const author = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const addressableKind = 30023; // long-form content, addressable + const dTag = 'my-article'; + const coordinate = '$addressableKind:$author:$dTag'; + + Nip01Event addressableEvent({required int createdAt, String content = 'v'}) { + return Nip01Event( + pubKey: author, + kind: addressableKind, + tags: const [ + ['d', dTag], + ], + content: content, + createdAt: createdAt, + ); + } + + Nip01Event coordinateDeletion({required int createdAt}) { + return Nip01Event( + pubKey: author, + kind: Deletion.kKind, + tags: const [ + ['a', coordinate], + ['k', '$addressableKind'], + ], + content: 'delete by coordinate', + createdAt: createdAt, + ); + } + + group('EventEvictionPlanner addressable (a-tag) deletion', () { + test('sweeps an addressable event deleted by coordinate', () { + final target = addressableEvent(createdAt: 1700000000); + final deletion = coordinateDeletion(createdAt: 1700000001); + + final plan = EventEvictionPlanner.plan( + rawEvents: [target, deletion], + lockedEventIds: const {}, + deliveredEventIds: const {}, + policy: const EvictionPolicy(), + now: 1700000100, + ); + + expect( + plan.eventIdsToRemove, + contains(target.id), + reason: 'addressable event deleted via `a` tag should be removed', + ); + expect(plan.removedDeleted, 1); + }); + + test('keeps a newer addressable version published after the deletion', () { + // NIP-09: a coordinate deletion only removes matches with + // created_at <= the deletion. A later re-publish must survive. + final deletion = coordinateDeletion(createdAt: 1700000001); + final newerVersion = addressableEvent( + createdAt: 1700000002, + content: 'republished', + ); + + final plan = EventEvictionPlanner.plan( + rawEvents: [deletion, newerVersion], + lockedEventIds: const {}, + deliveredEventIds: const {}, + policy: const EvictionPolicy(), + now: 1700000100, + ); + + expect( + plan.eventIdsToRemove, + isNot(contains(newerVersion.id)), + reason: 'a version newer than the deletion must not be swept', + ); + }); + + test( + 'state-record path sweeps an addressable event deleted by coordinate', + () { + final target = addressableEvent(createdAt: 1700000000); + final deletion = coordinateDeletion(createdAt: 1700000001); + final stateRecords = EventCacheStateRecord.buildForEvents([ + target, + deletion, + ], now: 1700000100); + + final plan = EventEvictionPlanner.planFromStateRecords( + stateRecords: stateRecords, + lockedEventIds: const {}, + deliveredEventIds: const {}, + policy: const EvictionPolicy(), + now: 1700000100, + ); + + expect(plan.eventIdsToRemove, contains(target.id)); + expect(plan.removedDeleted, 1); + }, + ); + }); + + group('MemCacheManager addressable (a-tag) deletion visibility', () { + test('hides an addressable event deleted by coordinate', () async { + final cache = MemCacheManager(); + final target = addressableEvent(createdAt: 1700000000); + final deletion = coordinateDeletion(createdAt: 1700000001); + + await cache.saveEvent(target); + await cache.saveEvent(deletion); + + final visible = await cache.loadEvents(ids: [target.id]); + + expect( + visible.map((e) => e.id), + isNot(contains(target.id)), + reason: + 'addressable event tombstoned via `a` tag should not be visible', + ); + }); + + test( + 'eviction uses derived state to sweep obsolete replaceable versions', + () async { + final cache = MemCacheManager(); + final oldVersion = addressableEvent( + createdAt: 1700000000, + content: 'old version', + ); + final newVersion = addressableEvent( + createdAt: 1700000001, + content: 'new version', + ); + + await cache.saveEvents([oldVersion, newVersion]); + + final result = await cache.evict( + const EvictionPolicy(sweepSuperseded: true), + ); + + expect(result.removedSuperseded, 1); + final remaining = await cache.loadEvents( + pubKeys: [author], + kinds: [addressableKind], + ); + expect(remaining.map((event) => event.id), contains(newVersion.id)); + expect( + remaining.map((event) => event.id), + isNot(contains(oldVersion.id)), + ); + }, + ); + }); +} diff --git a/packages/ndk/test/usecases/nip42_auth_test.dart b/packages/ndk/test/usecases/nip42_auth_test.dart index c5a702e83..9dc7e67d3 100644 --- a/packages/ndk/test/usecases/nip42_auth_test.dart +++ b/packages/ndk/test/usecases/nip42_auth_test.dart @@ -6,65 +6,68 @@ import '../mocks/mock_event_verifier.dart'; import '../mocks/mock_relay.dart'; void main() { - test('request on auth-required relay should return events after AUTH', - () async { - final key = Bip340.generatePrivateKey(); - final relay = MockRelay( - name: "relay", - explicitPort: 5099, - requireAuthForRequests: true, - ); - - final testEvent = await Bip340EventSigner( - privateKey: key.privateKey!, - publicKey: key.publicKey, - ).sign(Nip01Event( - pubKey: key.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test event", - )); - - await relay.startServer(textNotes: {key: testEvent}); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - )); - - ndk.accounts.loginPrivateKey( - pubkey: key.publicKey, - privkey: key.privateKey!, - ); - - final result = await ndk.requests.query( - filter: Filter(kinds: [Nip01Event.kTextNodeKind]), - explicitRelays: [relay.url], - ).future; - - expect(result, isNotEmpty); - expect(result.first.content, "test event"); + test( + 'request on auth-required relay should return events after AUTH', + () async { + final key = Bip340.generatePrivateKey(); + final relay = MockRelay(name: "relay", requireAuthForRequests: true); + + final testEvent = + await Bip340EventSigner( + privateKey: key.privateKey!, + publicKey: key.publicKey, + ).sign( + Nip01Event( + pubKey: key.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test event", + ), + ); + + await relay.startServer(textNotes: {key: testEvent}); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + ), + ); + + ndk.accounts.loginPrivateKey( + pubkey: key.publicKey, + privkey: key.privateKey!, + ); + + final result = await ndk.requests + .query( + filter: Filter(kinds: [Nip01Event.kTextNodeKind]), + explicitRelays: [relay.url], + ) + .future; + + expect(result, isNotEmpty); + expect(result.first.content, "test event"); - await ndk.destroy(); - await relay.stopServer(); - }); + await ndk.destroy(); + await relay.stopServer(); + }, + ); test('broadcast on auth-required relay should succeed after AUTH', () async { final key = Bip340.generatePrivateKey(); - final relay = MockRelay( - name: "relay", - explicitPort: 5100, - requireAuthForEvents: true, - ); + final relay = MockRelay(name: "relay", requireAuthForEvents: true); await relay.startServer(); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - )); + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + ), + ); ndk.accounts.loginPrivateKey( pubkey: key.publicKey, @@ -78,10 +81,9 @@ void main() { content: "test broadcast", ); - final result = await ndk.broadcast.broadcast( - nostrEvent: event, - specificRelays: [relay.url], - ).broadcastDoneFuture; + final result = await ndk.broadcast + .broadcast(nostrEvent: event, specificRelays: [relay.url]) + .broadcastDoneFuture; expect(result.any((r) => r.broadcastSuccessful), isTrue); @@ -89,86 +91,92 @@ void main() { await relay.stopServer(); }); - test('gift wrap broadcast on auth-required relay should authenticate as sender', - timeout: const Timeout(Duration(seconds: 5)), () async { - final senderKey = Bip340.generatePrivateKey(); - final recipientKey = Bip340.generatePrivateKey(); - final relay = MockRelay( - name: "gift wrap auth relay", - explicitPort: 5107, - requireAuthForEvents: true, - ); - - await relay.startServer(); + test( + 'gift wrap broadcast on auth-required relay should authenticate as sender', + timeout: const Timeout(Duration(seconds: 5)), + () async { + final senderKey = Bip340.generatePrivateKey(); + final recipientKey = Bip340.generatePrivateKey(); + final relay = MockRelay( + name: "gift wrap auth relay", + requireAuthForEvents: true, + ); + + await relay.startServer(); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + defaultBroadcastTimeout: const Duration(seconds: 2), + ), + ); + + addTearDown(() async { + await ndk.destroy(); + await relay.stopServer(); + }); + + ndk.accounts.loginPrivateKey( + pubkey: senderKey.publicKey, + privkey: senderKey.privateKey!, + ); + + final rumor = await ndk.giftWrap.createRumor( + content: "gift wrap auth-required broadcast", + kind: Nip01Event.kTextNodeKind, + tags: [ + ["p", recipientKey.publicKey], + ], + ); + final giftWrap = await ndk.giftWrap.toGiftWrap( + rumor: rumor, + recipientPubkey: recipientKey.publicKey, + ); + + final result = await ndk.broadcast + .broadcast(nostrEvent: giftWrap, specificRelays: [relay.url]) + .broadcastDoneFuture; + + expect(result.any((r) => r.broadcastSuccessful), isTrue); + }, + ); - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - defaultBroadcastTimeout: const Duration(milliseconds: 300), - )); + test( + 'request should complete when relay requires auth but sends no challenge', + timeout: const Timeout(Duration(seconds: 5)), + () async { + final key = Bip340.generatePrivateKey(); + final relay = MockRelay( + name: "relay auth no challenge", + requireAuthForRequests: true, + sendAuthChallenge: false, + ); + + await relay.startServer(); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + ), + ); + + final result = await ndk.requests + .query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key.publicKey], + ), + ) + .future; + + expect(result, isEmpty); - addTearDown(() async { await ndk.destroy(); await relay.stopServer(); - }); - - ndk.accounts.loginPrivateKey( - pubkey: senderKey.publicKey, - privkey: senderKey.privateKey!, - ); - - final rumor = await ndk.giftWrap.createRumor( - content: "gift wrap auth-required broadcast", - kind: Nip01Event.kTextNodeKind, - tags: [ - ["p", recipientKey.publicKey] - ], - ); - final giftWrap = await ndk.giftWrap.toGiftWrap( - rumor: rumor, - recipientPubkey: recipientKey.publicKey, - ); - - final result = await ndk.broadcast.broadcast( - nostrEvent: giftWrap, - specificRelays: [relay.url], - ).broadcastDoneFuture; - - expect(result.any((r) => r.broadcastSuccessful), isTrue); - }); - - test( - 'request should complete when relay requires auth but sends no challenge', - timeout: const Timeout(Duration(seconds: 5)), () async { - final key = Bip340.generatePrivateKey(); - final relay = MockRelay( - name: "relay auth no challenge", - explicitPort: 5101, - requireAuthForRequests: true, - sendAuthChallenge: false, - ); - - await relay.startServer(); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - )); - - final result = await ndk.requests - .query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], - authors: [key.publicKey], - ), - ) - .future; - - expect(result, isEmpty); - - await ndk.destroy(); - await relay.stopServer(); - }); + }, + ); } diff --git a/packages/ndk/test/usecases/nip77/nip77_integration_test.dart b/packages/ndk/test/usecases/nip77/nip77_integration_test.dart index 4046f9153..f194aee7d 100644 --- a/packages/ndk/test/usecases/nip77/nip77_integration_test.dart +++ b/packages/ndk/test/usecases/nip77/nip77_integration_test.dart @@ -34,17 +34,16 @@ void main() { tags: [], content: "content 2", ); - await ndk.broadcast.broadcast( - nostrEvent: event1, specificRelays: [relayUrl]).broadcastDoneFuture; - await ndk.broadcast.broadcast( - nostrEvent: event2, specificRelays: [relayUrl]).broadcastDoneFuture; + await ndk.broadcast + .broadcast(nostrEvent: event1, specificRelays: [relayUrl]) + .broadcastDoneFuture; + await ndk.broadcast + .broadcast(nostrEvent: event2, specificRelays: [relayUrl]) + .broadcastDoneFuture; final filter = Filter(authors: [keypair.publicKey]); - final reconcile = ndk.nip77.reconcile( - relayUrl: relayUrl, - filter: filter, - ); + final reconcile = ndk.nip77.reconcile(relayUrl: relayUrl, filter: filter); final res = await reconcile.future; @@ -54,7 +53,7 @@ void main() { await ndk.destroy(); }); - test("should return needIds with empty local cache", () async { + test("should return needIds with empty local cache", skip: true, () async { final relayUrl = "wss://relay.damus.io"; final ndk = Ndk( @@ -84,7 +83,7 @@ void main() { await ndk.destroy(); }); - test("should report local events as haveIds", () async { + test("should report local events as haveIds", skip: true, () async { final relayUrl = "wss://relay.damus.io"; final ndk = Ndk( @@ -127,27 +126,31 @@ void main() { await ndk.destroy(); }); - test("should fail gracefully when NIP-77 not supported", () async { - final relayUrl = "wss://relay.primal.net"; - - final ndk = Ndk( - NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relayUrl], - ), - ); - - try { - final reconcile = ndk.nip77.reconcile( - relayUrl: relayUrl, - filter: Filter(kinds: [1]), + test( + "should fail gracefully when NIP-77 not supported", + skip: true, + () async { + final relayUrl = "wss://relay.primal.net"; + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relayUrl], + ), ); - await reconcile.future; - } catch (e) { - expect(true, isTrue); - } - await ndk.destroy(); - }); + try { + final reconcile = ndk.nip77.reconcile( + relayUrl: relayUrl, + filter: Filter(kinds: [1]), + ); + await reconcile.future; + } catch (e) { + expect(true, isTrue); + } + + await ndk.destroy(); + }, + ); } diff --git a/packages/ndk/test/usecases/nwc/bitcoin_network_test.dart b/packages/ndk/test/usecases/nwc/bitcoin_network_test.dart index 25a91c2c8..79fdf6bbf 100644 --- a/packages/ndk/test/usecases/nwc/bitcoin_network_test.dart +++ b/packages/ndk/test/usecases/nwc/bitcoin_network_test.dart @@ -8,7 +8,9 @@ void main() { expect(BitcoinNetwork.fromPlaintext('testnet'), BitcoinNetwork.testnet); expect(BitcoinNetwork.fromPlaintext('signet'), BitcoinNetwork.signet); expect( - BitcoinNetwork.fromPlaintext('mutinynet'), BitcoinNetwork.mutinynet); + BitcoinNetwork.fromPlaintext('mutinynet'), + BitcoinNetwork.mutinynet, + ); expect(BitcoinNetwork.fromPlaintext('regtest'), BitcoinNetwork.regtest); }); diff --git a/packages/ndk/test/usecases/nwc/list_transactions_response_test.dart b/packages/ndk/test/usecases/nwc/list_transactions_response_test.dart index f39fbc18e..1a60edc58 100644 --- a/packages/ndk/test/usecases/nwc/list_transactions_response_test.dart +++ b/packages/ndk/test/usecases/nwc/list_transactions_response_test.dart @@ -20,9 +20,9 @@ void main() { 'expires_at': 1633123200, 'settled_at': 1633040400, 'metadata': {'key': 'value'}, - } - ] - } + }, + ], + }, }; final response = ListTransactionsResponse.deserialize(input); @@ -78,7 +78,7 @@ void main() { feesPaid: 10, createdAt: 1633036800, metadata: { - 'nostr': {'kind': 9734, 'pubkey': 'pubkey_123'} + 'nostr': {'kind': 9734, 'pubkey': 'pubkey_123'}, }, ); diff --git a/packages/ndk/test/usecases/nwc/nostr_wallet_connect_uri_test.dart b/packages/ndk/test/usecases/nwc/nostr_wallet_connect_uri_test.dart index 13b6293c6..9bff6082e 100644 --- a/packages/ndk/test/usecases/nwc/nostr_wallet_connect_uri_test.dart +++ b/packages/ndk/test/usecases/nwc/nostr_wallet_connect_uri_test.dart @@ -11,7 +11,9 @@ void main() { expect(nostrUri.walletPubkey, equals('pubkey123')); expect(nostrUri.relays, equals(['wss://relay.example.com'])); expect( - nostrUri.relay, equals('wss://relay.example.com')); // Legacy getter + nostrUri.relay, + equals('wss://relay.example.com'), + ); // Legacy getter expect(nostrUri.secret, equals('secret123')); expect(nostrUri.lud16, equals('lud16value')); }); @@ -23,31 +25,34 @@ void main() { expect(nostrUri.walletPubkey, equals('pubkey123')); expect( - nostrUri.relays, - equals([ - 'wss://relay1.example.com', - 'wss://relay2.example.com', - 'wss://relay3.example.com' - ])); + nostrUri.relays, + equals([ + 'wss://relay1.example.com', + 'wss://relay2.example.com', + 'wss://relay3.example.com', + ]), + ); expect(nostrUri.secret, equals('secret123')); }); test( - 'should parse a connection URI with multiple individual relay parameters', - () { - final uri = - 'nostr+walletconnect://pubkey123?relay=wss://relay1.example.com&relay=wss://relay2.example.com&relay=wss://relay3.example.com&secret=secret123'; - final nostrUri = NostrWalletConnectUri.parseConnectionUri(uri); - - expect(nostrUri.walletPubkey, equals('pubkey123')); - expect( + 'should parse a connection URI with multiple individual relay parameters', + () { + final uri = + 'nostr+walletconnect://pubkey123?relay=wss://relay1.example.com&relay=wss://relay2.example.com&relay=wss://relay3.example.com&secret=secret123'; + final nostrUri = NostrWalletConnectUri.parseConnectionUri(uri); + + expect(nostrUri.walletPubkey, equals('pubkey123')); + expect( nostrUri.relays, equals([ 'wss://relay1.example.com', 'wss://relay2.example.com', - 'wss://relay3.example.com' - ])); - expect(nostrUri.secret, equals('secret123')); - }); + 'wss://relay3.example.com', + ]), + ); + expect(nostrUri.secret, equals('secret123')); + }, + ); test('should parse a connection URI with mixed relay parameters', () { final uri = @@ -56,12 +61,13 @@ void main() { expect(nostrUri.walletPubkey, equals('pubkey123')); expect( - nostrUri.relays, - equals([ - 'wss://relay1.example.com', - 'wss://relay2.example.com', - 'wss://relay3.example.com' - ])); + nostrUri.relays, + equals([ + 'wss://relay1.example.com', + 'wss://relay2.example.com', + 'wss://relay3.example.com', + ]), + ); expect(nostrUri.secret, equals('secret123')); }); @@ -71,8 +77,10 @@ void main() { final nostrUri = NostrWalletConnectUri.parseConnectionUri(uri); expect(nostrUri.walletPubkey, equals('pubkey123')); - expect(nostrUri.relays, - equals(['wss://relay1.example.com', 'wss://relay2.example.com'])); + expect( + nostrUri.relays, + equals(['wss://relay1.example.com', 'wss://relay2.example.com']), + ); expect(nostrUri.secret, equals('secret123')); }); @@ -91,8 +99,11 @@ void main() { expect( () => NostrWalletConnectUri.parseConnectionUri(uri), - throwsA(predicate( - (e) => e.toString().contains('At least one relay is required'))), + throwsA( + predicate( + (e) => e.toString().contains('At least one relay is required'), + ), + ), ); }); @@ -114,24 +125,26 @@ void main() { expect(uri1, equals(uri2)); }); - test('should correctly compare two equal instances with multiple relays', - () { - final uri1 = NostrWalletConnectUri( - walletPubkey: 'pubkey123', - relays: ['wss://relay1.example.com', 'wss://relay2.example.com'], - secret: 'secret123', - lud16: 'lud16value', - ); + test( + 'should correctly compare two equal instances with multiple relays', + () { + final uri1 = NostrWalletConnectUri( + walletPubkey: 'pubkey123', + relays: ['wss://relay1.example.com', 'wss://relay2.example.com'], + secret: 'secret123', + lud16: 'lud16value', + ); - final uri2 = NostrWalletConnectUri( - walletPubkey: 'pubkey123', - relays: ['wss://relay1.example.com', 'wss://relay2.example.com'], - secret: 'secret123', - lud16: 'lud16value', - ); + final uri2 = NostrWalletConnectUri( + walletPubkey: 'pubkey123', + relays: ['wss://relay1.example.com', 'wss://relay2.example.com'], + secret: 'secret123', + lud16: 'lud16value', + ); - expect(uri1, equals(uri2)); - }); + expect(uri1, equals(uri2)); + }, + ); test('should correctly compare two different instances', () { final uri1 = NostrWalletConnectUri( @@ -191,9 +204,11 @@ void main() { final uriString = uri.toUri(); expect(uriString, contains('nostr+walletconnect://pubkey123')); expect( - uriString, - contains( - 'relays=wss%3A%2F%2Frelay1.example.com%2Cwss%3A%2F%2Frelay2.example.com')); + uriString, + contains( + 'relays=wss%3A%2F%2Frelay1.example.com%2Cwss%3A%2F%2Frelay2.example.com', + ), + ); expect(uriString, contains('secret=secret123')); }); @@ -206,23 +221,27 @@ void main() { ); expect(uri.walletPubkey, equals('pubkey123')); - expect(uri.relays, - equals(['wss://relay1.example.com', 'wss://relay2.example.com'])); + expect( + uri.relays, + equals(['wss://relay1.example.com', 'wss://relay2.example.com']), + ); expect(uri.secret, equals('secret123')); expect(uri.lud16, equals('lud16value')); }); - test('should throw error when createMultiRelay called with empty relays', - () { - expect( - () => NostrWalletConnectUri.createMultiRelay( - walletPubkey: 'pubkey123', - relays: [], - secret: 'secret123', - ), - throwsA(isA()), - ); - }); + test( + 'should throw error when createMultiRelay called with empty relays', + () { + expect( + () => NostrWalletConnectUri.createMultiRelay( + walletPubkey: 'pubkey123', + relays: [], + secret: 'secret123', + ), + throwsA(isA()), + ); + }, + ); test('should round-trip URI creation and parsing', () { final originalUri = NostrWalletConnectUri( @@ -239,15 +258,19 @@ void main() { }); test( - 'should parse a connection URI with comma-separated relays in the relay parameter', - () { - final uri = - 'nostr+walletconnect://pubkey123?relay=wss://relay1.com,wss://relay2.com&secret=secret123'; - final nostrUri = NostrWalletConnectUri.parseConnectionUri(uri); - - expect(nostrUri.walletPubkey, equals('pubkey123')); - expect(nostrUri.relays, equals(['wss://relay1.com', 'wss://relay2.com'])); - expect(nostrUri.secret, equals('secret123')); - }); + 'should parse a connection URI with comma-separated relays in the relay parameter', + () { + final uri = + 'nostr+walletconnect://pubkey123?relay=wss://relay1.com,wss://relay2.com&secret=secret123'; + final nostrUri = NostrWalletConnectUri.parseConnectionUri(uri); + + expect(nostrUri.walletPubkey, equals('pubkey123')); + expect( + nostrUri.relays, + equals(['wss://relay1.com', 'wss://relay2.com']), + ); + expect(nostrUri.secret, equals('secret123')); + }, + ); }); } diff --git a/packages/ndk/test/usecases/nwc/nwc_method_test.dart b/packages/ndk/test/usecases/nwc/nwc_method_test.dart index c87afdeeb..013876492 100644 --- a/packages/ndk/test/usecases/nwc/nwc_method_test.dart +++ b/packages/ndk/test/usecases/nwc/nwc_method_test.dart @@ -5,29 +5,49 @@ void main() { group('NwcMethod', () { test('should return the correct NwcMethod for known plaintext', () { expect(NwcMethod.fromPlaintext('get_info'), equals(NwcMethod.GET_INFO)); - expect(NwcMethod.fromPlaintext('get_balance'), - equals(NwcMethod.GET_BALANCE)); - expect( - NwcMethod.fromPlaintext('get_budget'), equals(NwcMethod.GET_BUDGET)); - expect(NwcMethod.fromPlaintext('pay_invoice'), - equals(NwcMethod.PAY_INVOICE)); - expect(NwcMethod.fromPlaintext('multi_pay_invoice'), - equals(NwcMethod.MULTI_PAY_INVOICE)); - expect(NwcMethod.fromPlaintext('pay_keysend'), - equals(NwcMethod.PAY_KEYSEND)); - expect(NwcMethod.fromPlaintext('multi_pay_keysend'), - equals(NwcMethod.MULTI_PAY_KEYSEND)); - expect(NwcMethod.fromPlaintext('make_invoice'), - equals(NwcMethod.MAKE_INVOICE)); - expect(NwcMethod.fromPlaintext('lookup_invoice'), - equals(NwcMethod.LOOKUP_INVOICE)); - expect(NwcMethod.fromPlaintext('list_transactions'), - equals(NwcMethod.LIST_TRANSACTIONS)); + expect( + NwcMethod.fromPlaintext('get_balance'), + equals(NwcMethod.GET_BALANCE), + ); + expect( + NwcMethod.fromPlaintext('get_budget'), + equals(NwcMethod.GET_BUDGET), + ); + expect( + NwcMethod.fromPlaintext('pay_invoice'), + equals(NwcMethod.PAY_INVOICE), + ); + expect( + NwcMethod.fromPlaintext('multi_pay_invoice'), + equals(NwcMethod.MULTI_PAY_INVOICE), + ); + expect( + NwcMethod.fromPlaintext('pay_keysend'), + equals(NwcMethod.PAY_KEYSEND), + ); + expect( + NwcMethod.fromPlaintext('multi_pay_keysend'), + equals(NwcMethod.MULTI_PAY_KEYSEND), + ); + expect( + NwcMethod.fromPlaintext('make_invoice'), + equals(NwcMethod.MAKE_INVOICE), + ); + expect( + NwcMethod.fromPlaintext('lookup_invoice'), + equals(NwcMethod.LOOKUP_INVOICE), + ); + expect( + NwcMethod.fromPlaintext('list_transactions'), + equals(NwcMethod.LIST_TRANSACTIONS), + ); }); test('should return UNKNOWN for unknown plaintext', () { expect( - NwcMethod.fromPlaintext('unknown_method'), equals(NwcMethod.UNKNOWN)); + NwcMethod.fromPlaintext('unknown_method'), + equals(NwcMethod.UNKNOWN), + ); }); }); } diff --git a/packages/ndk/test/usecases/nwc/nwc_notification_test.dart b/packages/ndk/test/usecases/nwc/nwc_notification_test.dart index 3b8f1f3f3..2c20b4701 100644 --- a/packages/ndk/test/usecases/nwc/nwc_notification_test.dart +++ b/packages/ndk/test/usecases/nwc/nwc_notification_test.dart @@ -19,8 +19,10 @@ void main() { 'metadata': {'key': 'value'}, }; - final notification = - NwcNotification.fromMap(NwcNotification.kPaymentReceived, map); + final notification = NwcNotification.fromMap( + NwcNotification.kPaymentReceived, + map, + ); expect(notification.notificationType, equals('payment_received')); expect(notification.type, equals('incoming')); @@ -66,8 +68,10 @@ void main() { 'settled_at': 1633040400, }; - final notification = - NwcNotification.fromMap(NwcNotification.kPaymentSent, map); + final notification = NwcNotification.fromMap( + NwcNotification.kPaymentSent, + map, + ); expect(notification.metadata, isNull); }); diff --git a/packages/ndk/test/usecases/paginated_query_test.dart b/packages/ndk/test/usecases/paginated_query_test.dart index ef5aa6f2b..4bce2e4b0 100644 --- a/packages/ndk/test/usecases/paginated_query_test.dart +++ b/packages/ndk/test/usecases/paginated_query_test.dart @@ -30,12 +30,14 @@ void main() async { await relay1.startServer(); - ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay1.url], - logLevel: LogLevel.off, - )); + ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay1.url], + logLevel: LogLevel.off, + ), + ); ndk.accounts.loginExternalSigner(signer: signer); }); @@ -49,13 +51,7 @@ void main() async { test('fetches all events across multiple pages', () async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Create 5 events spread across time - final timestamps = [ - now - 4000, - now - 3000, - now - 2000, - now - 1000, - now, - ]; + final timestamps = [now - 4000, now - 3000, now - 2000, now - 1000, now]; // Publish events to the relay for (final ts in timestamps) { @@ -66,9 +62,7 @@ void main() async { tags: [], createdAt: ts, ); - final response = ndk.broadcast.broadcast( - nostrEvent: event, - ); + final response = ndk.broadcast.broadcast(nostrEvent: event); await response.broadcastDoneFuture; } @@ -106,9 +100,7 @@ void main() async { tags: [], createdAt: ts, ); - final response = ndk.broadcast.broadcast( - nostrEvent: event, - ); + final response = ndk.broadcast.broadcast(nostrEvent: event); await response.broadcastDoneFuture; } @@ -153,9 +145,7 @@ void main() async { tags: [], createdAt: ts, ); - final response = ndk.broadcast.broadcast( - nostrEvent: event, - ); + final response = ndk.broadcast.broadcast(nostrEvent: event); await response.broadcastDoneFuture; } @@ -202,9 +192,7 @@ void main() async { tags: [], createdAt: timestamps[i], ); - final response = ndk.broadcast.broadcast( - nostrEvent: event, - ); + final response = ndk.broadcast.broadcast(nostrEvent: event); await response.broadcastDoneFuture; } @@ -283,19 +271,16 @@ void main() async { firstEventSigned, relay1EventSigned, middleEventSigned, - lastEventSigned + lastEventSigned, ]; final relay2Events = [ firstEventSigned, relay2EventSigned, - lastEventSigned + lastEventSigned, ]; // Create a second relay - final relay2 = MockRelay( - name: "relay 2", - explicitPort: 6071, - ); + final relay2 = MockRelay(name: "relay 2", explicitPort: 6071); await relay2.startServer(); try { diff --git a/packages/ndk/test/usecases/pending_broadcast_delivery_test.dart b/packages/ndk/test/usecases/pending_broadcast_delivery_test.dart new file mode 100644 index 000000000..74d7a52e1 --- /dev/null +++ b/packages/ndk/test/usecases/pending_broadcast_delivery_test.dart @@ -0,0 +1,1006 @@ +import 'dart:async'; + +import 'package:ndk/data_layer/models/nip_01_event_model.dart'; +import 'package:ndk/data_layer/repositories/cache_manager/mem_cache_manager.dart'; +import 'package:ndk/domain_layer/entities/broadcast_response.dart'; +import 'package:ndk/domain_layer/entities/broadcast_state.dart'; +import 'package:ndk/domain_layer/entities/event_cache_records.dart'; +import 'package:ndk/domain_layer/entities/global_state.dart'; +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:ndk/domain_layer/entities/pending_signer_request.dart'; +import 'package:ndk/domain_layer/entities/signer_request_rejected_exception.dart'; +import 'package:ndk/domain_layer/repositories/event_signer.dart'; +import 'package:ndk/domain_layer/usecases/accounts/accounts.dart'; +import 'package:ndk/domain_layer/usecases/broadcast/broadcast_sender.dart'; +import 'package:ndk/domain_layer/usecases/broadcast/pending_broadcast_delivery.dart'; +import 'package:ndk/domain_layer/usecases/engines/network_engine.dart'; +import 'package:test/test.dart'; + +void main() { + group('PendingBroadcastDelivery', () { + late MemCacheManager cacheManager; + late RecordingBroadcastSender broadcast; + late PendingBroadcastDelivery pendingDelivery; + late Accounts accounts; + late Nip01Event event; + late List reconnectAttempts; + + setUp(() async { + cacheManager = MemCacheManager(); + broadcast = RecordingBroadcastSender(cacheManager: cacheManager); + accounts = Accounts(_DummySignerFactory()); + pendingDelivery = PendingBroadcastDelivery( + cacheManager: cacheManager, + broadcastSender: broadcast, + accounts: accounts, + ); + reconnectAttempts = []; + event = Nip01Event( + id: 'event-1', + pubKey: 'pubkey', + createdAt: 1700000000, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'note', + sig: 'sig', + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.pending, + createdAt: event.createdAt, + updatedAt: event.createdAt, + ), + ); + }); + + tearDown(() async { + await pendingDelivery.stop(); + }); + + test('does not rebroadcast permanent failures during due flush', () async { + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: event.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.permanentFailure, + attemptCount: 1, + lastAttemptAt: 1700000001, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example', onlyDue: true); + + expect(broadcast.broadcastedEvents, isEmpty); + }); + + test( + 'does not rebroadcast auth-required targets before next retry time', + () async { + final now = Nip01Event.secondsSinceEpoch(); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: event.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.authRequired, + attemptCount: 1, + lastAttemptAt: now, + nextRetryAt: now + 60, + ), + ); + + await pendingDelivery.flushForRelay( + 'wss://relay.example', + onlyDue: true, + ); + + expect(broadcast.broadcastedEvents, isEmpty); + }, + ); + + test('rebroadcasts auth-required targets once they are due', () async { + final now = Nip01Event.secondsSinceEpoch(); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: event.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.authRequired, + attemptCount: 1, + lastAttemptAt: now - 60, + nextRetryAt: now - 1, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example', onlyDue: true); + + expect(broadcast.broadcastedEvents.map((e) => e.id), [event.id]); + }); + + test('drops expired pending delivery instead of rebroadcasting', () async { + final now = Nip01Event.secondsSinceEpoch(); + final expiredEvent = Nip01Event( + id: 'event-expired', + pubKey: 'pubkey', + createdAt: now - 120, + kind: Nip01Event.kTextNodeKind, + tags: [ + ['expiration', '${now - 60}'], + ], + content: 'note', + sig: 'sig', + ); + await cacheManager.saveEvent(expiredEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: expiredEvent.id, + status: EventDeliveryStatus.pending, + createdAt: expiredEvent.createdAt, + updatedAt: expiredEvent.createdAt, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: expiredEvent.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.transientFailure, + attemptCount: 1, + lastAttemptAt: now - 61, + nextRetryAt: now - 1, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example', onlyDue: true); + + expect(broadcast.broadcastedEvents, isEmpty); + expect( + await cacheManager.loadEventDeliveryRecord(expiredEvent.id), + isNull, + ); + expect( + await cacheManager.loadRelayDeliveryTargets(eventId: expiredEvent.id), + isEmpty, + ); + }); + + test( + 'periodic retry forces reconnect for disconnected relays with due targets', + () async { + final now = Nip01Event.secondsSinceEpoch(); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: event.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.pending, + attemptCount: 0, + lastAttemptAt: now - 60, + nextRetryAt: now - 1, + ), + ); + + await pendingDelivery.retryDueDeliveries( + connectedRelayUrls: () => const [], + reconnectRelay: (relayUrl) async { + reconnectAttempts.add(relayUrl); + return true; + }, + ); + + expect(reconnectAttempts, ['wss://relay.example']); + expect(broadcast.broadcastedEvents.map((e) => e.id), [event.id]); + }, + ); + + test( + 'purges ephemeral event and sidecars once delivery is complete', + () async { + final ephemeralEvent = Nip01Event( + id: 'ephemeral-1', + pubKey: 'pubkey', + createdAt: 1700000000, + kind: 21000, + tags: const [], + content: 'ephemeral note', + sig: 'sig', + ); + await cacheManager.saveEvent(ephemeralEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: ephemeralEvent.id, + status: EventDeliveryStatus.pending, + createdAt: ephemeralEvent.createdAt, + updatedAt: ephemeralEvent.createdAt, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: ephemeralEvent.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.pending, + ), + ); + + await pendingDelivery + .persistSpecificRelayBroadcastResult(ephemeralEvent, [ + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: true, + broadcastSuccessful: true, + ), + ]); + + expect(await cacheManager.loadEvent(ephemeralEvent.id), isNull); + expect( + await cacheManager.loadEventDeliveryRecord(ephemeralEvent.id), + isNull, + ); + expect( + await cacheManager.loadRelayDeliveryTargets( + eventId: ephemeralEvent.id, + ), + isEmpty, + ); + }, + ); + + test( + 'keeps ephemeral event cached while delivery is not yet terminal', + () async { + final ephemeralEvent = Nip01Event( + id: 'ephemeral-auth', + pubKey: 'pubkey', + createdAt: 1700000000, + kind: 21000, + tags: const [], + content: 'ephemeral awaiting auth', + sig: 'sig', + ); + await cacheManager.saveEvent(ephemeralEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: ephemeralEvent.id, + status: EventDeliveryStatus.pending, + createdAt: ephemeralEvent.createdAt, + updatedAt: ephemeralEvent.createdAt, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: ephemeralEvent.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.pending, + ), + ); + + // auth-required is a non-terminal outcome (status -> needsAction), so the + // event and its delivery state must survive for a later auth-gated retry. + await pendingDelivery + .persistSpecificRelayBroadcastResult(ephemeralEvent, [ + RelayBroadcastResponse( + relayUrl: 'wss://relay.example', + okReceived: false, + broadcastSuccessful: false, + msg: 'auth-required: need to authenticate', + ), + ]); + + expect(await cacheManager.loadEvent(ephemeralEvent.id), isNotNull); + final record = await cacheManager.loadEventDeliveryRecord( + ephemeralEvent.id, + ); + expect(record, isNotNull); + expect(record!.status, EventDeliveryStatus.needsAction); + }, + ); + + test('signs remote-signer events before broadcasting', () async { + final unsignedEvent = Nip01Event( + id: 'event-remote-sign', + pubKey: 'remote-pubkey', + createdAt: 1700000100, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'needs signing', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + onSign: (event) async => event.copyWith(sig: 'remote-sig'), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-remote-sign', + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example'); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(savedEvent?.sig, 'remote-sig'); + expect(savedRecord?.signingState, EventSigningState.signed); + expect(broadcast.broadcastedEvents.map((e) => e.id), [unsignedEvent.id]); + expect(broadcast.broadcastedEvents.single.sig, 'remote-sig'); + }); + + test( + 'replays pending delivery from serialized record when event row is missing', + () async { + final serializedRecord = EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.pending, + createdAt: event.createdAt, + updatedAt: event.createdAt, + serializedEventJson: Nip01EventModel.fromEntity(event).toJsonString(), + ); + await cacheManager.saveEventDeliveryRecord(serializedRecord); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-1', + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + cacheManager.events.remove(event.id); + + await pendingDelivery.flushForRelay('wss://relay.example'); + + expect(broadcast.broadcastedEvents.map((e) => e.id), [event.id]); + final restoredEvent = await cacheManager.loadEvent(event.id); + expect(restoredEvent?.content, event.content); + }, + ); + + test( + 'rejected remote signing becomes needsAction and does not broadcast', + () async { + final unsignedEvent = Nip01Event( + id: 'event-remote-rejected', + pubKey: 'remote-pubkey-rejected', + createdAt: 1700000200, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'needs approval', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + onSign: (_) => Future.error( + SignerRequestRejectedException( + requestId: 'req-1', + originalMessage: 'user rejected', + ), + ), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-remote-rejected', + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example'); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(savedEvent?.sig, isNull); + expect(savedRecord?.signingState, EventSigningState.needsAction); + expect(savedRecord?.status, EventDeliveryStatus.needsAction); + expect(broadcast.broadcastedEvents, isEmpty); + }, + ); + + test( + 'timed out signing attempt does not block a later retry forever if the original future never completes', + () async { + await pendingDelivery.stop(); + pendingDelivery = PendingBroadcastDelivery( + cacheManager: cacheManager, + broadcastSender: broadcast, + accounts: accounts, + signAttemptTimeout: const Duration(milliseconds: 20), + ); + + final hangingCompleter = Completer(); + var signAttempts = 0; + final unsignedEvent = Nip01Event( + id: 'event-remote-timeout-stall', + pubKey: 'remote-timeout-stall-pubkey', + createdAt: 1700000250, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'first sign hangs forever', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + onSign: (event) { + signAttempts += 1; + if (signAttempts == 1) { + return hangingCompleter.future; + } + return Future.value(event.copyWith(sig: 'signed-after-retry')); + }, + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-remote-timeout-stall', + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.flushForRelay('wss://relay.example'); + + final afterTimeoutRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + expect( + afterTimeoutRecord?.signingState, + EventSigningState.transientFailure, + ); + expect(signer.signCallCount, 1); + expect(broadcast.broadcastedEvents, isEmpty); + + await pendingDelivery.flushForRelay('wss://relay.example'); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(signer.signCallCount, 2); + expect(savedEvent?.sig, 'signed-after-retry'); + expect(savedRecord?.signingState, EventSigningState.signed); + expect(broadcast.broadcastedEvents.map((e) => e.id), [ + unsignedEvent.id, + ]); + }, + ); + + test( + 'skips network signer attempt while signer transport relays are offline', + () async { + pendingDelivery.startPeriodicRetry( + connectedRelayUrls: () => const { + 'wss://target-relay.example', + }, + reconnectRelay: (_) async => false, + retryInterval: const Duration(hours: 1), + ); + + final unsignedEvent = Nip01Event( + id: 'event-bunker-offline', + pubKey: 'bunker-offline-pubkey', + createdAt: 1700000300, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'offline bunker', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + requiresSignerNetwork: true, + transportRelayUrls: const ['wss://bunker-relay.example'], + onSign: (event) async => event.copyWith(sig: 'should-not-happen'), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-bunker-offline', + relayUrl: 'wss://target-relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.flushForRelay('wss://target-relay.example'); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(signer.signCallCount, 0); + expect(savedEvent?.sig, isNull); + expect(savedRecord?.signingState, EventSigningState.pending); + expect(broadcast.broadcastedEvents, isEmpty); + }, + ); + + test( + 'transport relay opening retries network signing and immediately flushes connected targets', + () async { + pendingDelivery.startPeriodicRetry( + connectedRelayUrls: () => const { + 'wss://bunker-relay.example', + 'wss://target-relay.example', + }, + reconnectRelay: (_) async => false, + retryInterval: const Duration(hours: 1), + ); + + final unsignedEvent = Nip01Event( + id: 'event-bunker-online', + pubKey: 'bunker-online-pubkey', + createdAt: 1700000400, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'online bunker', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + requiresSignerNetwork: true, + transportRelayUrls: const ['wss://bunker-relay.example'], + onSign: (event) async => event.copyWith(sig: 'bunker-sig'), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-bunker-online', + relayUrl: 'wss://target-relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.retryInteractiveSigningForTransportRelay( + 'wss://bunker-relay.example', + ); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(signer.signCallCount, 1); + expect(savedEvent?.sig, 'bunker-sig'); + expect(savedRecord?.signingState, EventSigningState.signed); + expect(broadcast.broadcastedEvents.map((e) => e.id), [ + unsignedEvent.id, + ]); + expect(broadcast.broadcastedEvents.single.sig, 'bunker-sig'); + }, + ); + + test( + 'non-matching transport relay opening does not retry network signer', + () async { + pendingDelivery.startPeriodicRetry( + connectedRelayUrls: () => const { + 'wss://other-relay.example', + 'wss://target-relay.example', + }, + reconnectRelay: (_) async => false, + retryInterval: const Duration(hours: 1), + ); + + final unsignedEvent = Nip01Event( + id: 'event-bunker-other-relay', + pubKey: 'bunker-other-relay-pubkey', + createdAt: 1700000500, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'wrong relay', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + requiresSignerNetwork: true, + transportRelayUrls: const ['wss://bunker-relay.example'], + onSign: (event) async => event.copyWith(sig: 'should-not-sign'), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-bunker-other-relay', + relayUrl: 'wss://target-relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.retryInteractiveSigningForTransportRelay( + 'wss://other-relay.example', + ); + + final savedEvent = await cacheManager.loadEvent(unsignedEvent.id); + expect(signer.signCallCount, 0); + expect(savedEvent?.sig, isNull); + expect(broadcast.broadcastedEvents, isEmpty); + }, + ); + + test( + 'network signer transient failure uses slower retry backoff', + () async { + pendingDelivery.startPeriodicRetry( + connectedRelayUrls: () => const { + 'wss://bunker-relay.example', + }, + reconnectRelay: (_) async => false, + retryInterval: const Duration(hours: 1), + ); + + final unsignedEvent = Nip01Event( + id: 'event-bunker-backoff', + pubKey: 'bunker-backoff-pubkey', + createdAt: 1700000600, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'backoff bunker', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + requiresSignerNetwork: true, + transportRelayUrls: const ['wss://bunker-relay.example'], + onSign: (_) => Future.error(Exception('temporary outage')), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-bunker-backoff', + relayUrl: 'wss://target-relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.retryInteractiveSigningForTransportRelay( + 'wss://bunker-relay.example', + ); + + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(savedRecord?.signingState, EventSigningState.transientFailure); + expect(savedRecord, isNotNull); + expect(savedRecord!.nextSignRetryAt, isNotNull); + expect(savedRecord.nextSignRetryAt! - savedRecord.updatedAt, 15); + }, + ); + + test( + 'non-network interactive signer transient failure uses faster retry backoff', + () async { + final unsignedEvent = Nip01Event( + id: 'event-local-backoff', + pubKey: 'local-backoff-pubkey', + createdAt: 1700000700, + kind: Nip01Event.kTextNodeKind, + tags: const [], + content: 'backoff local', + ); + final signer = _RemoteTestSigner( + pubKey: unsignedEvent.pubKey, + requiresSignerNetwork: false, + onSign: (_) => Future.error(Exception('temporary local failure')), + ); + accounts.loginExternalSigner(signer: signer); + + await cacheManager.saveEvent(unsignedEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: unsignedEvent.id, + status: EventDeliveryStatus.pending, + signingState: EventSigningState.pending, + createdAt: unsignedEvent.createdAt, + updatedAt: unsignedEvent.createdAt, + requiresInteractiveSigning: true, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-local-backoff', + relayUrl: 'wss://target-relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + + await pendingDelivery.flushForRelay('wss://target-relay.example'); + + final savedRecord = await cacheManager.loadEventDeliveryRecord( + unsignedEvent.id, + ); + + expect(savedRecord?.signingState, EventSigningState.transientFailure); + expect(savedRecord, isNotNull); + expect(savedRecord!.nextSignRetryAt, isNotNull); + expect(savedRecord.nextSignRetryAt! - savedRecord.updatedAt, 5); + }, + ); + }); +} + +class RecordingBroadcastSender extends BroadcastSender { + final List broadcastedEvents = []; + + RecordingBroadcastSender({required MemCacheManager cacheManager}) + : super( + globalState: GlobalState(), + cacheManager: cacheManager, + networkEngine: _ThrowingNetworkEngine(), + accounts: Accounts(_DummySignerFactory()), + considerDonePercent: 1, + timeout: const Duration(seconds: 1), + saveToCache: true, + ); + + @override + NdkBroadcastResponse broadcast({ + required Nip01Event nostrEvent, + Iterable? specificRelays, + EventSigner? customSigner, + double? considerDonePercent, + Duration? timeout, + bool? saveToCache, + }) { + broadcastedEvents.add(nostrEvent); + return NdkBroadcastResponse( + publishEvent: nostrEvent, + broadcastDoneStream: Stream.value(const []), + ); + } +} + +class _ThrowingNetworkEngine implements NetworkEngine { + @override + void handleRequest(requestState) { + throw UnimplementedError(); + } + + @override + NdkBroadcastResponse handleEventBroadcast({ + required Nip01Event nostrEvent, + required EventSigner? signer, + required BroadcastState broadcastState, + Iterable? specificRelays, + }) { + throw UnimplementedError(); + } +} + +class _DummySignerFactory implements LocalEventSignerFactory { + @override + EventSigner create({String? privateKey, String? publicKey}) { + return _DummySigner(publicKey ?? 'dummy'); + } + + @override + EventSigner createWithNewKeyPair() => _DummySigner('dummy-public'); + + @override + String derivePublicKey(String privateKey) => 'dummy'; + + @override + (String privateKey, String publicKey) generateKeyPair() => + ('dummy-private', 'dummy-public'); +} + +class _DummySigner implements EventSigner { + final String pubKey; + + _DummySigner(this.pubKey); + + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + + @override + bool canSign() => false; + + @override + bool cancelRequest(String requestId) => false; + + @override + Future decrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future decryptNip44({ + required String ciphertext, + required String senderPubKey, + }) async => null; + + @override + Future dispose() async {} + + @override + Future encrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future encryptNip44({ + required String plaintext, + required String recipientPubKey, + }) async => null; + + @override + String getPublicKey() => pubKey; + + @override + List get pendingRequests => const []; + + @override + Stream> get pendingRequestsStream => + const Stream.empty(); + + @override + Future sign(Nip01Event event) async => event; +} + +class _RemoteTestSigner implements EventSigner { + final String pubKey; + final Future Function(Nip01Event event) onSign; + final List _pendingRequests; + final bool _requiresSignerNetwork; + final List _transportRelayUrls; + int signCallCount = 0; + + _RemoteTestSigner({ + required this.pubKey, + required this.onSign, + bool requiresSignerNetwork = false, + List? transportRelayUrls, + List? pendingRequests, + }) : _pendingRequests = pendingRequests ?? [], + _requiresSignerNetwork = requiresSignerNetwork, + _transportRelayUrls = transportRelayUrls ?? const []; + + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => _requiresSignerNetwork; + + @override + Iterable get signerTransportRelayUrls => + List.unmodifiable(_transportRelayUrls); + + @override + bool canSign() => true; + + @override + bool cancelRequest(String requestId) => false; + + @override + Future decrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future decryptNip44({ + required String ciphertext, + required String senderPubKey, + }) async => null; + + @override + Future dispose() async {} + + @override + Future encrypt(String msg, String destPubKey, {String? id}) async => + null; + + @override + Future encryptNip44({ + required String plaintext, + required String recipientPubKey, + }) async => null; + + @override + String getPublicKey() => pubKey; + + @override + List get pendingRequests => + List.unmodifiable(_pendingRequests); + + @override + Stream> get pendingRequestsStream => + Stream.value(pendingRequests); + + @override + Future sign(Nip01Event event) { + signCallCount += 1; + return onSign(event); + } +} diff --git a/packages/ndk/test/usecases/query_cache_by_id_test.dart b/packages/ndk/test/usecases/query_cache_by_id_test.dart index 340138c52..61673a9cb 100644 --- a/packages/ndk/test/usecases/query_cache_by_id_test.dart +++ b/packages/ndk/test/usecases/query_cache_by_id_test.dart @@ -6,45 +6,51 @@ import 'package:test/test.dart'; import '../mocks/mock_event_verifier.dart'; void main() { - test('save signed event in cache then query it back with an ids-only filter', - () async { - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [], - )); - - final KeyPair keyPair = Bip340.generatePrivateKey(); - - final signedEvent = Nip01Utils.signWithPrivateKey( - event: Nip01Event( - pubKey: keyPair.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "hello from cache", - ), - privateKey: keyPair.privateKey!, - ); - - await ndk.config.cache.saveEvent(signedEvent); - - final stopwatch = Stopwatch()..start(); - final events = - await ndk.requests.query(filter: Filter(ids: [signedEvent.id])).future; - stopwatch.stop(); - - expect(events.length, equals(1)); - expect(events.first.id, equals(signedEvent.id)); - expect(events.first.sig, equals(signedEvent.sig)); - expect(events.first.content, equals("hello from cache")); - - expect( - stopwatch.elapsedMilliseconds, - lessThan(50), - reason: 'an ids-only query fully served by cache should return ' - 'immediately, not wait for the query timeout', - ); - - await ndk.destroy(); - }); + test( + 'save signed event in cache then query it back with an ids-only filter', + () async { + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [], + ), + ); + + final KeyPair keyPair = Bip340.generatePrivateKey(); + + final signedEvent = Nip01Utils.signWithPrivateKey( + event: Nip01Event( + pubKey: keyPair.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "hello from cache", + ), + privateKey: keyPair.privateKey!, + ); + + await ndk.config.cache.saveEvent(signedEvent); + + final stopwatch = Stopwatch()..start(); + final events = await ndk.requests + .query(filter: Filter(ids: [signedEvent.id])) + .future; + stopwatch.stop(); + + expect(events.length, equals(1)); + expect(events.first.id, equals(signedEvent.id)); + expect(events.first.sig, equals(signedEvent.sig)); + expect(events.first.content, equals("hello from cache")); + + expect( + stopwatch.elapsedMilliseconds, + lessThan(500), + reason: + 'an ids-only query fully served by cache should return ' + 'immediately, not wait for the query timeout', + ); + + await ndk.destroy(); + }, + ); } diff --git a/packages/ndk/test/usecases/signer_error_handling_test.dart b/packages/ndk/test/usecases/signer_error_handling_test.dart index 9a7ff17af..0580f6a63 100644 --- a/packages/ndk/test/usecases/signer_error_handling_test.dart +++ b/packages/ndk/test/usecases/signer_error_handling_test.dart @@ -19,11 +19,13 @@ void main() { relay0 = MockRelay(name: "relay 0", explicitPort: 5110); await relay0.startServer(); - ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay0.url], - )); + ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay0.url], + ), + ); failingSigner = MockFailingSigner(publicKey: key0.publicKey); ndk.accounts.loginExternalSigner(signer: failingSigner); @@ -44,9 +46,9 @@ void main() { ); await expectLater( - ndk.broadcast.broadcast( - nostrEvent: event, - specificRelays: [relay0.url]).broadcastDoneFuture, + ndk.broadcast + .broadcast(nostrEvent: event, specificRelays: [relay0.url]) + .broadcastDoneFuture, throwsA(isA()), ); }); diff --git a/packages/ndk/test/usecases/slow_signer_timeout_test.dart b/packages/ndk/test/usecases/slow_signer_timeout_test.dart index fb245674a..5b73139c5 100644 --- a/packages/ndk/test/usecases/slow_signer_timeout_test.dart +++ b/packages/ndk/test/usecases/slow_signer_timeout_test.dart @@ -7,101 +7,110 @@ import '../mocks/mock_relay.dart'; import '../mocks/mock_slow_signer.dart'; void main() { - test('broadcast with slow signer should not timeout during signing', - timeout: Timeout(Duration(seconds: 60)), () async { - final key = Bip340.generatePrivateKey(); - final relay = MockRelay(name: "relay", explicitPort: 5097); - await relay.startServer(); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - )); - - ndk.accounts.loginExternalSigner( - signer: MockSlowSigner( - innerSigner: Bip340EventSigner( - privateKey: key.privateKey!, - publicKey: key.publicKey, + test( + 'broadcast with slow signer should not timeout during signing', + timeout: Timeout(Duration(seconds: 60)), + () async { + final key = Bip340.generatePrivateKey(); + final relay = MockRelay(name: "relay"); + await relay.startServer(); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], ), - delay: const Duration(seconds: 12), - ), - ); - - final event = Nip01Event( - pubKey: key.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test", - ); - - // timeout (10s) < signing (12s) → should fail without fix - final result = await ndk.broadcast - .broadcast( - nostrEvent: event, - specificRelays: [relay.url], - timeout: const Duration(seconds: 10), - ) - .broadcastDoneFuture; - - expect(result.any((r) => r.broadcastSuccessful), isTrue); - - await ndk.destroy(); - await relay.stopServer(); - }); - - test('request with AUTH should not timeout during signing', - timeout: Timeout(Duration(seconds: 60)), () async { - final key = Bip340.generatePrivateKey(); - final relay = MockRelay( - name: "relay", - explicitPort: 5098, - requireAuthForRequests: true, - ); - - final testEvent = await Bip340EventSigner( - privateKey: key.privateKey!, - publicKey: key.publicKey, - ).sign(Nip01Event( - pubKey: key.publicKey, - kind: Nip01Event.kTextNodeKind, - tags: [], - content: "test event", - )); - - await relay.startServer(textNotes: {key: testEvent}); - - final ndk = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: [relay.url], - )); - - ndk.accounts.loginExternalSigner( - signer: MockSlowSigner( - innerSigner: Bip340EventSigner( - privateKey: key.privateKey!, - publicKey: key.publicKey, + ); + await ndk.relays.seedRelaysConnected; + + ndk.accounts.loginExternalSigner( + signer: MockSlowSigner( + innerSigner: Bip340EventSigner( + privateKey: key.privateKey!, + publicKey: key.publicKey, + ), + delay: const Duration(seconds: 12), + ), + ); + + final event = Nip01Event( + pubKey: key.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test", + ); + + // timeout (10s) < signing (12s) → should fail without fix + final result = await ndk.broadcast + .broadcast( + nostrEvent: event, + specificRelays: [relay.url], + timeout: const Duration(seconds: 10), + ) + .broadcastDoneFuture; + + expect(result.any((r) => r.broadcastSuccessful), isTrue); + + await ndk.destroy(); + await relay.stopServer(); + }, + ); + + test( + 'request with AUTH should not timeout during signing', + timeout: Timeout(Duration(seconds: 60)), + () async { + final key = Bip340.generatePrivateKey(); + final relay = MockRelay(name: "relay", requireAuthForRequests: true); + + final testEvent = + await Bip340EventSigner( + privateKey: key.privateKey!, + publicKey: key.publicKey, + ).sign( + Nip01Event( + pubKey: key.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test event", + ), + ); + + await relay.startServer(textNotes: {key: testEvent}); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], ), - delay: const Duration(seconds: 12), - ), - ); - - // timeout (10s) < signing (12s) → should fail without fix - final result = await ndk.requests - .query( - filter: Filter( - kinds: [Nip01Event.kTextNodeKind], + ); + await ndk.relays.seedRelaysConnected; + + ndk.accounts.loginExternalSigner( + signer: MockSlowSigner( + innerSigner: Bip340EventSigner( + privateKey: key.privateKey!, + publicKey: key.publicKey, ), - explicitRelays: [relay.url], - timeout: const Duration(seconds: 10), - ) - .future; - - expect(result, isNotEmpty); - - await ndk.destroy(); - await relay.stopServer(); - }); + delay: const Duration(seconds: 12), + ), + ); + + // timeout (10s) < signing (12s) → should fail without fix + final result = await ndk.requests + .query( + filter: Filter(kinds: [Nip01Event.kTextNodeKind]), + explicitRelays: [relay.url], + timeout: const Duration(seconds: 10), + ) + .future; + + expect(result, isNotEmpty); + + await ndk.destroy(); + await relay.stopServer(); + }, + ); } 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 f3a2673be..457e3cf7d 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 @@ -5,32 +5,131 @@ import 'package:ndk/domain_layer/usecases/stream_response_cleaner/stream_respons import 'package:ndk/ndk.dart'; void main() async { + const int fixedCreatedAt = 1782139515; + final List myEvents = [ Nip01Event( pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1_b", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "duplicate", + kind: 1, + tags: [], + content: "duplicate", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1_c", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_a", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "duplicate", + kind: 1, + tags: [], + content: "duplicate", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_b", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_c", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "duplicate", + kind: 1, + tags: [], + content: "duplicate", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "duplicate", + kind: 1, + tags: [], + content: "duplicate", + createdAt: fixedCreatedAt, ), - 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", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1_b", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey1", + kind: 1, + tags: [], + content: "content1_c", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_a", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_b", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "pubKey2", + kind: 1, + tags: [], + content: "content2_c", + createdAt: fixedCreatedAt, + ), + Nip01Event( + pubKey: "duplicate", + kind: 1, + tags: [], + content: "duplicate", + createdAt: fixedCreatedAt, + ), ]; group('stream response cleaner', () { diff --git a/packages/ndk/test/usecases/ta/trusted_assertions_test.dart b/packages/ndk/test/usecases/ta/trusted_assertions_test.dart index 90c84efd0..817f55a52 100644 --- a/packages/ndk/test/usecases/ta/trusted_assertions_test.dart +++ b/packages/ndk/test/usecases/ta/trusted_assertions_test.dart @@ -162,13 +162,15 @@ void main() { test('getUserMetrics returns null when no providers configured', () async { // Create NDK without trusted providers - final ndkNoProviders = Ndk(NdkConfig( - eventVerifier: MockEventVerifier(), - cache: MemCacheManager(), - engine: NdkEngine.RELAY_SETS, - bootstrapRelays: [relay.url], - defaultTrustedProviders: [], - )); + final ndkNoProviders = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [relay.url], + defaultTrustedProviders: [], + ), + ); await ndkNoProviders.relays.seedRelaysConnected; @@ -189,7 +191,7 @@ void main() { publicKey: provider2Key.publicKey, ); - final relay2 = MockRelay(name: 'nip85-relay-2', explicitPort: 5197); + final relay2 = MockRelay(name: 'nip85-relay-2', explicitPort: 5299); final assertionEvent2 = await provider2Signer.sign( createNip85UserAssertion( @@ -251,70 +253,72 @@ void main() { expect(tag, equals(['30382:followers', 'xyz789', 'wss://test.relay'])); }); - test('getEventMetrics merges metrics from multiple assertion events', - () async { - final eventId = 'event-id-123'; - - final provider1Key = Bip340.generatePrivateKey(); - final provider1Signer = Bip340EventSigner( - privateKey: provider1Key.privateKey, - publicKey: provider1Key.publicKey, - ); - - final provider2Key = Bip340.generatePrivateKey(); - final provider2Signer = Bip340EventSigner( - privateKey: provider2Key.privateKey, - publicKey: provider2Key.publicKey, - ); - - final relay2 = MockRelay(name: 'nip85-event-relay', explicitPort: 5198); - - final eventAssertion1 = await provider1Signer.sign( - createNip85EventAssertion( - providerPubkey: provider1Key.publicKey, - subjectEventId: eventId, - commentCount: 12, - ), - ); - - final eventAssertion2 = await provider2Signer.sign( - createNip85EventAssertion( - providerPubkey: provider2Key.publicKey, - subjectEventId: eventId, - reactionCount: 34, - ), - ); - - await relay2.startServer( - nip85Assertions: { - '${provider1Key.publicKey}:$eventId': eventAssertion1, - '${provider2Key.publicKey}:$eventId': eventAssertion2, - }, - ); - - final metrics = await ndk.ta.getEventMetrics( - eventId, - providers: [ - Nip85TrustedProvider( - kind: Nip85Kind.event, - metric: Nip85Metric.commentCount, - pubkey: provider1Key.publicKey, - relay: relay2.url, + test( + 'getEventMetrics merges metrics from multiple assertion events', + () async { + final eventId = 'event-id-123'; + + final provider1Key = Bip340.generatePrivateKey(); + final provider1Signer = Bip340EventSigner( + privateKey: provider1Key.privateKey, + publicKey: provider1Key.publicKey, + ); + + final provider2Key = Bip340.generatePrivateKey(); + final provider2Signer = Bip340EventSigner( + privateKey: provider2Key.privateKey, + publicKey: provider2Key.publicKey, + ); + + final relay2 = MockRelay(name: 'nip85-event-relay', explicitPort: 5300); + + final eventAssertion1 = await provider1Signer.sign( + createNip85EventAssertion( + providerPubkey: provider1Key.publicKey, + subjectEventId: eventId, + commentCount: 12, ), - Nip85TrustedProvider( - kind: Nip85Kind.event, - metric: Nip85Metric.reactionCount, - pubkey: provider2Key.publicKey, - relay: relay2.url, - ), - ], - ); - - expect(metrics, isNotNull); - expect(metrics!.commentCount, equals(12)); - expect(metrics.reactionCount, equals(34)); + ); - await relay2.stopServer(); - }); + final eventAssertion2 = await provider2Signer.sign( + createNip85EventAssertion( + providerPubkey: provider2Key.publicKey, + subjectEventId: eventId, + reactionCount: 34, + ), + ); + + await relay2.startServer( + nip85Assertions: { + '${provider1Key.publicKey}:$eventId': eventAssertion1, + '${provider2Key.publicKey}:$eventId': eventAssertion2, + }, + ); + + final metrics = await ndk.ta.getEventMetrics( + eventId, + providers: [ + Nip85TrustedProvider( + kind: Nip85Kind.event, + metric: Nip85Metric.commentCount, + pubkey: provider1Key.publicKey, + relay: relay2.url, + ), + Nip85TrustedProvider( + kind: Nip85Kind.event, + metric: Nip85Metric.reactionCount, + pubkey: provider2Key.publicKey, + relay: relay2.url, + ), + ], + ); + + expect(metrics, isNotNull); + expect(metrics!.commentCount, equals(12)); + expect(metrics.reactionCount, equals(34)); + + await relay2.stopServer(); + }, + ); }); } diff --git a/packages/ndk/test/usecases/ta/trusted_assertions_test_2.dart b/packages/ndk/test/usecases/ta/trusted_assertions_test_2.dart index 7aa07447f..eaa49927d 100644 --- a/packages/ndk/test/usecases/ta/trusted_assertions_test_2.dart +++ b/packages/ndk/test/usecases/ta/trusted_assertions_test_2.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:ndk/ndk.dart'; final relay = "wss://nip85.uid.ovh"; @@ -124,8 +125,10 @@ void main() { print("Start"); - final stream = - ndk.ta.streamUserMetrics(pubkey, metrics: {Nip85Metric.reactionsCount}); + final stream = ndk.ta.streamUserMetrics( + pubkey, + metrics: {Nip85Metric.reactionsCount}, + ); stream.listen((metrics) { print('postCount: ${metrics.reactionsCount}'); diff --git a/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart b/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart index 439d218aa..3e0972511 100644 --- a/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart +++ b/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart @@ -12,18 +12,20 @@ void main() async { KeyPair key0 = Bip340.generatePrivateKey(); final UserRelayList cache0 = UserRelayList( - pubKey: key0.publicKey, - relays: {}, - createdAt: 50, - refreshedTimestamp: 0); + pubKey: key0.publicKey, + relays: {}, + createdAt: 50, + refreshedTimestamp: 0, + ); KeyPair key1 = Bip340.generatePrivateKey(); final UserRelayList cache1 = UserRelayList( - pubKey: key1.publicKey, - relays: {}, - createdAt: 100, - refreshedTimestamp: 0); + pubKey: key1.publicKey, + relays: {}, + createdAt: 100, + refreshedTimestamp: 0, + ); KeyPair key3 = Bip340.generatePrivateKey(); @@ -53,6 +55,7 @@ void main() async { tearDown(() async { await ndk.destroy(); + await relay0.stopServer(); }); test('user relay lists equal', () { @@ -75,15 +78,20 @@ void main() async { final nip65 = Nip65.fromEvent(event); final userRelayList = UserRelayList.fromNip65(nip65); - expect(userRelayList.readUrls.toList(), - ['wss://relay.read', 'wss://relay.readwrite']); - expect(userRelayList.writeUrls.toList(), - ['wss://relay.write', 'wss://relay.readwrite']); + expect(userRelayList.readUrls.toList(), [ + 'wss://relay.read', + 'wss://relay.readwrite', + ]); + expect(userRelayList.writeUrls.toList(), [ + 'wss://relay.write', + 'wss://relay.readwrite', + ]); }); test('getSingleUserRelayList - cache', () async { - final rcv = - await ndk.userRelayLists.getSingleUserRelayList(key0.publicKey); + final rcv = await ndk.userRelayLists.getSingleUserRelayList( + key0.publicKey, + ); // cache expect(rcv, equals(cache0)); @@ -111,36 +119,48 @@ void main() async { }); test('broadcastAdd/RemoveNip65Relay', () async { - ndk.accounts - .loginPrivateKey(pubkey: key3.publicKey, privkey: key3.privateKey!); + ndk.accounts.loginPrivateKey( + pubkey: key3.publicKey, + privkey: key3.privateKey!, + ); final r1 = "wss://bla1.com"; // add await ndk.userRelayLists.broadcastAddNip65Relay( - relayUrl: r1, - marker: ReadWriteMarker.readWrite, - broadcastRelays: [relay0.url]); + relayUrl: r1, + marker: ReadWriteMarker.readWrite, + broadcastRelays: [relay0.url], + ); - UserRelayList? list = await ndk.userRelayLists - .getSingleUserRelayList(key3.publicKey, forceRefresh: true); + UserRelayList? list = await ndk.userRelayLists.getSingleUserRelayList( + key3.publicKey, + forceRefresh: true, + ); expect(list!.relays.keys.contains(r1), true); expect(list.relays[r1], ReadWriteMarker.readWrite); // update marker await ndk.userRelayLists.broadcastUpdateNip65RelayMarker( - relayUrl: r1, - marker: ReadWriteMarker.readOnly, - broadcastRelays: [relay0.url]); + relayUrl: r1, + marker: ReadWriteMarker.readOnly, + broadcastRelays: [relay0.url], + ); - list = await ndk.userRelayLists - .getSingleUserRelayList(key3.publicKey, forceRefresh: true); + list = await ndk.userRelayLists.getSingleUserRelayList( + key3.publicKey, + forceRefresh: true, + ); expect(list!.relays[r1], ReadWriteMarker.readOnly); // remove await ndk.userRelayLists.broadcastRemoveNip65Relay( - relayUrl: r1, broadcastRelays: [relay0.url]); + relayUrl: r1, + broadcastRelays: [relay0.url], + ); - list = await ndk.userRelayLists - .getSingleUserRelayList(key3.publicKey, forceRefresh: true); + list = await ndk.userRelayLists.getSingleUserRelayList( + key3.publicKey, + forceRefresh: true, + ); expect(list!.relays.containsKey(r1), false); }); }); diff --git a/packages/ndk/test/usecases/zaps/zap_receipt_test.dart b/packages/ndk/test/usecases/zaps/zap_receipt_test.dart index fb0c8188c..7dd8ff909 100644 --- a/packages/ndk/test/usecases/zaps/zap_receipt_test.dart +++ b/packages/ndk/test/usecases/zaps/zap_receipt_test.dart @@ -35,14 +35,14 @@ void main() { 'pubKey': 'testSender', 'tags': [ ['lnurl', 'testLnurl'], - ['amount', '1000'] - ] - }) + ['amount', '1000'], + ], + }), ], ['p', 'testRecipient'], ['e', 'testEventId'], ['anon', 'testAnon'], - ['P', 'testSender'] + ['P', 'testSender'], ]); when(mockEvent.createdAt).thenReturn(1234567890); @@ -86,17 +86,20 @@ void main() { 'content': '', 'tags': [ ['lnurl', 'testLnurl'], - ] - }) - ] + ], + }), + ], ]); final zapReceipt = ZapReceipt.fromEvent(mockEvent); expect( - zapReceipt.isValid( - nostrPubKey: 'testPubKey', recipientLnurl: 'testLnurl'), - isTrue); + zapReceipt.isValid( + nostrPubKey: 'testPubKey', + recipientLnurl: 'testLnurl', + ), + isTrue, + ); }); test('isValid returns false for invalid pubKey', () { @@ -116,17 +119,20 @@ void main() { 'content': '', 'tags': [ ['lnurl', 'testLnurl'], - ['amount', '1500000'] - ] - }) - ] + ['amount', '1500000'], + ], + }), + ], ]); final zapReceipt = ZapReceipt.fromEvent(mockEvent); expect( - zapReceipt.isValid( - nostrPubKey: 'testPubKey', recipientLnurl: 'testLnurl'), - isFalse); + zapReceipt.isValid( + nostrPubKey: 'testPubKey', + recipientLnurl: 'testLnurl', + ), + isFalse, + ); }); test('isValid returns false for mismatched amount', () { @@ -146,17 +152,20 @@ void main() { 'content': '', 'tags': [ ['lnurl', 'testLnurl'], - ['amount', '2000000'] - ] - }) - ] + ['amount', '2000000'], + ], + }), + ], ]); final zapReceipt = ZapReceipt.fromEvent(mockEvent); expect( - zapReceipt.isValid( - nostrPubKey: 'testPubKey', recipientLnurl: 'testLnurl'), - isFalse); + zapReceipt.isValid( + nostrPubKey: 'testPubKey', + recipientLnurl: 'testLnurl', + ), + isFalse, + ); }); test('isValid returns false for mismatched lnurl', () { @@ -176,18 +185,21 @@ void main() { 'content': '', 'tags': [ ['lnurl', 'wrongLnurl'], - ['amount', '1500000'] - ] - }) - ] + ['amount', '1500000'], + ], + }), + ], ]); final zapReceipt = ZapReceipt.fromEvent(mockEvent); expect( - zapReceipt.isValid( - nostrPubKey: 'testPubKey', recipientLnurl: 'testLnurl'), - isFalse); + zapReceipt.isValid( + nostrPubKey: 'testPubKey', + recipientLnurl: 'testLnurl', + ), + isFalse, + ); }); }); } 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 f9138f597..b6cc438c7 100644 --- a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart @@ -23,13 +23,8 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart' as _i2; // 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, - ); + _FakeNip01Event_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Nip01Event]. @@ -41,91 +36,86 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { } @override - String get id => (super.noSuchMethod( - Invocation.getter(#id), - returnValue: _i3.dummyValue( - this, - Invocation.getter(#id), - ), - ) as String); + String get id => + (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i3.dummyValue(this, Invocation.getter(#id)), + ) + as String); @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i3.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); + String get pubKey => + (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) + as String); @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); + int get createdAt => + (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) + as int); @override - int get kind => (super.noSuchMethod( - Invocation.getter(#kind), - returnValue: 0, - ) as int); + int get kind => + (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); @override - List> get tags => (super.noSuchMethod( - Invocation.getter(#tags), - returnValue: >[], - ) as List>); + List> get tags => + (super.noSuchMethod( + Invocation.getter(#tags), + returnValue: >[], + ) + as List>); @override - String get content => (super.noSuchMethod( - Invocation.getter(#content), - returnValue: _i3.dummyValue( - this, - Invocation.getter(#content), - ), - ) as String); + String get content => + (super.noSuchMethod( + Invocation.getter(#content), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#content), + ), + ) + as String); @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); + List get sources => + (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) + as List); @override - List get tTags => (super.noSuchMethod( - Invocation.getter(#tTags), - returnValue: [], - ) as List); + List get tTags => + (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) + as List); @override - List get pTags => (super.noSuchMethod( - Invocation.getter(#pTags), - returnValue: [], - ) as List); + List get pTags => + (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) + as List); @override - List get replyETags => (super.noSuchMethod( - Invocation.getter(#replyETags), - returnValue: [], - ) as List); + List get replyETags => + (super.noSuchMethod( + Invocation.getter(#replyETags), + returnValue: [], + ) + as List); @override set id(String? value) => super.noSuchMethod( - Invocation.setter( - #id, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#id, value), + returnValueForMissingStub: null, + ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter( - #createdAt, - value, - ), - returnValueForMissingStub: null, - ); + Invocation.setter(#createdAt, value), + returnValueForMissingStub: null, + ); @override _i2.Nip01Event copyWith({ @@ -140,27 +130,7 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method( - #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, - [], - { + Invocation.method(#copyWith, [], { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -170,23 +140,33 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }, - ), - ), - ) as _i2.Nip01Event); + }), + 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, + }), + ), + ) + as _i2.Nip01Event); @override - List getTags(String? tag) => (super.noSuchMethod( - Invocation.method( - #getTags, - [tag], - ), - returnValue: [], - ) as List); + List getTags(String? tag) => + (super.noSuchMethod( + Invocation.method(#getTags, [tag]), + returnValue: [], + ) + as List); @override - String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( - #getFirstTag, - [name], - )) as String?); + String? getFirstTag(String? name) => + (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); } diff --git a/packages/ndk/test/usecases/zaps/zaps_test.dart b/packages/ndk/test/usecases/zaps/zaps_test.dart index 42718c06a..e4af64985 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.dart @@ -23,8 +23,7 @@ void main() { EventSigner eventSignerFactory({ String? privateKey, required String publicKey, - }) => - Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); + }) => Bip340EventSigner(privateKey: privateKey, publicKey: publicKey); group('Zaps', () { KeyPair key = Bip340.generatePrivateKey(); @@ -39,26 +38,32 @@ void main() { final link = 'https://domain.com/.well-known/lnurlp/name'; // Mock the client.get method - when(client.get(Uri.parse(link), headers: {"Accept": "application/json"})) - .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); - - when(client.get( - argThat( - TypeMatcher().having((uri) => uri.toString(), 'uri', - startsWith('https://domain.com/callback')), - ), - headers: {"Accept": "application/json"})) - .thenAnswer((_) async => http.Response( - jsonEncode({ - "status": "OK", - "successAction": { - "tag": "message", - "message": "Payment Received!" - }, - "routes": [], - "pr": "lnbc1000...." - }), - 200)); + when( + client.get(Uri.parse(link), headers: {"Accept": "application/json"}), + ).thenAnswer((_) async => http.Response(jsonEncode(response), 200)); + + when( + client.get( + argThat( + TypeMatcher().having( + (uri) => uri.toString(), + 'uri', + startsWith('https://domain.com/callback'), + ), + ), + headers: {"Accept": "application/json"}, + ), + ).thenAnswer( + (_) async => http.Response( + jsonEncode({ + "status": "OK", + "successAction": {"tag": "message", "message": "Payment Received!"}, + "routes": [], + "pr": "lnbc1000....", + }), + 200, + ), + ); final amount = 1000; final ndk = Ndk.defaultConfig(); @@ -68,13 +73,16 @@ void main() { // Logger.setLogLevel(Logger.logLevels.trace); ZapRequest zapRequest = await zaps.createZapRequest( - amountSats: amount, - eventId: 'eventId', - comment: 'comment', - signer: eventSignerFactory( - privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: 'pubKey', - relays: ['relay1', 'relay2']); + amountSats: amount, + eventId: 'eventId', + comment: 'comment', + signer: eventSignerFactory( + privateKey: key.privateKey, + publicKey: key.publicKey, + ), + pubKey: 'pubKey', + relays: ['relay1', 'relay2'], + ); var invoiceResponse = await zaps.fetchInvoice( lud16Link: link, @@ -107,20 +115,23 @@ void main() { eventId: eventId, comment: comment, signer: eventSignerFactory( - privateKey: key.privateKey, publicKey: key.publicKey), + privateKey: key.privateKey, + publicKey: key.publicKey, + ), pubKey: pubKey, relays: relays, ); expect(zapRequest, isNotNull); expect( - zapRequest.tags, - containsAll([ - ['amount', (amount * 1000).toString()], - ['e', eventId], - ['p', pubKey], - ['relays', ...relays] - ])); + zapRequest.tags, + containsAll([ + ['amount', (amount * 1000).toString()], + ['e', eventId], + ['p', pubKey], + ['relays', ...relays], + ]), + ); expect(zapRequest.content, comment); expect(zapRequest.sig, isNotNull); }); @@ -134,7 +145,9 @@ void main() { eventId: 'eventId', comment: 'comment', signer: eventSignerFactory( - privateKey: key.privateKey, publicKey: key.publicKey), + privateKey: key.privateKey, + publicKey: key.publicKey, + ), pubKey: 'pubKey', relays: ['relay1', 'relay2'], ), @@ -150,7 +163,9 @@ void main() { eventId: 'eventId', comment: 'comment', signer: eventSignerFactory( - privateKey: key.privateKey, publicKey: key.publicKey), + privateKey: key.privateKey, + publicKey: key.publicKey, + ), pubKey: 'pubKey', relays: [], ); diff --git a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart index 7a49df125..514b735c3 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart @@ -27,24 +27,14 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [Client]. @@ -56,46 +46,30 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> post( @@ -105,28 +79,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> put( @@ -136,28 +105,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> patch( @@ -167,28 +131,23 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override _i3.Future<_i2.Response> delete( @@ -198,49 +157,36 @@ class MockClient extends _i1.Mock implements _i2.Client { _i4.Encoding? encoding, }) => (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => + _i3.Future read(Uri? url, {Map? headers}) => (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future); + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); @override _i3.Future<_i6.Uint8List> readBytes( @@ -248,37 +194,27 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), - ) as _i3.Future<_i6.Uint8List>); + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); } diff --git a/packages/ndk/test/verifiers/event_id_verification_test.dart b/packages/ndk/test/verifiers/event_id_verification_test.dart index a31f10e03..61ade67f7 100644 --- a/packages/ndk/test/verifiers/event_id_verification_test.dart +++ b/packages/ndk/test/verifiers/event_id_verification_test.dart @@ -12,7 +12,7 @@ void main() { "tags": [], "content": "content", "sig": - "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607" + "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607", }); expect(await Bip340EventVerifier().verify(event), isTrue); @@ -28,7 +28,7 @@ void main() { "tags": [], "content": "content", "sig": - "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607" + "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607", }); expect(await Bip340EventVerifier().verify(event), isFalse); @@ -44,7 +44,7 @@ void main() { "tags": [], "content": "content", "sig": - "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607" + "eff0ae7d37d9739baa920fee76792974bfc0b27219a20c728dd143d8188395abb35ff6b09acbf0049a60f5c73b0a2a154f014bc02e08902c78f07f5c422ae607", }); expect(await Bip340EventVerifier().verify(event), isFalse); diff --git a/packages/ndk/test/verifiers/rust_event_verifier_test.dart b/packages/ndk/test/verifiers/rust_event_verifier_test.dart index 2b0123b7c..15001ecc0 100644 --- a/packages/ndk/test/verifiers/rust_event_verifier_test.dart +++ b/packages/ndk/test/verifiers/rust_event_verifier_test.dart @@ -8,23 +8,31 @@ void main() { late RustEventVerifier verifier; late KeyPair keyPair; + String mutateHex(String value) { + final first = value[0].toLowerCase(); + final replacement = first == 'a' ? 'b' : 'a'; + return '$replacement${value.substring(1)}'; + } + setUp(() { verifier = RustEventVerifier(); keyPair = Bip340.generatePrivateKey(); }); test('verifies valid event', () async { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // Create an event (id is calculated automatically in constructor) final event = Nip01Event( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', sig: Bip340.sign( Nip01Utils.calculateEventIdSync( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', @@ -38,10 +46,12 @@ void main() { }); test('rejects event with invalid signature', () async { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final validSig = Bip340.sign( Nip01Utils.calculateEventIdSync( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', @@ -51,12 +61,11 @@ void main() { final event = Nip01Event( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', - // Invalid signature - just change first char - sig: 'a${validSig.substring(1)}', + sig: mutateHex(validSig), ); final result = await verifier.verify(event); @@ -64,9 +73,11 @@ void main() { }); test('rejects event with wrong id', () async { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final correctId = Nip01Utils.calculateEventIdSync( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', @@ -74,7 +85,7 @@ void main() { final event = Nip01Event( pubKey: keyPair.publicKey, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + createdAt: createdAt, kind: 1, tags: [], content: 'hello world', diff --git a/packages/ndk/test/verifiers/verify_event_stream_test.dart b/packages/ndk/test/verifiers/verify_event_stream_test.dart index d1e223e57..271752b06 100644 --- a/packages/ndk/test/verifiers/verify_event_stream_test.dart +++ b/packages/ndk/test/verifiers/verify_event_stream_test.dart @@ -78,10 +78,7 @@ void main() { test('should filter out invalid events', () async { mockVerifier = MockEventVerifier(result: false); - final events = [ - createMockEvent('1'), - createMockEvent('2'), - ]; + final events = [createMockEvent('1'), createMockEvent('2')]; final inputStream = Stream.fromIterable(events); final verifyStream = VerifyEventStream( @@ -137,10 +134,7 @@ void main() { }); test('should allow multiple listeners on broadcast stream', () async { - final events = [ - createMockEvent('1'), - createMockEvent('2'), - ]; + final events = [createMockEvent('1'), createMockEvent('2')]; final inputStream = Stream.fromIterable(events); final verifyStream = VerifyEventStream( @@ -224,16 +218,22 @@ void main() { await Future.delayed(Duration(milliseconds: 50)); - expect(resultList.length, equals(1), - reason: 'First event should be processed immediately'); + expect( + resultList.length, + equals(1), + reason: 'First event should be processed immediately', + ); expect(resultList[0].content, equals('content1')); controller.add(createMockEvent('2')); await Future.delayed(Duration(milliseconds: 50)); - expect(resultList.length, equals(2), - reason: 'Second event should be processed immediately'); + expect( + resultList.length, + equals(2), + reason: 'Second event should be processed immediately', + ); expect(resultList[1].content, equals('content2')); await subscription.cancel(); @@ -241,34 +241,38 @@ void main() { }); test( - 'should process events from non-closing stream with fewer events than maxConcurrent', - () async { - final controller = StreamController(); - final resultList = []; - - final verifyStream = VerifyEventStream( - unverifiedStreamInput: controller.stream, - eventVerifier: mockVerifier, - maxConcurrent: 10, - ); - - final subscription = verifyStream().listen((event) { - resultList.add(event); - }); - - controller.add(createMockEvent('1')); - controller.add(createMockEvent('2')); - controller.add(createMockEvent('3')); - - await Future.delayed(Duration(milliseconds: 100)); - - expect(resultList.length, equals(3), + 'should process events from non-closing stream with fewer events than maxConcurrent', + () async { + final controller = StreamController(); + final resultList = []; + + final verifyStream = VerifyEventStream( + unverifiedStreamInput: controller.stream, + eventVerifier: mockVerifier, + maxConcurrent: 10, + ); + + final subscription = verifyStream().listen((event) { + resultList.add(event); + }); + + controller.add(createMockEvent('1')); + controller.add(createMockEvent('2')); + controller.add(createMockEvent('3')); + + await Future.delayed(Duration(milliseconds: 100)); + + expect( + resultList.length, + equals(3), reason: - 'All events should be processed even when count < maxConcurrent'); + 'All events should be processed even when count < maxConcurrent', + ); - await subscription.cancel(); - await controller.close(); - }); + await subscription.cancel(); + await controller.close(); + }, + ); test('should process events as they complete, not in order', () async { final controller = StreamController(); @@ -291,10 +295,16 @@ void main() { await Future.delayed(Duration(milliseconds: 50)); - expect(resultList.length, equals(1), - reason: 'Fast event should complete first'); - expect(resultList[0].content, equals('contentfast'), - reason: 'Fast event should be yielded before slow event'); + expect( + resultList.length, + equals(1), + reason: 'Fast event should complete first', + ); + expect( + resultList[0].content, + equals('contentfast'), + reason: 'Fast event should be yielded before slow event', + ); await Future.delayed(Duration(milliseconds: 80)); @@ -305,8 +315,7 @@ void main() { await controller.close(); }); - test('should handle continuous stream of events without blocking', - () async { + test('should handle continuous stream of events without blocking', () async { final controller = StreamController(); final resultList = []; @@ -326,88 +335,103 @@ void main() { await Future.delayed(Duration(milliseconds: 10)); if (i >= 2) { - expect(resultList.length, greaterThan(0), - reason: - 'Events should be processed continuously, not waiting for stream end'); + expect( + resultList.length, + greaterThan(0), + reason: + 'Events should be processed continuously, not waiting for stream end', + ); } } await Future.delayed(Duration(milliseconds: 100)); - expect(resultList.length, equals(10), - reason: 'All events should be processed from continuous stream'); - - await subscription.cancel(); - await controller.close(); - }); - - test( - 'should not deadlock when maxConcurrent is reached with non-closing stream', - () async { - final controller = StreamController(); - final resultList = []; - - final verifyStream = VerifyEventStream( - unverifiedStreamInput: controller.stream, - eventVerifier: mockVerifier, - maxConcurrent: 2, + expect( + resultList.length, + equals(10), + reason: 'All events should be processed from continuous stream', ); - final subscription = verifyStream().listen((event) { - resultList.add(event); - }); - - controller.add(createMockEvent('1')); - controller.add(createMockEvent('2')); - controller.add(createMockEvent('3')); - controller.add(createMockEvent('4')); - - await Future.delayed(Duration(milliseconds: 200)); - - expect(resultList.length, equals(4), - reason: 'Should process all events without deadlocking'); - await subscription.cancel(); await controller.close(); }); - test('should yield events immediately upon verification completion', - () async { - final controller = StreamController(); - final resultTimes = []; - - final verifyStream = VerifyEventStream( - unverifiedStreamInput: controller.stream, - eventVerifier: mockVerifier, - maxConcurrent: 5, - ); - - final subscription = verifyStream().listen((event) { - resultTimes.add(DateTime.now()); - }); - - final startTime = DateTime.now(); - - controller.add(createMockEvent('1')); - await Future.delayed(Duration(milliseconds: 20)); - controller.add(createMockEvent('2')); - await Future.delayed(Duration(milliseconds: 20)); - controller.add(createMockEvent('3')); - - await Future.delayed(Duration(milliseconds: 100)); - - expect(resultTimes.length, equals(3)); + test( + 'should not deadlock when maxConcurrent is reached with non-closing stream', + () async { + final controller = StreamController(); + final resultList = []; + + final verifyStream = VerifyEventStream( + unverifiedStreamInput: controller.stream, + eventVerifier: mockVerifier, + maxConcurrent: 2, + ); + + final subscription = verifyStream().listen((event) { + resultList.add(event); + }); + + controller.add(createMockEvent('1')); + controller.add(createMockEvent('2')); + controller.add(createMockEvent('3')); + controller.add(createMockEvent('4')); + + await Future.delayed(Duration(milliseconds: 200)); + + expect( + resultList.length, + equals(4), + reason: 'Should process all events without deadlocking', + ); + + await subscription.cancel(); + await controller.close(); + }, + ); - for (int i = 0; i < resultTimes.length; i++) { - final timeDiff = resultTimes[i].difference(startTime).inMilliseconds; - expect(timeDiff, lessThan(200), + test( + 'should yield events immediately upon verification completion', + () async { + final controller = StreamController(); + final resultTimes = []; + + final verifyStream = VerifyEventStream( + unverifiedStreamInput: controller.stream, + eventVerifier: mockVerifier, + maxConcurrent: 5, + ); + + final subscription = verifyStream().listen((event) { + resultTimes.add(DateTime.now()); + }); + + final startTime = DateTime.now(); + + controller.add(createMockEvent('1')); + await Future.delayed(Duration(milliseconds: 20)); + controller.add(createMockEvent('2')); + await Future.delayed(Duration(milliseconds: 20)); + controller.add(createMockEvent('3')); + + await Future.delayed(Duration(milliseconds: 100)); + + expect(resultTimes.length, equals(3)); + + for (int i = 0; i < resultTimes.length; i++) { + final timeDiff = resultTimes[i].difference(startTime).inMilliseconds; + expect( + timeDiff, + lessThan(200), reason: - 'Event $i should be processed within 200ms, was ${timeDiff}ms'); - } + 'Event $i should be processed within 200ms, was ${timeDiff}ms', + ); + } - await subscription.cancel(); - await controller.close(); - }); + await subscription.cancel(); + await controller.close(); + }, + ); }); } diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite.dart index cdab6619d..a3a9db11d 100644 --- a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite.dart +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite.dart @@ -25,11 +25,18 @@ import 'package:ndk/ndk.dart'; import 'package:test/test.dart'; import 'package:ndk/entities.dart'; -import 'package:ndk/domain_layer/repositories/cache_manager.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; -import 'package:ndk/data_layer/repositories/signers/bip340_event_signer.dart'; +part 'cache_manager_test_suite_clear_all.dart'; +part 'cache_manager_test_suite_contact_list.dart'; part 'cache_manager_test_suite_cashu.dart'; +part 'cache_manager_test_suite_eviction.dart'; +part 'cache_manager_test_suite_event.dart'; +part 'cache_manager_test_suite_metadata.dart'; +part 'cache_manager_test_suite_nip05.dart'; +part 'cache_manager_test_suite_relay_set.dart'; +part 'cache_manager_test_suite_search.dart'; +part 'cache_manager_test_suite_user_relay_list.dart'; /// A factory function that creates a new [CacheManager] instance for testing. typedef CacheManagerFactory = Future Function(); @@ -110,1302 +117,12 @@ void runCacheManagerTestSuite({ _runClearAllTests(() => cacheManager); }); + group('Eviction Operations', () { + _runEvictionTests(() => cacheManager); + }); + group('Cashu Operations', () { _runCashuTests(() => cacheManager); }); }); } - -// ============================================================================ -// Event Tests -// ============================================================================ - -void _runEventTests(CacheManager Function() getCacheManager, - LocalEventSignerFactory eventSignerFactory) { - test('saveEvent and loadEvent', () async { - final cacheManager = getCacheManager(); - final event = Nip01Event( - pubKey: 'test_pubkey_event_1', - kind: 1, - tags: [ - ['p', 'another_pubkey'], - ['t', 'test'], - ], - content: 'Test event content', - createdAt: 1234567890, - ); - - await cacheManager.saveEvent(event); - final loadedEvent = await cacheManager.loadEvent(event.id); - - expect(loadedEvent, isNotNull); - expect(loadedEvent!.id, equals(event.id)); - expect(loadedEvent.pubKey, equals(event.pubKey)); - expect(loadedEvent.kind, equals(event.kind)); - expect(loadedEvent.content, equals(event.content)); - expect(loadedEvent.createdAt, equals(event.createdAt)); - }); - - test('saveEvents batch operation', () async { - final cacheManager = getCacheManager(); - final events = [ - Nip01Event( - pubKey: 'pubkey_batch_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890, - ), - Nip01Event( - pubKey: 'pubkey_batch_2', - kind: 1, - tags: [], - content: 'Event 2', - createdAt: 1234567891, - ), - ]; - - await cacheManager.saveEvents(events); - - for (final event in events) { - final loaded = await cacheManager.loadEvent(event.id); - expect(loaded, isNotNull); - expect(loaded!.content, equals(event.content)); - } - }); - - test('loadEvents with pubKeys filter', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_filter_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890, - ), - Nip01Event( - pubKey: 'pubkey_filter_2', - kind: 1, - tags: [], - content: 'Event 2', - createdAt: 1234567891, - ), - Nip01Event( - pubKey: 'pubkey_filter_1', - kind: 2, - tags: [], - content: 'Event 3', - createdAt: 1234567892, - ), - ]; - - await cacheManager.saveEvents(events); - - final loadedEvents = await cacheManager.loadEvents( - pubKeys: ['pubkey_filter_1'], - ); - - expect(loadedEvents.length, equals(2)); - expect(loadedEvents.every((e) => e.pubKey == 'pubkey_filter_1'), isTrue); - }); - - test('loadEvents with kinds filter', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_kind_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890, - ), - Nip01Event( - pubKey: 'pubkey_kind_2', - kind: 2, - tags: [], - content: 'Event 2', - createdAt: 1234567891, - ), - Nip01Event( - pubKey: 'pubkey_kind_3', - kind: 1, - tags: [], - content: 'Event 3', - createdAt: 1234567892, - ), - ]; - - await cacheManager.saveEvents(events); - - final loadedEvents = await cacheManager.loadEvents(kinds: [1]); - - expect(loadedEvents.length, equals(2)); - expect(loadedEvents.every((e) => e.kind == 1), isTrue); - }); - - test('loadEvents with tags filter', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_ptag_1', - kind: 1, - tags: [ - ['p', 'target_pubkey_ptag'], - ], - content: 'Event 1', - createdAt: 1234567890, - ), - Nip01Event( - pubKey: 'pubkey_ptag_2', - kind: 1, - tags: [ - ['p', 'other_pubkey'], - ], - content: 'Event 2', - createdAt: 1234567891, - ), - ]; - - await cacheManager.saveEvents(events); - - final loadedEvents = await cacheManager.loadEvents( - tags: { - 'p': ['target_pubkey_ptag'] - }, - ); - - expect(loadedEvents.length, equals(1)); - expect(loadedEvents.first.pTags.contains('target_pubkey_ptag'), isTrue); - }); - - test('loadEvents with time range filters', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_time_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1000, - ), - Nip01Event( - pubKey: 'pubkey_time_2', - kind: 1, - tags: [], - content: 'Event 2', - createdAt: 2000, - ), - Nip01Event( - pubKey: 'pubkey_time_3', - kind: 1, - tags: [], - content: 'Event 3', - createdAt: 3000, - ), - ]; - - await cacheManager.saveEvents(events); - - // Test since filter - final eventsSince = await cacheManager.loadEvents(since: 2000); - expect(eventsSince.length, equals(2)); - - // Test until filter - final eventsUntil = await cacheManager.loadEvents(until: 2000); - expect(eventsUntil.length, equals(2)); - - // Test both filters - final eventsRange = await cacheManager.loadEvents(since: 1500, until: 2500); - expect(eventsRange.length, equals(1)); - expect(eventsRange.first.createdAt, equals(2000)); - }); - - test('loadEvents with combined filters', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_combined_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890, - ), - Nip01Event( - pubKey: 'pubkey_combined_1', - kind: 2, - tags: [], - content: 'Event 2', - createdAt: 1234567891, - ), - Nip01Event( - pubKey: 'pubkey_combined_2', - kind: 1, - tags: [], - content: 'Event 3', - createdAt: 1234567892, - ), - ]; - - await cacheManager.saveEvents(events); - - final loadedEvents = await cacheManager.loadEvents( - pubKeys: ['pubkey_combined_1'], - kinds: [1], - ); - - expect(loadedEvents.length, equals(1)); - expect(loadedEvents.first.pubKey, equals('pubkey_combined_1')); - expect(loadedEvents.first.kind, equals(1)); - }); - - test('removeEvent', () async { - final cacheManager = getCacheManager(); - final event = Nip01Event( - pubKey: 'pubkey_remove', - kind: 1, - tags: [], - content: 'Test event to remove', - createdAt: 1234567890, - ); - - await cacheManager.saveEvent(event); - expect(await cacheManager.loadEvent(event.id), isNotNull); - - await cacheManager.removeEvent(event.id); - expect(await cacheManager.loadEvent(event.id), isNull); - }); - - test('removeEvents by ids', () async { - final cacheManager = getCacheManager(); - final key1 = Bip340.generatePrivateKey(); - final key2 = Bip340.generatePrivateKey(); - final key3 = Bip340.generatePrivateKey(); - final signer1 = eventSignerFactory.create( - privateKey: key1.privateKey, publicKey: key1.publicKey); - final signer2 = eventSignerFactory.create( - privateKey: key2.privateKey, publicKey: key2.publicKey); - final signer3 = eventSignerFactory.create( - privateKey: key3.privateKey, publicKey: key3.publicKey); - - final event1 = await signer1.sign(Nip01Event( - pubKey: key1.publicKey, kind: 1, tags: [], content: 'Event 1')); - final event2 = await signer2.sign(Nip01Event( - pubKey: key2.publicKey, kind: 1, tags: [], content: 'Event 2')); - final event3 = await signer3.sign(Nip01Event( - pubKey: key3.publicKey, kind: 1, tags: [], content: 'Event 3')); - - await cacheManager.saveEvents([event1, event2, event3]); - expect(await cacheManager.loadEvent(event1.id), isNotNull); - expect(await cacheManager.loadEvent(event2.id), isNotNull); - expect(await cacheManager.loadEvent(event3.id), isNotNull); - - await cacheManager.removeEvents(ids: [event1.id, event2.id]); - - expect(await cacheManager.loadEvent(event1.id), isNull); - expect(await cacheManager.loadEvent(event2.id), isNull); - expect(await cacheManager.loadEvent(event3.id), isNotNull); - }); - - test('removeEvents with pubKeys and kinds', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'author_filter_1', - kind: 1, - tags: [], - content: 'Event 1 kind 1', - createdAt: 1000, - ), - Nip01Event( - pubKey: 'author_filter_1', - kind: 2, - tags: [], - content: 'Event 2 kind 2', - createdAt: 2000, - ), - Nip01Event( - pubKey: 'author_filter_2', - kind: 1, - tags: [], - content: 'Event 3 kind 1', - createdAt: 3000, - ), - Nip01Event( - pubKey: 'author_filter_1', - kind: 1, - tags: [], - content: 'Event 4 kind 1', - createdAt: 4000, - ), - ]; - - await cacheManager.saveEvents(events); - - // Remove all kind 1 events from author_filter_1 - await cacheManager.removeEvents( - pubKeys: ['author_filter_1'], - kinds: [1], - ); - - // Event 1 and 4 should be removed (author_filter_1, kind 1) - expect(await cacheManager.loadEvent(events[0].id), isNull); - expect(await cacheManager.loadEvent(events[3].id), isNull); - - // Event 2 should remain (author_filter_1, but kind 2) - expect(await cacheManager.loadEvent(events[1].id), isNotNull); - - // Event 3 should remain (kind 1, but author_filter_2) - expect(await cacheManager.loadEvent(events[2].id), isNotNull); - }); - - test('removeEvents with time range', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'time_filter_author', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1000, - ), - Nip01Event( - pubKey: 'time_filter_author', - kind: 1, - tags: [], - content: 'Event 2', - createdAt: 2000, - ), - Nip01Event( - pubKey: 'time_filter_author', - kind: 1, - tags: [], - content: 'Event 3', - createdAt: 3000, - ), - Nip01Event( - pubKey: 'time_filter_author', - kind: 1, - tags: [], - content: 'Event 4', - createdAt: 4000, - ), - ]; - - await cacheManager.saveEvents(events); - - // Remove events from time_filter_author before timestamp 2500 - await cacheManager.removeEvents( - pubKeys: ['time_filter_author'], - until: 2500, - ); - - // Events 1 and 2 should be removed (createdAt <= 2500) - expect(await cacheManager.loadEvent(events[0].id), isNull); - expect(await cacheManager.loadEvent(events[1].id), isNull); - - // Events 3 and 4 should remain (createdAt > 2500) - expect(await cacheManager.loadEvent(events[2].id), isNotNull); - expect(await cacheManager.loadEvent(events[3].id), isNotNull); - }); - - test('removeEvents with empty parameters does nothing', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final event = Nip01Event( - pubKey: 'empty_filter_author', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1000, - ); - - await cacheManager.saveEvent(event); - - // Empty parameters should not delete anything - await cacheManager.removeEvents(); - - expect(await cacheManager.loadEvent(event.id), isNotNull); - }); - - test('removeEvents with tags filter', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'tag_filter_author', - kind: 1, - tags: [ - ['p', 'target_pubkey_1'] - ], - content: 'Event 1 with p tag', - createdAt: 1000, - ), - Nip01Event( - pubKey: 'tag_filter_author', - kind: 1, - tags: [ - ['p', 'target_pubkey_2'] - ], - content: 'Event 2 with different p tag', - createdAt: 2000, - ), - Nip01Event( - pubKey: 'tag_filter_author', - kind: 1, - tags: [ - ['e', 'some_event_id'] - ], - content: 'Event 3 with e tag', - createdAt: 3000, - ), - Nip01Event( - pubKey: 'tag_filter_author', - kind: 1, - tags: [], - content: 'Event 4 without tags', - createdAt: 4000, - ), - ]; - - await cacheManager.saveEvents(events); - - // Remove events with p tag = target_pubkey_1 - await cacheManager.removeEvents( - tags: { - 'p': ['target_pubkey_1'] - }, - ); - - // Event 1 should be removed (has p tag with target_pubkey_1) - expect(await cacheManager.loadEvent(events[0].id), isNull); - - // Events 2, 3, 4 should remain - expect(await cacheManager.loadEvent(events[1].id), isNotNull); - expect(await cacheManager.loadEvent(events[2].id), isNotNull); - expect(await cacheManager.loadEvent(events[3].id), isNotNull); - }); - - test('removeAllEventsByPubKey', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllEvents(); - - final events = [ - Nip01Event( - pubKey: 'pubkey_remove_all_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890), - Nip01Event( - pubKey: 'pubkey_remove_all_1', - kind: 2, - tags: [], - content: 'Event 2', - createdAt: 1234567891), - Nip01Event( - pubKey: 'pubkey_remove_all_2', - kind: 1, - tags: [], - content: 'Event 3', - createdAt: 1234567892), - ]; - - await cacheManager.saveEvents(events); - await cacheManager.removeAllEventsByPubKey('pubkey_remove_all_1'); - - expect(await cacheManager.loadEvent(events[0].id), isNull); - expect(await cacheManager.loadEvent(events[1].id), isNull); - expect(await cacheManager.loadEvent(events[2].id), isNotNull); - }); - - test('removeAllEvents', () async { - final cacheManager = getCacheManager(); - final events = [ - Nip01Event( - pubKey: 'pubkey_clear_1', - kind: 1, - tags: [], - content: 'Event 1', - createdAt: 1234567890), - Nip01Event( - pubKey: 'pubkey_clear_2', - kind: 1, - tags: [], - content: 'Event 2', - createdAt: 1234567891), - ]; - - await cacheManager.saveEvents(events); - await cacheManager.removeAllEvents(); - - for (final event in events) { - expect(await cacheManager.loadEvent(event.id), isNull); - } - }); - - test('event with tags preserved correctly', () async { - final cacheManager = getCacheManager(); - final event = Nip01Event( - pubKey: 'pubkey_tags', - kind: 1, - tags: [ - ['p', 'pubkey1', 'wss://relay.com', 'alias'], - ['e', 'event_id_ref'], - ['t', 'nostr'], - ['custom', 'value1', 'value2'], - ], - content: 'Event with tags', - createdAt: 1234567890, - ); - - await cacheManager.saveEvent(event); - final loaded = await cacheManager.loadEvent(event.id); - - expect(loaded, isNotNull); - expect(loaded!.tags.length, equals(event.tags.length)); - expect(loaded.tags, equals(event.tags)); - expect(loaded.pTags, contains('pubkey1')); - expect(loaded.tTags, contains('nostr')); - }); -} - -// ============================================================================ -// Metadata Tests -// ============================================================================ - -void _runMetadataTests(CacheManager Function() getCacheManager) { - test('saveMetadata and loadMetadata', () async { - final cacheManager = getCacheManager(); - final metadata = Metadata( - pubKey: 'metadata_pubkey_1', - name: 'Test User', - displayName: 'Test Display Name', - about: 'Test about text', - picture: 'https://example.com/pic.jpg', - banner: 'https://example.com/banner.jpg', - website: 'https://example.com', - nip05: 'test@example.com', - lud16: 'test@walletofsatoshi.com', - lud06: 'lnurl1234', - updatedAt: 1234567890, - ); - - await cacheManager.saveMetadata(metadata); - final loaded = await cacheManager.loadMetadata('metadata_pubkey_1'); - - expect(loaded, isNotNull); - expect(loaded!.pubKey, equals(metadata.pubKey)); - expect(loaded.name, equals(metadata.name)); - expect(loaded.displayName, equals(metadata.displayName)); - expect(loaded.about, equals(metadata.about)); - expect(loaded.picture, equals(metadata.picture)); - expect(loaded.banner, equals(metadata.banner)); - expect(loaded.website, equals(metadata.website)); - expect(loaded.nip05, equals(metadata.nip05)); - expect(loaded.lud16, equals(metadata.lud16)); - expect(loaded.lud06, equals(metadata.lud06)); - }); - - test('saveMetadatas batch operation', () async { - final cacheManager = getCacheManager(); - final metadatas = [ - Metadata(pubKey: 'metadata_batch_1', name: 'User 1'), - Metadata(pubKey: 'metadata_batch_2', name: 'User 2'), - ]; - - await cacheManager.saveMetadatas(metadatas); - - for (final metadata in metadatas) { - final loaded = await cacheManager.loadMetadata(metadata.pubKey); - expect(loaded, isNotNull); - expect(loaded!.name, equals(metadata.name)); - } - }); - - test('loadMetadatas batch operation', () async { - final cacheManager = getCacheManager(); - final metadatas = [ - Metadata(pubKey: 'metadata_load_batch_1', name: 'User 1'), - Metadata(pubKey: 'metadata_load_batch_2', name: 'User 2'), - ]; - - await cacheManager.saveMetadatas(metadatas); - final loaded = await cacheManager.loadMetadatas([ - 'metadata_load_batch_1', - 'metadata_load_batch_2', - 'nonexistent_pubkey', - ]); - - expect(loaded.length, equals(3)); - expect(loaded[0]?.name, equals('User 1')); - expect(loaded[1]?.name, equals('User 2')); - expect(loaded[2], isNull); - }); - - test('removeMetadata', () async { - final cacheManager = getCacheManager(); - final metadata = Metadata(pubKey: 'metadata_remove', name: 'Test User'); - - await cacheManager.saveMetadata(metadata); - expect(await cacheManager.loadMetadata('metadata_remove'), isNotNull); - - await cacheManager.removeMetadata('metadata_remove'); - expect(await cacheManager.loadMetadata('metadata_remove'), isNull); - }); - - test('removeAllMetadatas', () async { - final cacheManager = getCacheManager(); - final metadatas = [ - Metadata(pubKey: 'metadata_clear_1', name: 'User 1'), - Metadata(pubKey: 'metadata_clear_2', name: 'User 2'), - ]; - - await cacheManager.saveMetadatas(metadatas); - await cacheManager.removeAllMetadatas(); - - for (final metadata in metadatas) { - expect(await cacheManager.loadMetadata(metadata.pubKey), isNull); - } - }); - - test('metadata update overwrites existing', () async { - final cacheManager = getCacheManager(); - final metadata1 = Metadata( - pubKey: 'metadata_update', - name: 'Original Name', - updatedAt: 1000, - ); - - await cacheManager.saveMetadata(metadata1); - - final metadata2 = Metadata( - pubKey: 'metadata_update', - name: 'Updated Name', - updatedAt: 2000, - ); - - await cacheManager.saveMetadata(metadata2); - - final loaded = await cacheManager.loadMetadata('metadata_update'); - expect(loaded, isNotNull); - expect(loaded!.name, equals('Updated Name')); - }); - - test('metadata preserves tags and content', () async { - final cacheManager = getCacheManager(); - final metadata = Metadata( - pubKey: 'metadata_tags_rawcontent', - name: 'Test User', - displayName: 'Test Display', - tags: [ - ['i', 'github:user123', 'abc123'], - ['i', 'twitter:handle', 'xyz789'], - ], - content: { - 'name': 'Test User', - 'display_name': 'Test Display', - 'custom_field': 'custom_value', - 'nested': {'key': 'value'}, - }, - ); - - await cacheManager.saveMetadata(metadata); - final loaded = await cacheManager.loadMetadata('metadata_tags_rawcontent'); - - expect(loaded, isNotNull); - expect(loaded!.tags.length, equals(2)); - expect(loaded.tags[0], equals(['i', 'github:user123', 'abc123'])); - expect(loaded.tags[1], equals(['i', 'twitter:handle', 'xyz789'])); - expect(loaded.content, isNotNull); - expect(loaded.content['custom_field'], equals('custom_value')); - expect(loaded.content['nested'], equals({'key': 'value'})); - }); - - test('metadata with empty tags and content', () async { - final cacheManager = getCacheManager(); - final metadata = Metadata( - pubKey: 'metadata_empty_tags', - name: 'Test User', - ); - - await cacheManager.saveMetadata(metadata); - final loaded = await cacheManager.loadMetadata('metadata_empty_tags'); - - expect(loaded, isNotNull); - expect(loaded!.tags, isEmpty); - expect(loaded.content, isNotEmpty); - expect(loaded.content['name'], equals('Test User')); - }); -} - -// ============================================================================ -// ContactList Tests -// ============================================================================ - -void _runContactListTests(CacheManager Function() getCacheManager) { - test('saveContactList and loadContactList', () async { - final cacheManager = getCacheManager(); - final contactList = ContactList( - pubKey: 'contact_list_pubkey_1', - contacts: ['contact1', 'contact2', 'contact3'], - ); - contactList.createdAt = 1234567890; - contactList.petnames = ['Alice', 'Bob', 'Carol']; - contactList.followedTags = ['nostr', 'bitcoin']; - - await cacheManager.saveContactList(contactList); - final loaded = await cacheManager.loadContactList('contact_list_pubkey_1'); - - expect(loaded, isNotNull); - expect(loaded!.pubKey, equals(contactList.pubKey)); - expect(loaded.contacts, equals(contactList.contacts)); - expect(loaded.createdAt, equals(contactList.createdAt)); - expect(loaded.petnames, equals(contactList.petnames)); - expect(loaded.followedTags, equals(contactList.followedTags)); - }); - - test('saveContactLists batch operation', () async { - final cacheManager = getCacheManager(); - final contactLists = [ - ContactList(pubKey: 'contact_batch_1', contacts: ['c1']), - ContactList(pubKey: 'contact_batch_2', contacts: ['c2']), - ]; - - await cacheManager.saveContactLists(contactLists); - - for (final contactList in contactLists) { - final loaded = await cacheManager.loadContactList(contactList.pubKey); - expect(loaded, isNotNull); - expect(loaded!.contacts, equals(contactList.contacts)); - } - }); - - test('removeContactList', () async { - final cacheManager = getCacheManager(); - final contactList = ContactList( - pubKey: 'contact_remove', - contacts: ['contact1'], - ); - - await cacheManager.saveContactList(contactList); - expect(await cacheManager.loadContactList('contact_remove'), isNotNull); - - await cacheManager.removeContactList('contact_remove'); - expect(await cacheManager.loadContactList('contact_remove'), isNull); - }); - - test('removeAllContactLists', () async { - final cacheManager = getCacheManager(); - final contactLists = [ - ContactList(pubKey: 'contact_clear_1', contacts: ['c1']), - ContactList(pubKey: 'contact_clear_2', contacts: ['c2']), - ]; - - await cacheManager.saveContactLists(contactLists); - await cacheManager.removeAllContactLists(); - - for (final contactList in contactLists) { - expect(await cacheManager.loadContactList(contactList.pubKey), isNull); - } - }); -} - -// ============================================================================ -// Nip05 Tests -// ============================================================================ - -void _runNip05Tests(CacheManager Function() getCacheManager) { - test('saveNip05 and loadNip05', () async { - final cacheManager = getCacheManager(); - final nip05 = Nip05( - pubKey: 'nip05_pubkey_1', - nip05: 'test@example.com', - valid: true, - networkFetchTime: 1234567890, - relays: ['wss://relay1.com', 'wss://relay2.com'], - ); - - await cacheManager.saveNip05(nip05); - final loaded = await cacheManager.loadNip05(pubKey: 'nip05_pubkey_1'); - - expect(loaded, isNotNull); - expect(loaded!.pubKey, equals(nip05.pubKey)); - expect(loaded.nip05, equals(nip05.nip05)); - expect(loaded.valid, equals(nip05.valid)); - expect(loaded.networkFetchTime, equals(nip05.networkFetchTime)); - expect(loaded.relays, equals(nip05.relays)); - }); - - test('loadNip05 by identifier', () async { - final cacheManager = getCacheManager(); - final nip05 = Nip05( - pubKey: 'nip05_id_pubkey', - nip05: 'testuser@example.com', - valid: true, - networkFetchTime: 1234567890, - relays: ['wss://relay1.com'], - ); - - await cacheManager.saveNip05(nip05); - final loaded = - await cacheManager.loadNip05(identifier: 'testuser@example.com'); - - expect(loaded, isNotNull); - expect(loaded!.pubKey, equals(nip05.pubKey)); - expect(loaded.nip05, equals(nip05.nip05)); - expect(loaded.valid, equals(nip05.valid)); - }); - - test('saveNip05s batch operation', () async { - final cacheManager = getCacheManager(); - final nip05s = [ - Nip05(pubKey: 'nip05_batch_1', nip05: 'user1@example.com', valid: true), - Nip05(pubKey: 'nip05_batch_2', nip05: 'user2@example.com', valid: false), - ]; - - await cacheManager.saveNip05s(nip05s); - - for (final nip05 in nip05s) { - final loaded = await cacheManager.loadNip05(pubKey: nip05.pubKey); - expect(loaded, isNotNull); - expect(loaded!.nip05, equals(nip05.nip05)); - expect(loaded.valid, equals(nip05.valid)); - } - }); - - test('loadNip05s batch operation', () async { - final cacheManager = getCacheManager(); - final nip05s = [ - Nip05(pubKey: 'nip05_load_1', nip05: 'user1@example.com', valid: true), - Nip05(pubKey: 'nip05_load_2', nip05: 'user2@example.com', valid: false), - ]; - - await cacheManager.saveNip05s(nip05s); - final loaded = await cacheManager.loadNip05s([ - 'nip05_load_1', - 'nip05_load_2', - 'nonexistent', - ]); - - expect(loaded.length, equals(3)); - expect(loaded[0]?.nip05, equals('user1@example.com')); - expect(loaded[1]?.nip05, equals('user2@example.com')); - expect(loaded[2], isNull); - }); - - test('removeNip05', () async { - final cacheManager = getCacheManager(); - final nip05 = Nip05( - pubKey: 'nip05_remove', - nip05: 'test@example.com', - valid: true, - ); - - await cacheManager.saveNip05(nip05); - expect(await cacheManager.loadNip05(pubKey: 'nip05_remove'), isNotNull); - - await cacheManager.removeNip05('nip05_remove'); - expect(await cacheManager.loadNip05(pubKey: 'nip05_remove'), isNull); - }); - - test('removeAllNip05s', () async { - final cacheManager = getCacheManager(); - final nip05s = [ - Nip05(pubKey: 'nip05_clear_1', nip05: 'u1@ex.com', valid: true), - Nip05(pubKey: 'nip05_clear_2', nip05: 'u2@ex.com', valid: false), - ]; - - await cacheManager.saveNip05s(nip05s); - await cacheManager.removeAllNip05s(); - - for (final nip05 in nip05s) { - expect(await cacheManager.loadNip05(pubKey: nip05.pubKey), isNull); - } - }); -} - -// ============================================================================ -// UserRelayList Tests -// ============================================================================ - -void _runUserRelayListTests(CacheManager Function() getCacheManager) { - test('saveUserRelayList and loadUserRelayList', () async { - final cacheManager = getCacheManager(); - final userRelayList = UserRelayList( - pubKey: 'relay_list_pubkey_1', - createdAt: 1234567890, - refreshedTimestamp: 1234567895, - relays: { - 'wss://relay1.com': ReadWriteMarker.readWrite, - 'wss://relay2.com': ReadWriteMarker.readOnly, - 'wss://relay3.com': ReadWriteMarker.writeOnly, - }, - ); - - await cacheManager.saveUserRelayList(userRelayList); - final loaded = await cacheManager.loadUserRelayList('relay_list_pubkey_1'); - - expect(loaded, isNotNull); - expect(loaded!.pubKey, equals(userRelayList.pubKey)); - expect(loaded.createdAt, equals(userRelayList.createdAt)); - expect(loaded.relays.length, equals(3)); - expect( - loaded.relays['wss://relay1.com'], equals(ReadWriteMarker.readWrite)); - expect(loaded.relays['wss://relay2.com'], equals(ReadWriteMarker.readOnly)); - expect( - loaded.relays['wss://relay3.com'], equals(ReadWriteMarker.writeOnly)); - }); - - test('saveUserRelayLists batch operation', () async { - final cacheManager = getCacheManager(); - final userRelayLists = [ - UserRelayList( - pubKey: 'relay_batch_1', - createdAt: 1234567890, - refreshedTimestamp: 1234567890, - relays: {'wss://relay1.com': ReadWriteMarker.readWrite}, - ), - UserRelayList( - pubKey: 'relay_batch_2', - createdAt: 1234567891, - refreshedTimestamp: 1234567891, - relays: {'wss://relay2.com': ReadWriteMarker.readOnly}, - ), - ]; - - await cacheManager.saveUserRelayLists(userRelayLists); - - for (final userRelayList in userRelayLists) { - final loaded = await cacheManager.loadUserRelayList(userRelayList.pubKey); - expect(loaded, isNotNull); - expect(loaded!.relays.length, equals(1)); - } - }); - - test('removeUserRelayList', () async { - final cacheManager = getCacheManager(); - final userRelayList = UserRelayList( - pubKey: 'relay_remove', - createdAt: 1234567890, - refreshedTimestamp: 1234567890, - relays: {'wss://relay.com': ReadWriteMarker.readWrite}, - ); - - await cacheManager.saveUserRelayList(userRelayList); - expect(await cacheManager.loadUserRelayList('relay_remove'), isNotNull); - - await cacheManager.removeUserRelayList('relay_remove'); - expect(await cacheManager.loadUserRelayList('relay_remove'), isNull); - }); - - test('removeAllUserRelayLists', () async { - final cacheManager = getCacheManager(); - final userRelayLists = [ - UserRelayList( - pubKey: 'relay_clear_1', - createdAt: 1234567890, - refreshedTimestamp: 1234567890, - relays: {}, - ), - UserRelayList( - pubKey: 'relay_clear_2', - createdAt: 1234567891, - refreshedTimestamp: 1234567891, - relays: {}, - ), - ]; - - await cacheManager.saveUserRelayLists(userRelayLists); - await cacheManager.removeAllUserRelayLists(); - - for (final userRelayList in userRelayLists) { - expect( - await cacheManager.loadUserRelayList(userRelayList.pubKey), isNull); - } - }); -} - -// ============================================================================ -// RelaySet Tests -// ============================================================================ - -void _runRelaySetTests(CacheManager Function() getCacheManager) { - test('saveRelaySet and loadRelaySet', () async { - final cacheManager = getCacheManager(); - final relaySet = RelaySet( - name: 'test_set', - pubKey: 'relay_set_pubkey_1', - relayMinCountPerPubkey: 2, - direction: RelayDirection.outbox, - relaysMap: { - 'wss://relay1.com': [ - PubkeyMapping(pubKey: 'user1', rwMarker: ReadWriteMarker.readWrite), - PubkeyMapping(pubKey: 'user2', rwMarker: ReadWriteMarker.readOnly), - ], - 'wss://relay2.com': [ - PubkeyMapping(pubKey: 'user3', rwMarker: ReadWriteMarker.writeOnly), - ], - }, - notCoveredPubkeys: [], - fallbackToBootstrapRelays: true, - ); - - await cacheManager.saveRelaySet(relaySet); - final loaded = - await cacheManager.loadRelaySet('test_set', 'relay_set_pubkey_1'); - - expect(loaded, isNotNull); - expect(loaded!.name, equals(relaySet.name)); - expect(loaded.pubKey, equals(relaySet.pubKey)); - expect(loaded.relayMinCountPerPubkey, equals(2)); - expect(loaded.direction, equals(RelayDirection.outbox)); - expect(loaded.relaysMap.length, equals(2)); - expect(loaded.relaysMap['wss://relay1.com']?.length, equals(2)); - }); - - test('removeRelaySet', () async { - final cacheManager = getCacheManager(); - final relaySet = RelaySet( - name: 'set_to_remove', - pubKey: 'relay_set_remove', - relayMinCountPerPubkey: 1, - direction: RelayDirection.inbox, - relaysMap: {}, - notCoveredPubkeys: [], - ); - - await cacheManager.saveRelaySet(relaySet); - expect( - await cacheManager.loadRelaySet('set_to_remove', 'relay_set_remove'), - isNotNull, - ); - - await cacheManager.removeRelaySet('set_to_remove', 'relay_set_remove'); - expect( - await cacheManager.loadRelaySet('set_to_remove', 'relay_set_remove'), - isNull, - ); - }); - - test('removeAllRelaySets', () async { - final cacheManager = getCacheManager(); - final relaySets = [ - RelaySet( - name: 'set_clear_1', - pubKey: 'relay_set_clear_1', - relayMinCountPerPubkey: 1, - direction: RelayDirection.inbox, - relaysMap: {}, - notCoveredPubkeys: [], - ), - RelaySet( - name: 'set_clear_2', - pubKey: 'relay_set_clear_2', - relayMinCountPerPubkey: 1, - direction: RelayDirection.outbox, - relaysMap: {}, - notCoveredPubkeys: [], - ), - ]; - - for (final relaySet in relaySets) { - await cacheManager.saveRelaySet(relaySet); - } - await cacheManager.removeAllRelaySets(); - - for (final relaySet in relaySets) { - expect( - await cacheManager.loadRelaySet(relaySet.name, relaySet.pubKey), - isNull, - ); - } - }); -} - -// ============================================================================ -// Search Tests -// ============================================================================ - -void _runSearchTests(CacheManager Function() getCacheManager) { - test('searchMetadatas by name', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllMetadatas(); - - final metadatas = [ - Metadata( - pubKey: 'search_meta_1', name: 'Alice Smith', displayName: 'Alice'), - Metadata( - pubKey: 'search_meta_2', name: 'Bob Jones', displayName: 'Bobby'), - Metadata( - pubKey: 'search_meta_3', - name: 'Alice Wonder', - nip05: 'alice@example.com'), - ]; - - await cacheManager.saveMetadatas(metadatas); - - final aliceResults = await cacheManager.searchMetadatas('Alice', 10); - expect(aliceResults.length, greaterThanOrEqualTo(2)); - expect( - aliceResults.every((m) => - m.name?.toLowerCase().contains('alice') == true || - m.displayName?.toLowerCase().contains('alice') == true || - m.nip05?.toLowerCase().contains('alice') == true), - isTrue); - }); - - test('searchMetadatas with limit', () async { - final cacheManager = getCacheManager(); - await cacheManager.removeAllMetadatas(); - - final metadatas = List.generate( - 5, - (i) => Metadata(pubKey: 'search_limit_$i', name: 'User $i'), - ); - - await cacheManager.saveMetadatas(metadatas); - - final results = await cacheManager.searchMetadatas('User', 2); - expect(results.length, lessThanOrEqualTo(2)); - }); -} - -// ============================================================================ -// ClearAll Tests -// ============================================================================ - -void _runClearAllTests(CacheManager Function() getCacheManager) { - test('clearAll removes all cached data', () async { - final cacheManager = getCacheManager(); - - // Save data to all cache types - final event = Nip01Event( - pubKey: 'clearall_event_pubkey', - kind: 1, - tags: [], - content: 'Test event', - createdAt: 1234567890, - ); - await cacheManager.saveEvent(event); - - final metadata = Metadata(pubKey: 'clearall_metadata_pubkey', name: 'Test'); - await cacheManager.saveMetadata(metadata); - - final contactList = ContactList( - pubKey: 'clearall_contact_pubkey', - contacts: ['contact1'], - ); - await cacheManager.saveContactList(contactList); - - final nip05 = Nip05( - pubKey: 'clearall_nip05_pubkey', - nip05: 'test@example.com', - valid: true, - ); - await cacheManager.saveNip05(nip05); - - final userRelayList = UserRelayList( - pubKey: 'clearall_relay_pubkey', - createdAt: 1234567890, - refreshedTimestamp: 1234567890, - relays: {'wss://relay.com': ReadWriteMarker.readWrite}, - ); - await cacheManager.saveUserRelayList(userRelayList); - - final relaySet = RelaySet( - name: 'clearall_set', - pubKey: 'clearall_relayset_pubkey', - relayMinCountPerPubkey: 1, - direction: RelayDirection.inbox, - relaysMap: {}, - notCoveredPubkeys: [], - ); - await cacheManager.saveRelaySet(relaySet); - - // Save cashu data - final keyset = CahsuKeyset( - id: 'clearall_keyset', - mintUrl: 'https://clearall.mint.com', - unit: 'sat', - active: true, - inputFeePPK: 0, - mintKeyPairs: {}, - ); - await cacheManager.saveKeyset(keyset); - - final proof = CashuProof( - keysetId: 'clearall_keyset', - amount: 100, - secret: 'clearall_secret', - unblindedSig: 'clearall_sig', - state: CashuProofState.unspend, - ); - await cacheManager - .saveProofs(proofs: [proof], mintUrl: 'https://clearall.mint.com'); - - // Verify data exists - expect(await cacheManager.loadEvent(event.id), isNotNull); - expect( - await cacheManager.loadMetadata('clearall_metadata_pubkey'), isNotNull); - expect(await cacheManager.loadContactList('clearall_contact_pubkey'), - isNotNull); - expect(await cacheManager.loadNip05(pubKey: 'clearall_nip05_pubkey'), - isNotNull); - expect(await cacheManager.loadUserRelayList('clearall_relay_pubkey'), - isNotNull); - expect( - await cacheManager.loadRelaySet( - 'clearall_set', 'clearall_relayset_pubkey'), - isNotNull); - expect( - (await cacheManager.getKeysets(mintUrl: 'https://clearall.mint.com')) - .length, - equals(1)); - expect( - (await cacheManager.getProofs(mintUrl: 'https://clearall.mint.com')) - .length, - equals(1)); - - // Clear all - await cacheManager.clearAll(); - - // Verify all data is removed - expect(await cacheManager.loadEvent(event.id), isNull); - expect(await cacheManager.loadMetadata('clearall_metadata_pubkey'), isNull); - expect( - await cacheManager.loadContactList('clearall_contact_pubkey'), isNull); - expect( - await cacheManager.loadNip05(pubKey: 'clearall_nip05_pubkey'), isNull); - expect( - await cacheManager.loadUserRelayList('clearall_relay_pubkey'), isNull); - expect( - await cacheManager.loadRelaySet( - 'clearall_set', 'clearall_relayset_pubkey'), - isNull); - expect( - (await cacheManager.getKeysets(mintUrl: 'https://clearall.mint.com')) - .length, - equals(0)); - expect( - (await cacheManager.getProofs(mintUrl: 'https://clearall.mint.com')) - .length, - equals(0)); - }); -} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_cashu.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_cashu.dart index 3f26bd36c..344992f58 100644 --- a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_cashu.dart +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_cashu.dart @@ -17,8 +17,9 @@ void _runCashuTests(CacheManager Function() getCacheManager) { ); await cacheManager.saveKeyset(keyset); - final loadedKeysets = - await cacheManager.getKeysets(mintUrl: 'https://test.mint.com'); + final loadedKeysets = await cacheManager.getKeysets( + mintUrl: 'https://test.mint.com', + ); expect(loadedKeysets.length, equals(1)); expect(loadedKeysets[0].id, equals(keyset.id)); @@ -71,8 +72,10 @@ void _runCashuTests(CacheManager Function() getCacheManager) { ); await cacheManager.saveKeyset(cashuKeyset); - await cacheManager - .saveProofs(proofs: [proof], mintUrl: 'https://test.mint.com'); + await cacheManager.saveProofs( + proofs: [proof], + mintUrl: 'https://test.mint.com', + ); final loadedProofs = await cacheManager.getProofs( mintUrl: 'https://test.mint.com', state: CashuProofState.unspend, @@ -110,8 +113,10 @@ void _runCashuTests(CacheManager Function() getCacheManager) { ); await cacheManager.saveKeyset(cashuKeyset); - await cacheManager - .saveProofs(proofs: [proof1, proof2], mintUrl: 'https://mint.com'); + await cacheManager.saveProofs( + proofs: [proof1, proof2], + mintUrl: 'https://mint.com', + ); final unspendProofs = await cacheManager.getProofs( mintUrl: 'https://mint.com', @@ -139,8 +144,9 @@ void _runCashuTests(CacheManager Function() getCacheManager) { ); await cacheManager.saveMintInfo(mintInfo: mintInfo); - final loadedInfos = - await cacheManager.getMintInfos(mintUrls: ['https://mint.info.com']); + final loadedInfos = await cacheManager.getMintInfos( + mintUrls: ['https://mint.info.com'], + ); expect(loadedInfos!.length, equals(1)); expect(loadedInfos[0].urls, equals(mintInfo.urls)); expect(loadedInfos[0].name, equals(mintInfo.name)); @@ -152,20 +158,26 @@ void _runCashuTests(CacheManager Function() getCacheManager) { const initialCounter = 5; await cacheManager.setCashuSecretCounter( - mintUrl: 'https://counter.mint.com', - keysetId: keysetId, - counter: initialCounter); + mintUrl: 'https://counter.mint.com', + keysetId: keysetId, + counter: initialCounter, + ); final loadedCounter = await cacheManager.getCashuSecretCounter( - mintUrl: "https://counter.mint.com", keysetId: keysetId); + mintUrl: "https://counter.mint.com", + keysetId: keysetId, + ); expect(loadedCounter, equals(initialCounter)); const newCounter = 10; await cacheManager.setCashuSecretCounter( - mintUrl: 'https://counter.mint.com', - keysetId: keysetId, - counter: newCounter); + mintUrl: 'https://counter.mint.com', + keysetId: keysetId, + counter: newCounter, + ); final updatedCounter = await cacheManager.getCashuSecretCounter( - mintUrl: "https://counter.mint.com", keysetId: keysetId); + mintUrl: "https://counter.mint.com", + keysetId: keysetId, + ); expect(updatedCounter, equals(newCounter)); }); @@ -189,20 +201,28 @@ void _runCashuTests(CacheManager Function() getCacheManager) { ); await cacheManager.saveKeyset(cashuKeyset); - await cacheManager - .saveProofs(proofs: [proof], mintUrl: 'https://upsert.mint.com'); + await cacheManager.saveProofs( + proofs: [proof], + mintUrl: 'https://upsert.mint.com', + ); // Update the state proof.state = CashuProofState.pending; - await cacheManager - .saveProofs(proofs: [proof], mintUrl: 'https://upsert.mint.com'); + await cacheManager.saveProofs( + proofs: [proof], + mintUrl: 'https://upsert.mint.com', + ); final loadedProofsUnspend = await cacheManager.getProofs( - mintUrl: 'https://upsert.mint.com', state: CashuProofState.unspend); + mintUrl: 'https://upsert.mint.com', + state: CashuProofState.unspend, + ); expect(loadedProofsUnspend.length, equals(0)); final loadedProofsPending = await cacheManager.getProofs( - mintUrl: 'https://upsert.mint.com', state: CashuProofState.pending); + mintUrl: 'https://upsert.mint.com', + state: CashuProofState.pending, + ); expect(loadedProofsPending.length, equals(1)); expect(loadedProofsPending[0].state, equals(CashuProofState.pending)); }); diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_clear_all.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_clear_all.dart new file mode 100644 index 000000000..b5a6e08e5 --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_clear_all.dart @@ -0,0 +1,147 @@ +part of 'cache_manager_test_suite.dart'; + +// ignore_for_file: deprecated_member_use + +void _runClearAllTests(CacheManager Function() getCacheManager) { + test('clearAll removes all cached data', () async { + final cacheManager = getCacheManager(); + + final event = Nip01Event( + pubKey: 'clearall_event_pubkey', + kind: 1, + tags: [], + content: 'Test event', + createdAt: 1234567890, + ); + await cacheManager.saveEvent(event); + + final metadata = Metadata(pubKey: 'clearall_metadata_pubkey', name: 'Test'); + await cacheManager.saveMetadata(metadata); + + final contactList = ContactList( + pubKey: 'clearall_contact_pubkey', + contacts: ['contact1'], + ); + await cacheManager.saveContactList(contactList); + + final nip05 = Nip05( + pubKey: 'clearall_nip05_pubkey', + nip05: 'test@example.com', + valid: true, + ); + await cacheManager.saveNip05(nip05); + + final userRelayList = UserRelayList( + pubKey: 'clearall_relay_pubkey', + createdAt: 1234567890, + refreshedTimestamp: 1234567890, + relays: {'wss://relay.com': ReadWriteMarker.readWrite}, + ); + await cacheManager.saveUserRelayList(userRelayList); + + final relaySet = RelaySet( + name: 'clearall_set', + pubKey: 'clearall_relayset_pubkey', + relayMinCountPerPubkey: 1, + direction: RelayDirection.inbox, + relaysMap: {}, + notCoveredPubkeys: [], + ); + await cacheManager.saveRelaySet(relaySet); + + final keyset = CahsuKeyset( + id: 'clearall_keyset', + mintUrl: 'https://clearall.mint.com', + unit: 'sat', + active: true, + inputFeePPK: 0, + mintKeyPairs: {}, + ); + await cacheManager.saveKeyset(keyset); + + final proof = CashuProof( + keysetId: 'clearall_keyset', + amount: 100, + secret: 'clearall_secret', + unblindedSig: 'clearall_sig', + state: CashuProofState.unspend, + ); + await cacheManager.saveProofs( + proofs: [proof], + mintUrl: 'https://clearall.mint.com', + ); + + expect(await cacheManager.loadEvent(event.id), isNotNull); + expect( + await cacheManager.loadMetadata('clearall_metadata_pubkey'), + isNotNull, + ); + expect( + await cacheManager.loadContactList('clearall_contact_pubkey'), + isNotNull, + ); + expect( + await cacheManager.loadNip05(pubKey: 'clearall_nip05_pubkey'), + isNotNull, + ); + expect( + await cacheManager.loadUserRelayList('clearall_relay_pubkey'), + isNotNull, + ); + expect( + await cacheManager.loadRelaySet( + 'clearall_set', + 'clearall_relayset_pubkey', + ), + isNotNull, + ); + expect( + (await cacheManager.getKeysets( + mintUrl: 'https://clearall.mint.com', + )).length, + equals(1), + ); + expect( + (await cacheManager.getProofs( + mintUrl: 'https://clearall.mint.com', + )).length, + equals(1), + ); + + await cacheManager.clearAll(); + + expect(await cacheManager.loadEvent(event.id), isNull); + expect(await cacheManager.loadMetadata('clearall_metadata_pubkey'), isNull); + expect( + await cacheManager.loadContactList('clearall_contact_pubkey'), + isNull, + ); + expect( + await cacheManager.loadNip05(pubKey: 'clearall_nip05_pubkey'), + isNull, + ); + expect( + await cacheManager.loadUserRelayList('clearall_relay_pubkey'), + isNull, + ); + expect( + await cacheManager.loadRelaySet( + 'clearall_set', + 'clearall_relayset_pubkey', + ), + isNull, + ); + expect( + (await cacheManager.getKeysets( + mintUrl: 'https://clearall.mint.com', + )).length, + equals(0), + ); + expect( + (await cacheManager.getProofs( + mintUrl: 'https://clearall.mint.com', + )).length, + equals(0), + ); + }); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_contact_list.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_contact_list.dart new file mode 100644 index 000000000..f659ec127 --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_contact_list.dart @@ -0,0 +1,94 @@ +part of 'cache_manager_test_suite.dart'; + +// ignore_for_file: deprecated_member_use + +void _runContactListTests(CacheManager Function() getCacheManager) { + test('saveContactList and loadContactList', () async { + final cacheManager = getCacheManager(); + final contactList = ContactList( + pubKey: 'contact_list_pubkey_1', + contacts: ['contact1', 'contact2', 'contact3'], + ); + contactList.createdAt = 1234567890; + contactList.petnames = ['Alice', 'Bob', 'Carol']; + contactList.followedTags = ['nostr', 'bitcoin']; + + await cacheManager.saveContactList(contactList); + final loaded = await cacheManager.loadContactList('contact_list_pubkey_1'); + + expect(loaded, isNotNull); + expect(loaded!.pubKey, equals(contactList.pubKey)); + expect(loaded.contacts, equals(contactList.contacts)); + expect(loaded.createdAt, equals(contactList.createdAt)); + expect(loaded.petnames, equals(contactList.petnames)); + expect(loaded.followedTags, equals(contactList.followedTags)); + }); + + test('saveContactLists batch operation', () async { + final cacheManager = getCacheManager(); + final contactLists = [ + ContactList(pubKey: 'contact_batch_1', contacts: ['c1']), + ContactList(pubKey: 'contact_batch_2', contacts: ['c2']), + ]; + + await cacheManager.saveContactLists(contactLists); + + for (final contactList in contactLists) { + final loaded = await cacheManager.loadContactList(contactList.pubKey); + expect(loaded, isNotNull); + expect(loaded!.contacts, equals(contactList.contacts)); + } + }); + + test('removeContactList', () async { + final cacheManager = getCacheManager(); + final contactList = ContactList( + pubKey: 'contact_remove', + contacts: ['contact1'], + ); + + await cacheManager.saveContactList(contactList); + expect(await cacheManager.loadContactList('contact_remove'), isNotNull); + + await cacheManager.removeContactList('contact_remove'); + expect(await cacheManager.loadContactList('contact_remove'), isNull); + }); + + test('removeAllContactLists', () async { + final cacheManager = getCacheManager(); + final contactLists = [ + ContactList(pubKey: 'contact_clear_1', contacts: ['c1']), + ContactList(pubKey: 'contact_clear_2', contacts: ['c2']), + ]; + + await cacheManager.saveContactLists(contactLists); + await cacheManager.removeAllContactLists(); + + for (final contactList in contactLists) { + expect(await cacheManager.loadContactList(contactList.pubKey), isNull); + } + }); + + test( + 'loadContactList reads latest visible generic contact list event', + () async { + final cacheManager = getCacheManager(); + final older = ContactList( + pubKey: 'contact_from_event', + contacts: ['old-contact'], + )..createdAt = 1000; + final newer = + ContactList(pubKey: 'contact_from_event', contacts: ['new-contact']) + ..createdAt = 2000 + ..followedTags = ['nostr']; + + await cacheManager.saveEvents([older.toEvent(), newer.toEvent()]); + + final loaded = await cacheManager.loadContactList('contact_from_event'); + expect(loaded, isNotNull); + expect(loaded!.contacts, equals(['new-contact'])); + expect(loaded.followedTags, equals(['nostr'])); + expect(loaded.createdAt, equals(2000)); + }, + ); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_event.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_event.dart new file mode 100644 index 000000000..032029cad --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_event.dart @@ -0,0 +1,1113 @@ +part of 'cache_manager_test_suite.dart'; + +void _runEventTests( + CacheManager Function() getCacheManager, + LocalEventSignerFactory eventSignerFactory, +) { + test('saveEvent and loadEvent', () async { + final cacheManager = getCacheManager(); + final event = Nip01Event( + pubKey: 'test_pubkey_event_1', + kind: 1, + tags: [ + ['p', 'another_pubkey'], + ['t', 'test'], + ], + content: 'Test event content', + createdAt: 1234567890, + ); + + await cacheManager.saveEvent(event); + final loadedEvent = await cacheManager.loadEvent(event.id); + + expect(loadedEvent, isNotNull); + expect(loadedEvent!.id, equals(event.id)); + expect(loadedEvent.pubKey, equals(event.pubKey)); + expect(loadedEvent.kind, equals(event.kind)); + expect(loadedEvent.content, equals(event.content)); + expect(loadedEvent.createdAt, equals(event.createdAt)); + }); + + test('saveEvents batch operation', () async { + final cacheManager = getCacheManager(); + final events = [ + Nip01Event( + pubKey: 'pubkey_batch_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_batch_2', + kind: 1, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + ]; + + await cacheManager.saveEvents(events); + + for (final event in events) { + final loaded = await cacheManager.loadEvent(event.id); + expect(loaded, isNotNull); + expect(loaded!.content, equals(event.content)); + } + }); + + test('loadEvents with pubKeys filter', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_filter_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_filter_2', + kind: 1, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + Nip01Event( + pubKey: 'pubkey_filter_1', + kind: 2, + tags: [], + content: 'Event 3', + createdAt: 1234567892, + ), + ]; + + await cacheManager.saveEvents(events); + + final loadedEvents = await cacheManager.loadEvents( + pubKeys: ['pubkey_filter_1'], + ); + + expect(loadedEvents.length, equals(2)); + expect(loadedEvents.every((e) => e.pubKey == 'pubkey_filter_1'), isTrue); + }); + + test('loadEvents with kinds filter', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_kind_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_kind_2', + kind: 2, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + Nip01Event( + pubKey: 'pubkey_kind_3', + kind: 1, + tags: [], + content: 'Event 3', + createdAt: 1234567892, + ), + ]; + + await cacheManager.saveEvents(events); + + final loadedEvents = await cacheManager.loadEvents(kinds: [1]); + + expect(loadedEvents.length, equals(2)); + expect(loadedEvents.every((e) => e.kind == 1), isTrue); + }); + + test('loadEvents with tags filter', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_ptag_1', + kind: 1, + tags: [ + ['p', 'target_pubkey_ptag'], + ], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_ptag_2', + kind: 1, + tags: [ + ['p', 'other_pubkey'], + ], + content: 'Event 2', + createdAt: 1234567891, + ), + ]; + + await cacheManager.saveEvents(events); + + final loadedEvents = await cacheManager.loadEvents( + tags: { + 'p': ['target_pubkey_ptag'], + }, + ); + + expect(loadedEvents.length, equals(1)); + expect(loadedEvents.first.pTags.contains('target_pubkey_ptag'), isTrue); + }); + + test('loadEvents with time range filters', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_time_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1000, + ), + Nip01Event( + pubKey: 'pubkey_time_2', + kind: 1, + tags: [], + content: 'Event 2', + createdAt: 2000, + ), + Nip01Event( + pubKey: 'pubkey_time_3', + kind: 1, + tags: [], + content: 'Event 3', + createdAt: 3000, + ), + ]; + + await cacheManager.saveEvents(events); + + final eventsSince = await cacheManager.loadEvents(since: 2000); + expect(eventsSince.length, equals(2)); + + final eventsUntil = await cacheManager.loadEvents(until: 2000); + expect(eventsUntil.length, equals(2)); + + final eventsRange = await cacheManager.loadEvents(since: 1500, until: 2500); + expect(eventsRange.length, equals(1)); + expect(eventsRange.first.createdAt, equals(2000)); + }); + + test('loadEvents with combined filters', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_combined_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_combined_1', + kind: 2, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + Nip01Event( + pubKey: 'pubkey_combined_2', + kind: 1, + tags: [], + content: 'Event 3', + createdAt: 1234567892, + ), + ]; + + await cacheManager.saveEvents(events); + + final loadedEvents = await cacheManager.loadEvents( + pubKeys: ['pubkey_combined_1'], + kinds: [1], + ); + + expect(loadedEvents.length, equals(1)); + expect(loadedEvents.first.pubKey, equals('pubkey_combined_1')); + expect(loadedEvents.first.kind, equals(1)); + }); + + test('removeEvent', () async { + final cacheManager = getCacheManager(); + final event = Nip01Event( + pubKey: 'pubkey_remove', + kind: 1, + tags: [], + content: 'Test event to remove', + createdAt: 1234567890, + ); + + await cacheManager.saveEvent(event); + expect(await cacheManager.loadEvent(event.id), isNotNull); + + await cacheManager.removeEvent(event.id); + expect(await cacheManager.loadEvent(event.id), isNull); + }); + + test('removeEvents by ids', () async { + final cacheManager = getCacheManager(); + final key1 = Bip340.generatePrivateKey(); + final key2 = Bip340.generatePrivateKey(); + final key3 = Bip340.generatePrivateKey(); + final signer1 = eventSignerFactory.create( + privateKey: key1.privateKey, + publicKey: key1.publicKey, + ); + final signer2 = eventSignerFactory.create( + privateKey: key2.privateKey, + publicKey: key2.publicKey, + ); + final signer3 = eventSignerFactory.create( + privateKey: key3.privateKey, + publicKey: key3.publicKey, + ); + + final event1 = await signer1.sign( + Nip01Event(pubKey: key1.publicKey, kind: 1, tags: [], content: 'Event 1'), + ); + final event2 = await signer2.sign( + Nip01Event(pubKey: key2.publicKey, kind: 1, tags: [], content: 'Event 2'), + ); + final event3 = await signer3.sign( + Nip01Event(pubKey: key3.publicKey, kind: 1, tags: [], content: 'Event 3'), + ); + + await cacheManager.saveEvents([event1, event2, event3]); + expect(await cacheManager.loadEvent(event1.id), isNotNull); + expect(await cacheManager.loadEvent(event2.id), isNotNull); + expect(await cacheManager.loadEvent(event3.id), isNotNull); + + await cacheManager.removeEvents(ids: [event1.id, event2.id]); + + expect(await cacheManager.loadEvent(event1.id), isNull); + expect(await cacheManager.loadEvent(event2.id), isNull); + expect(await cacheManager.loadEvent(event3.id), isNotNull); + }); + + test('removeEvents with pubKeys and kinds', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'author_filter_1', + kind: 1, + tags: [], + content: 'Event 1 kind 1', + createdAt: 1000, + ), + Nip01Event( + pubKey: 'author_filter_1', + kind: 2, + tags: [], + content: 'Event 2 kind 2', + createdAt: 2000, + ), + Nip01Event( + pubKey: 'author_filter_2', + kind: 1, + tags: [], + content: 'Event 3 kind 1', + createdAt: 3000, + ), + Nip01Event( + pubKey: 'author_filter_1', + kind: 1, + tags: [], + content: 'Event 4 kind 1', + createdAt: 4000, + ), + ]; + + await cacheManager.saveEvents(events); + + await cacheManager.removeEvents(pubKeys: ['author_filter_1'], kinds: [1]); + + expect(await cacheManager.loadEvent(events[0].id), isNull); + expect(await cacheManager.loadEvent(events[3].id), isNull); + expect(await cacheManager.loadEvent(events[1].id), isNotNull); + expect(await cacheManager.loadEvent(events[2].id), isNotNull); + }); + + test('removeEvents with time range', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'time_filter_author', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1000, + ), + Nip01Event( + pubKey: 'time_filter_author', + kind: 1, + tags: [], + content: 'Event 2', + createdAt: 2000, + ), + Nip01Event( + pubKey: 'time_filter_author', + kind: 1, + tags: [], + content: 'Event 3', + createdAt: 3000, + ), + Nip01Event( + pubKey: 'time_filter_author', + kind: 1, + tags: [], + content: 'Event 4', + createdAt: 4000, + ), + ]; + + await cacheManager.saveEvents(events); + + await cacheManager.removeEvents( + pubKeys: ['time_filter_author'], + until: 2500, + ); + + expect(await cacheManager.loadEvent(events[0].id), isNull); + expect(await cacheManager.loadEvent(events[1].id), isNull); + expect(await cacheManager.loadEvent(events[2].id), isNotNull); + expect(await cacheManager.loadEvent(events[3].id), isNotNull); + }); + + test('removeEvents with empty parameters does nothing', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final event = Nip01Event( + pubKey: 'empty_filter_author', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1000, + ); + + await cacheManager.saveEvent(event); + await cacheManager.removeEvents(); + + expect(await cacheManager.loadEvent(event.id), isNotNull); + }); + + test('removeEvents with tags filter', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'tag_filter_author', + kind: 1, + tags: [ + ['p', 'target_pubkey_1'], + ], + content: 'Event 1 with p tag', + createdAt: 1000, + ), + Nip01Event( + pubKey: 'tag_filter_author', + kind: 1, + tags: [ + ['p', 'target_pubkey_2'], + ], + content: 'Event 2 with different p tag', + createdAt: 2000, + ), + Nip01Event( + pubKey: 'tag_filter_author', + kind: 1, + tags: [ + ['e', 'some_event_id'], + ], + content: 'Event 3 with e tag', + createdAt: 3000, + ), + Nip01Event( + pubKey: 'tag_filter_author', + kind: 1, + tags: [], + content: 'Event 4 without tags', + createdAt: 4000, + ), + ]; + + await cacheManager.saveEvents(events); + + await cacheManager.removeEvents( + tags: { + 'p': ['target_pubkey_1'], + }, + ); + + expect(await cacheManager.loadEvent(events[0].id), isNull); + expect(await cacheManager.loadEvent(events[1].id), isNotNull); + expect(await cacheManager.loadEvent(events[2].id), isNotNull); + expect(await cacheManager.loadEvent(events[3].id), isNotNull); + }); + + test('removeAllEventsByPubKey', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final events = [ + Nip01Event( + pubKey: 'pubkey_remove_all_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_remove_all_1', + kind: 2, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + Nip01Event( + pubKey: 'pubkey_remove_all_2', + kind: 1, + tags: [], + content: 'Event 3', + createdAt: 1234567892, + ), + ]; + + await cacheManager.saveEvents(events); + await cacheManager.removeAllEventsByPubKey('pubkey_remove_all_1'); + + expect(await cacheManager.loadEvent(events[0].id), isNull); + expect(await cacheManager.loadEvent(events[1].id), isNull); + expect(await cacheManager.loadEvent(events[2].id), isNotNull); + }); + + test('removeAllEvents', () async { + final cacheManager = getCacheManager(); + final events = [ + Nip01Event( + pubKey: 'pubkey_clear_1', + kind: 1, + tags: [], + content: 'Event 1', + createdAt: 1234567890, + ), + Nip01Event( + pubKey: 'pubkey_clear_2', + kind: 1, + tags: [], + content: 'Event 2', + createdAt: 1234567891, + ), + ]; + + await cacheManager.saveEvents(events); + await cacheManager.removeAllEvents(); + + for (final event in events) { + expect(await cacheManager.loadEvent(event.id), isNull); + } + }); + + test('event with tags preserved correctly', () async { + final cacheManager = getCacheManager(); + final event = Nip01Event( + pubKey: 'pubkey_tags', + kind: 1, + tags: [ + ['p', 'pubkey1', 'wss://relay.com', 'alias'], + ['e', 'event_id_ref'], + ['t', 'nostr'], + ['custom', 'value1', 'value2'], + ], + content: 'Event with tags', + createdAt: 1234567890, + ); + + await cacheManager.saveEvent(event); + final loaded = await cacheManager.loadEvent(event.id); + + expect(loaded, isNotNull); + expect(loaded!.tags.length, equals(event.tags.length)); + expect(loaded.tags, equals(event.tags)); + expect(loaded.pTags, contains('pubkey1')); + expect(loaded.tTags, contains('nostr')); + }); + + test( + 'replaceable events keep only the current winner in query results', + () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + const kind = 30023; + const pubKey = 'replaceable_author'; + const dTag = 'article-1'; + + final version1 = Nip01Event( + id: 'bbbb', + pubKey: pubKey, + kind: kind, + tags: const [ + ['d', dTag], + ], + content: 'version 1', + createdAt: 1000, + ); + + final version2 = Nip01Event( + id: 'cccc', + pubKey: pubKey, + kind: kind, + tags: const [ + ['d', dTag], + ], + content: 'version 2', + createdAt: 2000, + ); + + final version3 = Nip01Event( + id: 'aaaa', + pubKey: pubKey, + kind: kind, + tags: const [ + ['d', dTag], + ], + content: 'version 3', + createdAt: 2000, + ); + + await cacheManager.saveEvents([version1, version2, version3]); + + final loadedEvents = await cacheManager.loadEvents( + pubKeys: const [pubKey], + kinds: const [kind], + tags: const { + 'd': [dTag], + }, + ); + + expect(loadedEvents.length, equals(1)); + expect(loadedEvents.first.id, equals('aaaa')); + expect(loadedEvents.first.content, equals('version 3')); + }, + ); + + test('non-replaceable events do not compete with each other', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + const pubKey = 'normal_author'; + + final event1 = Nip01Event( + id: 'normal-1', + pubKey: pubKey, + kind: 1, + tags: const [], + content: 'note 1', + createdAt: 1000, + ); + + final event2 = Nip01Event( + id: 'normal-2', + pubKey: pubKey, + kind: 1, + tags: const [], + content: 'note 2', + createdAt: 2000, + ); + + await cacheManager.saveEvents([event1, event2]); + + final loadedEvents = await cacheManager.loadEvents( + pubKeys: const [pubKey], + kinds: const [1], + ); + + expect(loadedEvents.length, equals(2)); + expect(loadedEvents.map((e) => e.id).toSet(), {'normal-1', 'normal-2'}); + }); + + test('incoming deletion suppresses matching target event on reads', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final target = Nip01Event( + id: 'target-event', + pubKey: 'author-a', + kind: 1, + tags: const [], + content: 'target', + createdAt: 1000, + ); + + final deletion = Nip01Event( + id: 'deletion-event', + pubKey: 'author-a', + kind: 5, + tags: const [ + ['e', 'target-event'], + ], + content: 'delete target-event', + createdAt: 2000, + ); + + await cacheManager.saveEvents([target, deletion]); + + final loadedEvents = await cacheManager.loadEvents(ids: ['target-event']); + + expect(loadedEvents, isEmpty); + expect(await cacheManager.loadEvent('deletion-event'), isNotNull); + }); + + test('out-of-order deletion prevents target resurrection', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final deletion = Nip01Event( + id: 'deletion-first', + pubKey: 'author-a', + kind: 5, + tags: const [ + ['e', 'target-late'], + ], + content: 'delete target-late', + createdAt: 2000, + ); + + final target = Nip01Event( + id: 'target-late', + pubKey: 'author-a', + kind: 1, + tags: const [], + content: 'late target', + createdAt: 1000, + ); + + await cacheManager.saveEvent(deletion); + await cacheManager.saveEvent(target); + + final loadedEvents = await cacheManager.loadEvents(ids: ['target-late']); + + expect(loadedEvents, isEmpty); + }); + + test('deletions do not apply across authors', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final target = Nip01Event( + id: 'shared-id', + pubKey: 'author-b', + kind: 1, + tags: const [], + content: 'should survive', + createdAt: 1000, + ); + + final deletion = Nip01Event( + id: 'cross-author-deletion', + pubKey: 'author-a', + kind: 5, + tags: const [ + ['e', 'shared-id'], + ], + content: 'attempted delete', + createdAt: 2000, + ); + + await cacheManager.saveEvents([target, deletion]); + + final loadedEvents = await cacheManager.loadEvents(ids: ['shared-id']); + + expect(loadedEvents.length, equals(1)); + expect(loadedEvents.first.pubKey, equals('author-b')); + }); + + test( + 'expired events are filtered from normal reads but remain loadable by id', + () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEvents(); + + final expiredEvent = Nip01Event( + id: 'expired-event', + pubKey: 'expired-author', + kind: 1, + tags: const [ + ['expiration', '1'], + ], + content: 'expired', + createdAt: 1000, + ); + + await cacheManager.saveEvent(expiredEvent); + + final queryResults = await cacheManager.loadEvents( + ids: ['expired-event'], + ); + final directLookup = await cacheManager.loadEvent('expired-event'); + + expect(queryResults, isEmpty); + expect(directLookup, isNotNull); + }, + ); + + test( + 'adding the same event source provenance from multiple relays merges', + () async { + final cacheManager = getCacheManager(); + + await cacheManager.addEventSource( + eventId: 'relay-merge-event', + relayUrl: 'wss://relay-one.example', + ); + await cacheManager.addEventSource( + eventId: 'relay-merge-event', + relayUrl: 'wss://relay-two.example', + ); + + final sources = await cacheManager.loadEventSources('relay-merge-event'); + + expect(sources.toSet(), { + 'wss://relay-one.example', + 'wss://relay-two.example', + }); + }, + ); + + test( + 'addEventSource and loadEventSources dedupe and preserve all sources', + () async { + final cacheManager = getCacheManager(); + + await cacheManager.addEventSource( + eventId: 'source-event', + relayUrl: 'wss://relay-b.example', + ); + await cacheManager.addEventSource( + eventId: 'source-event', + relayUrl: 'wss://relay-a.example', + ); + await cacheManager.addEventSource( + eventId: 'source-event', + relayUrl: 'wss://relay-b.example', + ); + + final sources = await cacheManager.loadEventSources('source-event'); + + expect(sources, ['wss://relay-a.example', 'wss://relay-b.example']); + }, + ); + + test('removeEventSources clears provenance for an event', () async { + final cacheManager = getCacheManager(); + + await cacheManager.addEventSources( + eventId: 'source-event-remove', + relayUrls: const ['wss://relay-a.example', 'wss://relay-b.example'], + ); + + expect( + await cacheManager.loadEventSources('source-event-remove'), + isNotEmpty, + ); + + await cacheManager.removeEventSources('source-event-remove'); + + expect(await cacheManager.loadEventSources('source-event-remove'), isEmpty); + }); + + test( + 'saveEventDeliveryRecord and loadEventDeliveryRecord roundtrip', + () async { + final cacheManager = getCacheManager(); + + const record = EventDeliveryRecord( + eventId: 'delivery-event-1', + status: EventDeliveryStatus.partiallyDelivered, + createdAt: 1000, + updatedAt: 2000, + ); + + await cacheManager.saveEventDeliveryRecord(record); + final loaded = await cacheManager.loadEventDeliveryRecord(record.eventId); + + expect(loaded, isNotNull); + expect(loaded!.toJson(), equals(record.toJson())); + }, + ); + + test('loadEventDeliveryRecords filters by status', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEventDeliveryRecords(); + + const deliveredRecord = EventDeliveryRecord( + eventId: 'delivery-complete', + status: EventDeliveryStatus.delivered, + createdAt: 1000, + updatedAt: 1000, + completedAt: 1001, + ); + + const pendingRecord = EventDeliveryRecord( + eventId: 'delivery-pending', + status: EventDeliveryStatus.inProgress, + createdAt: 2000, + updatedAt: 2001, + ); + + await cacheManager.saveEventDeliveryRecords([ + deliveredRecord, + pendingRecord, + ]); + + final inProgress = await cacheManager.loadEventDeliveryRecords( + status: EventDeliveryStatus.inProgress, + ); + + expect(inProgress.map((record) => record.eventId), ['delivery-pending']); + }); + + test( + 'removeEventDeliveryRecord and removeAllEventDeliveryRecords work', + () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllEventDeliveryRecords(); + + const recordA = EventDeliveryRecord( + eventId: 'delivery-remove-a', + createdAt: 1000, + updatedAt: 1000, + ); + const recordB = EventDeliveryRecord( + eventId: 'delivery-remove-b', + createdAt: 1001, + updatedAt: 1001, + ); + + await cacheManager.saveEventDeliveryRecords([recordA, recordB]); + await cacheManager.removeEventDeliveryRecord(recordA.eventId); + + expect( + await cacheManager.loadEventDeliveryRecord(recordA.eventId), + isNull, + ); + expect( + await cacheManager.loadEventDeliveryRecord(recordB.eventId), + isNotNull, + ); + + await cacheManager.removeAllEventDeliveryRecords(); + + expect(await cacheManager.loadEventDeliveryRecords(), isEmpty); + }, + ); + + test('relay delivery targets roundtrip and query independently', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllRelayDeliveryTargets(); + + const targetA = RelayDeliveryTarget( + eventId: 'delivery-target-event', + relayUrl: 'wss://relay-a.example', + reason: RelayDeliveryReason.authorWrite, + state: RelayDeliveryState.acked, + attemptCount: 1, + lastOkMessage: 'ok', + ); + + const targetB = RelayDeliveryTarget( + eventId: 'delivery-target-event', + relayUrl: 'wss://relay-b.example', + reason: RelayDeliveryReason.replyAuthorRead, + state: RelayDeliveryState.transientFailure, + attemptCount: 2, + nextRetryAt: 3000, + lastError: 'timeout', + ); + + await cacheManager.saveRelayDeliveryTargets([targetA, targetB]); + + final loadedA = await cacheManager.loadRelayDeliveryTarget( + eventId: targetA.eventId, + relayUrl: targetA.relayUrl, + ); + final loadedForEvent = await cacheManager.loadRelayDeliveryTargets( + eventId: 'delivery-target-event', + ); + final nonAcked = await cacheManager.loadRelayDeliveryTargets( + excludeAcked: true, + ); + + expect(loadedA?.toJson(), targetA.toJson()); + expect(loadedForEvent.map((record) => record.relayUrl).toSet(), { + 'wss://relay-a.example', + 'wss://relay-b.example', + }); + expect(nonAcked.map((record) => record.relayUrl), [ + 'wss://relay-b.example', + ]); + }); + + test( + 'independent relay target updates for same event do not overwrite', + () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllRelayDeliveryTargets(); + + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'race-safe-event', + relayUrl: 'wss://relay-a.example', + reason: RelayDeliveryReason.authorWrite, + state: RelayDeliveryState.acked, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'race-safe-event', + relayUrl: 'wss://relay-b.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.transientFailure, + attemptCount: 1, + ), + ); + + final loaded = await cacheManager.loadRelayDeliveryTargets( + eventId: 'race-safe-event', + ); + + expect(loaded.length, 2); + expect(loaded.map((record) => record.relayUrl).toSet(), { + 'wss://relay-a.example', + 'wss://relay-b.example', + }); + }, + ); + + test( + 'removeRelayDeliveryTarget and removeRelayDeliveryTargets work', + () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllRelayDeliveryTargets(); + + await cacheManager.saveRelayDeliveryTargets(const [ + RelayDeliveryTarget( + eventId: 'target-remove-event', + relayUrl: 'wss://relay-a.example', + reason: RelayDeliveryReason.authorWrite, + ), + RelayDeliveryTarget( + eventId: 'target-remove-event', + relayUrl: 'wss://relay-b.example', + reason: RelayDeliveryReason.explicit, + ), + RelayDeliveryTarget( + eventId: 'other-target-remove-event', + relayUrl: 'wss://relay-c.example', + reason: RelayDeliveryReason.hint, + ), + ]); + + await cacheManager.removeRelayDeliveryTarget( + eventId: 'target-remove-event', + relayUrl: 'wss://relay-a.example', + ); + + expect( + await cacheManager.loadRelayDeliveryTarget( + eventId: 'target-remove-event', + relayUrl: 'wss://relay-a.example', + ), + isNull, + ); + + await cacheManager.removeRelayDeliveryTargets('target-remove-event'); + + expect( + await cacheManager.loadRelayDeliveryTargets( + eventId: 'target-remove-event', + ), + isEmpty, + ); + expect( + await cacheManager.loadRelayDeliveryTargets( + eventId: 'other-target-remove-event', + ), + isNotEmpty, + ); + }, + ); + + test( + 'removing an event also removes its provenance and delivery state', + () async { + final cacheManager = getCacheManager(); + + final event = Nip01Event( + id: 'event-with-associated-state', + pubKey: 'author-associated', + kind: 1, + tags: const [], + content: 'hello', + createdAt: 1000, + ); + + await cacheManager.saveEvent(event); + await cacheManager.addEventSources( + eventId: event.id, + relayUrls: const ['wss://relay-a.example', 'wss://relay-b.example'], + ); + await cacheManager.saveEventDeliveryRecord( + const EventDeliveryRecord( + eventId: 'event-with-associated-state', + createdAt: 1000, + updatedAt: 1001, + ), + ); + await cacheManager.saveRelayDeliveryTarget( + const RelayDeliveryTarget( + eventId: 'event-with-associated-state', + relayUrl: 'wss://relay-a.example', + reason: RelayDeliveryReason.authorWrite, + ), + ); + + await cacheManager.removeEvent(event.id); + + expect(await cacheManager.loadEvent(event.id), isNull); + expect(await cacheManager.loadEventSources(event.id), isEmpty); + expect(await cacheManager.loadEventDeliveryRecord(event.id), isNull); + expect( + await cacheManager.loadRelayDeliveryTargets(eventId: event.id), + isEmpty, + ); + }, + ); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_eviction.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_eviction.dart new file mode 100644 index 000000000..38ddc3f2b --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_eviction.dart @@ -0,0 +1,363 @@ +part of 'cache_manager_test_suite.dart'; + +void _runEvictionTests(CacheManager Function() getCacheManager) { + test('evict removes expired events without delivery state', () async { + final cacheManager = getCacheManager(); + final expiredEvent = Nip01Event( + pubKey: 'evict_expired_pubkey', + kind: 1, + tags: [ + ['expiration', '1'], + ], + content: 'expired', + createdAt: 10, + ); + + await cacheManager.saveEvent(expiredEvent); + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedEvents, equals(1)); + expect(result.removedExpired, equals(1)); + expect(await cacheManager.loadEvent(expiredEvent.id), isNull); + }); + + test('evict keeps events with delivery state even if expired', () async { + final cacheManager = getCacheManager(); + final expiredEvent = Nip01Event( + pubKey: 'evict_locked_pubkey', + kind: 1, + tags: [ + ['expiration', '1'], + ], + content: 'expired but queued', + createdAt: 11, + ); + + await cacheManager.saveEvent(expiredEvent); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: expiredEvent.id, + createdAt: 11, + updatedAt: 11, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedEvents, equals(0)); + expect(result.keptDueToDeliveryState, equals(1)); + expect(await cacheManager.loadEvent(expiredEvent.id), isNotNull); + }); + + test( + 'evict removes superseded replaceable events but keeps latest', + () async { + final cacheManager = getCacheManager(); + final older = Nip01Event( + pubKey: 'evict_replaceable_pubkey', + kind: Metadata.kKind, + tags: [], + content: '{"name":"old"}', + createdAt: 100, + ); + final newer = Nip01Event( + pubKey: 'evict_replaceable_pubkey', + kind: Metadata.kKind, + tags: [], + content: '{"name":"new"}', + createdAt: 200, + ); + + await cacheManager.saveEvents([older, newer]); + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedSuperseded, equals(1)); + expect(await cacheManager.loadEvent(older.id), isNull); + expect(await cacheManager.loadEvent(newer.id), isNotNull); + expect( + (await cacheManager.loadEvents( + pubKeys: ['evict_replaceable_pubkey'], + kinds: [Metadata.kKind], + )).single.content, + contains('new'), + ); + }, + ); + + test('evict removes author-deleted events and their sidecars', () async { + final cacheManager = getCacheManager(); + final target = Nip01Event( + pubKey: 'evict_deleted_pubkey', + kind: 1, + tags: [], + content: 'deleted later', + createdAt: 300, + ); + final deletion = Nip01Event( + pubKey: 'evict_deleted_pubkey', + kind: 5, + tags: [ + ['e', target.id], + ], + content: 'delete', + createdAt: 301, + ); + + await cacheManager.saveEvent(target); + await cacheManager.addEventSource( + eventId: target.id, + relayUrl: 'wss://relay.example.com', + ); + await cacheManager.saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord( + eventId: target.id, + viewerPubKey: 'viewer', + plaintextContent: 'secret', + createdAt: 1, + updatedAt: 1, + ), + ); + await cacheManager.saveEvent(deletion); + + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedDeleted, equals(1)); + expect(await cacheManager.loadEvent(target.id), isNull); + expect(await cacheManager.loadEventSources(target.id), isEmpty); + expect( + await cacheManager.loadDecryptedEventPayloadRecord( + eventId: target.id, + viewerPubKey: 'viewer', + ), + isNull, + ); + expect(await cacheManager.loadEvent(deletion.id), isNotNull); + }); + + test('evict applies per-kind caps to visible regular events', () async { + final cacheManager = getCacheManager(); + final older = Nip01Event( + pubKey: 'cap_pubkey_1', + kind: 1, + tags: const [], + content: 'older', + createdAt: 100, + ); + final middle = Nip01Event( + pubKey: 'cap_pubkey_2', + kind: 1, + tags: const [], + content: 'middle', + createdAt: 200, + ); + final newer = Nip01Event( + pubKey: 'cap_pubkey_3', + kind: 1, + tags: const [], + content: 'newer', + createdAt: 300, + ); + + await cacheManager.saveEvents([older, middle, newer]); + final result = await cacheManager.evict( + const EvictionPolicy(kindCaps: {1: 2}, protectedKinds: {}), + ); + + expect(result.removedByKindCap, equals(1)); + expect(await cacheManager.loadEvent(older.id), isNull); + expect(await cacheManager.loadEvent(middle.id), isNotNull); + expect(await cacheManager.loadEvent(newer.id), isNotNull); + }); + + test( + 'evict keeps protected pubkeys even when a kind cap would remove them', + () async { + final cacheManager = getCacheManager(); + final protected = Nip01Event( + pubKey: 'protected_author', + kind: 1, + tags: const [], + content: 'protected', + createdAt: 100, + ); + final unprotected = Nip01Event( + pubKey: 'unprotected_author', + kind: 1, + tags: const [], + content: 'unprotected', + createdAt: 200, + ); + + await cacheManager.saveEvents([protected, unprotected]); + final result = await cacheManager.evict( + const EvictionPolicy( + kindCaps: {1: 0}, + protectedKinds: {}, + protectedPubKeys: {'protected_author'}, + ), + ); + + expect(result.keptProtected, equals(1)); + expect(result.removedByKindCap, equals(1)); + expect(await cacheManager.loadEvent(protected.id), isNotNull); + expect(await cacheManager.loadEvent(unprotected.id), isNull); + }, + ); + + test( + 'evict keeps default protected kinds even when capped to zero', + () async { + final cacheManager = getCacheManager(); + final metadata = Nip01Event( + pubKey: 'protected_kind_author', + kind: Metadata.kKind, + tags: const [], + content: '{"name":"still here"}', + createdAt: 100, + ); + + await cacheManager.saveEvent(metadata); + final result = await cacheManager.evict( + const EvictionPolicy(kindCaps: {Metadata.kKind: 0}), + ); + + expect(result.keptProtected, equals(1)); + expect(result.removedEvents, equals(0)); + expect(await cacheManager.loadEvent(metadata.id), isNotNull); + }, + ); + + test( + 'evict sweeps aged delivered delivery records but keeps the event', + () async { + final cacheManager = getCacheManager(); + final now = Nip01Event.secondsSinceEpoch(); + final event = Nip01Event( + pubKey: 'evict_delivered_pubkey', + kind: 1, + tags: const [], + content: 'delivered note', + createdAt: now - 100000, + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: now - 100000, + updatedAt: now - 100000, + completedAt: now - (9 * 3600), // 9h ago, past the 8h retention + ), + ); + await cacheManager.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: event.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.authorWrite, + state: RelayDeliveryState.acked, + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedCompletedDeliveries, equals(1)); + expect(await cacheManager.loadEventDeliveryRecord(event.id), isNull); + expect( + await cacheManager.loadRelayDeliveryTargets(eventId: event.id), + isEmpty, + ); + expect(await cacheManager.loadEvent(event.id), isNotNull); + }, + ); + + test( + 'evict keeps delivered delivery records still within retention', + () async { + final cacheManager = getCacheManager(); + final now = Nip01Event.secondsSinceEpoch(); + final event = Nip01Event( + pubKey: 'evict_delivered_recent_pubkey', + kind: 1, + tags: const [], + content: 'recent delivered note', + createdAt: now - 100, + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.delivered, + createdAt: now - 100, + updatedAt: now - 100, + completedAt: now - 3600, // 1h ago, within the 8h retention + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedCompletedDeliveries, equals(0)); + expect(await cacheManager.loadEventDeliveryRecord(event.id), isNotNull); + }, + ); + + test('evict keeps terminally failed delivery records by default', () async { + final cacheManager = getCacheManager(); + final now = Nip01Event.secondsSinceEpoch(); + final event = Nip01Event( + pubKey: 'evict_failed_pubkey', + kind: 1, + tags: const [], + content: 'failed note', + createdAt: now - 100000, + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.failed, + createdAt: now - 100000, + updatedAt: now - (48 * 3600), // 48h ago + ), + ); + + final result = await cacheManager.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedTerminalFailedDeliveries, equals(0)); + expect(await cacheManager.loadEventDeliveryRecord(event.id), isNotNull); + }); + + test( + 'evict sweeps aged terminally failed delivery records when enabled', + () async { + final cacheManager = getCacheManager(); + final now = Nip01Event.secondsSinceEpoch(); + final event = Nip01Event( + pubKey: 'evict_failed_enabled_pubkey', + kind: 1, + tags: const [], + content: 'failed note', + createdAt: now - 100000, + ); + + await cacheManager.saveEvent(event); + await cacheManager.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: event.id, + status: EventDeliveryStatus.failed, + createdAt: now - 100000, + updatedAt: now - (48 * 3600), // 48h ago, past the 24h retention + ), + ); + + final result = await cacheManager.evict( + const EvictionPolicy(sweepTerminalFailedDeliveries: true), + ); + + expect(result.removedTerminalFailedDeliveries, equals(1)); + expect(await cacheManager.loadEventDeliveryRecord(event.id), isNull); + }, + ); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_metadata.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_metadata.dart new file mode 100644 index 000000000..d6a5c3473 --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_metadata.dart @@ -0,0 +1,186 @@ +part of 'cache_manager_test_suite.dart'; + +// ignore_for_file: deprecated_member_use + +void _runMetadataTests(CacheManager Function() getCacheManager) { + test('saveMetadata and loadMetadata', () async { + final cacheManager = getCacheManager(); + final metadata = Metadata( + pubKey: 'metadata_pubkey_1', + name: 'Test User', + displayName: 'Test Display Name', + about: 'Test about text', + picture: 'https://example.com/pic.jpg', + banner: 'https://example.com/banner.jpg', + website: 'https://example.com', + nip05: 'test@example.com', + lud16: 'test@walletofsatoshi.com', + lud06: 'lnurl1234', + updatedAt: 1234567890, + ); + + await cacheManager.saveMetadata(metadata); + final loaded = await cacheManager.loadMetadata('metadata_pubkey_1'); + + expect(loaded, isNotNull); + expect(loaded!.pubKey, equals(metadata.pubKey)); + expect(loaded.name, equals(metadata.name)); + expect(loaded.displayName, equals(metadata.displayName)); + expect(loaded.about, equals(metadata.about)); + expect(loaded.picture, equals(metadata.picture)); + expect(loaded.banner, equals(metadata.banner)); + expect(loaded.website, equals(metadata.website)); + expect(loaded.nip05, equals(metadata.nip05)); + expect(loaded.lud16, equals(metadata.lud16)); + expect(loaded.lud06, equals(metadata.lud06)); + }); + + test('saveMetadatas batch operation', () async { + final cacheManager = getCacheManager(); + final metadatas = [ + Metadata(pubKey: 'metadata_batch_1', name: 'User 1'), + Metadata(pubKey: 'metadata_batch_2', name: 'User 2'), + ]; + + await cacheManager.saveMetadatas(metadatas); + + for (final metadata in metadatas) { + final loaded = await cacheManager.loadMetadata(metadata.pubKey); + expect(loaded, isNotNull); + expect(loaded!.name, equals(metadata.name)); + } + }); + + test('loadMetadatas batch operation', () async { + final cacheManager = getCacheManager(); + final metadatas = [ + Metadata(pubKey: 'metadata_load_batch_1', name: 'User 1'), + Metadata(pubKey: 'metadata_load_batch_2', name: 'User 2'), + ]; + + await cacheManager.saveMetadatas(metadatas); + final loaded = await cacheManager.loadMetadatas([ + 'metadata_load_batch_1', + 'metadata_load_batch_2', + 'nonexistent_pubkey', + ]); + + expect(loaded.length, equals(3)); + expect(loaded[0]?.name, equals('User 1')); + expect(loaded[1]?.name, equals('User 2')); + expect(loaded[2], isNull); + }); + + test('removeMetadata', () async { + final cacheManager = getCacheManager(); + final metadata = Metadata(pubKey: 'metadata_remove', name: 'Test User'); + + await cacheManager.saveMetadata(metadata); + expect(await cacheManager.loadMetadata('metadata_remove'), isNotNull); + + await cacheManager.removeMetadata('metadata_remove'); + expect(await cacheManager.loadMetadata('metadata_remove'), isNull); + }); + + test('removeAllMetadatas', () async { + final cacheManager = getCacheManager(); + final metadatas = [ + Metadata(pubKey: 'metadata_clear_1', name: 'User 1'), + Metadata(pubKey: 'metadata_clear_2', name: 'User 2'), + ]; + + await cacheManager.saveMetadatas(metadatas); + await cacheManager.removeAllMetadatas(); + + for (final metadata in metadatas) { + expect(await cacheManager.loadMetadata(metadata.pubKey), isNull); + } + }); + + test('metadata update overwrites existing', () async { + final cacheManager = getCacheManager(); + final metadata1 = Metadata( + pubKey: 'metadata_update', + name: 'Original Name', + updatedAt: 1000, + ); + + await cacheManager.saveMetadata(metadata1); + + final metadata2 = Metadata( + pubKey: 'metadata_update', + name: 'Updated Name', + updatedAt: 2000, + ); + + await cacheManager.saveMetadata(metadata2); + + final loaded = await cacheManager.loadMetadata('metadata_update'); + expect(loaded, isNotNull); + expect(loaded!.name, equals('Updated Name')); + }); + + test('loadMetadata reads latest visible generic metadata event', () async { + final cacheManager = getCacheManager(); + final older = Metadata( + pubKey: 'metadata_from_event', + name: 'Older Name', + updatedAt: 1000, + ).toEvent(); + final newer = Metadata( + pubKey: 'metadata_from_event', + name: 'Newer Name', + updatedAt: 2000, + ).toEvent(); + + await cacheManager.saveEvents([older, newer]); + + final loaded = await cacheManager.loadMetadata('metadata_from_event'); + expect(loaded, isNotNull); + expect(loaded!.name, equals('Newer Name')); + expect(loaded.updatedAt, equals(2000)); + }); + + test('metadata preserves tags and content', () async { + final cacheManager = getCacheManager(); + final metadata = Metadata( + pubKey: 'metadata_tags_rawcontent', + name: 'Test User', + displayName: 'Test Display', + tags: [ + ['i', 'github:user123', 'abc123'], + ['i', 'twitter:handle', 'xyz789'], + ], + content: { + 'name': 'Test User', + 'display_name': 'Test Display', + 'custom_field': 'custom_value', + 'nested': {'key': 'value'}, + }, + ); + + await cacheManager.saveMetadata(metadata); + final loaded = await cacheManager.loadMetadata('metadata_tags_rawcontent'); + + expect(loaded, isNotNull); + expect(loaded!.tags.length, equals(2)); + expect(loaded.tags[0], equals(['i', 'github:user123', 'abc123'])); + expect(loaded.tags[1], equals(['i', 'twitter:handle', 'xyz789'])); + expect(loaded.content, isNotNull); + expect(loaded.content['custom_field'], equals('custom_value')); + expect(loaded.content['nested'], equals({'key': 'value'})); + }); + + test('metadata with empty tags and content', () async { + final cacheManager = getCacheManager(); + final metadata = Metadata(pubKey: 'metadata_empty_tags', name: 'Test User'); + + await cacheManager.saveMetadata(metadata); + final loaded = await cacheManager.loadMetadata('metadata_empty_tags'); + + expect(loaded, isNotNull); + expect(loaded!.tags, isEmpty); + expect(loaded.content, isNotEmpty); + expect(loaded.content['name'], equals('Test User')); + }); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_nip05.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_nip05.dart new file mode 100644 index 000000000..80503f9ac --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_nip05.dart @@ -0,0 +1,112 @@ +part of 'cache_manager_test_suite.dart'; + +void _runNip05Tests(CacheManager Function() getCacheManager) { + test('saveNip05 and loadNip05', () async { + final cacheManager = getCacheManager(); + final nip05 = Nip05( + pubKey: 'nip05_pubkey_1', + nip05: 'test@example.com', + valid: true, + networkFetchTime: 1234567890, + relays: ['wss://relay1.com', 'wss://relay2.com'], + ); + + await cacheManager.saveNip05(nip05); + final loaded = await cacheManager.loadNip05(pubKey: 'nip05_pubkey_1'); + + expect(loaded, isNotNull); + expect(loaded!.pubKey, equals(nip05.pubKey)); + expect(loaded.nip05, equals(nip05.nip05)); + expect(loaded.valid, equals(nip05.valid)); + expect(loaded.networkFetchTime, equals(nip05.networkFetchTime)); + expect(loaded.relays, equals(nip05.relays)); + }); + + test('loadNip05 by identifier', () async { + final cacheManager = getCacheManager(); + final nip05 = Nip05( + pubKey: 'nip05_id_pubkey', + nip05: 'testuser@example.com', + valid: true, + networkFetchTime: 1234567890, + relays: ['wss://relay1.com'], + ); + + await cacheManager.saveNip05(nip05); + final loaded = await cacheManager.loadNip05( + identifier: 'testuser@example.com', + ); + + expect(loaded, isNotNull); + expect(loaded!.pubKey, equals(nip05.pubKey)); + expect(loaded.nip05, equals(nip05.nip05)); + expect(loaded.valid, equals(nip05.valid)); + }); + + test('saveNip05s batch operation', () async { + final cacheManager = getCacheManager(); + final nip05s = [ + Nip05(pubKey: 'nip05_batch_1', nip05: 'user1@example.com', valid: true), + Nip05(pubKey: 'nip05_batch_2', nip05: 'user2@example.com', valid: false), + ]; + + await cacheManager.saveNip05s(nip05s); + + for (final nip05 in nip05s) { + final loaded = await cacheManager.loadNip05(pubKey: nip05.pubKey); + expect(loaded, isNotNull); + expect(loaded!.nip05, equals(nip05.nip05)); + expect(loaded.valid, equals(nip05.valid)); + } + }); + + test('loadNip05s batch operation', () async { + final cacheManager = getCacheManager(); + final nip05s = [ + Nip05(pubKey: 'nip05_load_1', nip05: 'user1@example.com', valid: true), + Nip05(pubKey: 'nip05_load_2', nip05: 'user2@example.com', valid: false), + ]; + + await cacheManager.saveNip05s(nip05s); + final loaded = await cacheManager.loadNip05s([ + 'nip05_load_1', + 'nip05_load_2', + 'nonexistent', + ]); + + expect(loaded.length, equals(3)); + expect(loaded[0]?.nip05, equals('user1@example.com')); + expect(loaded[1]?.nip05, equals('user2@example.com')); + expect(loaded[2], isNull); + }); + + test('removeNip05', () async { + final cacheManager = getCacheManager(); + final nip05 = Nip05( + pubKey: 'nip05_remove', + nip05: 'test@example.com', + valid: true, + ); + + await cacheManager.saveNip05(nip05); + expect(await cacheManager.loadNip05(pubKey: 'nip05_remove'), isNotNull); + + await cacheManager.removeNip05('nip05_remove'); + expect(await cacheManager.loadNip05(pubKey: 'nip05_remove'), isNull); + }); + + test('removeAllNip05s', () async { + final cacheManager = getCacheManager(); + final nip05s = [ + Nip05(pubKey: 'nip05_clear_1', nip05: 'u1@ex.com', valid: true), + Nip05(pubKey: 'nip05_clear_2', nip05: 'u2@ex.com', valid: false), + ]; + + await cacheManager.saveNip05s(nip05s); + await cacheManager.removeAllNip05s(); + + for (final nip05 in nip05s) { + expect(await cacheManager.loadNip05(pubKey: nip05.pubKey), isNull); + } + }); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_relay_set.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_relay_set.dart new file mode 100644 index 000000000..34559b535 --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_relay_set.dart @@ -0,0 +1,96 @@ +part of 'cache_manager_test_suite.dart'; + +void _runRelaySetTests(CacheManager Function() getCacheManager) { + test('saveRelaySet and loadRelaySet', () async { + final cacheManager = getCacheManager(); + final relaySet = RelaySet( + name: 'test_set', + pubKey: 'relay_set_pubkey_1', + relayMinCountPerPubkey: 2, + direction: RelayDirection.outbox, + relaysMap: { + 'wss://relay1.com': [ + PubkeyMapping(pubKey: 'user1', rwMarker: ReadWriteMarker.readWrite), + PubkeyMapping(pubKey: 'user2', rwMarker: ReadWriteMarker.readOnly), + ], + 'wss://relay2.com': [ + PubkeyMapping(pubKey: 'user3', rwMarker: ReadWriteMarker.writeOnly), + ], + }, + notCoveredPubkeys: [], + fallbackToBootstrapRelays: true, + ); + + await cacheManager.saveRelaySet(relaySet); + final loaded = await cacheManager.loadRelaySet( + 'test_set', + 'relay_set_pubkey_1', + ); + + expect(loaded, isNotNull); + expect(loaded!.name, equals(relaySet.name)); + expect(loaded.pubKey, equals(relaySet.pubKey)); + expect(loaded.relayMinCountPerPubkey, equals(2)); + expect(loaded.direction, equals(RelayDirection.outbox)); + expect(loaded.relaysMap.length, equals(2)); + expect(loaded.relaysMap['wss://relay1.com']?.length, equals(2)); + }); + + test('removeRelaySet', () async { + final cacheManager = getCacheManager(); + final relaySet = RelaySet( + name: 'set_to_remove', + pubKey: 'relay_set_remove', + relayMinCountPerPubkey: 1, + direction: RelayDirection.inbox, + relaysMap: {}, + notCoveredPubkeys: [], + ); + + await cacheManager.saveRelaySet(relaySet); + expect( + await cacheManager.loadRelaySet('set_to_remove', 'relay_set_remove'), + isNotNull, + ); + + await cacheManager.removeRelaySet('set_to_remove', 'relay_set_remove'); + expect( + await cacheManager.loadRelaySet('set_to_remove', 'relay_set_remove'), + isNull, + ); + }); + + test('removeAllRelaySets', () async { + final cacheManager = getCacheManager(); + final relaySets = [ + RelaySet( + name: 'set_clear_1', + pubKey: 'relay_set_clear_1', + relayMinCountPerPubkey: 1, + direction: RelayDirection.inbox, + relaysMap: {}, + notCoveredPubkeys: [], + ), + RelaySet( + name: 'set_clear_2', + pubKey: 'relay_set_clear_2', + relayMinCountPerPubkey: 1, + direction: RelayDirection.outbox, + relaysMap: {}, + notCoveredPubkeys: [], + ), + ]; + + for (final relaySet in relaySets) { + await cacheManager.saveRelaySet(relaySet); + } + await cacheManager.removeAllRelaySets(); + + for (final relaySet in relaySets) { + expect( + await cacheManager.loadRelaySet(relaySet.name, relaySet.pubKey), + isNull, + ); + } + }); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_search.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_search.dart new file mode 100644 index 000000000..0eae0ab85 --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_search.dart @@ -0,0 +1,57 @@ +part of 'cache_manager_test_suite.dart'; + +// ignore_for_file: deprecated_member_use + +void _runSearchTests(CacheManager Function() getCacheManager) { + test('searchMetadatas by name', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllMetadatas(); + + final metadatas = [ + Metadata( + pubKey: 'search_meta_1', + name: 'Alice Smith', + displayName: 'Alice', + ), + Metadata( + pubKey: 'search_meta_2', + name: 'Bob Jones', + displayName: 'Bobby', + ), + Metadata( + pubKey: 'search_meta_3', + name: 'Alice Wonder', + nip05: 'alice@example.com', + ), + ]; + + await cacheManager.saveMetadatas(metadatas); + + final aliceResults = await cacheManager.searchMetadatas('Alice', 10); + expect(aliceResults.length, greaterThanOrEqualTo(2)); + expect( + aliceResults.every( + (m) => + m.name?.toLowerCase().contains('alice') == true || + m.displayName?.toLowerCase().contains('alice') == true || + m.nip05?.toLowerCase().contains('alice') == true, + ), + isTrue, + ); + }); + + test('searchMetadatas with limit', () async { + final cacheManager = getCacheManager(); + await cacheManager.removeAllMetadatas(); + + final metadatas = List.generate( + 5, + (i) => Metadata(pubKey: 'search_limit_$i', name: 'User $i'), + ); + + await cacheManager.saveMetadatas(metadatas); + + final results = await cacheManager.searchMetadatas('User', 2); + expect(results.length, lessThanOrEqualTo(2)); + }); +} diff --git a/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_user_relay_list.dart b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_user_relay_list.dart new file mode 100644 index 000000000..2330f841b --- /dev/null +++ b/packages/ndk_cache_manager_test_suite/lib/src/cache_manager_test_suite_user_relay_list.dart @@ -0,0 +1,157 @@ +part of 'cache_manager_test_suite.dart'; + +void _runUserRelayListTests(CacheManager Function() getCacheManager) { + test('saveUserRelayList and loadUserRelayList', () async { + final cacheManager = getCacheManager(); + final userRelayList = UserRelayList( + pubKey: 'relay_list_pubkey_1', + createdAt: 1234567890, + refreshedTimestamp: 1234567895, + relays: { + 'wss://relay1.com': ReadWriteMarker.readWrite, + 'wss://relay2.com': ReadWriteMarker.readOnly, + 'wss://relay3.com': ReadWriteMarker.writeOnly, + }, + ); + + await cacheManager.saveUserRelayList(userRelayList); + final loaded = await cacheManager.loadUserRelayList('relay_list_pubkey_1'); + + expect(loaded, isNotNull); + expect(loaded!.pubKey, equals(userRelayList.pubKey)); + expect(loaded.createdAt, equals(userRelayList.createdAt)); + expect(loaded.relays.length, equals(3)); + expect( + loaded.relays['wss://relay1.com'], + equals(ReadWriteMarker.readWrite), + ); + expect(loaded.relays['wss://relay2.com'], equals(ReadWriteMarker.readOnly)); + expect( + loaded.relays['wss://relay3.com'], + equals(ReadWriteMarker.writeOnly), + ); + }); + + test('saveUserRelayLists batch operation', () async { + final cacheManager = getCacheManager(); + final userRelayLists = [ + UserRelayList( + pubKey: 'relay_batch_1', + createdAt: 1234567890, + refreshedTimestamp: 1234567890, + relays: {'wss://relay1.com': ReadWriteMarker.readWrite}, + ), + UserRelayList( + pubKey: 'relay_batch_2', + createdAt: 1234567891, + refreshedTimestamp: 1234567891, + relays: {'wss://relay2.com': ReadWriteMarker.readOnly}, + ), + ]; + + await cacheManager.saveUserRelayLists(userRelayLists); + + for (final userRelayList in userRelayLists) { + final loaded = await cacheManager.loadUserRelayList(userRelayList.pubKey); + expect(loaded, isNotNull); + expect(loaded!.relays.length, equals(1)); + } + }); + + test('removeUserRelayList', () async { + final cacheManager = getCacheManager(); + final userRelayList = UserRelayList( + pubKey: 'relay_remove', + createdAt: 1234567890, + refreshedTimestamp: 1234567890, + relays: {'wss://relay.com': ReadWriteMarker.readWrite}, + ); + + await cacheManager.saveUserRelayList(userRelayList); + expect(await cacheManager.loadUserRelayList('relay_remove'), isNotNull); + + await cacheManager.removeUserRelayList('relay_remove'); + expect(await cacheManager.loadUserRelayList('relay_remove'), isNull); + }); + + test('removeAllUserRelayLists', () async { + final cacheManager = getCacheManager(); + final userRelayLists = [ + UserRelayList( + pubKey: 'relay_clear_1', + createdAt: 1234567890, + refreshedTimestamp: 1234567890, + relays: {}, + ), + UserRelayList( + pubKey: 'relay_clear_2', + createdAt: 1234567891, + refreshedTimestamp: 1234567891, + relays: {}, + ), + ]; + + await cacheManager.saveUserRelayLists(userRelayLists); + await cacheManager.removeAllUserRelayLists(); + + for (final userRelayList in userRelayLists) { + expect( + await cacheManager.loadUserRelayList(userRelayList.pubKey), + isNull, + ); + } + }); + + test('kind 10002 event recomputes user relay list projection', () async { + final cacheManager = getCacheManager(); + final event = Nip65( + pubKey: 'relay_projection_pubkey', + createdAt: 2000, + relays: { + 'wss://relay1.com': ReadWriteMarker.readWrite, + 'wss://relay2.com': ReadWriteMarker.readOnly, + }, + ).toEvent(); + + await cacheManager.saveEvent(event); + + final loaded = await cacheManager.loadUserRelayList( + 'relay_projection_pubkey', + ); + expect(loaded, isNotNull); + expect(loaded!.createdAt, equals(2000)); + expect( + loaded.relays['wss://relay1.com'], + equals(ReadWriteMarker.readWrite), + ); + expect(loaded.relays['wss://relay2.com'], equals(ReadWriteMarker.readOnly)); + }); + + test('kind 10002 takes precedence over kind 3 relay projection', () async { + final cacheManager = getCacheManager(); + final kind3 = Nip01Event( + pubKey: 'relay_precedence_pubkey', + kind: ContactList.kKind, + createdAt: 1000, + tags: const [], + content: '{"wss://legacy-relay.com":{"read":true,"write":true}}', + ); + final kind10002 = Nip65( + pubKey: 'relay_precedence_pubkey', + createdAt: 2000, + relays: {'wss://preferred-relay.com': ReadWriteMarker.writeOnly}, + ).toEvent(); + + await cacheManager.saveEvents([kind3, kind10002]); + + final loaded = await cacheManager.loadUserRelayList( + 'relay_precedence_pubkey', + ); + expect(loaded, isNotNull); + expect(loaded!.relays.keys, equals(['wss://preferred-relay.com'])); + expect( + loaded.relays['wss://preferred-relay.com'], + equals(ReadWriteMarker.writeOnly), + ); + }); +} diff --git a/packages/ndk_flutter/README.md b/packages/ndk_flutter/README.md index 7a2b314f8..118cca0e0 100644 --- a/packages/ndk_flutter/README.md +++ b/packages/ndk_flutter/README.md @@ -30,29 +30,31 @@ MaterialApp( ## Usage -By default, the logged user is used for user widgets, you can overwrite it by providing a pubkey as parameter. - ```dart -import 'package:nostr_widgets/nostr_widgets.dart'; - -// available widgets -NBanner(ndk); -NPicture(ndk); -NName(ndk); -NUserProfile(ndk); -NLogin(ndk); -NSwitchAccount(ndk); - -final ndkFlutter = NdkFlutter(ndk: ndk) - -// this method read the saved state from secure storage and add the signers in ndk -// typicaly called before runApp -ndkFlutter.restoreAccountsState(); - -// call this every time the auth state change -ndkFlutter.saveAccountsState(); +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +// wrap your Ndk instance +final ndkFlutter = NdkFlutter(ndk: ndk); + +// reads saved accounts from secure storage and registers their signers in ndk +// typically called before runApp +await ndkFlutter.restoreAccountsState(); + +// call this every time the auth state changes +await ndkFlutter.saveAccountsState(); + +// available widgets (take ndkFlutter, not ndk) +NBanner(ndkFlutter: ndkFlutter); +NPicture(ndkFlutter: ndkFlutter); +NName(ndkFlutter: ndkFlutter); +NUserProfile(ndkFlutter: ndkFlutter); +NLogin(ndkFlutter: ndkFlutter); +NSwitchAccount(ndkFlutter: ndkFlutter); ``` +By default, the logged-in user is used for user widgets; you can override it by passing a `pubkey` parameter. + ## TODO - [ ] NUserProfile optionnal show nsec and copy diff --git a/packages/ndk_flutter/android/build.gradle b/packages/ndk_flutter/android/build.gradle index 6312bb6fd..a1be67820 100644 --- a/packages/ndk_flutter/android/build.gradle +++ b/packages/ndk_flutter/android/build.gradle @@ -21,7 +21,15 @@ allprojects { } apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' + +// Built-in Kotlin migration: only apply the Kotlin Gradle Plugin on AGP < 9, +// where AGP does not provide built-in Kotlin support. Uses `project.apply` so +// Flutter's KGP-usage detector does not flag this plugin as unmigrated. +// See https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-plugin-authors +def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int +if (agpMajor < 9) { + project.apply([plugin: 'kotlin-android']) +} android { if (project.android.hasProperty("namespace")) { @@ -35,10 +43,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = '17' - } - sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' @@ -67,6 +71,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + subprojects { afterEvaluate { project -> if (project.extensions.findByName("android") != null) { diff --git a/packages/ndk_flutter/android/src/main/AndroidManifest.xml b/packages/ndk_flutter/android/src/main/AndroidManifest.xml index 44bda0c13..83a54f851 100644 --- a/packages/ndk_flutter/android/src/main/AndroidManifest.xml +++ b/packages/ndk_flutter/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + via the nostrsigner: scheme. --> diff --git a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt index e570a1625..35d1662b7 100644 --- a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt +++ b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt @@ -23,8 +23,8 @@ import org.json.JSONObject /// ndk_flutter native plugin. /// -/// Hosts the NIP-55 "Android Signer Application" bridge (external signer apps -/// such as Amber, Primal, Aegis, ...). Communication happens either silently +/// Hosts the NIP-55 "Android Signer Application" bridge for external signer +/// apps. Communication happens either silently /// through a ContentResolver query (when the user has pre-authorized the /// permission) or, as a fallback, by launching the signer via an Intent and /// reading the result in [onActivityResult]. @@ -80,14 +80,14 @@ class DartNdkPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, val id = paramsMap[keyId] as? String ?: "" val uriData = paramsMap[keyUriData] as? String ?: "" val permissions = paramsMap[keyPermissions] as? String ?: "" - // Signer app package captured at login (Amber, Primal, ...). + // Signer app package captured at login. // Empty for get_public_key / legacy accounts. val signerPackage = paramsMap[keyPackage] as? String ?: "" // First try the silent ContentResolver path (pre-authorized // permissions). Only attempt it when we know which signer to // query (a captured package): querying a foreign provider - // (e.g. Amber for a Primal account) returns wrong/empty data. + // returns wrong/empty data. // get_public_key (login) is Intent-only per NIP-55. if (requestType != "get_public_key" && signerPackage.isNotEmpty()) { val data = getDataFromContentResolver( diff --git a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt index 294936a87..59c087b28 100644 --- a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt +++ b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt @@ -2,8 +2,8 @@ package relaystr.ndk /// Constants for the NIP-55 "Android Signer Application" protocol. /// -/// NIP-55 is a protocol implemented by several external signer apps -/// (Amber, Primal, Aegis, ...). The wire format below is generic. +/// NIP-55 is a protocol implemented by several external signer apps. +/// The wire format below is generic. const val nostrSignerScheme = "nostrsigner" diff --git a/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart b/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart index be67eab09..77f183453 100644 --- a/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart +++ b/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart @@ -28,20 +28,20 @@ class Nip55LoginResult { /// The user's public key, in hex format. final String pubkey; - /// The signer app package name (e.g. Amber, Primal), if the signer returned + /// The signer app package name, if the signer returned /// it. Used to target the same signer for subsequent requests. final String? package; } /// Dart bridge to a NIP-55 "Android Signer Application". /// -/// NIP-55 is a protocol implemented by several external signer apps -/// (Amber, Primal, Aegis, ...). This class talks to whichever compatible -/// signer is installed through the native `ndk` method channel +/// NIP-55 is a protocol implemented by several external signer apps. +/// This class talks to whichever compatible signer is installed through the +/// native `ndk` method channel /// ([DartNdkPlugin]). /// /// Every method resolves to a `Map` that contains (at least) a `signature` -/// key with the result, mirroring the historical `amberflutter` API. +/// key with the result, mirroring the historical external-signer plugin API. class Nip55Signer { /// The method channel shared with the native [DartNdkPlugin]. static const MethodChannel _channel = MethodChannel('ndk'); @@ -56,7 +56,7 @@ class Nip55Signer { Nip55Permission(type: 'nip44_decrypt'), ]; - /// The signer app package (e.g. Amber, Primal), captured at login. Used to + /// The signer app package, captured at login. Used to /// target the right signer for both the ContentResolver and the Intent. /// When `null`, the native side lets Android route through a compatible /// signer app. @@ -92,7 +92,7 @@ class Nip55Signer { /// if the user rejected or no key was returned. /// /// NIP-55 returns the key in the `result` field, in hex format. Older - /// signers (and Amber legacy) may return an npub instead, so both are + /// signer implementations may return an npub instead, so both are /// accepted. See https://github.com/nostr-protocol/nips/blob/master/55.md Future getPublicKeyHex({List? permissions}) async { return (await login(permissions: permissions))?.pubkey; diff --git a/packages/ndk_flutter/lib/data_layer/repositories/signers/nip55_event_signer.dart b/packages/ndk_flutter/lib/data_layer/repositories/signers/nip55_event_signer.dart index e7e96e35f..74e65a01d 100644 --- a/packages/ndk_flutter/lib/data_layer/repositories/signers/nip55_event_signer.dart +++ b/packages/ndk_flutter/lib/data_layer/repositories/signers/nip55_event_signer.dart @@ -14,8 +14,8 @@ class _PendingRequestEntry { /// Event signer backed by a NIP-55 external signer application. /// -/// NIP-55 is a protocol implemented by several external signer apps -/// (Amber, Primal, Aegis, ...). This signer delegates all cryptographic +/// NIP-55 is a protocol implemented by several external signer apps. +/// This signer delegates all cryptographic /// operations to whichever compatible signer is installed via [Nip55Signer]. class Nip55EventSigner with ConcurrencyLimiterMixin implements EventSigner { final Nip55Signer nip55Signer; @@ -179,6 +179,15 @@ class Nip55EventSigner with ConcurrencyLimiterMixin implements EventSigner { return publicKey.isNotEmpty; } + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + @override Future encryptNip44({ required String plaintext, diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index 9a0e42f8c..b12e95fa2 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -15,6 +15,7 @@ import 'app_localizations_ja.dart'; import 'app_localizations_pl.dart'; import 'app_localizations_pt.dart'; import 'app_localizations_ru.dart'; +import 'app_localizations_sk.dart'; import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -114,6 +115,7 @@ abstract class AppLocalizations { Locale('pt'), Locale('pt', 'BR'), Locale('ru'), + Locale('sk'), Locale('zh'), ]; @@ -2311,6 +2313,7 @@ class _AppLocalizationsDelegate 'pl', 'pt', 'ru', + 'sk', 'zh', ].contains(locale.languageCode); @@ -2353,6 +2356,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsPt(); case 'ru': return AppLocalizationsRu(); + case 'sk': + return AppLocalizationsSk(); case 'zh': return AppLocalizationsZh(); } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart new file mode 100644 index 000000000..82581ca95 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -0,0 +1,1136 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Slovak (`sk`). +class AppLocalizationsSk extends AppLocalizations { + AppLocalizationsSk([String locale = 'sk']) : super(locale); + + @override + String get createAccount => 'Vytvorte si účet'; + + @override + String get newHere => 'Ste tu prvýkrát?'; + + @override + String get nostrAddress => 'Nostr adresa'; + + @override + String get publicKey => 'Verejný kľúč'; + + @override + String get privateKey => 'Súkromný kľúč (nezabezpečené)'; + + @override + String get browserExtension => 'Rozšírenie prehliadača'; + + @override + String get connect => 'Pripojiť'; + + @override + String get install => 'Inštalovať'; + + @override + String get logout => 'Odhlásiť sa'; + + @override + String get nostrAddressHint => 'meno@example.com'; + + @override + String get invalidAddress => 'Neplatná adresa'; + + @override + String get unableToConnect => 'Nepodarilo sa pripojiť'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => 'Nostr je pre vás nový?'; + + @override + String get getStarted => 'Začať'; + + @override + String get bunker => 'Bunker'; + + @override + String get bunkerAuthentication => 'Overenie cez Bunker'; + + @override + String tapToOpen(String url) { + return 'Ťuknutím otvoríte: $url'; + } + + @override + String get showNostrConnectQrcode => 'Zobraziť Nostr Connect QR kód'; + + @override + String get loginWithSignerApp => 'Prihlásiť sa cez podpisovaciu aplikáciu'; + + @override + String get nostrConnectUrl => 'Nostr Connect URL'; + + @override + String get copy => 'Kopírovať'; + + @override + String get addAccount => 'Pridať účet'; + + @override + String get readOnly => 'Len na čítanie'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => 'Rozšírenie'; + + @override + String get userMetadata => 'Metadáta používateľa'; + + @override + String get shortTextNote => 'Krátka textová poznámka'; + + @override + String get recommendRelay => 'Odporučiť relay'; + + @override + String get follows => 'Sledovaní'; + + @override + String get encryptedDirectMessages => 'Šifrované priame správy'; + + @override + String get eventDeletionRequest => 'Žiadosť o vymazanie udalosti'; + + @override + String get repost => 'Repost'; + + @override + String get reaction => 'Reakcia'; + + @override + String get badgeAward => 'Udelenie odznaku'; + + @override + String get chatMessage => 'Správa v chate'; + + @override + String get groupChatThreadedReply => 'Vláknová odpoveď v skupinovom chate'; + + @override + String get thread => 'Vlákno'; + + @override + String get groupThreadReply => 'Odpoveď v skupinovom vlákne'; + + @override + String get seal => 'Pečať'; + + @override + String get directMessage => 'Priama správa'; + + @override + String get fileMessage => 'Správa so súborom'; + + @override + String get genericRepost => 'Všeobecný repost'; + + @override + String get reactionToWebsite => 'Reakcia na webstránku'; + + @override + String get picture => 'Obrázok'; + + @override + String get videoEvent => 'Video udalosť'; + + @override + String get shortFormPortraitVideoEvent => 'Krátke video na výšku'; + + @override + String get internalReference => 'Interný odkaz'; + + @override + String get externalReference => 'Externý odkaz'; + + @override + String get hardcopyReference => 'Odkaz na tlačenú kópiu'; + + @override + String get promptReference => 'Odkaz na prompt'; + + @override + String get channelCreation => 'Vytvorenie kanála'; + + @override + String get channelMetadata => 'Metadáta kanála'; + + @override + String get channelMessage => 'Správa kanála'; + + @override + String get channelHideMessage => 'Skrytie správy v kanáli'; + + @override + String get channelMuteUser => 'Stlmenie používateľa v kanáli'; + + @override + String get requestToVanish => 'Žiadosť o zmiznutie'; + + @override + String get chessPgn => 'Šach (PGN)'; + + @override + String get mlsKeyPackage => 'MLS KeyPackage'; + + @override + String get mlsWelcome => 'MLS Welcome'; + + @override + String get mlsGroupEvent => 'MLS skupinová udalosť'; + + @override + String get mergeRequests => 'Merge requesty'; + + @override + String get pollResponse => 'Odpoveď na anketu'; + + @override + String get marketplaceBid => 'Ponuka na trhovisku'; + + @override + String get marketplaceBidConfirmation => 'Potvrdenie ponuky na trhovisku'; + + @override + String get openTimestamps => 'OpenTimestamps'; + + @override + String get giftWrap => 'Gift Wrap'; + + @override + String get fileMetadata => 'Metadáta súboru'; + + @override + String get poll => 'Anketa'; + + @override + String get comment => 'Komentár'; + + @override + String get voiceMessage => 'Hlasová správa'; + + @override + String get voiceMessageComment => 'Komentár k hlasovej správe'; + + @override + String get liveChatMessage => 'Správa v živom chate'; + + @override + String get codeSnippet => 'Úryvok kódu'; + + @override + String get gitPatch => 'Git patch'; + + @override + String get gitPullRequest => 'Git pull request'; + + @override + String get gitStatusUpdate => 'Aktualizácia stavu Git'; + + @override + String get gitIssue => 'Git issue'; + + @override + String get gitIssueUpdate => 'Aktualizácia Git issue'; + + @override + String get status => 'Stav'; + + @override + String get statusUpdate => 'Aktualizácia stavu'; + + @override + String get statusDelete => 'Vymazanie stavu'; + + @override + String get statusReply => 'Odpoveď na stav'; + + @override + String get problemTracker => 'Sledovanie problémov'; + + @override + String get reporting => 'Nahlasovanie'; + + @override + String get label => 'Štítok'; + + @override + String get relayReviews => 'Recenzie relayov'; + + @override + String get aiEmbeddings => 'AI embeddingy / vektorové zoznamy'; + + @override + String get torrent => 'Torrent'; + + @override + String get torrentComment => 'Komentár k torrentu'; + + @override + String get coinjoinPool => 'Coinjoin pool'; + + @override + String get communityPostApproval => 'Schválenie príspevku komunity'; + + @override + String get jobRequest => 'Žiadosť o úlohu'; + + @override + String get jobResult => 'Výsledok úlohy'; + + @override + String get jobFeedback => 'Spätná väzba k úlohe'; + + @override + String get cashuWalletToken => 'Token Cashu peňaženky'; + + @override + String get cashuWalletProofs => 'Dôkazy Cashu peňaženky'; + + @override + String get cashuWalletHistory => 'História Cashu peňaženky'; + + @override + String get geocacheCreate => 'Vytvorenie geokešky'; + + @override + String get geocacheUpdate => 'Aktualizácia geokešky'; + + @override + String get groupControlEvent => 'Riadiaca udalosť skupiny'; + + @override + String get zapGoal => 'Zap cieľ'; + + @override + String get nutzap => 'Nutzap'; + + @override + String get tidalLogin => 'Tidal prihlásenie'; + + @override + String get zapRequest => 'Žiadosť o zap'; + + @override + String get zap => 'Zap'; + + @override + String get highlights => 'Zvýraznenia'; + + @override + String get muteList => 'Zoznam stlmených'; + + @override + String get pinList => 'Zoznam pripnutých'; + + @override + String get relayListMetadata => 'Metadáta zoznamu relayov'; + + @override + String get bookmarkList => 'Zoznam záložiek'; + + @override + String get communitiesList => 'Zoznam komunít'; + + @override + String get publicChatsList => 'Zoznam verejných chatov'; + + @override + String get blockedRelaysList => 'Zoznam blokovaných relayov'; + + @override + String get searchRelaysList => 'Zoznam vyhľadávacích relayov'; + + @override + String get userGroups => 'Skupiny používateľa'; + + @override + String get favoritesList => 'Zoznam obľúbených'; + + @override + String get privateEventsList => 'Zoznam súkromných udalostí'; + + @override + String get interestsList => 'Zoznam záujmov'; + + @override + String get mediaFollowsList => 'Zoznam sledovaných médií'; + + @override + String get peopleFollowsList => 'Zoznam sledovaných ľudí'; + + @override + String get userEmojiList => 'Zoznam emoji používateľa'; + + @override + String get dmRelayList => 'Zoznam DM relayov'; + + @override + String get keyPackageRelayList => 'Zoznam KeyPackage relayov'; + + @override + String get userServerList => 'Zoznam serverov používateľa'; + + @override + String get fileStorageServerList => 'Zoznam serverov na ukladanie súborov'; + + @override + String get relayMonitorAnnouncement => 'Oznámenie monitora relayov'; + + @override + String get roomPresence => 'Prítomnosť v miestnosti'; + + @override + String get proxyAnnouncement => 'Oznámenie proxy'; + + @override + String get transportMethodAnnouncement => 'Oznámenie spôsobu prenosu'; + + @override + String get walletInfo => 'Informácie o peňaženke'; + + @override + String get cashuWalletEvent => 'Udalosť Cashu peňaženky'; + + @override + String get lightningPubRpc => 'Lightning Pub RPC'; + + @override + String get clientAuthentication => 'Autentifikácia klienta'; + + @override + String get walletRequest => 'Žiadosť peňaženky'; + + @override + String get walletResponse => 'Odpoveď peňaženky'; + + @override + String get nostrConnectEvent => 'Nostr Connect'; + + @override + String get blobsStoredOnMediaservers => 'Bloby uložené na mediaserveroch'; + + @override + String get httpAuth => 'HTTP autentifikácia'; + + @override + String get categorizedPeopleList => 'Kategorizovaný zoznam ľudí'; + + @override + String get categorizedBookmarkList => 'Kategorizovaný zoznam záložiek'; + + @override + String get categorizedRelayList => 'Kategorizovaný zoznam relayov'; + + @override + String get bookmarkSets => 'Sady záložiek'; + + @override + String get curationSets => 'Kurátorské sady'; + + @override + String get videoSets => 'Sady videí'; + + @override + String get kindMuteSets => 'Sady stlmených kindov'; + + @override + String get profileBadges => 'Odznaky profilu'; + + @override + String get badgeDefinition => 'Definícia odznaku'; + + @override + String get interestSets => 'Sady záujmov'; + + @override + String get createOrUpdateStall => 'Vytvoriť alebo aktualizovať stánok'; + + @override + String get createOrUpdateProduct => 'Vytvoriť alebo aktualizovať produkt'; + + @override + String get marketplaceUiUx => 'UI/UX trhoviska'; + + @override + String get productSoldAsAuction => 'Produkt predávaný ako aukcia'; + + @override + String get longFormContent => 'Dlhoformátový obsah'; + + @override + String get draftLongFormContent => 'Koncept dlhoformátového obsahu'; + + @override + String get emojiSets => 'Sady emoji'; + + @override + String get curatedPublicationItem => 'Položka kurátorskej publikácie'; + + @override + String get curatedPublicationDraft => 'Koncept kurátorskej publikácie'; + + @override + String get releaseArtifactSets => 'Sady artefaktov vydania'; + + @override + String get applicationSpecificData => 'Dáta špecifické pre aplikáciu'; + + @override + String get relayDiscovery => 'Objavovanie relayov'; + + @override + String get appCurationSets => 'Kurátorské sady aplikácií'; + + @override + String get liveEvent => 'Živá udalosť'; + + @override + String get userStatus => 'Stav používateľa'; + + @override + String get slideSet => 'Sada snímok'; + + @override + String get classifiedListing => 'Inzerát'; + + @override + String get draftClassifiedListing => 'Koncept inzerátu'; + + @override + String get repositoryAnnouncement => 'Oznámenie repozitára'; + + @override + String get repositoryStateAnnouncement => 'Oznámenie stavu repozitára'; + + @override + String get wikiArticle => 'Wiki článok'; + + @override + String get redirects => 'Presmerovania'; + + @override + String get draftEvent => 'Koncept udalosti'; + + @override + String get linkSet => 'Sada odkazov'; + + @override + String get feed => 'Feed'; + + @override + String get dateBasedCalendarEvent => 'Kalendárová udalosť podľa dátumu'; + + @override + String get timeBasedCalendarEvent => 'Kalendárová udalosť podľa času'; + + @override + String get calendar => 'Kalendár'; + + @override + String get calendarEventRsvp => 'RSVP na kalendárovú udalosť'; + + @override + String get handlerRecommendation => 'Odporúčanie handlera'; + + @override + String get handlerInformation => 'Informácie o handleri'; + + @override + String get softwareApplication => 'Softvérová aplikácia'; + + @override + String get videoView => 'Zhliadnutie videa'; + + @override + String get communityDefinition => 'Definícia komunity'; + + @override + String get geocacheListing => 'Záznam geokešky'; + + @override + String get mintAnnouncement => 'Oznámenie mintu'; + + @override + String get mintQuote => 'Ponuka mintu'; + + @override + String get peerToPeerOrder => 'Peer-to-peer objednávka'; + + @override + String get groupMetadata => 'Metadáta skupiny'; + + @override + String get groupAdminMetadata => 'Metadáta administrátora skupiny'; + + @override + String get groupMemberMetadata => 'Metadáta člena skupiny'; + + @override + String get groupAdminsList => 'Zoznam administrátorov skupiny'; + + @override + String get groupMembersList => 'Zoznam členov skupiny'; + + @override + String get groupRoles => 'Roly skupiny'; + + @override + String get groupPermissions => 'Oprávnenia skupiny'; + + @override + String get groupChatMessage => 'Správa skupinového chatu'; + + @override + String get groupChatThread => 'Vlákno skupinového chatu'; + + @override + String get groupPinned => 'Pripnuté v skupine'; + + @override + String get starterPacks => 'Štartovacie balíčky'; + + @override + String get mediaStarterPacks => 'Mediálne štartovacie balíčky'; + + @override + String get webBookmarks => 'Webové záložky'; + + @override + String unknownEventKind(int kind) { + return 'Druh udalosti $kind'; + } + + @override + String get walletsTitle => 'Peňaženky'; + + @override + String get recentActivityTitle => 'Nedávna aktivita'; + + @override + String get addCashuWallet => 'Pridať Cashu peňaženku'; + + @override + String get addNwcWallet => 'Pridať NWC peňaženku'; + + @override + String get addLnurlWallet => 'Pridať LNURL peňaženku'; + + @override + String get addCashuTooltip => 'Pridať Cashu peňaženku'; + + @override + String get addNwcTooltip => 'Pridať NWC peňaženku'; + + @override + String get addLnurlTooltip => 'Pridať LNURL peňaženku'; + + @override + String get addCashuWalletTitle => 'Pridať Cashu peňaženku'; + + @override + String get enterMintUrl => 'Zadajte URL mintu na pridanie Cashu peňaženky.'; + + @override + String get mintUrl => 'URL mintu'; + + @override + String get mintUrlHint => 'https://mint.example.com'; + + @override + String get pleaseEnterMintUrl => 'Zadajte URL mintu'; + + @override + String get cashuWalletAdded => 'Cashu peňaženka bola úspešne pridaná!'; + + @override + String get failedToAddMint => + 'Nepodarilo sa pridať mint. Skontrolujte URL a skúste to znova.'; + + @override + String get addNwcWalletTitle => 'Pridať NWC peňaženku'; + + @override + String get faucet => 'Faucet'; + + @override + String get manual => 'Manuálne'; + + @override + String get nwcFaucetDescription => + 'Vytvorte testovaciu peňaženku so satmi z NWC faucetu.'; + + @override + String get startingBalance => 'Počiatočný zostatok'; + + @override + String get startingBalanceHint => '10000'; + + @override + String get nwcConnectionUri => 'URI pripojenia NWC'; + + @override + String get nwcConnectionUriHint => 'nostr+walletconnect://...'; + + @override + String get nwcWalletAdded => 'NWC peňaženka bola úspešne pridaná!'; + + @override + String nwcFaucetWalletAdded(int balance) { + return 'NWC faucet peňaženka pridaná s $balance satmi!'; + } + + @override + String get invalidFaucetResponse => 'Neplatná odpoveď z faucetu'; + + @override + String get errorCreatingWallet => 'Chyba pri vytváraní peňaženky'; + + @override + String get addLnurlWalletTitle => 'Pridať LNURL peňaženku'; + + @override + String get enterLnurlIdentifier => + 'Zadajte svoj LNURL identifikátor (user@domain.com).'; + + @override + String get lnurlIdentifierHint => 'user@example.com'; + + @override + String get pleaseEnterValidIdentifier => + 'Zadajte platný identifikátor (user@domain.com)'; + + @override + String get lnurlWalletAdded => 'LNURL peňaženka bola úspešne pridaná!'; + + @override + String get cancel => 'Zrušiť'; + + @override + String get add => 'Pridať'; + + @override + String get send => 'Odoslať'; + + @override + String get receive => 'Prijať'; + + @override + String get setAsDefaultForReceiving => + 'Nastaviť ako predvolenú na prijímanie'; + + @override + String get setAsDefaultForSending => 'Nastaviť ako predvolenú na odosielanie'; + + @override + String get defaultForReceiving => 'Predvolená na prijímanie'; + + @override + String get defaultForSending => 'Predvolená na odosielanie'; + + @override + String get defaultWalletForReceivingTooltip => + 'Táto peňaženka je predvolená na prijímanie platieb.'; + + @override + String get defaultWalletForSendingTooltip => + 'Táto peňaženka je predvolená na odosielanie platieb.'; + + @override + String get sendOptionsTitle => 'Možnosti odoslania'; + + @override + String get sendByToken => 'Odoslať tokenom'; + + @override + String get sendByTokenDescription => 'Vytvoriť Cashu token na odoslanie'; + + @override + String get sendByLightning => 'Odoslať cez Lightning'; + + @override + String get sendByLightningDescription => 'Zaplatiť Lightning faktúru'; + + @override + String get payInvoiceTitle => 'Zaplatiť faktúru'; + + @override + String get invoice => 'Faktúra'; + + @override + String get invoiceHint => 'lnbc...'; + + @override + String get pleaseEnterInvoice => 'Zadajte faktúru'; + + @override + String get invoicePaid => 'Faktúra zaplatená!'; + + @override + String paymentFailed(String message) { + return 'Platba zlyhala: $message'; + } + + @override + String get receiveOptionsTitle => 'Možnosti prijatia'; + + @override + String get receiveByToken => 'Prijať tokenom'; + + @override + String get receiveByTokenDescription => 'Prijať Cashu token'; + + @override + String get receiveByLightning => 'Prijať cez Lightning'; + + @override + String get receiveByLightningDescription => 'Vytvoriť Lightning faktúru'; + + @override + String get receiveByTokenTitle => 'Prijať tokenom'; + + @override + String get token => 'Token'; + + @override + String get tokenHint => 'Sem vložte token...'; + + @override + String get pleaseEnterToken => 'Zadajte token'; + + @override + String get tokenReceived => 'Token prijatý!'; + + @override + String get createInvoiceTitle => 'Vytvoriť faktúru'; + + @override + String get amount => 'Suma'; + + @override + String get amountHint => '100'; + + @override + String get pleaseEnterValidAmount => 'Zadajte platnú sumu'; + + @override + String get tokenCopiedToClipboard => 'Token skopírovaný do schránky!'; + + @override + String get invoiceCreatedAndCopied => 'Faktúra vytvorená a skopírovaná!'; + + @override + String get invoiceTrackingTitle => 'Lightning faktúra'; + + @override + String get invoiceCreatedMessage => 'Faktúra vytvorená a skopírovaná!'; + + @override + String get close => 'Zavrieť'; + + @override + String get copyAgain => 'Kopírovať znova'; + + @override + String get copied => 'Skopírované!'; + + @override + String get paymentReceived => 'Platba prijatá!'; + + @override + String get waitingForPayment => 'Čaká sa na platbu...'; + + @override + String get paid => 'Zaplatené!'; + + @override + String get createToken => 'Vytvoriť token'; + + @override + String get pay => 'Zaplatiť'; + + @override + String get create => 'Vytvoriť'; + + @override + String get pendingTransactions => 'Čakajúce'; + + @override + String get backupSeedWarning => 'Zálohujte si frázu na obnovenie Cashu'; + + @override + String get backupSeedTitle => 'Zálohovať frázu na obnovenie Cashu'; + + @override + String get backupSeedInstructions => + 'Zapíšte si tieto slová v uvedenom poradí a uložte ich na bezpečné miesto. Sú jediným spôsobom, ako obnoviť vaše Cashu prostriedky pri strate tohto zariadenia.'; + + @override + String get backupSeedConfirm => + 'Zapísal(a) som si frázu na obnovenie a bezpečne som ju uložil(a)'; + + @override + String get backupSeedDone => 'Mám ju zálohovanú'; + + @override + String get reclaimPendingFunds => 'Získať späť čakajúce prostriedky'; + + @override + String get reclaimPendingTitle => 'Získať späť čakajúce prostriedky'; + + @override + String get recentTransactions => 'Nedávne transakcie'; + + @override + String get noRecentTransactions => 'Žiadne nedávne transakcie'; + + @override + String get noWalletsYet => 'Zatiaľ žiadne peňaženky'; + + @override + String get noWalletsAvailable => 'Žiadne dostupné peňaženky'; + + @override + String get tapToAddWallet => 'Ťuknite na + a pridajte peňaženku'; + + @override + String get delete => 'Vymazať'; + + @override + String error(String message) { + return 'Chyba: $message'; + } + + @override + String get unknownWalletType => 'Neznáma'; + + @override + String get cashuWallet => 'Cashu'; + + @override + String get nwcWallet => 'NWC'; + + @override + String get lnurlWallet => 'LNURL'; + + @override + String get nwcWalletSubtitle => 'NWC peňaženka'; + + @override + String get balance => 'Zostatok'; + + @override + String get sats => 'sats'; + + @override + String get selected => 'VYBRANÁ'; + + @override + String get receiveOnlyWallet => 'Peňaženka len na prijímanie'; + + @override + String receiveRange(int min, int max) { + return 'Prijímanie: $min - $max sats'; + } + + @override + String get limitsUnavailable => 'Limity nedostupné'; + + @override + String get tokenCopied => 'Token skopírovaný'; + + @override + String get deleteWalletConfirmation => 'Vymazať peňaženku?'; + + @override + String get deleteWalletConfirmationMessage => + 'Naozaj chcete vymazať túto peňaženku? Túto akciu nie je možné vrátiť späť.'; + + @override + String get addWalletTitle => 'Pridať peňaženku'; + + @override + String get chooseWalletType => 'Vyberte typ peňaženky'; + + @override + String get nwcWalletTypeTitle => 'Nostr Wallet Connect'; + + @override + String get nwcWalletTypeSubtitle => 'Pripojiť vzdialenú peňaženku cez NWC'; + + @override + String get lnurlWalletTypeTitle => 'Lightning adresa (LNURL)'; + + @override + String get lnurlWalletTypeSubtitle => + 'Použiť Lightning adresu (LNURL) len na prijímanie'; + + @override + String get cashuWalletTypeTitle => 'Cashu'; + + @override + String get cashuWalletTypeSubtitle => + 'Použiť ecash peňaženku podporovanú Cashu mintom'; + + @override + String get cashuOption => 'Cashu'; + + @override + String get nwcOption => 'NWC'; + + @override + String get lnurlOption => 'LNURL'; + + @override + String get connectNwcTitle => 'Pripojiť NWC'; + + @override + String get chooseNwcMethod => 'Vyberte spôsob pripojenia'; + + @override + String get albyGoOption => 'Alby Go'; + + @override + String get manualOption => 'Manuálne'; + + @override + String get faucetOption => 'Faucet'; + + @override + String get invalidNwcQrCode => 'Neplatný NWC QR kód'; + + @override + String get scanNwcQrCodeTitle => 'Naskenovať NWC QR kód'; + + @override + String get cameraNotAvailable => 'Kamera nie je dostupná'; + + @override + String get scanNwcInstructions => + 'Naskenujte QR kód z vašej NWC peňaženkovej aplikácie'; + + @override + String get invalidNwcUri => 'Neplatné NWC URI'; + + @override + String get paste => 'Vložiť'; + + @override + String get fromYourProfile => 'Z vášho profilu'; + + @override + String get orEnterManually => 'Alebo zadajte manuálne:'; + + @override + String get renameWallet => 'Premenovať'; + + @override + String get pickColor => 'Vybrať farbu'; + + @override + String get deleteWallet => 'Vymazať'; + + @override + String get walletName => 'Názov peňaženky'; + + @override + String get walletNameHint => 'Zadajte názov peňaženky'; + + @override + String get save => 'Uložiť'; + + @override + String get walletRenamed => 'Peňaženka premenovaná'; + + @override + String budgetUsedOf(int used, int total) { + final intl.NumberFormat usedNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String usedString = usedNumberFormat.format(used); + final intl.NumberFormat totalNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String totalString = totalNumberFormat.format(total); + + return 'Rozpočet: $usedString / $totalString'; + } + + @override + String budgetRenewsIn(int days) { + return 'Obnoví sa o $days dní'; + } + + @override + String get budgetDaily => 'Denne'; + + @override + String get budgetWeekly => 'Týždenne'; + + @override + String get budgetMonthly => 'Mesačne'; + + @override + String get budgetYearly => 'Ročne'; + + @override + String get budgetNever => 'Nikdy'; + + @override + String get backup => 'Zálohovať'; + + @override + String get restore => 'Obnoviť'; + + @override + String get cashuBackupTitle => 'Cashu záloha'; + + @override + String get cashuBackupWarning => + 'Táto záloha obsahuje vaše ecash dôkazy, ktoré sú prostriedkami na doručiteľa. Udržujte ju v súkromí a uložte na bezpečné miesto. Vaša seed fráza sa zálohuje samostatne.'; + + @override + String get generatingBackup => 'Generuje sa záloha...'; + + @override + String get copyBackup => 'Kopírovať zálohu'; + + @override + String get backupCopiedToClipboard => 'Záloha skopírovaná do schránky'; + + @override + String get cashuRestoreTitle => 'Obnoviť Cashu zálohu'; + + @override + String get backupJson => 'JSON zálohy'; + + @override + String get backupJsonHint => 'Sem vložte JSON zálohy'; + + @override + String get pleaseEnterBackup => 'Zadajte zálohu'; + + @override + String get restoringBackup => 'Obnovuje sa záloha...'; + + @override + String restoreSuccess(int count) { + return 'Obnovených $count dôkazov zo zálohy'; + } +} diff --git a/packages/ndk_flutter/lib/l10n/app_sk.arb b/packages/ndk_flutter/lib/l10n/app_sk.arb new file mode 100644 index 000000000..091528a1b --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -0,0 +1,456 @@ +{ + "@@locale": "sk", + "createAccount": "Vytvorte si účet", + "newHere": "Ste tu prvýkrát?", + "nostrAddress": "Nostr adresa", + "publicKey": "Verejný kľúč", + "privateKey": "Súkromný kľúč (nezabezpečené)", + "browserExtension": "Rozšírenie prehliadača", + "connect": "Pripojiť", + "install": "Inštalovať", + "logout": "Odhlásiť sa", + "nostrAddressHint": "meno@example.com", + "invalidAddress": "Neplatná adresa", + "unableToConnect": "Nepodarilo sa pripojiť", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "Nostr je pre vás nový?", + "getStarted": "Začať", + "bunker": "Bunker", + "bunkerAuthentication": "Overenie cez Bunker", + "tapToOpen": "Ťuknutím otvoríte: {url}", + "@tapToOpen": { + "description": "Description for bunker authentication toast", + "placeholders": { + "url": { + "type": "String", + "example": "https://example.com/auth" + } + } + }, + "showNostrConnectQrcode": "Zobraziť Nostr Connect QR kód", + "loginWithSignerApp": "Prihlásiť sa cez podpisovaciu aplikáciu", + "nostrConnectUrl": "Nostr Connect URL", + "copy": "Kopírovať", + "addAccount": "Pridať účet", + "readOnly": "Len na čítanie", + "nsec": "Nsec", + "extension": "Rozšírenie", + "userMetadata": "Metadáta používateľa", + "shortTextNote": "Krátka textová poznámka", + "recommendRelay": "Odporučiť relay", + "follows": "Sledovaní", + "encryptedDirectMessages": "Šifrované priame správy", + "eventDeletionRequest": "Žiadosť o vymazanie udalosti", + "repost": "Repost", + "reaction": "Reakcia", + "badgeAward": "Udelenie odznaku", + "chatMessage": "Správa v chate", + "groupChatThreadedReply": "Vláknová odpoveď v skupinovom chate", + "thread": "Vlákno", + "groupThreadReply": "Odpoveď v skupinovom vlákne", + "seal": "Pečať", + "directMessage": "Priama správa", + "fileMessage": "Správa so súborom", + "genericRepost": "Všeobecný repost", + "reactionToWebsite": "Reakcia na webstránku", + "picture": "Obrázok", + "videoEvent": "Video udalosť", + "shortFormPortraitVideoEvent": "Krátke video na výšku", + "internalReference": "Interný odkaz", + "externalReference": "Externý odkaz", + "hardcopyReference": "Odkaz na tlačenú kópiu", + "promptReference": "Odkaz na prompt", + "channelCreation": "Vytvorenie kanála", + "channelMetadata": "Metadáta kanála", + "channelMessage": "Správa kanála", + "channelHideMessage": "Skrytie správy v kanáli", + "channelMuteUser": "Stlmenie používateľa v kanáli", + "requestToVanish": "Žiadosť o zmiznutie", + "chessPgn": "Šach (PGN)", + "mlsKeyPackage": "MLS KeyPackage", + "mlsWelcome": "MLS Welcome", + "mlsGroupEvent": "MLS skupinová udalosť", + "mergeRequests": "Merge requesty", + "pollResponse": "Odpoveď na anketu", + "marketplaceBid": "Ponuka na trhovisku", + "marketplaceBidConfirmation": "Potvrdenie ponuky na trhovisku", + "openTimestamps": "OpenTimestamps", + "giftWrap": "Gift Wrap", + "fileMetadata": "Metadáta súboru", + "poll": "Anketa", + "comment": "Komentár", + "voiceMessage": "Hlasová správa", + "voiceMessageComment": "Komentár k hlasovej správe", + "liveChatMessage": "Správa v živom chate", + "codeSnippet": "Úryvok kódu", + "gitPatch": "Git patch", + "gitPullRequest": "Git pull request", + "gitStatusUpdate": "Aktualizácia stavu Git", + "gitIssue": "Git issue", + "gitIssueUpdate": "Aktualizácia Git issue", + "status": "Stav", + "statusUpdate": "Aktualizácia stavu", + "statusDelete": "Vymazanie stavu", + "statusReply": "Odpoveď na stav", + "problemTracker": "Sledovanie problémov", + "reporting": "Nahlasovanie", + "label": "Štítok", + "relayReviews": "Recenzie relayov", + "aiEmbeddings": "AI embeddingy / vektorové zoznamy", + "torrent": "Torrent", + "torrentComment": "Komentár k torrentu", + "coinjoinPool": "Coinjoin pool", + "communityPostApproval": "Schválenie príspevku komunity", + "jobRequest": "Žiadosť o úlohu", + "jobResult": "Výsledok úlohy", + "jobFeedback": "Spätná väzba k úlohe", + "cashuWalletToken": "Token Cashu peňaženky", + "cashuWalletProofs": "Dôkazy Cashu peňaženky", + "cashuWalletHistory": "História Cashu peňaženky", + "geocacheCreate": "Vytvorenie geokešky", + "geocacheUpdate": "Aktualizácia geokešky", + "groupControlEvent": "Riadiaca udalosť skupiny", + "zapGoal": "Zap cieľ", + "nutzap": "Nutzap", + "tidalLogin": "Tidal prihlásenie", + "zapRequest": "Žiadosť o zap", + "zap": "Zap", + "highlights": "Zvýraznenia", + "muteList": "Zoznam stlmených", + "pinList": "Zoznam pripnutých", + "relayListMetadata": "Metadáta zoznamu relayov", + "bookmarkList": "Zoznam záložiek", + "communitiesList": "Zoznam komunít", + "publicChatsList": "Zoznam verejných chatov", + "blockedRelaysList": "Zoznam blokovaných relayov", + "searchRelaysList": "Zoznam vyhľadávacích relayov", + "userGroups": "Skupiny používateľa", + "favoritesList": "Zoznam obľúbených", + "privateEventsList": "Zoznam súkromných udalostí", + "interestsList": "Zoznam záujmov", + "mediaFollowsList": "Zoznam sledovaných médií", + "peopleFollowsList": "Zoznam sledovaných ľudí", + "userEmojiList": "Zoznam emoji používateľa", + "dmRelayList": "Zoznam DM relayov", + "keyPackageRelayList": "Zoznam KeyPackage relayov", + "userServerList": "Zoznam serverov používateľa", + "fileStorageServerList": "Zoznam serverov na ukladanie súborov", + "relayMonitorAnnouncement": "Oznámenie monitora relayov", + "roomPresence": "Prítomnosť v miestnosti", + "proxyAnnouncement": "Oznámenie proxy", + "transportMethodAnnouncement": "Oznámenie spôsobu prenosu", + "walletInfo": "Informácie o peňaženke", + "cashuWalletEvent": "Udalosť Cashu peňaženky", + "lightningPubRpc": "Lightning Pub RPC", + "clientAuthentication": "Autentifikácia klienta", + "walletRequest": "Žiadosť peňaženky", + "walletResponse": "Odpoveď peňaženky", + "nostrConnectEvent": "Nostr Connect", + "blobsStoredOnMediaservers": "Bloby uložené na mediaserveroch", + "httpAuth": "HTTP autentifikácia", + "categorizedPeopleList": "Kategorizovaný zoznam ľudí", + "categorizedBookmarkList": "Kategorizovaný zoznam záložiek", + "categorizedRelayList": "Kategorizovaný zoznam relayov", + "bookmarkSets": "Sady záložiek", + "curationSets": "Kurátorské sady", + "videoSets": "Sady videí", + "kindMuteSets": "Sady stlmených kindov", + "profileBadges": "Odznaky profilu", + "badgeDefinition": "Definícia odznaku", + "interestSets": "Sady záujmov", + "createOrUpdateStall": "Vytvoriť alebo aktualizovať stánok", + "createOrUpdateProduct": "Vytvoriť alebo aktualizovať produkt", + "marketplaceUiUx": "UI/UX trhoviska", + "productSoldAsAuction": "Produkt predávaný ako aukcia", + "longFormContent": "Dlhoformátový obsah", + "draftLongFormContent": "Koncept dlhoformátového obsahu", + "emojiSets": "Sady emoji", + "curatedPublicationItem": "Položka kurátorskej publikácie", + "curatedPublicationDraft": "Koncept kurátorskej publikácie", + "releaseArtifactSets": "Sady artefaktov vydania", + "applicationSpecificData": "Dáta špecifické pre aplikáciu", + "relayDiscovery": "Objavovanie relayov", + "appCurationSets": "Kurátorské sady aplikácií", + "liveEvent": "Živá udalosť", + "userStatus": "Stav používateľa", + "slideSet": "Sada snímok", + "classifiedListing": "Inzerát", + "draftClassifiedListing": "Koncept inzerátu", + "repositoryAnnouncement": "Oznámenie repozitára", + "repositoryStateAnnouncement": "Oznámenie stavu repozitára", + "wikiArticle": "Wiki článok", + "redirects": "Presmerovania", + "draftEvent": "Koncept udalosti", + "linkSet": "Sada odkazov", + "feed": "Feed", + "dateBasedCalendarEvent": "Kalendárová udalosť podľa dátumu", + "timeBasedCalendarEvent": "Kalendárová udalosť podľa času", + "calendar": "Kalendár", + "calendarEventRsvp": "RSVP na kalendárovú udalosť", + "handlerRecommendation": "Odporúčanie handlera", + "handlerInformation": "Informácie o handleri", + "softwareApplication": "Softvérová aplikácia", + "videoView": "Zhliadnutie videa", + "communityDefinition": "Definícia komunity", + "geocacheListing": "Záznam geokešky", + "mintAnnouncement": "Oznámenie mintu", + "mintQuote": "Ponuka mintu", + "peerToPeerOrder": "Peer-to-peer objednávka", + "groupMetadata": "Metadáta skupiny", + "groupAdminMetadata": "Metadáta administrátora skupiny", + "groupMemberMetadata": "Metadáta člena skupiny", + "groupAdminsList": "Zoznam administrátorov skupiny", + "groupMembersList": "Zoznam členov skupiny", + "groupRoles": "Roly skupiny", + "groupPermissions": "Oprávnenia skupiny", + "groupChatMessage": "Správa skupinového chatu", + "groupChatThread": "Vlákno skupinového chatu", + "groupPinned": "Pripnuté v skupine", + "starterPacks": "Štartovacie balíčky", + "mediaStarterPacks": "Mediálne štartovacie balíčky", + "webBookmarks": "Webové záložky", + "unknownEventKind": "Druh udalosti {kind}", + "@unknownEventKind": { + "description": "Fallback for unknown event kinds", + "placeholders": { + "kind": { + "type": "int", + "description": "The numeric kind identifier" + } + } + }, + "walletsTitle": "Peňaženky", + "recentActivityTitle": "Nedávna aktivita", + "addCashuWallet": "Pridať Cashu peňaženku", + "addNwcWallet": "Pridať NWC peňaženku", + "addLnurlWallet": "Pridať LNURL peňaženku", + "addCashuTooltip": "Pridať Cashu peňaženku", + "addNwcTooltip": "Pridať NWC peňaženku", + "addLnurlTooltip": "Pridať LNURL peňaženku", + "addCashuWalletTitle": "Pridať Cashu peňaženku", + "enterMintUrl": "Zadajte URL mintu na pridanie Cashu peňaženky.", + "mintUrl": "URL mintu", + "mintUrlHint": "https://mint.example.com", + "pleaseEnterMintUrl": "Zadajte URL mintu", + "cashuWalletAdded": "Cashu peňaženka bola úspešne pridaná!", + "failedToAddMint": "Nepodarilo sa pridať mint. Skontrolujte URL a skúste to znova.", + "addNwcWalletTitle": "Pridať NWC peňaženku", + "faucet": "Faucet", + "manual": "Manuálne", + "nwcFaucetDescription": "Vytvorte testovaciu peňaženku so satmi z NWC faucetu.", + "startingBalance": "Počiatočný zostatok", + "startingBalanceHint": "10000", + "nwcConnectionUri": "URI pripojenia NWC", + "nwcConnectionUriHint": "nostr+walletconnect://...", + "nwcWalletAdded": "NWC peňaženka bola úspešne pridaná!", + "nwcFaucetWalletAdded": "NWC faucet peňaženka pridaná s {balance} satmi!", + "@nwcFaucetWalletAdded": { + "description": "Success message when NWC faucet wallet is added", + "placeholders": { + "balance": { + "type": "int", + "description": "The balance amount in sats" + } + } + }, + "invalidFaucetResponse": "Neplatná odpoveď z faucetu", + "errorCreatingWallet": "Chyba pri vytváraní peňaženky", + "addLnurlWalletTitle": "Pridať LNURL peňaženku", + "enterLnurlIdentifier": "Zadajte svoj LNURL identifikátor (user@domain.com).", + "lnurlIdentifierHint": "user@example.com", + "pleaseEnterValidIdentifier": "Zadajte platný identifikátor (user@domain.com)", + "lnurlWalletAdded": "LNURL peňaženka bola úspešne pridaná!", + "cancel": "Zrušiť", + "add": "Pridať", + "send": "Odoslať", + "receive": "Prijať", + "setAsDefaultForReceiving": "Nastaviť ako predvolenú na prijímanie", + "setAsDefaultForSending": "Nastaviť ako predvolenú na odosielanie", + "defaultForReceiving": "Predvolená na prijímanie", + "defaultForSending": "Predvolená na odosielanie", + "defaultWalletForReceivingTooltip": "Táto peňaženka je predvolená na prijímanie platieb.", + "defaultWalletForSendingTooltip": "Táto peňaženka je predvolená na odosielanie platieb.", + "sendOptionsTitle": "Možnosti odoslania", + "sendByToken": "Odoslať tokenom", + "sendByTokenDescription": "Vytvoriť Cashu token na odoslanie", + "sendByLightning": "Odoslať cez Lightning", + "sendByLightningDescription": "Zaplatiť Lightning faktúru", + "payInvoiceTitle": "Zaplatiť faktúru", + "invoice": "Faktúra", + "invoiceHint": "lnbc...", + "pleaseEnterInvoice": "Zadajte faktúru", + "invoicePaid": "Faktúra zaplatená!", + "paymentFailed": "Platba zlyhala: {message}", + "@paymentFailed": { + "description": "Error message when payment fails", + "placeholders": { + "message": { + "type": "String", + "description": "The error message" + } + } + }, + "receiveOptionsTitle": "Možnosti prijatia", + "receiveByToken": "Prijať tokenom", + "receiveByTokenDescription": "Prijať Cashu token", + "receiveByLightning": "Prijať cez Lightning", + "receiveByLightningDescription": "Vytvoriť Lightning faktúru", + "receiveByTokenTitle": "Prijať tokenom", + "token": "Token", + "tokenHint": "Sem vložte token...", + "pleaseEnterToken": "Zadajte token", + "tokenReceived": "Token prijatý!", + "createInvoiceTitle": "Vytvoriť faktúru", + "amount": "Suma", + "amountHint": "100", + "pleaseEnterValidAmount": "Zadajte platnú sumu", + "tokenCopiedToClipboard": "Token skopírovaný do schránky!", + "invoiceCreatedAndCopied": "Faktúra vytvorená a skopírovaná!", + "invoiceTrackingTitle": "Lightning faktúra", + "invoiceCreatedMessage": "Faktúra vytvorená a skopírovaná!", + "close": "Zavrieť", + "copyAgain": "Kopírovať znova", + "copied": "Skopírované!", + "paymentReceived": "Platba prijatá!", + "waitingForPayment": "Čaká sa na platbu...", + "paid": "Zaplatené!", + "createToken": "Vytvoriť token", + "pay": "Zaplatiť", + "create": "Vytvoriť", + "pendingTransactions": "Čakajúce", + "backupSeedWarning": "Zálohujte si frázu na obnovenie Cashu", + "backupSeedTitle": "Zálohovať frázu na obnovenie Cashu", + "backupSeedInstructions": "Zapíšte si tieto slová v uvedenom poradí a uložte ich na bezpečné miesto. Sú jediným spôsobom, ako obnoviť vaše Cashu prostriedky pri strate tohto zariadenia.", + "backupSeedConfirm": "Zapísal(a) som si frázu na obnovenie a bezpečne som ju uložil(a)", + "backupSeedDone": "Mám ju zálohovanú", + "reclaimPendingFunds": "Získať späť čakajúce prostriedky", + "reclaimPendingTitle": "Získať späť čakajúce prostriedky", + "recentTransactions": "Nedávne transakcie", + "noRecentTransactions": "Žiadne nedávne transakcie", + "noWalletsYet": "Zatiaľ žiadne peňaženky", + "noWalletsAvailable": "Žiadne dostupné peňaženky", + "tapToAddWallet": "Ťuknite na + a pridajte peňaženku", + "delete": "Vymazať", + "error": "Chyba: {message}", + "@error": { + "description": "Error message for general errors", + "placeholders": { + "message": { + "type": "String", + "description": "The error message" + } + } + }, + "unknownWalletType": "Neznáma", + "cashuWallet": "Cashu", + "nwcWallet": "NWC", + "lnurlWallet": "LNURL", + "nwcWalletSubtitle": "NWC peňaženka", + "balance": "Zostatok", + "sats": "sats", + "selected": "VYBRANÁ", + "receiveOnlyWallet": "Peňaženka len na prijímanie", + "receiveRange": "Prijímanie: {min} - {max} sats", + "@receiveRange": { + "description": "Label showing receive range for LNURL wallet", + "placeholders": { + "min": { + "type": "int", + "description": "Minimum amount" + }, + "max": { + "type": "int", + "description": "Maximum amount" + } + } + }, + "limitsUnavailable": "Limity nedostupné", + "tokenCopied": "Token skopírovaný", + "deleteWalletConfirmation": "Vymazať peňaženku?", + "deleteWalletConfirmationMessage": "Naozaj chcete vymazať túto peňaženku? Túto akciu nie je možné vrátiť späť.", + "addWalletTitle": "Pridať peňaženku", + "chooseWalletType": "Vyberte typ peňaženky", + "nwcWalletTypeTitle": "Nostr Wallet Connect", + "nwcWalletTypeSubtitle": "Pripojiť vzdialenú peňaženku cez NWC", + "lnurlWalletTypeTitle": "Lightning adresa (LNURL)", + "lnurlWalletTypeSubtitle": "Použiť Lightning adresu (LNURL) len na prijímanie", + "cashuWalletTypeTitle": "Cashu", + "cashuWalletTypeSubtitle": "Použiť ecash peňaženku podporovanú Cashu mintom", + "cashuOption": "Cashu", + "nwcOption": "NWC", + "lnurlOption": "LNURL", + "connectNwcTitle": "Pripojiť NWC", + "chooseNwcMethod": "Vyberte spôsob pripojenia", + "albyGoOption": "Alby Go", + "manualOption": "Manuálne", + "faucetOption": "Faucet", + "invalidNwcQrCode": "Neplatný NWC QR kód", + "scanNwcQrCodeTitle": "Naskenovať NWC QR kód", + "cameraNotAvailable": "Kamera nie je dostupná", + "scanNwcInstructions": "Naskenujte QR kód z vašej NWC peňaženkovej aplikácie", + "invalidNwcUri": "Neplatné NWC URI", + "paste": "Vložiť", + "fromYourProfile": "Z vášho profilu", + "orEnterManually": "Alebo zadajte manuálne:", + "renameWallet": "Premenovať", + "pickColor": "Vybrať farbu", + "deleteWallet": "Vymazať", + "walletName": "Názov peňaženky", + "walletNameHint": "Zadajte názov peňaženky", + "save": "Uložiť", + "walletRenamed": "Peňaženka premenovaná", + "budgetUsedOf": "Rozpočet: {used} / {total}", + "@budgetUsedOf": { + "description": "Budget information showing used and total amount", + "placeholders": { + "used": { + "type": "int", + "format": "decimalPattern", + "description": "Amount already used" + }, + "total": { + "type": "int", + "format": "decimalPattern", + "description": "Total budget amount" + } + } + }, + "budgetRenewsIn": "Obnoví sa o {days} dní", + "@budgetRenewsIn": { + "description": "Budget renewal information showing days until renewal", + "placeholders": { + "days": { + "type": "int", + "description": "Days until budget renews" + } + } + }, + "budgetDaily": "Denne", + "budgetWeekly": "Týždenne", + "budgetMonthly": "Mesačne", + "budgetYearly": "Ročne", + "budgetNever": "Nikdy", + "backup": "Zálohovať", + "restore": "Obnoviť", + "cashuBackupTitle": "Cashu záloha", + "cashuBackupWarning": "Táto záloha obsahuje vaše ecash dôkazy, ktoré sú prostriedkami na doručiteľa. Udržujte ju v súkromí a uložte na bezpečné miesto. Vaša seed fráza sa zálohuje samostatne.", + "generatingBackup": "Generuje sa záloha...", + "copyBackup": "Kopírovať zálohu", + "backupCopiedToClipboard": "Záloha skopírovaná do schránky", + "cashuRestoreTitle": "Obnoviť Cashu zálohu", + "backupJson": "JSON zálohy", + "backupJsonHint": "Sem vložte JSON zálohy", + "pleaseEnterBackup": "Zadajte zálohu", + "restoringBackup": "Obnovuje sa záloha...", + "restoreSuccess": "Obnovených {count} dôkazov zo zálohy", + "@restoreSuccess": { + "description": "Confirmation after a successful restore", + "placeholders": { + "count": { + "type": "int", + "description": "Number of restored proofs" + } + } + } +} diff --git a/packages/ndk_flutter/lib/main/ndk_flutter.dart b/packages/ndk_flutter/lib/main/ndk_flutter.dart index 7b7d6ceea..db37a4005 100644 --- a/packages/ndk_flutter/lib/main/ndk_flutter.dart +++ b/packages/ndk_flutter/lib/main/ndk_flutter.dart @@ -109,7 +109,7 @@ class NdkFlutter { } if (account.signer is Nip55EventSigner) { - // `signerSeed` holds the signer app package (Amber, Primal, ...) so + // `signerSeed` holds the signer app package so // silent signing keeps working after restart. final signer = account.signer as Nip55EventSigner; accounts.accounts.add( diff --git a/packages/ndk_flutter/lib/signers/src/event_signer_web.dart b/packages/ndk_flutter/lib/signers/src/event_signer_web.dart index 4eb9d21d6..7ef916489 100644 --- a/packages/ndk_flutter/lib/signers/src/event_signer_web.dart +++ b/packages/ndk_flutter/lib/signers/src/event_signer_web.dart @@ -61,6 +61,15 @@ class WebEventSigner implements EventSigner { _init(); } + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + Future _init() async { if (!_jsInjected) { _injectJS(); diff --git a/packages/ndk_flutter/lib/signers/src/nip07_event_signer_stub.dart b/packages/ndk_flutter/lib/signers/src/nip07_event_signer_stub.dart index 294bbd4f5..00a68d820 100644 --- a/packages/ndk_flutter/lib/signers/src/nip07_event_signer_stub.dart +++ b/packages/ndk_flutter/lib/signers/src/nip07_event_signer_stub.dart @@ -15,6 +15,15 @@ class Nip07EventSigner implements EventSigner { return false; } + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + @override Future decrypt(String msg, String destPubKey) async { throw UnsupportedError('NIP-07 is not available on this platform'); diff --git a/packages/ndk_flutter/lib/signers/src/nip07_event_signer_web.dart b/packages/ndk_flutter/lib/signers/src/nip07_event_signer_web.dart index f2725c50b..e5d2bbb13 100644 --- a/packages/ndk_flutter/lib/signers/src/nip07_event_signer_web.dart +++ b/packages/ndk_flutter/lib/signers/src/nip07_event_signer_web.dart @@ -103,6 +103,15 @@ class Nip07EventSigner with ConcurrencyLimiterMixin implements EventSigner { return js.nostr != null; } + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + @override Future decrypt(String msg, String destPubKey) async { if (js.nostr == null) { diff --git a/packages/ndk_flutter/lib/signers/src/web_event_signer_stub.dart b/packages/ndk_flutter/lib/signers/src/web_event_signer_stub.dart index 6bbf327ea..36e5d801f 100644 --- a/packages/ndk_flutter/lib/signers/src/web_event_signer_stub.dart +++ b/packages/ndk_flutter/lib/signers/src/web_event_signer_stub.dart @@ -16,6 +16,15 @@ class WebEventSigner implements EventSigner { ); } + @override + bool get requiresInteractiveSigning => false; + + @override + bool get requiresSignerNetwork => false; + + @override + Iterable get signerTransportRelayUrls => const []; + @override Future sign(Nip01Event event) { throw UnsupportedError( diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart index dcd986322..11ced4ef2 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -496,7 +496,11 @@ class _NWalletCardState extends State final bool isCashuWallet = widget.wallet is CashuWallet; final reclaimable = isCashuWallet ? reclaimablePending( - widget.ndkFlutter.ndk.cashu.pendingTransactions + widget + .ndkFlutter + .ndk + .cashu + .pendingTransactions .valueOrNull ?? const [], mintUrl: (widget.wallet as CashuWallet).mintUrl, @@ -676,16 +680,10 @@ class _NWalletCardState extends State ); break; case 'backup': - showBackupDialog( - context, - widget.wallet as CashuWallet, - ); + showBackupDialog(context, widget.wallet as CashuWallet); break; case 'restore': - showRestoreDialog( - context, - widget.wallet as CashuWallet, - ); + showRestoreDialog(context, widget.wallet as CashuWallet); break; case 'set_default_receive': widget.ndkFlutter.ndk.wallets diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 205594025..752b814a5 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -816,7 +816,7 @@ mixin WalletActionDialogsMixin on State { data: invoice.toUpperCase(), errorCorrectLevel: QrErrorCorrectLevel.M, decoration: const PrettyQrDecoration( - quietZone: PrettyQrQuietZone.standart, + quietZone: PrettyQrQuietZone.standard, background: Colors.white, shape: PrettyQrSmoothSymbol( color: Colors.black, @@ -933,7 +933,7 @@ mixin WalletActionDialogsMixin on State { data: invoice.toUpperCase(), errorCorrectLevel: QrErrorCorrectLevel.M, decoration: const PrettyQrDecoration( - quietZone: PrettyQrQuietZone.standart, + quietZone: PrettyQrQuietZone.standard, background: Colors.white, shape: PrettyQrSmoothSymbol( color: Colors.black, 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 506e71fdf..7d3814bad 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 @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; +import 'package:ndk/shared/nips/nip01/event_kind_classification.dart'; +import 'package:ndk/shared/nips/nip01/event_eviction_planner.dart'; import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; @@ -12,10 +15,8 @@ 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_key_value.dart'; -import 'schema/db_metadata.dart'; import 'schema/db_nip_01_event.dart'; import 'schema/db_relay_set.dart'; import 'schema/db_user_relay_list.dart'; @@ -27,10 +28,20 @@ class DbObjectBox extends WalletsRepo implements CacheManager { 'default_wallet_for_receiving'; static const String _defaultWalletForSendingKey = 'default_wallet_for_sending'; + static const String _eventSourcesKeyPrefix = 'event_sources:'; + static const String _eventDeliveryRecordKeyPrefix = 'event_delivery_record:'; + static const String _relayDeliveryTargetKeyPrefix = 'relay_delivery_target:'; + static const String _decryptedEventPayloadKeyPrefix = + 'decrypted_event_payload:'; final Completer _initCompleter = Completer(); Future get dbRdy => _initCompleter.future; late ObjectBoxInit _objectBox; + final Map> _eventSources = {}; + final Map _eventDeliveryRecords = {}; + final Map _relayDeliveryTargets = {}; + final Map _decryptedEventPayloadRecords = + {}; /// crates objectbox db instace /// [attach] to attach to already open instance (e.g. for isolates) @@ -58,30 +69,398 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future loadContactList(String pubKey) async { await dbRdy; - final contactListBox = _objectBox.store.box(); - final existingContact = contactListBox - .query(DbContactList_.pubKey.equals(pubKey)) - .order(DbContactList_.createdAt, flags: Order.descending) - .build() - .findFirst(); - if (existingContact == null) { - return null; - } - return existingContact.toNdk(); + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: ContactList.kKind, + ); + if (event == null) return null; + return ContactList.fromEvent(event); } @override Future loadEvent(String id) async { await dbRdy; final eventBox = _objectBox.store.box(); - final existingEvent = - eventBox.query(DbNip01Event_.nostrId.equals(id)).build().findFirst(); + final existingEvent = eventBox + .query(DbNip01Event_.nostrId.equals(id)) + .build() + .findFirst(); if (existingEvent == null) { return null; } return existingEvent.toNdk(); } + @override + Future addEventSource({ + required String eventId, + required String relayUrl, + }) async { + await addEventSources(eventId: eventId, relayUrls: [relayUrl]); + } + + @override + Future addEventSources({ + required String eventId, + required Iterable relayUrls, + }) async { + await dbRdy; + final sources = _eventSources.putIfAbsent(eventId, () => {}); + sources.addAll(relayUrls); + final sortedSources = sources.toList()..sort(); + _upsertKeyValue(_eventSourcesKey(eventId), jsonEncode(sortedSources)); + } + + @override + Future> loadEventSources(String eventId) async { + await dbRdy; + final cached = _eventSources[eventId]; + if (cached != null) { + final sources = cached.toList()..sort(); + return sources; + } + final stored = _getKeyValue(_eventSourcesKey(eventId)); + if (stored == null) { + return []; + } + final sources = (jsonDecode(stored) as List) + .map((entry) => entry.toString()) + .toSet(); + _eventSources[eventId] = sources; + final sortedSources = sources.toList()..sort(); + return sortedSources; + } + + @override + Future removeEventSources(String eventId) async { + await dbRdy; + _eventSources.remove(eventId); + _removeKeyValue(_eventSourcesKey(eventId)); + } + + @override + Future saveEventDeliveryRecord(EventDeliveryRecord record) async { + await dbRdy; + _eventDeliveryRecords[record.eventId] = record; + _upsertKeyValue( + _eventDeliveryRecordKey(record.eventId), + jsonEncode(record.toJson()), + ); + } + + @override + Future saveEventDeliveryRecords( + List records, + ) async { + await dbRdy; + for (final record in records) { + _eventDeliveryRecords[record.eventId] = record; + _upsertKeyValue( + _eventDeliveryRecordKey(record.eventId), + jsonEncode(record.toJson()), + ); + } + } + + @override + Future loadEventDeliveryRecord(String eventId) async { + await dbRdy; + final cached = _eventDeliveryRecords[eventId]; + if (cached != null) { + return cached; + } + final stored = _getKeyValue(_eventDeliveryRecordKey(eventId)); + if (stored == null) { + return null; + } + final record = EventDeliveryRecord.fromJson( + jsonDecode(stored) as Map, + ); + _eventDeliveryRecords[eventId] = record; + return record; + } + + @override + Future> loadEventDeliveryRecords({ + EventDeliveryStatus? status, + int? limit, + }) async { + await dbRdy; + final records = _loadKeyValuesByPrefix(_eventDeliveryRecordKeyPrefix) + .map( + (entry) => EventDeliveryRecord.fromJson( + jsonDecode(entry.value!) as Map, + ), + ) + .toList(); + _eventDeliveryRecords + ..clear() + ..addEntries(records.map((record) => MapEntry(record.eventId, record))); + + var filtered = records.where((record) { + return status == null || record.status == status; + }).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + if (limit != null && limit < filtered.length) { + filtered = filtered.take(limit).toList(); + } + + return filtered; + } + + @override + Future removeEventDeliveryRecord(String eventId) async { + await dbRdy; + _eventDeliveryRecords.remove(eventId); + _removeKeyValue(_eventDeliveryRecordKey(eventId)); + } + + @override + Future removeAllEventDeliveryRecords() async { + await dbRdy; + _eventDeliveryRecords.clear(); + _removeKeyValuesByPrefix(_eventDeliveryRecordKeyPrefix); + } + + @override + Future saveRelayDeliveryTarget(RelayDeliveryTarget target) async { + await dbRdy; + _relayDeliveryTargets[target.key] = target; + _upsertKeyValue( + _relayDeliveryTargetKey(target.key), + jsonEncode(target.toJson()), + ); + } + + @override + Future saveRelayDeliveryTargets( + List targets, + ) async { + await dbRdy; + for (final target in targets) { + _relayDeliveryTargets[target.key] = target; + _upsertKeyValue( + _relayDeliveryTargetKey(target.key), + jsonEncode(target.toJson()), + ); + } + } + + @override + Future loadRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + await dbRdy; + final key = '$eventId|$relayUrl'; + final cached = _relayDeliveryTargets[key]; + if (cached != null) { + return cached; + } + final stored = _getKeyValue(_relayDeliveryTargetKey(key)); + if (stored == null) { + return null; + } + final target = RelayDeliveryTarget.fromJson( + jsonDecode(stored) as Map, + ); + _relayDeliveryTargets[key] = target; + return target; + } + + @override + Future> loadRelayDeliveryTargets({ + String? eventId, + String? relayUrl, + RelayDeliveryState? state, + bool excludeAcked = false, + int? limit, + }) async { + await dbRdy; + final targets = _loadKeyValuesByPrefix(_relayDeliveryTargetKeyPrefix) + .map( + (entry) => RelayDeliveryTarget.fromJson( + jsonDecode(entry.value!) as Map, + ), + ) + .toList(); + _relayDeliveryTargets + ..clear() + ..addEntries(targets.map((target) => MapEntry(target.key, target))); + + var filtered = + targets.where((target) { + if (eventId != null && target.eventId != eventId) { + return false; + } + if (relayUrl != null && target.relayUrl != relayUrl) { + return false; + } + if (state != null && target.state != state) { + return false; + } + if (excludeAcked && target.state == RelayDeliveryState.acked) { + return false; + } + return true; + }).toList()..sort((a, b) { + final retryA = a.nextRetryAt ?? 0; + final retryB = b.nextRetryAt ?? 0; + if (retryA != retryB) { + return retryA.compareTo(retryB); + } + return a.key.compareTo(b.key); + }); + + if (limit != null && limit < filtered.length) { + filtered = filtered.take(limit).toList(); + } + + return filtered; + } + + @override + Future removeRelayDeliveryTarget({ + required String eventId, + required String relayUrl, + }) async { + await dbRdy; + final key = '$eventId|$relayUrl'; + _relayDeliveryTargets.remove(key); + _removeKeyValue(_relayDeliveryTargetKey(key)); + } + + @override + Future removeRelayDeliveryTargets(String eventId) async { + await dbRdy; + _relayDeliveryTargets.removeWhere((key, _) => key.startsWith('$eventId|')); + _removeKeyValuesByPrefix(_relayDeliveryTargetKeyPrefixForEvent(eventId)); + } + + @override + Future removeAllRelayDeliveryTargets() async { + await dbRdy; + _relayDeliveryTargets.clear(); + _removeKeyValuesByPrefix(_relayDeliveryTargetKeyPrefix); + } + + @override + Future saveDecryptedEventPayloadRecord( + DecryptedEventPayloadRecord record, + ) async { + await dbRdy; + _decryptedEventPayloadRecords[record.key] = record; + _upsertKeyValue( + _decryptedEventPayloadKey(record.key), + jsonEncode(record.toJson()), + ); + } + + @override + Future saveDecryptedEventPayloadRecords( + List records, + ) async { + await dbRdy; + for (final record in records) { + _decryptedEventPayloadRecords[record.key] = record; + _upsertKeyValue( + _decryptedEventPayloadKey(record.key), + jsonEncode(record.toJson()), + ); + } + } + + @override + Future loadDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + await dbRdy; + final key = '$eventId|$viewerPubKey'; + final cached = _decryptedEventPayloadRecords[key]; + if (cached != null) { + return cached; + } + final stored = _getKeyValue(_decryptedEventPayloadKey(key)); + if (stored == null) { + return null; + } + final record = DecryptedEventPayloadRecord.fromJson( + jsonDecode(stored) as Map, + ); + _decryptedEventPayloadRecords[key] = record; + return record; + } + + @override + Future> loadDecryptedEventPayloadRecords({ + String? eventId, + String? viewerPubKey, + DecryptedPayloadStatus? status, + int? limit, + }) async { + await dbRdy; + final records = _loadKeyValuesByPrefix(_decryptedEventPayloadKeyPrefix) + .map( + (entry) => DecryptedEventPayloadRecord.fromJson( + jsonDecode(entry.value!) as Map, + ), + ) + .toList(); + _decryptedEventPayloadRecords + ..clear() + ..addEntries(records.map((record) => MapEntry(record.key, record))); + + var filtered = records.where((record) { + if (eventId != null && record.eventId != eventId) { + return false; + } + if (viewerPubKey != null && record.viewerPubKey != viewerPubKey) { + return false; + } + if (status != null && record.status != status) { + return false; + } + return true; + }).toList(); + + filtered.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + if (limit != null && limit > 0 && filtered.length > limit) { + filtered = filtered.take(limit).toList(); + } + + return filtered; + } + + @override + Future removeDecryptedEventPayloadRecord({ + required String eventId, + required String viewerPubKey, + }) async { + await dbRdy; + final key = '$eventId|$viewerPubKey'; + _decryptedEventPayloadRecords.remove(key); + _removeKeyValue(_decryptedEventPayloadKey(key)); + } + + @override + Future removeDecryptedEventPayloadRecords(String eventId) async { + await dbRdy; + final prefix = '$eventId|'; + _decryptedEventPayloadRecords.removeWhere( + (key, _) => key.startsWith(prefix), + ); + _removeKeyValuesByPrefix(_decryptedEventPayloadKeyPrefixForEvent(eventId)); + } + + @override + Future removeAllDecryptedEventPayloadRecords() async { + await dbRdy; + _decryptedEventPayloadRecords.clear(); + _removeKeyValuesByPrefix(_decryptedEventPayloadKeyPrefix); + } + @override Future> loadEvents({ List? ids, @@ -107,14 +486,16 @@ class DbObjectBox extends WalletsRepo implements CacheManager { // Add ids filter if (ids != null && ids.isNotEmpty) { Condition idsCondition = DbNip01Event_.nostrId.oneOf(ids); - condition = - (condition == null) ? idsCondition : condition.and(idsCondition); + condition = (condition == null) + ? idsCondition + : condition.and(idsCondition); } // Add pubKeys filter if (pubKeys != null && pubKeys.isNotEmpty) { - Condition pubKeysCondition = - DbNip01Event_.pubKey.oneOf(pubKeys); + Condition pubKeysCondition = DbNip01Event_.pubKey.oneOf( + pubKeys, + ); condition = (condition == null) ? pubKeysCondition : condition.and(pubKeysCondition); @@ -123,24 +504,27 @@ class DbObjectBox extends WalletsRepo implements CacheManager { // Add kinds filter if (kinds != null && kinds.isNotEmpty) { Condition kindsCondition = DbNip01Event_.kind.oneOf(kinds); - condition = - (condition == null) ? kindsCondition : condition.and(kindsCondition); + condition = (condition == null) + ? kindsCondition + : condition.and(kindsCondition); } // Add since filter if (since != null) { - Condition sinceCondition = - DbNip01Event_.createdAt.greaterOrEqual(since); - condition = - (condition == null) ? sinceCondition : condition.and(sinceCondition); + Condition sinceCondition = DbNip01Event_.createdAt + .greaterOrEqual(since); + condition = (condition == null) + ? sinceCondition + : condition.and(sinceCondition); } // Add until filter if (until != null) { - Condition untilCondition = - DbNip01Event_.createdAt.lessOrEqual(until); - condition = - (condition == null) ? untilCondition : condition.and(untilCondition); + Condition untilCondition = DbNip01Event_.createdAt + .lessOrEqual(until); + condition = (condition == null) + ? untilCondition + : condition.and(untilCondition); } if (tags != null && tags.isNotEmpty) { @@ -150,8 +534,9 @@ class DbObjectBox extends WalletsRepo implements CacheManager { } final tagCondition = DbNip01Event_.dbId.oneOf(matchingEventIds.toList()); - condition = - (condition == null) ? tagCondition : condition.and(tagCondition); + condition = (condition == null) + ? tagCondition + : condition.and(tagCondition); } // Create and build the query @@ -167,57 +552,50 @@ class DbObjectBox extends WalletsRepo implements CacheManager { // Build and execute the query final query = queryBuilder.build(); - if (limit != null && limit > 0) { - query.limit = limit; - } final results = query.find(); - return results.map((dbEvent) => dbEvent.toNdk()).toList(); + final deletions = eventBox + .query(DbNip01Event_.kind.equals(5)) + .build() + .find() + .map((dbEvent) => dbEvent.toNdk()) + .toList(); + + var events = _applyEventVisibilityRules( + results.map((dbEvent) => dbEvent.toNdk()).toList(), + deletions, + )..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (limit != null && limit > 0 && events.length > limit) { + events = events.take(limit).toList(); + } + + return events; } @override Future loadMetadata(String pubKey) async { await dbRdy; - final metadataBox = _objectBox.store.box(); - final existingMetadata = metadataBox - .query(DbMetadata_.pubKey.equals(pubKey)) - .order(DbMetadata_.updatedAt, flags: Order.descending) - .build() - .findFirst(); - if (existingMetadata == null) { - return null; - } - return existingMetadata.toNdk(); + final event = await _loadLatestVisibleEvent( + pubKey: pubKey, + kind: Metadata.kKind, + ); + if (event == null) return null; + final metadata = Metadata.fromEvent(event); + metadata.refreshedTimestamp = Nip01Event.secondsSinceEpoch(); + return metadata; } @override Future> loadMetadatas(List pubKeys) async { await dbRdy; - final metadataBox = _objectBox.store.box(); - final existingMetadatas = metadataBox - .query(DbMetadata_.pubKey.oneOf(pubKeys)) - .order(DbMetadata_.updatedAt, flags: Order.descending) - .build() - .find(); - - // Create a map for quick lookup - final metadataMap = {}; - for (final dbMetadata in existingMetadatas) { - // Only keep the first (most recent) entry per pubKey - if (!metadataMap.containsKey(dbMetadata.pubKey)) { - metadataMap[dbMetadata.pubKey] = dbMetadata.toNdk(); - } - } - - // Return list in the same order as input, with null for not found - return pubKeys.map((pubKey) => metadataMap[pubKey]).toList(); + return Future.wait(pubKeys.map(loadMetadata)); } @override Future removeAllContactLists() async { await dbRdy; - final contactListBox = _objectBox.store.box(); - contactListBox.removeAll(); + await removeEvents(kinds: [ContactList.kKind]); } @override @@ -225,45 +603,40 @@ class DbObjectBox extends WalletsRepo implements CacheManager { await dbRdy; final eventBox = _objectBox.store.box(); eventBox.removeAll(); + _objectBox.store.box().removeAll(); + _removeAllEventSidecars(); } @override Future removeAllEventsByPubKey(String pubKey) async { await dbRdy; final eventBox = _objectBox.store.box(); - final events = - eventBox.query(DbNip01Event_.pubKey.equals(pubKey)).build().find(); + final events = eventBox + .query(DbNip01Event_.pubKey.equals(pubKey)) + .build() + .find(); + final eventIds = events.map((e) => e.nostrId).toList(); eventBox.removeMany(events.map((e) => e.dbId).toList()); + _removeEventSidecarsByIds(eventIds); + await _syncUserRelayListProjection(pubKey); } @override Future removeAllMetadatas() async { await dbRdy; - final metadataBox = _objectBox.store.box(); - metadataBox.removeAll(); + await removeEvents(kinds: [Metadata.kKind]); } @override Future saveContactList(ContactList contactList) async { await dbRdy; - final contactListBox = _objectBox.store.box(); - final existingContact = contactListBox - .query(DbContactList_.pubKey.equals(contactList.pubKey)) - .order(DbContactList_.createdAt, flags: Order.descending) - .build() - .findFirst(); - if (existingContact != null) { - contactListBox.remove(existingContact.dbId); - } - contactListBox.put(DbContactList.fromNdk(contactList)); + await saveEvent(contactList.toEvent()); } @override Future saveContactLists(List contactLists) async { await dbRdy; - final contactListBox = _objectBox.store.box(); - contactListBox - .putMany(contactLists.map((cl) => DbContactList.fromNdk(cl)).toList()); + await saveEvents(contactLists.map((cl) => cl.toEvent()).toList()); } @override @@ -290,28 +663,13 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future saveMetadata(Metadata metadata) async { await dbRdy; - final metadataBox = _objectBox.store.box(); - final existingMetadatas = metadataBox - .query(DbMetadata_.pubKey.equals(metadata.pubKey)) - .order(DbMetadata_.updatedAt, flags: Order.descending) - .build() - .find(); - if (existingMetadatas.length > 1) { - metadataBox.removeMany(existingMetadatas.map((e) => e.dbId).toList()); - } - if (existingMetadatas.isNotEmpty && - metadata.updatedAt! < existingMetadatas[0].updatedAt!) { - return; - } - metadataBox.put(DbMetadata.fromNdk(metadata)); + await saveEvent(metadata.toEvent()); } @override Future saveMetadatas(List metadatas) async { await dbRdy; - for (final metadata in metadatas) { - await saveMetadata(metadata); - } + await saveEvents(metadatas.map((metadata) => metadata.toEvent()).toList()); } @override @@ -377,6 +735,11 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future loadUserRelayList(String pubKey) async { await dbRdy; + final fromEvents = await _deriveUserRelayListFromEvents(pubKey); + if (fromEvents != null) { + return fromEvents; + } + final userRelayListBox = _objectBox.store.box(); final existingUserRelayList = userRelayListBox .query(DbUserRelayList_.pubKey.equals(pubKey)) @@ -389,6 +752,66 @@ class DbObjectBox extends WalletsRepo implements CacheManager { return existingUserRelayList.toNdk(); } + Future _deriveUserRelayListFromEvents(String pubKey) async { + final events = await loadEvents( + pubKeys: [pubKey], + kinds: [Nip65.kKind, ContactList.kKind], + ); + + Nip01Event? latestNip65; + Nip01Event? latestContactListWithRelays; + for (final event in events) { + if (event.kind == Nip65.kKind) { + latestNip65 ??= event; + } else if (event.kind == ContactList.kKind && + ContactList.relaysFromContent(event).isNotEmpty) { + latestContactListWithRelays ??= event; + } + } + + if (latestNip65 != null) { + return UserRelayList.fromNip65(Nip65.fromEvent(latestNip65)); + } + + if (latestContactListWithRelays != null) { + return UserRelayList.fromNip02EventContent(latestContactListWithRelays); + } + + return null; + } + + Future _syncUserRelayListProjection(String pubKey) async { + final derived = await _deriveUserRelayListFromEvents(pubKey); + final userRelayListBox = _objectBox.store.box(); + final existingUserRelayList = userRelayListBox + .query(DbUserRelayList_.pubKey.equals(pubKey)) + .build() + .findFirst(); + if (existingUserRelayList != null) { + userRelayListBox.remove(existingUserRelayList.dbId); + } + if (derived != null) { + userRelayListBox.put(DbUserRelayList.fromNdk(derived)); + } + } + + Future _syncUserRelayListProjectionForEvent(Nip01Event event) async { + if (!_affectsUserRelayListProjection(event.kind)) { + return; + } + await _syncUserRelayListProjection(event.pubKey); + } + + Future _syncUserRelayListProjections(Iterable pubKeys) async { + for (final pubKey in pubKeys.toSet()) { + await _syncUserRelayListProjection(pubKey); + } + } + + bool _affectsUserRelayListProjection(int kind) { + return kind == ContactList.kKind || kind == Nip65.kKind; + } + @override Future removeAllNip05s() async { await dbRdy; @@ -413,25 +836,141 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future removeContactList(String pubKey) async { await dbRdy; - final contactListBox = _objectBox.store.box(); - final existingContact = contactListBox - .query(DbContactList_.pubKey.equals(pubKey)) - .build() - .findFirst(); - if (existingContact != null) { - contactListBox.remove(existingContact.dbId); - } + await removeEvents(pubKeys: [pubKey], kinds: [ContactList.kKind]); } @override Future removeEvent(String id) async { await dbRdy; final eventBox = _objectBox.store.box(); - final existingEvent = - eventBox.query(DbNip01Event_.nostrId.equals(id)).build().findFirst(); + final existingEvent = eventBox + .query(DbNip01Event_.nostrId.equals(id)) + .build() + .findFirst(); if (existingEvent != null) { eventBox.remove(existingEvent.dbId); } + _removeEventSidecarsByIds([id]); + if (existingEvent != null) { + await _syncUserRelayListProjectionForEvent(existingEvent.toNdk()); + } + } + + @override + Future evict(EvictionPolicy policy) async { + await dbRdy; + final eventBox = _objectBox.store.box(); + final rawEvents = eventBox.getAll().map((event) => event.toNdk()).toList(); + final deliveryRecords = await loadEventDeliveryRecords(); + final relayTargets = await loadRelayDeliveryTargets(); + final activeDeliveryEventIds = deliveryRecords + .where((record) => record.status != EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final lockedEventIds = { + ...activeDeliveryEventIds, + ...relayTargets + .where((target) => target.state != RelayDeliveryState.acked) + .map((target) => target.eventId), + }; + final deliveredEventIds = deliveryRecords + .where((record) => record.status == EventDeliveryStatus.delivered) + .map((record) => record.eventId) + .toSet(); + final plan = EventEvictionPlanner.plan( + rawEvents: rawEvents, + lockedEventIds: lockedEventIds, + deliveredEventIds: deliveredEventIds, + policy: policy, + ); + + // Sweep leftover delivery records whose event was kept (or never stored). + // A terminally failed record swept here unpins its event for the next run. + final remainingDeliveryRecords = deliveryRecords + .where((record) => !plan.eventIdsToRemove.contains(record.eventId)) + .toList(); + final deliverySweep = EventEvictionPlanner.planDeliverySweep( + deliveryRecords: remainingDeliveryRecords, + policy: policy, + ); + + if (plan.eventIdsToRemove.isEmpty && + deliverySweep.deliveryEventIdsToRemove.isEmpty) { + return plan.toResult(); + } + + if (plan.eventIdsToRemove.isNotEmpty) { + final query = eventBox + .query(DbNip01Event_.nostrId.oneOf(plan.eventIdsToRemove.toList())) + .build(); + final results = query.find(); + query.close(); + eventBox.removeMany(results.map((event) => event.dbId).toList()); + _removeEventSidecarsByIds(plan.eventIdsToRemove); + } + + if (deliverySweep.deliveryEventIdsToRemove.isNotEmpty) { + _removeDeliveryRecordsByIds(deliverySweep.deliveryEventIdsToRemove); + } + + return plan.toResult().copyWith( + removedCompletedDeliveries: deliverySweep.removedCompletedDeliveries, + removedTerminalFailedDeliveries: + deliverySweep.removedTerminalFailedDeliveries, + ); + } + + /// Removes only the delivery record and relay delivery targets for + /// [eventIds]. Unlike [_removeEventSidecarsByIds] this leaves the event, its + /// provenance sources and decrypted payload sidecars intact, so it is safe to + /// call for events that are being kept in cache. + void _removeDeliveryRecordsByIds(Iterable eventIds) { + final eventIdSet = eventIds.toSet(); + if (eventIdSet.isEmpty) return; + + _eventDeliveryRecords.removeWhere( + (eventId, value) => eventIdSet.contains(eventId), + ); + _relayDeliveryTargets.removeWhere( + (key, target) => eventIdSet.contains(target.eventId), + ); + final box = _objectBox.store.box(); + for (final eventId in eventIdSet) { + _removeKeyValue(_eventDeliveryRecordKey(eventId), box: box); + _removeKeyValuesByPrefix( + _relayDeliveryTargetKeyPrefixForEvent(eventId), + box: box, + ); + } + } + + void _removeEventSidecarsByIds(Iterable eventIds) { + final eventIdSet = eventIds.toSet(); + if (eventIdSet.isEmpty) return; + + _eventSources.removeWhere((eventId, value) => eventIdSet.contains(eventId)); + _eventDeliveryRecords.removeWhere( + (eventId, value) => eventIdSet.contains(eventId), + ); + _relayDeliveryTargets.removeWhere( + (key, target) => eventIdSet.contains(target.eventId), + ); + _decryptedEventPayloadRecords.removeWhere( + (key, record) => eventIdSet.contains(record.eventId), + ); + final box = _objectBox.store.box(); + for (final eventId in eventIdSet) { + _removeKeyValue(_eventSourcesKey(eventId), box: box); + _removeKeyValue(_eventDeliveryRecordKey(eventId), box: box); + _removeKeyValuesByPrefix( + _relayDeliveryTargetKeyPrefixForEvent(eventId), + box: box, + ); + _removeKeyValuesByPrefix( + _decryptedEventPayloadKeyPrefixForEvent(eventId), + box: box, + ); + } } @override @@ -491,7 +1030,83 @@ class DbObjectBox extends WalletsRepo implements CacheManager { final results = query.find(); // Remove matching events + final removedEventIds = results.map((e) => e.nostrId).toList(); + final affectedPubKeys = results.map((e) => e.pubKey).toSet(); eventBox.removeMany(results.map((e) => e.dbId).toList()); + _removeEventSidecarsByIds(removedEventIds); + await _syncUserRelayListProjections(affectedPubKeys); + } + + List _applyEventVisibilityRules( + List events, + List deletions, + ) { + final visible = []; + final replaceableWinners = {}; + final now = Nip01Event.secondsSinceEpoch(); + + for (final event in events) { + if (_isExpired(event, now)) continue; + if (_isDeletedByAuthor(event, deletions)) continue; + + final coordinateKey = _coordinateKey(event); + if (coordinateKey == null) { + visible.add(event); + continue; + } + + final current = replaceableWinners[coordinateKey]; + if (current == null || _isMoreRecentReplaceable(event, current)) { + replaceableWinners[coordinateKey] = event; + } + } + + visible.addAll(replaceableWinners.values); + return visible; + } + + bool _isDeletedByAuthor(Nip01Event target, List deletions) { + if (target.kind == 5) return false; + + for (final deletion in deletions) { + if (deletion.pubKey != target.pubKey) continue; + if (deletion.getTags('e').contains(target.id.toLowerCase())) { + return true; + } + } + + return false; + } + + bool _isExpired(Nip01Event event, int now) { + final expirationValue = event.getFirstTag('expiration'); + if (expirationValue == null) return false; + final expiration = int.tryParse(expirationValue); + if (expiration == null) return false; + return expiration <= now; + } + + String? _coordinateKey(Nip01Event event) { + if (!EventKindClassification.isReplaceableKind(event.kind)) return null; + final dTag = event.getDtag() ?? ''; + return '${event.kind}:${event.pubKey}:$dTag'; + } + + bool _isMoreRecentReplaceable(Nip01Event candidate, Nip01Event current) { + if (candidate.createdAt != current.createdAt) { + return candidate.createdAt > current.createdAt; + } + + return candidate.id.compareTo(current.id) < 0; + } + + Future _loadLatestVisibleEvent({ + required String pubKey, + required int kind, + }) async { + final events = await loadEvents(pubKeys: [pubKey], kinds: [kind], limit: 1); + if (events.isEmpty) return null; + return events.first; } /// Find event DB IDs matching all given tag filters. @@ -548,14 +1163,7 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future removeMetadata(String pubKey) async { await dbRdy; - final metadataBox = _objectBox.store.box(); - final existingMetadata = metadataBox - .query(DbMetadata_.pubKey.equals(pubKey)) - .build() - .findFirst(); - if (existingMetadata != null) { - metadataBox.remove(existingMetadata.dbId); - } + await removeEvents(pubKeys: [pubKey], kinds: [Metadata.kKind]); } @override @@ -659,26 +1267,18 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future> searchMetadatas(String search, int limit) async { await dbRdy; - final metadataBox = _objectBox.store.box(); - - // Create a query with OR condition - final query = metadataBox - .query(DbMetadata_.splitNameWords - .containsElement(search, caseSensitive: false) - .or(DbMetadata_.name - .startsWith(search, caseSensitive: false) - .or(DbMetadata_.splitDisplayNameWords - .containsElement(search, caseSensitive: false)) - .or(DbMetadata_.displayName - .startsWith(search, caseSensitive: false)) - .or(DbMetadata_.nip05 - .startsWith(search, caseSensitive: false)))) - .order(DbMetadata_.name, flags: Order.descending) - .build(); - query..limit = limit; - final results = query.find(); + final events = await loadEvents(kinds: [Metadata.kKind]); + final normalizedSearch = search.trim().toLowerCase(); + final matches = events.map((event) => Metadata.fromEvent(event)).where(( + metadata, + ) { + if (normalizedSearch.isEmpty) return true; + return metadata.matchesSearch(normalizedSearch) || + (metadata.about?.toLowerCase().contains(normalizedSearch) ?? false) || + (metadata.cleanNip05?.contains(normalizedSearch) ?? false); + }).toList()..sort((a, b) => (b.updatedAt ?? 0).compareTo(a.updatedAt ?? 0)); - return results.map((dbMetadata) => dbMetadata.toNdk()).take(limit); + return matches.take(limit); } @override @@ -711,7 +1311,8 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future saveFilterFetchedRangeRecord( - FilterFetchedRangeRecord record) async { + FilterFetchedRangeRecord record, + ) async { await dbRdy; final box = _objectBox.store.box(); box.put(DbFilterFetchedRangeRecord.fromNdk(record)); @@ -719,16 +1320,19 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future saveFilterFetchedRangeRecords( - List records) async { + List records, + ) async { await dbRdy; final box = _objectBox.store.box(); box.putMany( - records.map((r) => DbFilterFetchedRangeRecord.fromNdk(r)).toList()); + records.map((r) => DbFilterFetchedRangeRecord.fromNdk(r)).toList(), + ); } @override Future> loadFilterFetchedRangeRecords( - String filterHash) async { + String filterHash, + ) async { await dbRdy; final box = _objectBox.store.box(); final results = box @@ -740,13 +1344,17 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future> loadFilterFetchedRangeRecordsByRelay( - String filterHash, String relayUrl) async { + String filterHash, + String relayUrl, + ) async { await dbRdy; final box = _objectBox.store.box(); final results = box - .query(DbFilterFetchedRangeRecord_.filterHash - .equals(filterHash) - .and(DbFilterFetchedRangeRecord_.relayUrl.equals(relayUrl))) + .query( + DbFilterFetchedRangeRecord_.filterHash + .equals(filterHash) + .and(DbFilterFetchedRangeRecord_.relayUrl.equals(relayUrl)), + ) .build() .find(); return results.map((r) => r.toNdk()).toList(); @@ -754,7 +1362,7 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future> - loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { + loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl) async { await dbRdy; final box = _objectBox.store.box(); final results = box @@ -779,13 +1387,17 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future removeFilterFetchedRangeRecordsByFilterAndRelay( - String filterHash, String relayUrl) async { + String filterHash, + String relayUrl, + ) async { await dbRdy; final box = _objectBox.store.box(); final existing = box - .query(DbFilterFetchedRangeRecord_.filterHash - .equals(filterHash) - .and(DbFilterFetchedRangeRecord_.relayUrl.equals(relayUrl))) + .query( + DbFilterFetchedRangeRecord_.filterHash + .equals(filterHash) + .and(DbFilterFetchedRangeRecord_.relayUrl.equals(relayUrl)), + ) .build() .find(); if (existing.isNotEmpty) { @@ -912,8 +1524,8 @@ class DbObjectBox extends WalletsRepo implements CacheManager { @override Future saveKeyset(CahsuKeyset keyset) async { _objectBox.store.box().put( - DbWalletCahsuKeyset.fromNdk(keyset), - ); + DbWalletCahsuKeyset.fromNdk(keyset), + ); return Future.value(); } @@ -930,13 +1542,15 @@ class DbObjectBox extends WalletsRepo implements CacheManager { store.runInTransaction(TxMode.write, () { final box = store.box(); - final dbTokens = - proofs.map((t) => DbWalletCashuProof.fromNdk(t)).toList(); + 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(); + final query = box + .query(DbWalletCashuProof_.secret.oneOf(secretsToCheck)) + .build(); try { final existing = query.find(); @@ -971,14 +1585,17 @@ class DbObjectBox extends WalletsRepo implements CacheManager { } if (unit != null && unit.isNotEmpty) { final unitCondition = DbWalletTransaction_.unit.equals(unit); - condition = - (condition == null) ? unitCondition : condition.and(unitCondition); + condition = (condition == null) + ? unitCondition + : condition.and(unitCondition); } if (walletType != null) { - final typeCondition = - DbWalletTransaction_.walletType.equals(walletType.toString()); - condition = - (condition == null) ? typeCondition : condition.and(typeCondition); + final typeCondition = DbWalletTransaction_.walletType.equals( + walletType.toString(), + ); + condition = (condition == null) + ? typeCondition + : condition.and(typeCondition); } QueryBuilder queryBuilder; if (condition != null) { @@ -988,8 +1605,10 @@ class DbObjectBox extends WalletsRepo implements CacheManager { } // sort - queryBuilder.order(DbWalletTransaction_.transactionDate, - flags: Order.descending); + queryBuilder.order( + DbWalletTransaction_.transactionDate, + flags: Order.descending, + ); final query = queryBuilder.build(); // limit @@ -1013,14 +1632,16 @@ class DbObjectBox extends WalletsRepo implements CacheManager { store.runInTransaction(TxMode.write, () { final box = store.box(); - final dbTransactions = - transactions.map((t) => DbWalletTransaction.fromNdk(t)).toList(); + 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(); + final query = box + .query(DbWalletTransaction_.id.oneOf(idsToCheck)) + .build(); try { final existing = query.find(); @@ -1054,8 +1675,9 @@ class DbObjectBox extends WalletsRepo implements CacheManager { try { final transactionsToRemove = query.find(); if (transactionsToRemove.isNotEmpty) { - transactionBox - .removeMany(transactionsToRemove.map((t) => t.dbId).toList()); + transactionBox.removeMany( + transactionsToRemove.map((t) => t.dbId).toList(), + ); } } finally { query.close(); @@ -1067,14 +1689,19 @@ class DbObjectBox extends WalletsRepo implements CacheManager { 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(), + _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(), ); } @@ -1171,9 +1798,11 @@ class DbObjectBox extends WalletsRepo implements CacheManager { await dbRdy; final box = _objectBox.store.box(); final existing = box - .query(DbCashuSecretCounter_.mintUrl - .equals(mintUrl) - .and(DbCashuSecretCounter_.keysetId.equals(keysetId))) + .query( + DbCashuSecretCounter_.mintUrl + .equals(mintUrl) + .and(DbCashuSecretCounter_.keysetId.equals(keysetId)), + ) .build() .findFirst(); if (existing == null) { @@ -1191,42 +1820,47 @@ class DbObjectBox extends WalletsRepo implements CacheManager { await dbRdy; final box = _objectBox.store.box(); final existing = box - .query(DbCashuSecretCounter_.mintUrl - .equals(mintUrl) - .and(DbCashuSecretCounter_.keysetId.equals(keysetId))) + .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, - )); + box.put( + DbCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + counter: counter, + ), + ); return Future.value(); } Future clearAll() async { await dbRdy; - await _objectBox.store.runInTransactionAsync( - TxMode.write, - (Store store, _) { - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - store.box().removeAll(); - }, - null, - ); + await _objectBox.store.runInTransactionAsync(TxMode.write, ( + Store store, + _, + ) { + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + store.box().removeAll(); + }, null); + _eventSources.clear(); + _eventDeliveryRecords.clear(); + _relayDeliveryTargets.clear(); + _decryptedEventPayloadRecords.clear(); } @override @@ -1283,4 +1917,75 @@ class DbObjectBox extends WalletsRepo implements CacheManager { } return null; } + + String _eventSourcesKey(String eventId) => '$_eventSourcesKeyPrefix$eventId'; + + String _eventDeliveryRecordKey(String eventId) { + return '$_eventDeliveryRecordKeyPrefix$eventId'; + } + + String _relayDeliveryTargetKey(String key) { + return '$_relayDeliveryTargetKeyPrefix$key'; + } + + String _relayDeliveryTargetKeyPrefixForEvent(String eventId) { + return '$_relayDeliveryTargetKeyPrefix$eventId|'; + } + + String _decryptedEventPayloadKey(String key) { + return '$_decryptedEventPayloadKeyPrefix$key'; + } + + String _decryptedEventPayloadKeyPrefixForEvent(String eventId) { + return '$_decryptedEventPayloadKeyPrefix$eventId|'; + } + + String? _getKeyValue(String key) { + final box = _objectBox.store.box(); + final existing = _findKeyValue(box, key); + return existing?.value; + } + + void _upsertKeyValue(String key, String? value) { + final box = _objectBox.store.box(); + _removeKeyValue(key, box: box); + box.put(DbKeyValue(key: key, value: value)); + } + + void _removeKeyValue(String key, {Box? box}) { + final targetBox = box ?? _objectBox.store.box(); + final existing = _findKeyValue(targetBox, key); + if (existing != null) { + targetBox.remove(existing.dbId); + } + } + + List _loadKeyValuesByPrefix(String prefix) { + final box = _objectBox.store.box(); + return box.getAll().where((entry) => entry.key.startsWith(prefix)).toList(); + } + + void _removeKeyValuesByPrefix(String prefix, {Box? box}) { + final targetBox = box ?? _objectBox.store.box(); + final idsToRemove = targetBox + .getAll() + .where((entry) => entry.key.startsWith(prefix)) + .map((entry) => entry.dbId) + .toList(); + if (idsToRemove.isNotEmpty) { + targetBox.removeMany(idsToRemove); + } + } + + void _removeAllEventSidecars() { + _eventSources.clear(); + _eventDeliveryRecords.clear(); + _relayDeliveryTargets.clear(); + _decryptedEventPayloadRecords.clear(); + final box = _objectBox.store.box(); + _removeKeyValuesByPrefix(_eventSourcesKeyPrefix, box: box); + _removeKeyValuesByPrefix(_eventDeliveryRecordKeyPrefix, box: box); + _removeKeyValuesByPrefix(_relayDeliveryTargetKeyPrefix, box: box); + _removeKeyValuesByPrefix(_decryptedEventPayloadKeyPrefix, box: box); + } } 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 index a97a7ad32..8c49c78bf 100644 --- 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 @@ -61,9 +61,7 @@ class DbCashuMintInfo { version: ndkM.version, description: ndkM.description, descriptionLong: ndkM.descriptionLong, - contactJson: jsonEncode( - ndkM.contact.map((c) => c.toJson()).toList(), - ), + contactJson: jsonEncode(ndkM.contact.map((c) => c.toJson()).toList()), motd: ndkM.motd, iconUrl: ndkM.iconUrl, urls: ndkM.urls, @@ -78,9 +76,11 @@ class DbCashuMintInfo { ndk_entities.CashuMintInfo toNdk() { final decodedContact = (jsonDecode(contactJson) as List) - .map((e) => ndk_entities.CashuMintContact.fromJson( - Map.from(e as Map), - )) + .map( + (e) => ndk_entities.CashuMintContact.fromJson( + Map.from(e as Map), + ), + ) .toList(); final decodedNutsRaw = Map.from( diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_contact_list.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_contact_list.dart deleted file mode 100644 index 35a114109..000000000 --- a/packages/objectbox/lib/data_layer/db/object_box/schema/db_contact_list.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:objectbox/objectbox.dart'; -import 'package:ndk/entities.dart' as ndk_entities; - -@Entity() -class DbContactList { - @Id() - int dbId = 0; - - @Property() - late String pubKey; - - @Property() - List contacts = []; - - @Property() - List contactRelays = []; - - @Property() - List petnames = []; - - @Property() - List followedTags = []; - - @Property() - List followedCommunities = []; - - @Property() - List followedEvents = []; - - @Property() - int createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - @Property() - int? loadedTimestamp; - - @Property() - List sources = []; - - DbContactList({ - required this.pubKey, - required this.contacts, - }); - - ndk_entities.ContactList toNdk() { - final ndkContactList = ndk_entities.ContactList( - pubKey: pubKey, - contacts: contacts, - ); - - ndkContactList.contactRelays = contactRelays; - ndkContactList.petnames = petnames; - ndkContactList.followedTags = followedTags; - ndkContactList.followedCommunities = followedCommunities; - ndkContactList.followedEvents = followedEvents; - ndkContactList.sources = sources; - ndkContactList.createdAt = createdAt; - ndkContactList.loadedTimestamp = loadedTimestamp; - - return ndkContactList; - } - - factory DbContactList.fromNdk(ndk_entities.ContactList ndkContactList) { - final dbContactList = DbContactList( - pubKey: ndkContactList.pubKey, - contacts: ndkContactList.contacts, - ); - dbContactList.contactRelays = ndkContactList.contactRelays; - dbContactList.petnames = ndkContactList.petnames; - dbContactList.followedTags = ndkContactList.followedTags; - dbContactList.followedCommunities = ndkContactList.followedCommunities; - dbContactList.followedEvents = ndkContactList.followedEvents; - dbContactList.sources = ndkContactList.sources; - dbContactList.createdAt = ndkContactList.createdAt; - dbContactList.loadedTimestamp = ndkContactList.loadedTimestamp; - return dbContactList; - } -} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_metadata.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_metadata.dart deleted file mode 100644 index 5709ed43b..000000000 --- a/packages/objectbox/lib/data_layer/db/object_box/schema/db_metadata.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:convert'; - -import 'package:objectbox/objectbox.dart'; -import 'package:ndk/entities.dart' as ndk_entities; - -@Entity() -class DbMetadata { - @Id() - int dbId = 0; - - static const int KIND = 0; - - @Property() - late String pubKey; - - @Property() - String? name; - - @Property() - String? displayName; - - @Property() - String? picture; - - @Property() - String? banner; - - @Property() - String? website; - - @Property() - String? about; - - @Property() - String? nip05; - - @Property() - String? lud16; - - @Property() - String? lud06; - - @Property() - int? updatedAt; - - @Property() - int? refreshedTimestamp; - - @Property() - List? splitDisplayNameWords; - - @Property() - List? splitNameWords; - - /// JSON encoded tags (List>) - @Property() - String? tagsJson; - - /// JSON encoded rawContent (Map) - @Property() - String? rawContentJson; - - DbMetadata({ - this.pubKey = "", - this.name, - this.displayName, - this.splitNameWords, - this.splitDisplayNameWords, - this.picture, - this.banner, - this.website, - this.about, - this.nip05, - this.lud16, - this.lud06, - this.updatedAt, - this.refreshedTimestamp, - this.tagsJson, - this.rawContentJson, - }); - - String? get cleanNip05 { - if (nip05 != null) { - if (nip05!.startsWith("_@")) { - return nip05!.trim().toLowerCase().replaceAll("_@", "@"); - } - return nip05!.trim().toLowerCase(); - } - return null; - } - - Map toFullJson() { - var data = toJson(); - data['pub_key'] = pubKey; - return data; - } - - Map toJson() { - final Map data = {}; - data['name'] = name; - data['display_name'] = displayName; - data['splitNameWords'] = name?.trim().toLowerCase().split(" "); - data['splitDisplayNameWords'] = - displayName?.trim().toLowerCase().split(" "); - data['picture'] = picture; - data['banner'] = banner; - data['website'] = website; - data['about'] = about; - data['nip05'] = nip05; - data['lud16'] = lud16; - data['lud06'] = lud06; - return data; - } - - bool matchesSearch(String str) { - str = str.trim().toLowerCase(); - String d = displayName != null ? displayName!.toLowerCase() : ""; - String n = name != null ? name!.toLowerCase() : ""; - String str2 = " $str"; - return d.startsWith(str) || - d.contains(str2) || - n.startsWith(str) || - n.contains(str2); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DbMetadata && - runtimeType == other.runtimeType && - pubKey == other.pubKey; - - @override - int get hashCode => pubKey.hashCode; - - ndk_entities.Metadata toNdk() { - // Parse tags from JSON - List>? parsedTags; - if (tagsJson != null) { - parsedTags = (jsonDecode(tagsJson!) as List) - .map((tag) => (tag as List).map((e) => e.toString()).toList()) - .toList(); - } - - final ndkM = ndk_entities.Metadata( - pubKey: pubKey, - name: name, - displayName: displayName, - picture: picture, - banner: banner, - website: website, - about: about, - nip05: nip05, - lud16: lud16, - lud06: lud06, - updatedAt: updatedAt, - refreshedTimestamp: refreshedTimestamp, - tags: parsedTags, - content: rawContentJson != null - ? jsonDecode(rawContentJson!) as Map - : null, - ); - - return ndkM; - } - - factory DbMetadata.fromNdk(ndk_entities.Metadata ndkM) { - final dbM = DbMetadata( - pubKey: ndkM.pubKey, - name: ndkM.name, - displayName: ndkM.displayName, - splitDisplayNameWords: ndkM.displayName?.trim().toLowerCase().split(" "), - splitNameWords: ndkM.name?.trim().toLowerCase().split(" "), - picture: ndkM.picture, - banner: ndkM.banner, - website: ndkM.website, - about: ndkM.about, - nip05: ndkM.nip05, - lud16: ndkM.lud16, - lud06: ndkM.lud06, - updatedAt: ndkM.updatedAt, - refreshedTimestamp: ndkM.refreshedTimestamp, - tagsJson: ndkM.tags.isNotEmpty ? jsonEncode(ndkM.tags) : null, - rawContentJson: ndkM.content.isNotEmpty ? jsonEncode(ndkM.content) : null, - ); - - return dbM; - } -} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_nip_01_event.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_nip_01_event.dart index b4971aef7..992cc8dd3 100644 --- a/packages/objectbox/lib/data_layer/db/object_box/schema/db_nip_01_event.dart +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_nip_01_event.dart @@ -70,7 +70,7 @@ class DbNip01Event { tagsIndex = [ for (final tag in value) if (tag.length >= 2 && tag[0].isNotEmpty && tag[1].isNotEmpty) - '${tag[0]}:${tag[1].trim().toLowerCase()}' + '${tag[0]}:${tag[1].trim().toLowerCase()}', ]; } @@ -97,7 +97,7 @@ class DbNip01Event { return [ for (final tag in list) if (tag.isNotEmpty && tag[0] == tagKey && tag.length >= 2) - tag[1].trim().toLowerCase() + tag[1].trim().toLowerCase(), ]; } @@ -106,13 +106,13 @@ class DbNip01Event { List get pTags => getTagValues(tags, "p"); List get replyETags => [ - for (final tag in tags) - if (tag.isNotEmpty && - tag[0] == "e" && - tag.length >= 4 && - tag[3] == "reply") - tag[1].trim().toLowerCase() - ]; + for (final tag in tags) + if (tag.isNotEmpty && + tag[0] == "e" && + tag.length >= 4 && + tag[3] == "reply") + tag[1].trim().toLowerCase(), + ]; String? getDtag() { for (var tag in tags) { @@ -129,6 +129,7 @@ class DbNip01Event { } ndk_entities.Nip01Event toNdk() { + // ignore: deprecated_member_use return ndk_entities.Nip01Event( pubKey: pubKey, content: content, @@ -153,6 +154,7 @@ class DbNip01Event { dbE.tags = ndkE.tags; dbE.sig = ndkE.sig; dbE.validSig = ndkE.validSig; + // ignore: deprecated_member_use dbE.sources = ndkE.sources; return dbE; } diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_relay_set.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_relay_set.dart index 4357f83e8..a0abf545c 100644 --- a/packages/objectbox/lib/data_layer/db/object_box/schema/db_relay_set.dart +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_relay_set.dart @@ -72,10 +72,7 @@ class DbRelaySet { final Map>> encoded = {}; relaysMap.forEach((url, mappings) { encoded[url] = mappings - .map((m) => { - 'pubKey': m.pubKey, - 'rwMarker': m.rwMarker.name, - }) + .map((m) => {'pubKey': m.pubKey, 'rwMarker': m.rwMarker.name}) .toList(); }); return json.encode(encoded); diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_user_relay_list.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_user_relay_list.dart index ef5558188..86d4d7eed 100644 --- a/packages/objectbox/lib/data_layer/db/object_box/schema/db_user_relay_list.dart +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_user_relay_list.dart @@ -48,14 +48,19 @@ class DbUserRelayList { // Helper method to encode relays map to JSON string static String _encodeRelays(Map relays) { - return json - .encode(relays.map((key, value) => MapEntry(key, value.toString()))); + return json.encode( + relays.map((key, value) => MapEntry(key, value.toString())), + ); } // Helper method to decode JSON string to relays map static Map _decodeRelays(String relaysJson) { Map decodedMap = json.decode(relaysJson); - return decodedMap.map((key, value) => MapEntry( - key, ReadWriteMarker.values.firstWhere((e) => e.toString() == value))); + return decodedMap.map( + (key, value) => MapEntry( + key, + ReadWriteMarker.values.firstWhere((e) => e.toString() == value), + ), + ); } } 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 index 280cff054..0b9be5cdb 100644 --- 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 @@ -52,17 +52,18 @@ class DbWalletTransaction { 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)); + 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; } diff --git a/packages/objectbox/lib/objectbox-model.json b/packages/objectbox/lib/objectbox-model.json index 6ca023a9b..63f238a62 100644 --- a/packages/objectbox/lib/objectbox-model.json +++ b/packages/objectbox/lib/objectbox-model.json @@ -105,70 +105,6 @@ ], "relations": [] }, - { - "id": "3:3682591720006600703", - "lastPropertyId": "11:7545952194051366512", - "name": "DbContactList", - "properties": [ - { - "id": "1:7780742043370837153", - "name": "dbId", - "type": 6, - "flags": 1 - }, - { - "id": "2:8602175201301812817", - "name": "pubKey", - "type": 9 - }, - { - "id": "3:6588469063491727348", - "name": "contacts", - "type": 30 - }, - { - "id": "4:75249076290576666", - "name": "contactRelays", - "type": 30 - }, - { - "id": "5:6804406115324337031", - "name": "petnames", - "type": 30 - }, - { - "id": "6:7693263173149769891", - "name": "followedTags", - "type": 30 - }, - { - "id": "7:8268207314327809040", - "name": "followedCommunities", - "type": 30 - }, - { - "id": "8:4512242970244363995", - "name": "followedEvents", - "type": 30 - }, - { - "id": "9:7332918471334278463", - "name": "createdAt", - "type": 6 - }, - { - "id": "10:3729392581718427500", - "name": "loadedTimestamp", - "type": 6 - }, - { - "id": "11:7545952194051366512", - "name": "sources", - "type": 30 - } - ], - "relations": [] - }, { "id": "4:7018303747071848120", "lastPropertyId": "5:7933414869525867708", @@ -207,100 +143,6 @@ ], "relations": [] }, - { - "id": "5:1919098435759190214", - "lastPropertyId": "17:2030042811439309784", - "name": "DbMetadata", - "properties": [ - { - "id": "1:1011081857613632613", - "name": "dbId", - "type": 6, - "flags": 1 - }, - { - "id": "2:3039105406833333056", - "name": "pubKey", - "type": 9 - }, - { - "id": "3:4507919589268535077", - "name": "name", - "type": 9 - }, - { - "id": "4:1595155449261590699", - "name": "displayName", - "type": 9 - }, - { - "id": "5:1689945174078317757", - "name": "picture", - "type": 9 - }, - { - "id": "6:6416486645197017169", - "name": "banner", - "type": 9 - }, - { - "id": "7:5134474379640930908", - "name": "website", - "type": 9 - }, - { - "id": "8:8254452512617269229", - "name": "about", - "type": 9 - }, - { - "id": "9:220859641688426961", - "name": "nip05", - "type": 9 - }, - { - "id": "10:5474484448460733707", - "name": "lud16", - "type": 9 - }, - { - "id": "11:8100712289343384841", - "name": "lud06", - "type": 9 - }, - { - "id": "12:7649815872288579579", - "name": "updatedAt", - "type": 6 - }, - { - "id": "13:3152299183673097071", - "name": "refreshedTimestamp", - "type": 6 - }, - { - "id": "14:5738030133151021974", - "name": "splitDisplayNameWords", - "type": 30 - }, - { - "id": "15:9036410895671859279", - "name": "splitNameWords", - "type": 30 - }, - { - "id": "16:3929789748611864129", - "name": "tagsJson", - "type": 9 - }, - { - "id": "17:2030042811439309784", - "name": "rawContentJson", - "type": 9 - } - ], - "relations": [] - }, { "id": "6:218101082138538195", "lastPropertyId": "11:5409780622498583870", @@ -711,9 +553,41 @@ "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, - "retiredEntityUids": [], + "retiredEntityUids": [ + 3682591720006600703, + 1919098435759190214 + ], "retiredIndexUids": [], - "retiredPropertyUids": [], + "retiredPropertyUids": [ + 7780742043370837153, + 8602175201301812817, + 6588469063491727348, + 75249076290576666, + 6804406115324337031, + 7693263173149769891, + 8268207314327809040, + 4512242970244363995, + 7332918471334278463, + 3729392581718427500, + 7545952194051366512, + 1011081857613632613, + 3039105406833333056, + 4507919589268535077, + 1595155449261590699, + 1689945174078317757, + 6416486645197017169, + 5134474379640930908, + 8254452512617269229, + 220859641688426961, + 5474484448460733707, + 8100712289343384841, + 7649815872288579579, + 3152299183673097071, + 5738030133151021974, + 9036410895671859279, + 3929789748611864129, + 2030042811439309784 + ], "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 ad804c93e..86aa32abd 100644 --- a/packages/objectbox/lib/objectbox.g.dart +++ b/packages/objectbox/lib/objectbox.g.dart @@ -18,10 +18,8 @@ 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_key_value.dart'; -import 'data_layer/db/object_box/schema/db_metadata.dart'; import 'data_layer/db/object_box/schema/db_nip_01_event.dart'; import 'data_layer/db/object_box/schema/db_nip_05.dart'; import 'data_layer/db/object_box/schema/db_relay_set.dart'; @@ -150,82 +148,6 @@ final _entities = [ relations: [], backlinks: [], ), - obx_int.ModelEntity( - id: const obx_int.IdUid(3, 3682591720006600703), - name: 'DbContactList', - lastPropertyId: const obx_int.IdUid(11, 7545952194051366512), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 7780742043370837153), - name: 'dbId', - type: 6, - flags: 1, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 8602175201301812817), - name: 'pubKey', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 6588469063491727348), - name: 'contacts', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 75249076290576666), - name: 'contactRelays', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 6804406115324337031), - name: 'petnames', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 7693263173149769891), - name: 'followedTags', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 8268207314327809040), - name: 'followedCommunities', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(8, 4512242970244363995), - name: 'followedEvents', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(9, 7332918471334278463), - name: 'createdAt', - type: 6, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 3729392581718427500), - name: 'loadedTimestamp', - type: 6, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(11, 7545952194051366512), - name: 'sources', - type: 30, - flags: 0, - ), - ], - relations: [], - backlinks: [], - ), obx_int.ModelEntity( id: const obx_int.IdUid(4, 7018303747071848120), name: 'DbFilterFetchedRangeRecord', @@ -268,118 +190,6 @@ final _entities = [ relations: [], backlinks: [], ), - obx_int.ModelEntity( - id: const obx_int.IdUid(5, 1919098435759190214), - name: 'DbMetadata', - lastPropertyId: const obx_int.IdUid(17, 2030042811439309784), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 1011081857613632613), - name: 'dbId', - type: 6, - flags: 1, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 3039105406833333056), - name: 'pubKey', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 4507919589268535077), - name: 'name', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 1595155449261590699), - name: 'displayName', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 1689945174078317757), - name: 'picture', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 6416486645197017169), - name: 'banner', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 5134474379640930908), - name: 'website', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(8, 8254452512617269229), - name: 'about', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(9, 220859641688426961), - name: 'nip05', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 5474484448460733707), - name: 'lud16', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(11, 8100712289343384841), - name: 'lud06', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(12, 7649815872288579579), - name: 'updatedAt', - type: 6, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(13, 3152299183673097071), - name: 'refreshedTimestamp', - type: 6, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(14, 5738030133151021974), - name: 'splitDisplayNameWords', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(15, 9036410895671859279), - name: 'splitNameWords', - type: 30, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(16, 3929789748611864129), - name: 'tagsJson', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(17, 2030042811439309784), - name: 'rawContentJson', - type: 9, - flags: 0, - ), - ], - relations: [], - backlinks: [], - ), obx_int.ModelEntity( id: const obx_int.IdUid(6, 218101082138538195), name: 'DbNip01Event', @@ -904,9 +714,38 @@ obx_int.ModelDefinition getObjectBoxModel() { lastIndexId: const obx_int.IdUid(5, 8083061451592629115), lastRelationId: const obx_int.IdUid(0, 0), lastSequenceId: const obx_int.IdUid(0, 0), - retiredEntityUids: const [], + retiredEntityUids: const [3682591720006600703, 1919098435759190214], retiredIndexUids: const [], - retiredPropertyUids: const [], + retiredPropertyUids: const [ + 7780742043370837153, + 8602175201301812817, + 6588469063491727348, + 75249076290576666, + 6804406115324337031, + 7693263173149769891, + 8268207314327809040, + 4512242970244363995, + 7332918471334278463, + 3729392581718427500, + 7545952194051366512, + 1011081857613632613, + 3039105406833333056, + 4507919589268535077, + 1595155449261590699, + 1689945174078317757, + 6416486645197017169, + 5134474379640930908, + 8254452512617269229, + 220859641688426961, + 5474484448460733707, + 8100712289343384841, + 7649815872288579579, + 3152299183673097071, + 5738030133151021974, + 9036410895671859279, + 3929789748611864129, + 2030042811439309784, + ], retiredRelationUids: const [], modelVersion: 5, modelVersionParserMinimum: 5, @@ -923,10 +762,12 @@ obx_int.ModelDefinition getObjectBoxModel() { 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 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!); @@ -934,15 +775,18 @@ obx_int.ModelDefinition getObjectBoxModel() { ? 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 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 tosUrlOffset = object.tosUrl == null + ? null + : fbb.writeString(object.tosUrl!); final nutsJsonOffset = fbb.writeString(object.nutsJson); fbb.startTable(13); fbb.addInt64(0, object.dbId); @@ -1059,315 +903,67 @@ obx_int.ModelDefinition getObjectBoxModel() { return object; }, ), - DbContactList: obx_int.EntityDefinition( - model: _entities[2], - toOneRelations: (DbContactList object) => [], - toManyRelations: (DbContactList object) => {}, - getId: (DbContactList object) => object.dbId, - setId: (DbContactList object, int id) { - object.dbId = id; - }, - objectToFB: (DbContactList object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final contactsOffset = fbb.writeList( - object.contacts.map(fbb.writeString).toList(growable: false), - ); - final contactRelaysOffset = fbb.writeList( - object.contactRelays.map(fbb.writeString).toList(growable: false), - ); - final petnamesOffset = fbb.writeList( - object.petnames.map(fbb.writeString).toList(growable: false), - ); - final followedTagsOffset = fbb.writeList( - object.followedTags.map(fbb.writeString).toList(growable: false), - ); - final followedCommunitiesOffset = fbb.writeList( - object.followedCommunities - .map(fbb.writeString) - .toList(growable: false), - ); - final followedEventsOffset = fbb.writeList( - object.followedEvents.map(fbb.writeString).toList(growable: false), - ); - final sourcesOffset = fbb.writeList( - object.sources.map(fbb.writeString).toList(growable: false), - ); - fbb.startTable(12); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addOffset(2, contactsOffset); - fbb.addOffset(3, contactRelaysOffset); - fbb.addOffset(4, petnamesOffset); - fbb.addOffset(5, followedTagsOffset); - fbb.addOffset(6, followedCommunitiesOffset); - fbb.addOffset(7, followedEventsOffset); - fbb.addInt64(8, object.createdAt); - fbb.addInt64(9, object.loadedTimestamp); - fbb.addOffset(10, sourcesOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 6, ''); - final contactsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 8, []); - final object = - DbContactList(pubKey: pubKeyParam, contacts: contactsParam) - ..dbId = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 4, - 0, - ) - ..contactRelays = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 10, []) - ..petnames = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 12, []) - ..followedTags = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 14, []) - ..followedCommunities = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 16, []) - ..followedEvents = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 18, []) - ..createdAt = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 20, - 0, - ) - ..loadedTimestamp = const fb.Int64Reader().vTableGetNullable( - buffer, - rootOffset, - 22, - ) - ..sources = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 24, []); - - return object; - }, - ), 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[4], - toOneRelations: (DbMetadata object) => [], - toManyRelations: (DbMetadata object) => {}, - getId: (DbMetadata object) => object.dbId, - setId: (DbMetadata object, int id) { - object.dbId = id; - }, - objectToFB: (DbMetadata object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final nameOffset = - object.name == null ? null : fbb.writeString(object.name!); - final displayNameOffset = object.displayName == null - ? null - : fbb.writeString(object.displayName!); - final pictureOffset = - object.picture == null ? null : fbb.writeString(object.picture!); - final bannerOffset = - object.banner == null ? null : fbb.writeString(object.banner!); - final websiteOffset = - object.website == null ? null : fbb.writeString(object.website!); - final aboutOffset = - object.about == null ? null : fbb.writeString(object.about!); - final nip05Offset = - object.nip05 == null ? null : fbb.writeString(object.nip05!); - final lud16Offset = - object.lud16 == null ? null : fbb.writeString(object.lud16!); - final lud06Offset = - object.lud06 == null ? null : fbb.writeString(object.lud06!); - final splitDisplayNameWordsOffset = object.splitDisplayNameWords == null - ? null - : fbb.writeList( - object.splitDisplayNameWords! - .map(fbb.writeString) - .toList(growable: false), - ); - final splitNameWordsOffset = object.splitNameWords == null - ? null - : fbb.writeList( - object.splitNameWords! - .map(fbb.writeString) - .toList(growable: false), - ); - final tagsJsonOffset = - object.tagsJson == null ? null : fbb.writeString(object.tagsJson!); - final rawContentJsonOffset = object.rawContentJson == null - ? null - : fbb.writeString(object.rawContentJson!); - fbb.startTable(18); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addOffset(2, nameOffset); - fbb.addOffset(3, displayNameOffset); - fbb.addOffset(4, pictureOffset); - fbb.addOffset(5, bannerOffset); - fbb.addOffset(6, websiteOffset); - fbb.addOffset(7, aboutOffset); - fbb.addOffset(8, nip05Offset); - fbb.addOffset(9, lud16Offset); - fbb.addOffset(10, lud06Offset); - fbb.addInt64(11, object.updatedAt); - fbb.addInt64(12, object.refreshedTimestamp); - fbb.addOffset(13, splitDisplayNameWordsOffset); - fbb.addOffset(14, splitNameWordsOffset); - fbb.addOffset(15, tagsJsonOffset); - fbb.addOffset(16, rawContentJsonOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 6, ''); - final nameParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 8); - final displayNameParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 10); - final splitNameWordsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGetNullable(buffer, rootOffset, 32); - final splitDisplayNameWordsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGetNullable(buffer, rootOffset, 30); - final pictureParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 12); - final bannerParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 14); - final websiteParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 16); - final aboutParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 18); - final nip05Param = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 20); - final lud16Param = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 22); - final lud06Param = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 24); - final updatedAtParam = const fb.Int64Reader().vTableGetNullable( - buffer, - rootOffset, - 26, - ); - final refreshedTimestampParam = - const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 28); - final tagsJsonParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 34); - final rawContentJsonParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 36); - final object = DbMetadata( - pubKey: pubKeyParam, - name: nameParam, - displayName: displayNameParam, - splitNameWords: splitNameWordsParam, - splitDisplayNameWords: splitDisplayNameWordsParam, - picture: pictureParam, - banner: bannerParam, - website: websiteParam, - about: aboutParam, - nip05: nip05Param, - lud16: lud16Param, - lud06: lud06Param, - updatedAt: updatedAtParam, - refreshedTimestamp: refreshedTimestampParam, - tagsJson: tagsJsonParam, - rawContentJson: rawContentJsonParam, - )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }, - ), + model: _entities[2], + 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; + }, + ), DbNip01Event: obx_int.EntityDefinition( - model: _entities[5], + model: _entities[3], toOneRelations: (DbNip01Event object) => [], toManyRelations: (DbNip01Event object) => {}, getId: (DbNip01Event object) => object.dbId, @@ -1378,8 +974,9 @@ obx_int.ModelDefinition getObjectBoxModel() { final nostrIdOffset = fbb.writeString(object.nostrId); final pubKeyOffset = fbb.writeString(object.pubKey); final contentOffset = fbb.writeString(object.content); - final sigOffset = - object.sig == null ? null : fbb.writeString(object.sig!); + final sigOffset = object.sig == null + ? null + : fbb.writeString(object.sig!); final sourcesOffset = fbb.writeList( object.sources.map(fbb.writeString).toList(growable: false), ); @@ -1428,45 +1025,46 @@ obx_int.ModelDefinition getObjectBoxModel() { 10, 0, ); - final object = DbNip01Event( - pubKey: pubKeyParam, - kind: kindParam, - content: contentParam, - nostrId: nostrIdParam, - createdAt: createdAtParam, - ) - ..dbId = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 4, - 0, - ) - ..sig = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 16) - ..validSig = const fb.BoolReader().vTableGetNullable( - buffer, - rootOffset, - 18, - ) - ..sources = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 20, []) - ..tagsPacked = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 22, []) - ..tagsIndex = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 24, []); + final object = + DbNip01Event( + pubKey: pubKeyParam, + kind: kindParam, + content: contentParam, + nostrId: nostrIdParam, + createdAt: createdAtParam, + ) + ..dbId = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 4, + 0, + ) + ..sig = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 16) + ..validSig = const fb.BoolReader().vTableGetNullable( + buffer, + rootOffset, + 18, + ) + ..sources = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 20, []) + ..tagsPacked = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 22, []) + ..tagsIndex = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 24, []); return object; }, ), DbNip05: obx_int.EntityDefinition( - model: _entities[6], + model: _entities[4], toOneRelations: (DbNip05 object) => [], toManyRelations: (DbNip05 object) => {}, getId: (DbNip05 object) => object.dbId, @@ -1525,7 +1123,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbRelaySet: obx_int.EntityDefinition( - model: _entities[7], + model: _entities[5], toOneRelations: (DbRelaySet object) => [], toManyRelations: (DbRelaySet object) => {}, getId: (DbRelaySet object) => object.dbId, @@ -1594,7 +1192,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbUserRelayList: obx_int.EntityDefinition( - model: _entities[8], + model: _entities[6], toOneRelations: (DbUserRelayList object) => [], toManyRelations: (DbUserRelayList object) => {}, getId: (DbUserRelayList object) => object.dbId, @@ -1645,7 +1243,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbWallet: obx_int.EntityDefinition( - model: _entities[9], + model: _entities[7], toOneRelations: (DbWallet object) => [], toManyRelations: (DbWallet object) => {}, getId: (DbWallet object) => object.dbId, @@ -1703,7 +1301,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbWalletCahsuKeyset: obx_int.EntityDefinition( - model: _entities[10], + model: _entities[8], toOneRelations: (DbWalletCahsuKeyset object) => [], toManyRelations: (DbWalletCahsuKeyset object) => {}, getId: (DbWalletCahsuKeyset object) => object.dbId, @@ -1776,7 +1374,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbWalletCashuProof: obx_int.EntityDefinition( - model: _entities[11], + model: _entities[9], toOneRelations: (DbWalletCashuProof object) => [], toManyRelations: (DbWalletCashuProof object) => {}, getId: (DbWalletCashuProof object) => object.dbId, @@ -1831,7 +1429,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbWalletTransaction: obx_int.EntityDefinition( - model: _entities[12], + model: _entities[10], toOneRelations: (DbWalletTransaction object) => [], toManyRelations: (DbWalletTransaction object) => {}, getId: (DbWalletTransaction object) => object.dbId, @@ -1922,7 +1520,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbKeyValue: obx_int.EntityDefinition( - model: _entities[13], + model: _entities[11], toOneRelations: (DbKeyValue object) => [], toManyRelations: (DbKeyValue object) => {}, getId: (DbKeyValue object) => object.dbId, @@ -1931,8 +1529,9 @@ obx_int.ModelDefinition getObjectBoxModel() { }, objectToFB: (DbKeyValue object, fb.Builder fbb) { final keyOffset = fbb.writeString(object.key); - final valueOffset = - object.value == null ? null : fbb.writeString(object.value!); + final valueOffset = object.value == null + ? null + : fbb.writeString(object.value!); fbb.startTable(4); fbb.addInt64(0, object.dbId); fbb.addOffset(1, keyOffset); @@ -2046,176 +1645,32 @@ class DbCashuSecretCounter_ { ); } -/// [DbContactList] entity fields to define ObjectBox queries. -class DbContactList_ { - /// See [DbContactList.dbId]. - static final dbId = obx.QueryIntegerProperty( - _entities[2].properties[0], - ); - - /// See [DbContactList.pubKey]. - static final pubKey = obx.QueryStringProperty( - _entities[2].properties[1], - ); - - /// See [DbContactList.contacts]. - static final contacts = obx.QueryStringVectorProperty( - _entities[2].properties[2], - ); - - /// See [DbContactList.contactRelays]. - static final contactRelays = obx.QueryStringVectorProperty( - _entities[2].properties[3], - ); - - /// See [DbContactList.petnames]. - static final petnames = obx.QueryStringVectorProperty( - _entities[2].properties[4], - ); - - /// See [DbContactList.followedTags]. - static final followedTags = obx.QueryStringVectorProperty( - _entities[2].properties[5], - ); - - /// See [DbContactList.followedCommunities]. - static final followedCommunities = - obx.QueryStringVectorProperty(_entities[2].properties[6]); - - /// See [DbContactList.followedEvents]. - static final followedEvents = obx.QueryStringVectorProperty( - _entities[2].properties[7], - ); - - /// See [DbContactList.createdAt]. - static final createdAt = obx.QueryIntegerProperty( - _entities[2].properties[8], - ); - - /// See [DbContactList.loadedTimestamp]. - static final loadedTimestamp = obx.QueryIntegerProperty( - _entities[2].properties[9], - ); - - /// See [DbContactList.sources]. - static final sources = obx.QueryStringVectorProperty( - _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], + _entities[2].properties[0], ); /// See [DbFilterFetchedRangeRecord.filterHash]. static final filterHash = obx.QueryStringProperty( - _entities[3].properties[1], + _entities[2].properties[1], ); /// See [DbFilterFetchedRangeRecord.relayUrl]. static final relayUrl = obx.QueryStringProperty( - _entities[3].properties[2], + _entities[2].properties[2], ); /// See [DbFilterFetchedRangeRecord.rangeStart]. static final rangeStart = obx.QueryIntegerProperty( - _entities[3].properties[3], - ); + _entities[2].properties[3], + ); /// See [DbFilterFetchedRangeRecord.rangeEnd]. static final rangeEnd = obx.QueryIntegerProperty( - _entities[3].properties[4], - ); -} - -/// [DbMetadata] entity fields to define ObjectBox queries. -class DbMetadata_ { - /// See [DbMetadata.dbId]. - static final dbId = obx.QueryIntegerProperty( - _entities[4].properties[0], - ); - - /// See [DbMetadata.pubKey]. - static final pubKey = obx.QueryStringProperty( - _entities[4].properties[1], - ); - - /// See [DbMetadata.name]. - static final name = obx.QueryStringProperty( - _entities[4].properties[2], - ); - - /// See [DbMetadata.displayName]. - static final displayName = obx.QueryStringProperty( - _entities[4].properties[3], - ); - - /// See [DbMetadata.picture]. - static final picture = obx.QueryStringProperty( - _entities[4].properties[4], - ); - - /// See [DbMetadata.banner]. - static final banner = obx.QueryStringProperty( - _entities[4].properties[5], - ); - - /// See [DbMetadata.website]. - static final website = obx.QueryStringProperty( - _entities[4].properties[6], - ); - - /// See [DbMetadata.about]. - static final about = obx.QueryStringProperty( - _entities[4].properties[7], - ); - - /// See [DbMetadata.nip05]. - static final nip05 = obx.QueryStringProperty( - _entities[4].properties[8], - ); - - /// See [DbMetadata.lud16]. - static final lud16 = obx.QueryStringProperty( - _entities[4].properties[9], - ); - - /// See [DbMetadata.lud06]. - static final lud06 = obx.QueryStringProperty( - _entities[4].properties[10], - ); - - /// See [DbMetadata.updatedAt]. - static final updatedAt = obx.QueryIntegerProperty( - _entities[4].properties[11], - ); - - /// See [DbMetadata.refreshedTimestamp]. - static final refreshedTimestamp = obx.QueryIntegerProperty( - _entities[4].properties[12], - ); - - /// See [DbMetadata.splitDisplayNameWords]. - static final splitDisplayNameWords = - obx.QueryStringVectorProperty(_entities[4].properties[13]); - - /// See [DbMetadata.splitNameWords]. - static final splitNameWords = obx.QueryStringVectorProperty( - _entities[4].properties[14], - ); - - /// See [DbMetadata.tagsJson]. - static final tagsJson = obx.QueryStringProperty( - _entities[4].properties[15], - ); - - /// See [DbMetadata.rawContentJson]. - static final rawContentJson = obx.QueryStringProperty( - _entities[4].properties[16], + _entities[2].properties[4], ); } @@ -2223,57 +1678,57 @@ class DbMetadata_ { class DbNip01Event_ { /// See [DbNip01Event.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[5].properties[0], + _entities[3].properties[0], ); /// See [DbNip01Event.nostrId]. static final nostrId = obx.QueryStringProperty( - _entities[5].properties[1], + _entities[3].properties[1], ); /// See [DbNip01Event.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[5].properties[2], + _entities[3].properties[2], ); /// See [DbNip01Event.createdAt]. static final createdAt = obx.QueryIntegerProperty( - _entities[5].properties[3], + _entities[3].properties[3], ); /// See [DbNip01Event.kind]. static final kind = obx.QueryIntegerProperty( - _entities[5].properties[4], + _entities[3].properties[4], ); /// See [DbNip01Event.content]. static final content = obx.QueryStringProperty( - _entities[5].properties[5], + _entities[3].properties[5], ); /// See [DbNip01Event.sig]. static final sig = obx.QueryStringProperty( - _entities[5].properties[6], + _entities[3].properties[6], ); /// See [DbNip01Event.validSig]. static final validSig = obx.QueryBooleanProperty( - _entities[5].properties[7], + _entities[3].properties[7], ); /// See [DbNip01Event.sources]. static final sources = obx.QueryStringVectorProperty( - _entities[5].properties[8], + _entities[3].properties[8], ); /// See [DbNip01Event.tagsPacked]. static final tagsPacked = obx.QueryStringVectorProperty( - _entities[5].properties[9], + _entities[3].properties[9], ); /// See [DbNip01Event.tagsIndex]. static final tagsIndex = obx.QueryStringVectorProperty( - _entities[5].properties[10], + _entities[3].properties[10], ); } @@ -2281,32 +1736,32 @@ class DbNip01Event_ { class DbNip05_ { /// See [DbNip05.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[6].properties[0], + _entities[4].properties[0], ); /// See [DbNip05.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[6].properties[1], + _entities[4].properties[1], ); /// See [DbNip05.nip05]. static final nip05 = obx.QueryStringProperty( - _entities[6].properties[2], + _entities[4].properties[2], ); /// See [DbNip05.valid]. static final valid = obx.QueryBooleanProperty( - _entities[6].properties[3], + _entities[4].properties[3], ); /// See [DbNip05.networkFetchTime]. static final networkFetchTime = obx.QueryIntegerProperty( - _entities[6].properties[4], + _entities[4].properties[4], ); /// See [DbNip05.relays]. static final relays = obx.QueryStringVectorProperty( - _entities[6].properties[5], + _entities[4].properties[5], ); } @@ -2314,42 +1769,42 @@ class DbNip05_ { class DbRelaySet_ { /// See [DbRelaySet.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[7].properties[0], + _entities[5].properties[0], ); /// See [DbRelaySet.id]. static final id = obx.QueryStringProperty( - _entities[7].properties[1], + _entities[5].properties[1], ); /// See [DbRelaySet.name]. static final name = obx.QueryStringProperty( - _entities[7].properties[2], + _entities[5].properties[2], ); /// See [DbRelaySet.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[7].properties[3], + _entities[5].properties[3], ); /// See [DbRelaySet.relayMinCountPerPubkey]. static final relayMinCountPerPubkey = obx.QueryIntegerProperty( - _entities[7].properties[4], + _entities[5].properties[4], ); /// See [DbRelaySet.direction]. static final direction = obx.QueryStringProperty( - _entities[7].properties[5], + _entities[5].properties[5], ); /// See [DbRelaySet.relaysMapJson]. static final relaysMapJson = obx.QueryStringProperty( - _entities[7].properties[6], + _entities[5].properties[6], ); /// See [DbRelaySet.fallbackToBootstrapRelays]. static final fallbackToBootstrapRelays = obx.QueryBooleanProperty( - _entities[7].properties[7], + _entities[5].properties[7], ); } @@ -2357,27 +1812,27 @@ class DbRelaySet_ { class DbUserRelayList_ { /// See [DbUserRelayList.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[8].properties[0], + _entities[6].properties[0], ); /// See [DbUserRelayList.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[8].properties[1], + _entities[6].properties[1], ); /// See [DbUserRelayList.createdAt]. static final createdAt = obx.QueryIntegerProperty( - _entities[8].properties[2], + _entities[6].properties[2], ); /// See [DbUserRelayList.refreshedTimestamp]. static final refreshedTimestamp = obx.QueryIntegerProperty( - _entities[8].properties[3], + _entities[6].properties[3], ); /// See [DbUserRelayList.relaysJson]. static final relaysJson = obx.QueryStringProperty( - _entities[8].properties[4], + _entities[6].properties[4], ); } @@ -2385,32 +1840,32 @@ class DbUserRelayList_ { class DbWallet_ { /// See [DbWallet.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[9].properties[0], + _entities[7].properties[0], ); /// See [DbWallet.id]. static final id = obx.QueryStringProperty( - _entities[9].properties[1], + _entities[7].properties[1], ); /// See [DbWallet.type]. static final type = obx.QueryStringProperty( - _entities[9].properties[2], + _entities[7].properties[2], ); /// See [DbWallet.supportedUnits]. static final supportedUnits = obx.QueryStringVectorProperty( - _entities[9].properties[3], + _entities[7].properties[3], ); /// See [DbWallet.name]. static final name = obx.QueryStringProperty( - _entities[9].properties[4], + _entities[7].properties[4], ); /// See [DbWallet.metadataJsonString]. static final metadataJsonString = obx.QueryStringProperty( - _entities[9].properties[5], + _entities[7].properties[5], ); } @@ -2418,43 +1873,43 @@ class DbWallet_ { class DbWalletCahsuKeyset_ { /// See [DbWalletCahsuKeyset.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[10].properties[0], + _entities[8].properties[0], ); /// See [DbWalletCahsuKeyset.id]. static final id = obx.QueryStringProperty( - _entities[10].properties[1], + _entities[8].properties[1], ); /// See [DbWalletCahsuKeyset.mintUrl]. static final mintUrl = obx.QueryStringProperty( - _entities[10].properties[2], + _entities[8].properties[2], ); /// See [DbWalletCahsuKeyset.unit]. static final unit = obx.QueryStringProperty( - _entities[10].properties[3], + _entities[8].properties[3], ); /// See [DbWalletCahsuKeyset.active]. static final active = obx.QueryBooleanProperty( - _entities[10].properties[4], + _entities[8].properties[4], ); /// See [DbWalletCahsuKeyset.inputFeePPK]. static final inputFeePPK = obx.QueryIntegerProperty( - _entities[10].properties[5], + _entities[8].properties[5], ); /// See [DbWalletCahsuKeyset.mintKeyPairs]. static final mintKeyPairs = obx.QueryStringVectorProperty( - _entities[10].properties[6], - ); + _entities[8].properties[6], + ); /// See [DbWalletCahsuKeyset.fetchedAt]. static final fetchedAt = obx.QueryIntegerProperty( - _entities[10].properties[7], + _entities[8].properties[7], ); } @@ -2462,32 +1917,32 @@ class DbWalletCahsuKeyset_ { class DbWalletCashuProof_ { /// See [DbWalletCashuProof.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[11].properties[0], + _entities[9].properties[0], ); /// See [DbWalletCashuProof.keysetId]. static final keysetId = obx.QueryStringProperty( - _entities[11].properties[1], + _entities[9].properties[1], ); /// See [DbWalletCashuProof.amount]. static final amount = obx.QueryIntegerProperty( - _entities[11].properties[2], + _entities[9].properties[2], ); /// See [DbWalletCashuProof.secret]. static final secret = obx.QueryStringProperty( - _entities[11].properties[3], + _entities[9].properties[3], ); /// See [DbWalletCashuProof.unblindedSig]. static final unblindedSig = obx.QueryStringProperty( - _entities[11].properties[4], + _entities[9].properties[4], ); /// See [DbWalletCashuProof.state]. static final state = obx.QueryStringProperty( - _entities[11].properties[5], + _entities[9].properties[5], ); } @@ -2495,75 +1950,75 @@ class DbWalletCashuProof_ { class DbWalletTransaction_ { /// See [DbWalletTransaction.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[12].properties[0], + _entities[10].properties[0], ); /// See [DbWalletTransaction.id]. static final id = obx.QueryStringProperty( - _entities[12].properties[1], + _entities[10].properties[1], ); /// See [DbWalletTransaction.walletId]. static final walletId = obx.QueryStringProperty( - _entities[12].properties[2], + _entities[10].properties[2], ); /// See [DbWalletTransaction.changeAmount]. static final changeAmount = obx.QueryIntegerProperty( - _entities[12].properties[3], + _entities[10].properties[3], ); /// See [DbWalletTransaction.unit]. static final unit = obx.QueryStringProperty( - _entities[12].properties[4], + _entities[10].properties[4], ); /// See [DbWalletTransaction.walletType]. static final walletType = obx.QueryStringProperty( - _entities[12].properties[5], + _entities[10].properties[5], ); /// See [DbWalletTransaction.state]. static final state = obx.QueryStringProperty( - _entities[12].properties[6], + _entities[10].properties[6], ); /// See [DbWalletTransaction.completionMsg]. static final completionMsg = obx.QueryStringProperty( - _entities[12].properties[7], + _entities[10].properties[7], ); /// See [DbWalletTransaction.transactionDate]. static final transactionDate = obx.QueryIntegerProperty( - _entities[12].properties[8], + _entities[10].properties[8], ); /// See [DbWalletTransaction.initiatedDate]. static final initiatedDate = obx.QueryIntegerProperty( - _entities[12].properties[9], + _entities[10].properties[9], ); /// See [DbWalletTransaction.metadataJsonString]. static final metadataJsonString = obx.QueryStringProperty( - _entities[12].properties[10], - ); + _entities[10].properties[10], + ); } /// [DbKeyValue] entity fields to define ObjectBox queries. class DbKeyValue_ { /// See [DbKeyValue.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[13].properties[0], + _entities[11].properties[0], ); /// See [DbKeyValue.key]. static final key = obx.QueryStringProperty( - _entities[13].properties[1], + _entities[11].properties[1], ); /// See [DbKeyValue.value]. static final value = obx.QueryStringProperty( - _entities[13].properties[2], + _entities[11].properties[2], ); } diff --git a/packages/objectbox/test/data_layer/cache_manager/objectbox_cache_manager_test.dart b/packages/objectbox/test/data_layer/cache_manager/objectbox_cache_manager_test.dart index 0fd33f291..3b09abe4e 100644 --- a/packages/objectbox/test/data_layer/cache_manager/objectbox_cache_manager_test.dart +++ b/packages/objectbox/test/data_layer/cache_manager/objectbox_cache_manager_test.dart @@ -2,10 +2,10 @@ import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; import 'package:ndk/entities.dart'; import 'package:ndk_cache_manager_test_suite/ndk_cache_manager_test_suite.dart'; import 'package:ndk_objectbox/ndk_objectbox.dart'; -import 'package:test/test.dart'; void main() async { // Run shared test suite for comprehensive coverage @@ -14,8 +14,9 @@ void main() async { runCacheManagerTestSuite( name: 'ObjectBoxCacheManager (Shared Suite)', createCacheManager: () async { - sharedTempDir = - await Directory.systemTemp.createTemp('objectbox_shared_test'); + sharedTempDir = await Directory.systemTemp.createTemp( + 'objectbox_shared_test', + ); final cacheManager = DbObjectBox(directory: sharedTempDir.path); await cacheManager.dbRdy; return cacheManager; @@ -29,8 +30,9 @@ void main() async { ); test('saveProofs and getProofs', () async { - sharedTempDir = - await Directory.systemTemp.createTemp('objectbox_shared_test'); + sharedTempDir = await Directory.systemTemp.createTemp( + 'objectbox_shared_test', + ); final cacheManager = DbObjectBox(directory: sharedTempDir.path); await cacheManager.dbRdy; @@ -52,8 +54,10 @@ void main() async { ); cacheManager.saveKeyset(cashuKeyset); - await cacheManager - .saveProofs(proofs: [proof], mintUrl: 'https://test.mint.com'); + await cacheManager.saveProofs( + proofs: [proof], + mintUrl: 'https://test.mint.com', + ); final loadedProofs = await cacheManager.getProofs( mintUrl: 'https://test.mint.com', state: CashuProofState.unspend, @@ -63,4 +67,184 @@ void main() async { expect(loadedProofs[0].keysetId, equals(proof.keysetId)); expect(loadedProofs[0].amount, equals(proof.amount)); }); + + test('reopen persists event sidecar records', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'objectbox_restart_test', + ); + + try { + final first = DbObjectBox(directory: tempDir.path); + await first.dbRdy; + + const deliveryRecord = EventDeliveryRecord( + eventId: 'event-1', + status: EventDeliveryStatus.inProgress, + signingState: EventSigningState.pending, + createdAt: 1700000000, + updatedAt: 1700000100, + serializedEventJson: '{"id":"event-1"}', + requiresInteractiveSigning: true, + signAttemptCount: 2, + lastSignAttemptAt: 1700000090, + nextSignRetryAt: 1700000400, + lastSignError: 'signer unavailable', + ); + const relayTarget = RelayDeliveryTarget( + eventId: 'event-1', + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + state: RelayDeliveryState.permanentFailure, + attemptCount: 3, + lastAttemptAt: 1700000091, + nextRetryAt: 1700000500, + lastError: 'timeout', + ); + const decryptedRecord = DecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + scheme: DecryptedPayloadScheme.nip04, + status: DecryptedPayloadStatus.ready, + plaintextContent: 'hello', + createdAt: 1700000000, + updatedAt: 1700000100, + decryptedAt: 1700000101, + sourceEventPubKey: 'author-1', + sourceEventKind: 4, + ); + + await first.addEventSources( + eventId: 'event-1', + relayUrls: const ['wss://relay-a.example', 'wss://relay-b.example'], + ); + await first.saveEventDeliveryRecord(deliveryRecord); + await first.saveRelayDeliveryTarget(relayTarget); + await first.saveDecryptedEventPayloadRecord(decryptedRecord); + await first.close(); + + final reopened = DbObjectBox(directory: tempDir.path); + await reopened.dbRdy; + + expect( + await reopened.loadEventSources('event-1'), + unorderedEquals(['wss://relay-a.example', 'wss://relay-b.example']), + ); + expect(await reopened.loadEventDeliveryRecord('event-1'), isNotNull); + expect( + await reopened.loadRelayDeliveryTarget( + eventId: 'event-1', + relayUrl: 'wss://relay.example', + ), + isNotNull, + ); + expect( + await reopened.loadDecryptedEventPayloadRecord( + eventId: 'event-1', + viewerPubKey: 'viewer-1', + ), + isNotNull, + ); + + await reopened.close(); + } finally { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + }); + + test( + 'reopen keeps expired event locked by persisted delivery record', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'objectbox_evict_restart_test', + ); + + try { + final first = DbObjectBox(directory: tempDir.path); + await first.dbRdy; + + final expiredEvent = Nip01Event( + pubKey: 'locked_after_restart_delivery', + kind: 1, + tags: const [ + ['expiration', '1'], + ], + content: 'expired but queued', + createdAt: 11, + ); + + await first.saveEvent(expiredEvent); + await first.saveEventDeliveryRecord( + EventDeliveryRecord( + eventId: expiredEvent.id, + createdAt: 11, + updatedAt: 11, + ), + ); + await first.close(); + + final reopened = DbObjectBox(directory: tempDir.path); + await reopened.dbRdy; + + final result = await reopened.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedEvents, equals(0)); + expect(result.keptDueToDeliveryState, equals(1)); + expect(await reopened.loadEvent(expiredEvent.id), isNotNull); + + await reopened.close(); + } finally { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + }, + ); + + test('reopen keeps expired event locked by persisted relay target', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'objectbox_evict_restart_test', + ); + + try { + final first = DbObjectBox(directory: tempDir.path); + await first.dbRdy; + + final expiredEvent = Nip01Event( + pubKey: 'locked_after_restart_target', + kind: 1, + tags: const [ + ['expiration', '1'], + ], + content: 'expired but waiting for relay', + createdAt: 12, + ); + + await first.saveEvent(expiredEvent); + await first.saveRelayDeliveryTarget( + RelayDeliveryTarget( + eventId: expiredEvent.id, + relayUrl: 'wss://relay.example', + reason: RelayDeliveryReason.explicit, + ), + ); + await first.close(); + + final reopened = DbObjectBox(directory: tempDir.path); + await reopened.dbRdy; + + final result = await reopened.evict(const EvictionPolicy.safeSweep()); + + expect(result.removedEvents, equals(0)); + expect(result.keptDueToDeliveryState, equals(1)); + expect(await reopened.loadEvent(expiredEvent.id), isNotNull); + + await reopened.close(); + } finally { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + }); } diff --git a/packages/sample-app/README.md b/packages/sample-app/README.md index 943586321..0616390f1 100644 --- a/packages/sample-app/README.md +++ b/packages/sample-app/README.md @@ -20,5 +20,4 @@ Main package: [🔗 Dart Nostr Development Kit (NDK)](https://pub.dev/packages/n ## NIP-55 signer A NIP-55 compatible external Nostr signer is optional for running this -application. Amber is one app that implements NIP-55, but the sample app is not -specific to Amber. +application. The sample app is not tied to any specific signer app. diff --git a/packages/sample-app/android/app/build.gradle b/packages/sample-app/android/app/build.gradle index a741026ec..ee9d774a4 100644 --- a/packages/sample-app/android/app/build.gradle +++ b/packages/sample-app/android/app/build.gradle @@ -1,6 +1,5 @@ plugins { id "com.android.application" - id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } @@ -41,10 +40,6 @@ android { targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = '17' - } - sourceSets { main.java.srcDirs += 'src/main/kotlin' } @@ -75,6 +70,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + flutter { source '../..' } diff --git a/packages/sample-app/lib/accounts_page.dart b/packages/sample-app/lib/accounts_page.dart index 3571c5d5f..8825464d9 100644 --- a/packages/sample-app/lib/accounts_page.dart +++ b/packages/sample-app/lib/accounts_page.dart @@ -46,9 +46,9 @@ class _AccountsPageState extends State { const SizedBox(height: 8), Text( l10n.accountsDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), ), const SizedBox(height: 16), if (isLoggedIn) @@ -73,9 +73,7 @@ class _AccountsPageState extends State { child: FilledButton.icon( onPressed: _openLoginPopup, icon: const Icon(Icons.person_add), - label: Text( - isLoggedIn ? l10n.addAnotherAccount : l10n.logIn, - ), + label: Text(isLoggedIn ? l10n.addAnotherAccount : l10n.logIn), ), ), ), diff --git a/packages/sample-app/lib/blossom_page.dart b/packages/sample-app/lib/blossom_page.dart index 52ac85a62..335255976 100644 --- a/packages/sample-app/lib/blossom_page.dart +++ b/packages/sample-app/lib/blossom_page.dart @@ -10,10 +10,7 @@ import 'package:ndk_demo/l10n/app_localizations_context.dart'; class BlossomMediaPage extends StatefulWidget { final Ndk ndk; - const BlossomMediaPage({ - super.key, - required this.ndk, - }); + const BlossomMediaPage({super.key, required this.ndk}); @override State createState() => _BlossomMediaPageState(); @@ -68,7 +65,7 @@ class _BlossomMediaPageState extends State { serverUrls: [ // 'https://blossom.f7z.io', "https://nostr.download", - "https://cdn.hzrd149.com" + "https://cdn.hzrd149.com", ], useAuth: false, ); @@ -100,7 +97,7 @@ class _BlossomMediaPageState extends State { serverUrls: [ 'https://blossom.f7z.io', "https://nostr.download", - "https://cdn.hzrd149.com" + "https://cdn.hzrd149.com", ], useAuth: false, ); @@ -222,8 +219,9 @@ class _BlossomMediaPageState extends State { ); setState(() { - _downloadedFilePath = - kIsWeb ? context.l10n.blossomDownloadedToBrowser : outputPath; + _downloadedFilePath = kIsWeb + ? context.l10n.blossomDownloadedToBrowser + : outputPath; }); } catch (e) { setState(() { @@ -240,9 +238,7 @@ class _BlossomMediaPageState extends State { Widget build(BuildContext context) { final l10n = context.l10n; return Scaffold( - appBar: AppBar( - title: Text(l10n.blossomPageTitle), - ), + appBar: AppBar(title: Text(l10n.blossomPageTitle)), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), @@ -255,14 +251,18 @@ class _BlossomMediaPageState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Text(l10n.blossomImageDemoTitle, - style: Theme.of(context).textTheme.titleLarge), + Text( + l10n.blossomImageDemoTitle, + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(height: 16), if (_isLoadingImage) const Center(child: CircularProgressIndicator()) else if (_imageError.isNotEmpty) - Text(l10n.errorLabel(_imageError), - style: const TextStyle(color: Colors.red)) + Text( + l10n.errorLabel(_imageError), + style: const TextStyle(color: Colors.red), + ) else if (_blobResponse != null) Column( children: [ @@ -272,11 +272,15 @@ class _BlossomMediaPageState extends State { ), const SizedBox(height: 16), if (_blobResponse?.mimeType != null) - Text(l10n - .blossomMimeType(_blobResponse!.mimeType!)), + Text( + l10n.blossomMimeType(_blobResponse!.mimeType!), + ), if (_blobResponse?.contentLength != null) - Text(l10n.blossomFileSizeBytes( - _blobResponse!.contentLength.toString())), + Text( + l10n.blossomFileSizeBytes( + _blobResponse!.contentLength.toString(), + ), + ), ], ) else @@ -312,22 +316,24 @@ class _BlossomMediaPageState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Text(l10n.blossomVideoDemoTitle, - style: Theme.of(context).textTheme.titleLarge), + Text( + l10n.blossomVideoDemoTitle, + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(height: 16), if (_isLoadingVideo) const Center(child: CircularProgressIndicator()) else if (_videoError.isNotEmpty) - Text(l10n.errorLabel(_videoError), - style: const TextStyle(color: Colors.red)) + Text( + l10n.errorLabel(_videoError), + style: const TextStyle(color: Colors.red), + ) else if (_videoUrl != null) Column( children: [ AspectRatio( aspectRatio: 16 / 9, - child: Video( - controller: _videoController, - ), + child: Video(controller: _videoController), ), const SizedBox(height: 16), Row( @@ -366,8 +372,9 @@ class _BlossomMediaPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton( - onPressed: - _isLoadingVideo ? null : _checkAndInitVideo, + onPressed: _isLoadingVideo + ? null + : _checkAndInitVideo, child: Text(l10n.blossomLoadVideo), ), ElevatedButton( @@ -395,8 +402,10 @@ class _BlossomMediaPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.blossomUploadTitle, - style: Theme.of(context).textTheme.titleLarge), + Text( + l10n.blossomUploadTitle, + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(height: 16), Text( l10n.blossomUploadDescription, @@ -406,25 +415,39 @@ class _BlossomMediaPageState extends State { if (_isUploading) ...[ LinearProgressIndicator(value: _uploadProgress), const SizedBox(height: 8), - Text(l10n.blossomUploadingProgress( - (_uploadProgress * 100).toStringAsFixed(1))), + Text( + l10n.blossomUploadingProgress( + (_uploadProgress * 100).toStringAsFixed(1), + ), + ), ] else if (_uploadError.isNotEmpty) - Text(l10n.errorLabel(_uploadError), - style: const TextStyle(color: Colors.red)) + Text( + l10n.errorLabel(_uploadError), + style: const TextStyle(color: Colors.red), + ) else if (_uploadedSha256 != null) ...[ - Text(l10n.blossomUploadSuccess, - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold)), + Text( + l10n.blossomUploadSuccess, + style: const TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 8), - Text(l10n.blossomSha256(_uploadedSha256!), - style: const TextStyle( - fontSize: 12, fontFamily: 'monospace')), + Text( + l10n.blossomSha256(_uploadedSha256!), + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + ), const SizedBox(height: 4), - Text(l10n.blossomUrl(_uploadedUrl!), - style: const TextStyle(fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis), + Text( + l10n.blossomUrl(_uploadedUrl!), + style: const TextStyle(fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ] else Text(l10n.blossomNoUploadedFileYet), const SizedBox(height: 16), @@ -463,8 +486,10 @@ class _BlossomMediaPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.blossomDownloadTitle, - style: Theme.of(context).textTheme.titleLarge), + Text( + l10n.blossomDownloadTitle, + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(height: 16), Text( l10n.blossomDownloadDescription, @@ -474,18 +499,25 @@ class _BlossomMediaPageState extends State { if (_isDownloading) const Center(child: CircularProgressIndicator()) else if (_downloadError.isNotEmpty) - Text(l10n.errorLabel(_downloadError), - style: const TextStyle(color: Colors.red)) + Text( + l10n.errorLabel(_downloadError), + style: const TextStyle(color: Colors.red), + ) else if (_downloadedFilePath != null) ...[ - Text(l10n.downloadSuccess, - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold)), + Text( + l10n.downloadSuccess, + style: const TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 8), - Text(l10n.blossomSavedTo(_downloadedFilePath!), - style: const TextStyle(fontSize: 12), - maxLines: 3, - overflow: TextOverflow.ellipsis), + Text( + l10n.blossomSavedTo(_downloadedFilePath!), + style: const TextStyle(fontSize: 12), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), ] else Text(l10n.blossomNoDownloadedFileYet), const SizedBox(height: 16), @@ -516,7 +548,7 @@ class _BlossomMediaPageState extends State { padding: const EdgeInsets.only(top: 8.0), child: Text( l10n.blossomUploadFirstToEnableDownload, - style: TextStyle( + style: const TextStyle( fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey, diff --git a/packages/sample-app/lib/dm_live_state.dart b/packages/sample-app/lib/dm_live_state.dart new file mode 100644 index 000000000..8268709e8 --- /dev/null +++ b/packages/sample-app/lib/dm_live_state.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:ndk/ndk.dart'; + +class DmLiveState extends ChangeNotifier { + final Ndk ndk; + + StreamSubscription? _authSubscription; + StreamSubscription? _dmSubscription; + String? _activeRequestId; + String? _activePubKey; + final Set _seenRumorIds = {}; + final Map> _unreadRumorIdsByPeer = + >{}; + final Map _latestUnreadCreatedAtByPeer = {}; + + int _eventVersion = 0; + + DmLiveState({required this.ndk}); + + int get unreadCount => + _unreadRumorIdsByPeer.values.fold(0, (sum, ids) => sum + ids.length); + int get unreadPeerCount => _unreadRumorIdsByPeer.length; + int get eventVersion => _eventVersion; + String? get latestUnreadPeerPubKey { + if (_latestUnreadCreatedAtByPeer.isEmpty) { + return null; + } + return _latestUnreadCreatedAtByPeer.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + } + + int unreadCountForPeer(String peerPubKey) => + _unreadRumorIdsByPeer[peerPubKey]?.length ?? 0; + + void start() { + _authSubscription ??= ndk.accounts.authStateChanges.listen((_) { + _restart(); + }); + _restart(); + } + + Future _restart() async { + await _stopDmSubscription(); + + _seenRumorIds.clear(); + _unreadRumorIdsByPeer.clear(); + _latestUnreadCreatedAtByPeer.clear(); + _activePubKey = ndk.accounts.getPublicKey(); + notifyListeners(); + + final pubKey = _activePubKey; + if (pubKey == null) { + return; + } + + try { + final existingConversations = await ndk.dms.loadConversations(); + if (pubKey != _activePubKey) { + return; + } + for (final conversation in existingConversations) { + for (final message in conversation.messages) { + _seenRumorIds.add(message.id); + } + } + } catch (_) { + // If the initial snapshot fails, continue with the live subscription. + } + + final dmRelays = await ndk.userRelayLists.getDmRelays(pubKey); + if (pubKey != _activePubKey || dmRelays == null || dmRelays.isEmpty) { + return; + } + + final response = ndk.requests.subscription( + name: 'sample-app-dm-live', + explicitRelays: dmRelays, + cacheRead: false, + cacheWrite: true, + filter: Filter(kinds: [GiftWrap.kGiftWrapEventkind], pTags: [pubKey]), + ); + + _activeRequestId = response.requestId; + _dmSubscription = response.stream.listen((wrappedEvent) async { + try { + final message = await ndk.dms.parseWrappedMessage( + wrappedEvent: wrappedEvent, + ); + if (message == null) { + return; + } + + final isNewMessage = _seenRumorIds.add(message.id); + if (!isNewMessage) { + return; + } + + if (!message.isOutgoing) { + _unreadRumorIdsByPeer + .putIfAbsent(message.peerPubKey, () => {}) + .add(message.id); + _latestUnreadCreatedAtByPeer[message.peerPubKey] = message.createdAt; + } + _eventVersion++; + notifyListeners(); + } catch (_) { + // Ignore malformed or undecryptable incoming gift wraps. + } + }); + } + + void clearUnread() { + if (_unreadRumorIdsByPeer.isEmpty) { + return; + } + _unreadRumorIdsByPeer.clear(); + _latestUnreadCreatedAtByPeer.clear(); + notifyListeners(); + } + + void clearUnreadForPeer(String peerPubKey) { + final removedUnread = _unreadRumorIdsByPeer.remove(peerPubKey); + _latestUnreadCreatedAtByPeer.remove(peerPubKey); + if (removedUnread == null) { + return; + } + notifyListeners(); + } + + Future _stopDmSubscription() async { + await _dmSubscription?.cancel(); + _dmSubscription = null; + + final requestId = _activeRequestId; + _activeRequestId = null; + if (requestId != null) { + await ndk.requests.closeSubscription( + requestId, + debugLabel: 'sample-app-dm-live', + ); + } + } + + @override + void dispose() { + unawaited(_authSubscription?.cancel()); + _authSubscription = null; + unawaited(_stopDmSubscription()); + super.dispose(); + } +} diff --git a/packages/sample-app/lib/dm_page.dart b/packages/sample-app/lib/dm_page.dart new file mode 100644 index 000000000..81930ffa4 --- /dev/null +++ b/packages/sample-app/lib/dm_page.dart @@ -0,0 +1,804 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/widgets/widgets.dart'; + +import 'main.dart'; + +class DmInboxPage extends StatefulWidget { + const DmInboxPage({super.key}); + + @override + State createState() => _DmInboxPageState(); +} + +class _DmInboxPageState extends State { + List _conversations = const []; + Map _peerMetadatas = const {}; + bool _loading = false; + String? _error; + VoidCallback? _dmListener; + int _lastDmEventVersion = -1; + int _loadGeneration = 0; + + @override + void initState() { + super.initState(); + _lastDmEventVersion = dmLiveState.eventVersion; + _dmListener = () { + if (!mounted) return; + if (_lastDmEventVersion == dmLiveState.eventVersion) { + setState(() {}); + return; + } + _lastDmEventVersion = dmLiveState.eventVersion; + _loadInbox(forceRefresh: false); + }; + dmLiveState.addListener(_dmListener!); + if (ndk.accounts.getPublicKey() != null) { + _loadInbox(forceRefresh: false); + } + } + + @override + void dispose() { + if (_dmListener != null) { + dmLiveState.removeListener(_dmListener!); + } + super.dispose(); + } + + Future _loadInbox({required bool forceRefresh}) async { + final loadGeneration = ++_loadGeneration; + final myPubKey = ndk.accounts.getPublicKey(); + if (myPubKey == null) { + setState(() { + _error = + 'Log in first. This demo needs a signer and your own kind:10050 DM relay list.'; + }); + return; + } + + setState(() { + _loading = _conversations.isEmpty || forceRefresh; + _error = null; + }); + + if (!forceRefresh) { + try { + final cachedConversations = await ndk.dms.loadConversationsSnapshot(); + if (!mounted || loadGeneration != _loadGeneration) return; + if (cachedConversations.isNotEmpty || _conversations.isEmpty) { + setState(() { + _conversations = cachedConversations; + _loading = false; + }); + unawaited( + _loadPeerMetadatas( + cachedConversations, + loadGeneration: loadGeneration, + ), + ); + } + } catch (_) { + // Ignore cache snapshot failures and continue with network refresh. + } + } + + try { + final conversations = await ndk.dms.loadConversations( + forceRefresh: forceRefresh, + ); + if (!mounted || loadGeneration != _loadGeneration) return; + setState(() { + _conversations = conversations; + _loading = false; + }); + unawaited( + _loadPeerMetadatas(conversations, loadGeneration: loadGeneration), + ); + } catch (e) { + if (!mounted || loadGeneration != _loadGeneration) return; + setState(() { + _error = 'Inbox load failed: $e'; + }); + } finally { + if (mounted && loadGeneration == _loadGeneration) { + setState(() { + _loading = false; + }); + } + } + } + + Future _loadPeerMetadatas( + List conversations, { + required int loadGeneration, + }) async { + final peerPubKeys = conversations + .map((conversation) => conversation.peerPubKey) + .toSet() + .toList(); + + if (peerPubKeys.isEmpty) { + if (!mounted || loadGeneration != _loadGeneration) { + return; + } + setState(() {}); + return; + } + + try { + final metadatas = await ndk.metadata.loadMetadatas( + peerPubKeys, + null, + onLoad: (metadata) { + if (!mounted || loadGeneration != _loadGeneration) { + return; + } + _applyLoadedMetadata(metadata); + }, + ); + + if (!mounted || loadGeneration != _loadGeneration) { + return; + } + + for (final metadata in metadatas) { + _applyLoadedMetadata(metadata); + } + } finally { + // no-op + } + } + + void _applyLoadedMetadata(Metadata metadata) { + final current = _peerMetadatas[metadata.pubKey]; + if (current?.updatedAt == metadata.updatedAt && + current?.displayName == metadata.displayName && + current?.name == metadata.name && + current?.picture == metadata.picture) { + return; + } + + setState(() { + _peerMetadatas = {..._peerMetadatas, metadata.pubKey: metadata}; + }); + } + + @override + Widget build(BuildContext context) { + final myPubKey = ndk.accounts.getPublicKey(); + + return Scaffold( + appBar: AppBar( + title: const Text('DM'), + actions: [ + IconButton( + tooltip: 'Refresh inbox', + onPressed: myPubKey == null || _loading + ? null + : () => _loadInbox(forceRefresh: true), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: _buildBody(context, myPubKey), + floatingActionButton: myPubKey == null + ? null + : FloatingActionButton.extended( + onPressed: () => context.push('/dm/compose'), + icon: const Icon(Icons.edit), + label: const Text('Compose'), + ), + ); + } + + Widget _buildBody(BuildContext context, String? myPubKey) { + if (myPubKey == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Log in first to inspect your direct messages.'), + ), + ); + } + + if (_loading && _conversations.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_conversations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error ?? 'No conversations discovered yet.'), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Text(_error!), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _conversations.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final conversation = _conversations[index]; + final unreadCount = dmLiveState.unreadCountForPeer( + conversation.peerPubKey, + ); + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + leading: SizedBox( + width: 52, + height: 52, + child: NPicture( + ndkFlutter: ndkFlutter, + pubkey: conversation.peerPubKey, + metadata: _peerMetadatas[conversation.peerPubKey], + circleAvatarRadius: 26, + ), + ), + title: Text(_displayNameFor(conversation.peerPubKey)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _shorten(conversation.peerPubKey), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + conversation.latestMessage.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: unreadCount > 0 + ? _ConversationUnreadBadge(count: unreadCount) + : const Icon(Icons.chevron_right), + onTap: () { + dmLiveState.clearUnreadForPeer(conversation.peerPubKey); + context.push('/dm/conversation/${conversation.peerPubKey}'); + }, + ), + ); + }, + ), + ), + ], + ); + } + + String _displayNameFor(String peerPubKey) { + final metadata = _peerMetadatas[peerPubKey]; + final displayName = metadata?.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + final name = metadata?.name?.trim(); + if (name != null && name.isNotEmpty) { + return name; + } + return _shorten(peerPubKey); + } +} + +class DmConversationPage extends StatefulWidget { + final String peerPubKey; + + const DmConversationPage({super.key, required this.peerPubKey}); + + @override + State createState() => _DmConversationPageState(); +} + +class _DmConversationPageState extends State { + List _messages = const []; + Metadata? _peerMetadata; + final _messageController = TextEditingController(); + bool _loading = false; + bool _sending = false; + String? _error; + VoidCallback? _dmListener; + int _lastDmEventVersion = -1; + int _loadGeneration = 0; + + @override + void initState() { + super.initState(); + dmLiveState.clearUnreadForPeer(widget.peerPubKey); + _lastDmEventVersion = dmLiveState.eventVersion; + _dmListener = () { + if (!mounted) return; + dmLiveState.clearUnreadForPeer(widget.peerPubKey); + if (_lastDmEventVersion == dmLiveState.eventVersion) { + return; + } + _lastDmEventVersion = dmLiveState.eventVersion; + _loadConversation(forceRefresh: false); + }; + dmLiveState.addListener(_dmListener!); + if (ndk.accounts.getPublicKey() != null) { + _loadConversation(forceRefresh: false); + } + } + + @override + void dispose() { + _messageController.dispose(); + if (_dmListener != null) { + dmLiveState.removeListener(_dmListener!); + } + super.dispose(); + } + + Future _sendInlineMessage() async { + final myPubKey = ndk.accounts.getPublicKey(); + final content = _messageController.text.trim(); + if (myPubKey == null || content.isEmpty || _sending) { + return; + } + + setState(() { + _sending = true; + _error = null; + }); + + try { + await ndk.dms.sendMessage( + recipientPubKey: widget.peerPubKey, + content: content, + ); + _messageController.clear(); + if (!mounted) { + return; + } + await _loadConversation(forceRefresh: false); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _error = 'Send failed: $e'; + }); + } finally { + if (mounted) { + setState(() { + _sending = false; + }); + } + } + } + + Future _loadConversation({required bool forceRefresh}) async { + final loadGeneration = ++_loadGeneration; + final myPubKey = ndk.accounts.getPublicKey(); + if (myPubKey == null) { + setState(() { + _error = + 'Log in first. This demo needs a signer and your own kind:10050 DM relay list.'; + }); + return; + } + + setState(() { + _loading = _messages.isEmpty || forceRefresh; + _error = null; + }); + + if (!forceRefresh) { + try { + final cachedMessages = await ndk.dms.loadConversationSnapshot( + peerPubKey: widget.peerPubKey, + ); + if (!mounted || loadGeneration != _loadGeneration) return; + if (cachedMessages.isNotEmpty || _messages.isEmpty) { + setState(() { + _messages = cachedMessages; + _loading = false; + }); + } + } catch (_) { + // Ignore cache snapshot failures and continue with network refresh. + } + } + + unawaited( + _loadPeerMetadata( + forceRefresh: forceRefresh, + loadGeneration: loadGeneration, + ), + ); + + try { + final messages = await ndk.dms.loadConversation( + peerPubKey: widget.peerPubKey, + forceRefresh: forceRefresh, + ); + if (!mounted || loadGeneration != _loadGeneration) return; + setState(() { + _messages = messages; + _loading = false; + }); + } catch (e) { + if (!mounted || loadGeneration != _loadGeneration) return; + setState(() { + _error = 'Conversation load failed: $e'; + }); + } finally { + if (mounted && loadGeneration == _loadGeneration) { + setState(() { + _loading = false; + }); + } + } + } + + Future _loadPeerMetadata({ + required bool forceRefresh, + required int loadGeneration, + }) async { + try { + final metadata = await ndk.metadata.loadMetadata( + widget.peerPubKey, + forceRefresh: forceRefresh, + ); + if (!mounted || loadGeneration != _loadGeneration) { + return; + } + setState(() { + _peerMetadata = metadata; + }); + } catch (_) { + // Ignore metadata failures; the conversation remains usable without it. + } + } + + @override + Widget build(BuildContext context) { + final myPubKey = ndk.accounts.getPublicKey(); + final title = _displayNameFor(widget.peerPubKey); + + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: [ + IconButton( + tooltip: 'Refresh thread', + onPressed: myPubKey == null || _loading + ? null + : () => _loadConversation(forceRefresh: true), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: _buildBody(context, myPubKey), + ); + } + + Widget _buildBody(BuildContext context, String? myPubKey) { + if (myPubKey == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Log in first to inspect this conversation.'), + ), + ); + } + + if (_loading && _messages.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Text(_error!), + ), + Expanded( + child: _messages.isEmpty + ? const Center(child: Text('No conversation loaded yet.')) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final message = _messages[index]; + return Align( + alignment: message.isOutgoing + ? Alignment.centerRight + : Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + color: message.isOutgoing + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(12), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(message.content), + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: Text( + _formatMessageTime(message.createdAt), + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.65), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + _buildComposer(context, myPubKey), + ], + ); + } + + Widget _buildComposer(BuildContext context, String? myPubKey) { + final canSend = myPubKey != null && !_sending; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _messageController, + enabled: myPubKey != null && !_sending, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendInlineMessage(), + decoration: InputDecoration( + hintText: myPubKey == null + ? 'Log in to send a message' + : 'Message', + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: 'Send', + onPressed: canSend ? _sendInlineMessage : null, + icon: _sending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + ), + ], + ), + ); + } + + String _displayNameFor(String peerPubKey) { + final displayName = _peerMetadata?.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + final name = _peerMetadata?.name?.trim(); + if (name != null && name.isNotEmpty) { + return name; + } + return _shorten(peerPubKey); + } + + String _formatMessageTime(int createdAt) { + final dateTime = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ).toLocal(); + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } +} + +class DmComposePage extends StatefulWidget { + final String? initialRecipientPubKey; + + const DmComposePage({super.key, this.initialRecipientPubKey}); + + @override + State createState() => _DmComposePageState(); +} + +class _DmComposePageState extends State { + late final TextEditingController _recipientController; + final _messageController = TextEditingController(); + bool _sending = false; + String? _status; + + @override + void initState() { + super.initState(); + _recipientController = TextEditingController( + text: widget.initialRecipientPubKey?.trim() ?? '', + ); + } + + @override + void dispose() { + _recipientController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + Future _sendMessage() async { + final recipient = _recipientController.text.trim(); + final content = _messageController.text.trim(); + if (recipient.isEmpty || content.isEmpty) { + setState(() { + _status = 'Enter both recipient pubkey and message.'; + }); + return; + } + + setState(() { + _sending = true; + _status = 'Sending direct message...'; + }); + + try { + await ndk.dms.sendMessage(recipientPubKey: recipient, content: content); + if (!mounted) return; + context.go('/dm/conversation/$recipient'); + } catch (e) { + if (!mounted) return; + setState(() { + _status = 'Send failed: $e'; + }); + } finally { + if (mounted) { + setState(() { + _sending = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final myPubKey = ndk.accounts.getPublicKey(); + + return Scaffold( + appBar: AppBar(title: const Text('Compose DM')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + myPubKey == null + ? 'Log in first. This demo needs a signer and your own kind:10050 DM relay list.' + : 'Logged in as ${_shorten(myPubKey)}. Compose a NIP-17 direct message to a peer pubkey.', + ), + const SizedBox(height: 12), + TextField( + controller: _recipientController, + decoration: const InputDecoration( + labelText: 'Recipient pubkey', + border: OutlineInputBorder(), + ), + minLines: 1, + maxLines: 2, + ), + const SizedBox(height: 12), + TextField( + controller: _messageController, + decoration: const InputDecoration( + labelText: 'Message', + border: OutlineInputBorder(), + ), + minLines: 5, + maxLines: 10, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: myPubKey == null || _sending ? null : _sendMessage, + icon: const Icon(Icons.send), + label: Text(_sending ? 'Sending...' : 'Send'), + ), + OutlinedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ), + if (_status != null) ...[ + const SizedBox(height: 12), + Text(_status!), + ], + ], + ), + ), + ); + } +} + +String _shorten(String value) { + if (value.length <= 16) { + return value; + } + return '${value.substring(0, 8)}...${value.substring(value.length - 8)}'; +} + +class _ConversationUnreadBadge extends StatelessWidget { + final int count; + + const _ConversationUnreadBadge({required this.count}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onError, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ); + } +} diff --git a/packages/sample-app/lib/follows_page.dart b/packages/sample-app/lib/follows_page.dart new file mode 100644 index 000000000..00da78d51 --- /dev/null +++ b/packages/sample-app/lib/follows_page.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/widgets/widgets.dart'; + +import 'main.dart'; + +class FollowsPage extends StatefulWidget { + const FollowsPage({super.key}); + + @override + State createState() => _FollowsPageState(); +} + +class _FollowsPageState extends State { + final _searchController = TextEditingController(); + bool _loading = true; + bool _metadataLoading = false; + String? _error; + List<_FollowProfile> _profiles = const []; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + _loadFollows(); + } + + @override + void dispose() { + _searchController + ..removeListener(_onSearchChanged) + ..dispose(); + super.dispose(); + } + + Future _loadFollows({bool forceRefresh = false}) async { + final myPubKey = ndk.accounts.getPublicKey(); + if (myPubKey == null) { + setState(() { + _loading = false; + _metadataLoading = false; + _error = 'Log in first to load your contact list.'; + _profiles = const []; + }); + return; + } + + setState(() { + _loading = true; + _metadataLoading = false; + _error = null; + }); + + try { + final contactList = await ndk.follows.getContactList( + myPubKey, + forceRefresh: forceRefresh, + ); + final contacts = + contactList?.contacts.toSet().toList() ?? const []; + + if (contacts.isEmpty) { + if (!mounted) return; + setState(() { + _loading = false; + _metadataLoading = false; + _profiles = const []; + }); + return; + } + + final profiles = + contacts + .map((pubKey) => _FollowProfile(pubKey: pubKey, metadata: null)) + .toList() + ..sort((a, b) => a.sortKey.compareTo(b.sortKey)); + + if (!mounted) return; + setState(() { + _loading = false; + _metadataLoading = true; + _profiles = profiles; + }); + + final metadatas = await ndk.metadata.loadMetadatas( + contacts, + null, + onLoad: _applyLoadedMetadata, + ); + for (final metadata in metadatas) { + _applyLoadedMetadata(metadata); + } + + if (!mounted) return; + setState(() { + _metadataLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _metadataLoading = false; + _error = 'Failed to load follows: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + final myPubKey = ndk.accounts.getPublicKey(); + + return Scaffold( + appBar: AppBar( + title: const Text('Follows'), + actions: [ + if (_metadataLoading) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + IconButton( + tooltip: 'Refresh', + onPressed: _loading ? null : () => _loadFollows(forceRefresh: true), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: _buildBody(context, myPubKey), + ); + } + + Widget _buildBody(BuildContext context, String? myPubKey) { + if (myPubKey == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Log in first to inspect your follows and send DMs.'), + ), + ); + } + + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)), + ); + } + + if (_profiles.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No follows found in your contact list.'), + ), + ); + } + + final filteredProfiles = _filteredProfiles; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'Search follows', + hintText: 'Search by display name or name', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: _searchController.clear, + icon: const Icon(Icons.close), + ), + border: const OutlineInputBorder(), + ), + ), + ), + Expanded( + child: filteredProfiles.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No follows match the current search.'), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: filteredProfiles.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final profile = filteredProfiles[index]; + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: SizedBox( + width: 48, + height: 48, + child: NPicture( + ndkFlutter: ndkFlutter, + metadata: profile.metadata, + pubkey: profile.metadata == null + ? profile.pubKey + : null, + circleAvatarRadius: 24, + ), + ), + title: Text(profile.primaryLabel), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (profile.secondaryLabel != null) + Text(profile.secondaryLabel!), + Text( + _shorten(profile.pubKey), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + trailing: IconButton( + tooltip: 'Send DM', + onPressed: () => context.push( + '/dm/conversation/${profile.pubKey}', + ), + icon: const Icon(Icons.forum_outlined), + ), + onTap: () => + context.push('/profile', extra: profile.pubKey), + ), + ); + }, + ), + ), + ], + ); + } + + List<_FollowProfile> get _filteredProfiles { + final query = _searchController.text.trim().toLowerCase(); + if (query.isEmpty) { + return _profiles; + } + return _profiles + .where((profile) => profile.searchText.contains(query)) + .toList(); + } + + void _onSearchChanged() { + if (mounted) { + setState(() {}); + } + } + + void _applyLoadedMetadata(Metadata metadata) { + if (!mounted) return; + final index = _profiles.indexWhere( + (profile) => profile.pubKey == metadata.pubKey, + ); + if (index == -1) { + return; + } + + final current = _profiles[index]; + if (current.metadata?.updatedAt == metadata.updatedAt && + current.metadata?.displayName == metadata.displayName && + current.metadata?.name == metadata.name && + current.metadata?.picture == metadata.picture) { + return; + } + + final updatedProfiles = List<_FollowProfile>.from(_profiles); + updatedProfiles[index] = current.copyWith(metadata: metadata); + updatedProfiles.sort((a, b) => a.sortKey.compareTo(b.sortKey)); + + setState(() { + _profiles = updatedProfiles; + }); + } + + String _shorten(String value) { + if (value.length <= 16) { + return value; + } + return '${value.substring(0, 8)}...${value.substring(value.length - 8)}'; + } +} + +class _FollowProfile { + final String pubKey; + final Metadata? metadata; + + const _FollowProfile({required this.pubKey, required this.metadata}); + + _FollowProfile copyWith({Metadata? metadata}) { + return _FollowProfile(pubKey: pubKey, metadata: metadata ?? this.metadata); + } + + String get primaryLabel { + final displayName = metadata?.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + + final name = metadata?.name?.trim(); + if (name != null && name.isNotEmpty) { + return name; + } + + return pubKey; + } + + String? get secondaryLabel { + final displayName = metadata?.displayName?.trim(); + final name = metadata?.name?.trim(); + + if (displayName != null && + displayName.isNotEmpty && + name != null && + name.isNotEmpty && + displayName != name) { + return name; + } + + return null; + } + + String get sortKey => primaryLabel.toLowerCase(); + + String get searchText => [ + metadata?.displayName?.trim().toLowerCase(), + metadata?.name?.trim().toLowerCase(), + pubKey.toLowerCase(), + ].whereType().where((value) => value.isNotEmpty).join(' '); +} diff --git a/packages/sample-app/lib/home_page.dart b/packages/sample-app/lib/home_page.dart index 41fc55fca..7209431f2 100644 --- a/packages/sample-app/lib/home_page.dart +++ b/packages/sample-app/lib/home_page.dart @@ -18,6 +18,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { StreamSubscription? _authSub; + VoidCallback? _dmListener; @override void initState() { @@ -25,11 +26,20 @@ class _HomePageState extends State { _authSub = ndk.accounts.authStateChanges.listen((_) { if (mounted) setState(() {}); }); + _dmListener = () { + if (mounted) { + setState(() {}); + } + }; + dmLiveState.addListener(_dmListener!); } @override void dispose() { _authSub?.cancel(); + if (_dmListener != null) { + dmLiveState.removeListener(_dmListener!); + } super.dispose(); } @@ -40,16 +50,10 @@ class _HomePageState extends State { return Scaffold( appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.appBarTitle), - const SizedBox(width: 8), - Text( - 'v$packageVersion', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + title: Text( + '${l10n.appBarTitle} · v$packageVersion', + maxLines: 1, + overflow: TextOverflow.ellipsis, ), actions: [ NLocaleSwitcher( @@ -62,8 +66,9 @@ class _HomePageState extends State { final profileIcon = loggedPubkey == null ? CircleAvatar( radius: 14, - backgroundColor: - Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: Icon( Icons.person_outline, size: 16, @@ -71,9 +76,23 @@ class _HomePageState extends State { ), ) : NPicture(ndkFlutter: ndkFlutter, circleAvatarRadius: 14); + final unreadDmCount = loggedPubkey == null + ? 0 + : dmLiveState.unreadCount; return IconButton( tooltip: l10n.profileTooltip, onPressed: () { + final unreadTargetPubKey = dmLiveState.latestUnreadPeerPubKey; + final unreadPeerCount = dmLiveState.unreadPeerCount; + if (loggedPubkey != null && unreadDmCount > 0) { + if (unreadPeerCount == 1 && unreadTargetPubKey != null) { + dmLiveState.clearUnreadForPeer(unreadTargetPubKey); + context.push('/dm/conversation/$unreadTargetPubKey'); + return; + } + context.push('/dm'); + return; + } if (loggedPubkey != null) { context.push('/profile'); return; @@ -84,7 +103,44 @@ class _HomePageState extends State { onLoggedIn: () {}, ); }, - icon: profileIcon, + icon: Stack( + clipBehavior: Clip.none, + children: [ + profileIcon, + if (unreadDmCount > 0) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1, + ), + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Text( + unreadDmCount > 99 ? '99+' : '$unreadDmCount', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of(context).colorScheme.onError, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), ); }, ), @@ -164,6 +220,32 @@ class _HomePageState extends State { onTap: () => context.push('/widgets'), ), const SizedBox(height: 8), + _NavCard( + icon: Icons.people_alt_outlined, + title: 'Follows', + onTap: () => context.push('/follows'), + ), + const SizedBox(height: 8), + _NavCard( + icon: Icons.forum, + title: 'DM', + trailing: loggedPubkey != null && dmLiveState.unreadCount > 0 + ? _UnreadBadge(count: dmLiveState.unreadCount) + : null, + onTap: () { + final unreadTargetPubKey = dmLiveState.latestUnreadPeerPubKey; + final unreadPeerCount = dmLiveState.unreadPeerCount; + if (loggedPubkey != null && dmLiveState.unreadCount > 0) { + if (unreadPeerCount == 1 && unreadTargetPubKey != null) { + dmLiveState.clearUnreadForPeer(unreadTargetPubKey); + context.push('/dm/conversation/$unreadTargetPubKey'); + return; + } + } + context.push('/dm'); + }, + ), + const SizedBox(height: 8), _NavCard( icon: Icons.security, title: 'Quantum Secure', @@ -179,11 +261,13 @@ class _NavCard extends StatelessWidget { final IconData icon; final String title; final VoidCallback onTap; + final Widget? trailing; const _NavCard({ required this.icon, required this.title, required this.onTap, + this.trailing, }); @override @@ -193,9 +277,33 @@ class _NavCard extends StatelessWidget { child: ListTile( leading: Icon(icon), title: Text(title), - trailing: const Icon(Icons.chevron_right), + trailing: trailing ?? const Icon(Icons.chevron_right), onTap: onTap, ), ); } } + +class _UnreadBadge extends StatelessWidget { + final int count; + + const _UnreadBadge({required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onError, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart index 0b55dc213..abe2eb731 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart @@ -70,13 +70,15 @@ import 'sample_app_localizations_zh.dart'; /// property. abstract class SampleAppLocalizations { SampleAppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; static SampleAppLocalizations? of(BuildContext context) { return Localizations.of( - context, SampleAppLocalizations); + context, + SampleAppLocalizations, + ); } static const LocalizationsDelegate delegate = @@ -94,11 +96,11 @@ abstract class SampleAppLocalizations { /// of delegates is preferred or required. static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ @@ -110,7 +112,7 @@ abstract class SampleAppLocalizations { Locale('ja'), Locale('pl'), Locale('ru'), - Locale('zh') + Locale('zh'), ]; /// No description provided for @appName. @@ -799,21 +801,22 @@ class _SampleAppLocalizationsDelegate @override Future load(Locale locale) { return SynchronousFuture( - lookupSampleAppLocalizations(locale)); + lookupSampleAppLocalizations(locale), + ); } @override bool isSupported(Locale locale) => [ - 'de', - 'en', - 'es', - 'fr', - 'it', - 'ja', - 'pl', - 'ru', - 'zh' - ].contains(locale.languageCode); + 'de', + 'en', + 'es', + 'fr', + 'it', + 'ja', + 'pl', + 'ru', + 'zh', + ].contains(locale.languageCode); @override bool shouldReload(_SampleAppLocalizationsDelegate old) => false; @@ -843,8 +846,9 @@ SampleAppLocalizations lookupSampleAppLocalizations(Locale locale) { } throw FlutterError( - 'SampleAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.'); + 'SampleAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); } diff --git a/packages/sample-app/lib/main.dart b/packages/sample-app/lib/main.dart index c645d80bf..799e6ad2c 100644 --- a/packages/sample-app/lib/main.dart +++ b/packages/sample-app/lib/main.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -13,14 +14,16 @@ import 'package:ndk_flutter/ndk_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:protocol_handler/protocol_handler.dart'; +import 'dm_live_state.dart'; import 'l10n/generated/sample_app_localizations.dart'; - bool signerAppAvailable = false; late Ndk ndk; final ndkFlutter = NdkFlutter(ndk: ndk); final localeNotifier = ValueNotifier(const Locale('en')); +DmLiveState? _dmLiveState; +DmLiveState get dmLiveState => _dmLiveState ??= DmLiveState(ndk: ndk)..start(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -41,11 +44,12 @@ Future main() async { final cacheManager = kIsWeb ? await DriftCacheManager.create() : await SembastCacheManager.create( - databasePath: (await getApplicationDocumentsDirectory()).path); + databasePath: (await getApplicationDocumentsDirectory()).path, + ); // Load the cashu seed phrase from secure storage, generating a fresh one on // first run. Never hardcode this — it controls cashu funds. - final cashuSeedPhrase = await CashuSeedStore().loadOrCreate(); + final cashuSeedPhrase = await const CashuSeedStore().loadOrCreate(); final eventVerifier = kIsWeb ? WebEventVerifier() : RustEventVerifier(); ndk = Ndk( @@ -54,11 +58,10 @@ Future main() async { cache: cacheManager, walletsRepo: FlutterSecureStorageWalletsRepo(), logLevel: Logger.logLevels.info, - cashuUserSeedphrase: CashuUserSeedphrase( - seedPhrase: cashuSeedPhrase, - ), + cashuUserSeedphrase: CashuUserSeedphrase(seedPhrase: cashuSeedPhrase), ), ); + final _ = dmLiveState; await ndkFlutter.restoreAccountsState(); @@ -132,7 +135,7 @@ class _MyAppState extends State with ProtocolListener { routerConfig: appRouter, builder: (context, child) => Stack( children: [ - child ?? const SizedBox.shrink(), + SafeArea(top: false, child: child ?? const SizedBox.shrink()), NPendingRequests(ndkFlutter: ndkFlutter), ], ), diff --git a/packages/sample-app/lib/nip55_signer_page.dart b/packages/sample-app/lib/nip55_signer_page.dart index ea9013200..98493bcf1 100644 --- a/packages/sample-app/lib/nip55_signer_page.dart +++ b/packages/sample-app/lib/nip55_signer_page.dart @@ -25,22 +25,20 @@ class _Nip55SignerPageState extends State { children: [ FilledButton( onPressed: () async { - nip55Signer.getPublicKey( - permissions: [ - const Nip55Permission( - type: "nip04_encrypt", - ), - const Nip55Permission( - type: "nip04_decrypt", - ), - ], - ).then((value) { - _npub = value['signature'] ?? ''; - _pubkeyHex = Nip19.decode(_npub); - setState(() { - _text = '$value'; - }); - }); + nip55Signer + .getPublicKey( + permissions: [ + const Nip55Permission(type: "nip04_encrypt"), + const Nip55Permission(type: "nip04_decrypt"), + ], + ) + .then((value) { + _npub = value['signature'] ?? ''; + _pubkeyHex = Nip19.decode(_npub); + setState(() { + _text = '$value'; + }); + }); }, child: const Text('Get Public Key'), ), @@ -51,22 +49,19 @@ class _Nip55SignerPageState extends State { 'pubkey': Nip19.decode(_npub), 'kind': 1, 'content': 'Hello from NDK Flutter!', - 'created_at': - (DateTime.now().millisecondsSinceEpoch / 1000).round(), + 'created_at': (DateTime.now().millisecondsSinceEpoch / 1000) + .round(), 'tags': [], 'sig': '', }); nip55Signer - .signEvent( - currentUser: _npub, - eventJson: eventJson, - ) + .signEvent(currentUser: _npub, eventJson: eventJson) .then((value) { - setState(() { - _text = '$value'; - }); - }); + setState(() { + _text = '$value'; + }); + }); }, child: const Text('Sign Event'), ), @@ -77,8 +72,8 @@ class _Nip55SignerPageState extends State { 'pubkey': Nip19.decode(_npub), 'kind': 1, 'content': 'Hello from NDK Flutter!', - 'created_at': - (DateTime.now().millisecondsSinceEpoch / 1000).round(), + 'created_at': (DateTime.now().millisecondsSinceEpoch / 1000) + .round(), 'tags': [], 'sig': '', }); @@ -91,10 +86,10 @@ class _Nip55SignerPageState extends State { eventVerifier .verify(Nip01EventModel.fromJson(json.decode(value['event']))) .then((valid) { - setState(() { - _text = valid ? "✅ Valid" : "❌ Invalid"; - }); - }); + setState(() { + _text = valid ? "✅ Valid" : "❌ Invalid"; + }); + }); }, child: const Text('Verify signature'), ), @@ -102,16 +97,16 @@ class _Nip55SignerPageState extends State { onPressed: () { nip55Signer .nip04Encrypt( - plaintext: "Hello from NDK Flutter, Nip 04!", - currentUser: _npub, - pubKey: _pubkeyHex, - ) + plaintext: "Hello from NDK Flutter, Nip 04!", + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - _cipherText = value['signature'] ?? ''; - setState(() { - _text = '$value'; - }); - }); + _cipherText = value['signature'] ?? ''; + setState(() { + _text = '$value'; + }); + }); }, child: const Text('Nip 04 Encrypt'), ), @@ -119,39 +114,39 @@ class _Nip55SignerPageState extends State { onPressed: () async { nip55Signer .nip04Decrypt( - ciphertext: _cipherText, - currentUser: _npub, - pubKey: _pubkeyHex, - ) + ciphertext: _cipherText, + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - setState(() { - _text = '$value 1'; - }); - }); + setState(() { + _text = '$value 1'; + }); + }); // ; nip55Signer .nip04Decrypt( - ciphertext: _cipherText, - currentUser: _npub, - pubKey: _pubkeyHex, - ) + ciphertext: _cipherText, + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - setState(() { - _text = '$value 2'; - }); - }); + setState(() { + _text = '$value 2'; + }); + }); // , nip55Signer .nip04Decrypt( - ciphertext: _cipherText, - currentUser: _npub, - pubKey: _pubkeyHex, - ) + ciphertext: _cipherText, + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - setState(() { - _text = '$value 3'; - }); - }); + setState(() { + _text = '$value 3'; + }); + }); }, child: const Text('Nip 04 Decrypt'), ), @@ -159,16 +154,16 @@ class _Nip55SignerPageState extends State { onPressed: () { nip55Signer .nip44Encrypt( - plaintext: "Hello from NDK Flutter, Nip 44!", - currentUser: _npub, - pubKey: _pubkeyHex, - ) + plaintext: "Hello from NDK Flutter, Nip 44!", + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - _cipherText = value['signature'] ?? ''; - setState(() { - _text = '$value'; - }); - }); + _cipherText = value['signature'] ?? ''; + setState(() { + _text = '$value'; + }); + }); }, child: const Text('Nip 44 Encrypt'), ), @@ -176,15 +171,15 @@ class _Nip55SignerPageState extends State { onPressed: () { nip55Signer .nip44Decrypt( - ciphertext: _cipherText, - currentUser: _npub, - pubKey: _pubkeyHex, - ) + ciphertext: _cipherText, + currentUser: _npub, + pubKey: _pubkeyHex, + ) .then((value) { - setState(() { - _text = '$value'; - }); - }); + setState(() { + _text = '$value'; + }); + }); }, child: const Text('Nip 44 Decrypt'), ), diff --git a/packages/sample-app/lib/nwc_page.dart b/packages/sample-app/lib/nwc_page.dart index cbf3b13aa..b67fadaa4 100644 --- a/packages/sample-app/lib/nwc_page.dart +++ b/packages/sample-app/lib/nwc_page.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print, experimental_member_use, use_build_context_synchronously import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -7,7 +8,6 @@ import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_kind.dart'; -import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk/domain_layer/usecases/nwc/nwc_notification.dart'; import 'package:ndk/domain_layer/usecases/nwc/responses/nwc_response.dart'; import 'package:ndk/ndk.dart'; @@ -36,24 +36,24 @@ class NwcPageState extends State with WidgetsBindingObserver { TextEditingController(); // For paying invoices NwcConnection? connection; KeyPair? - nwcAppKey; // Our app's NWC keypair, should be generated once and reused. + nwcAppKey; // Our app's NWC keypair, should be generated once and reused. GetBalanceResponse? balance; // State variables to hold context from the NIP-47 auth initiation String? - _pendingDiscoveryRelayUrl; // The relay specified in the nostr+walletauth URI's 'relay=' param, + _pendingDiscoveryRelayUrl; // The relay specified in the nostr+walletauth URI's 'relay=' param, // where we expect the kind 13194 event to be. String? - _pendingAppPubkeyForAuth; // Our app's pubkey that was sent in the nostr+walletauth URI's 'pubkey=' param + _pendingAppPubkeyForAuth; // Our app's pubkey that was sent in the nostr+walletauth URI's 'pubkey=' param // and expected in the 'p' tag of the kind 13194 event. MakeInvoiceResponse? - makeInvoice; // Will store result of normal or hold invoice creation + makeInvoice; // Will store result of normal or hold invoice creation PayInvoiceResponse? payInvoice; // MakeInvoiceResponse? makeHoldInvoiceResponse; // Removed, merged into makeInvoice String? holdInvoicePreimage; String? - holdInvoicePaymentHash; // Still needed to identify the hold invoice for notifications/settle/cancel + holdInvoicePaymentHash; // Still needed to identify the hold invoice for notifications/settle/cancel bool isHoldInvoice = false; // For the checkbox bool _currentInvoiceWasHold = false; // To track if the current 'makeInvoice' is a hold type @@ -183,8 +183,10 @@ class NwcPageState extends State with WidgetsBindingObserver { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'NIP-47 callback received. Fetching wallet connection info from $discoveryRelay...')), + content: Text( + 'NIP-47 callback received. Fetching wallet connection info from $discoveryRelay...', + ), + ), ); try { @@ -203,10 +205,12 @@ class NwcPageState extends State with WidgetsBindingObserver { if (nwcAppKey == null || nwcAppKey!.privateKey == null) { print( - 'NIP-47 Error: nwcAppKey or its private key is null. Cannot construct NWC URI.'); + 'NIP-47 Error: nwcAppKey or its private key is null. Cannot construct NWC URI.', + ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Error: App NWC key not fully initialized.')), + content: Text('Error: App NWC key not fully initialized.'), + ), ); return; } @@ -226,9 +230,11 @@ class NwcPageState extends State with WidgetsBindingObserver { Nip01Event? foundWalletAuthEvent = await stream.first; print( - 'Successfully fetched and validated NWC Info Event (Kind ${NwcKind.INFO.value}):'); + 'Successfully fetched and validated NWC Info Event (Kind ${NwcKind.INFO.value}):', + ); print( - ' Author (Wallet NWC Service Pubkey): ${foundWalletAuthEvent.pubKey}'); + ' Author (Wallet NWC Service Pubkey): ${foundWalletAuthEvent.pubKey}', + ); // Construct the NWC URI as per user's explicit instructions: // walletPubKey is the author of the 13194 event @@ -236,7 +242,7 @@ class NwcPageState extends State with WidgetsBindingObserver { // secret is the nwcAppKey the private part final String walletNwcServicePubkey = foundWalletAuthEvent.pubKey; - final String nwcRelayForConnectionUri = + const String nwcRelayForConnectionUri = discoveryRelayForQuery; // This was _pendingDiscoveryRelayUrl final String appNwcPrivateKeyForSecret = nwcAppKey!.privateKey!; @@ -244,7 +250,8 @@ class NwcPageState extends State with WidgetsBindingObserver { 'nostr+walletconnect://$walletNwcServicePubkey?relay=${Uri.encodeComponent(nwcRelayForConnectionUri)}&secret=$appNwcPrivateKeyForSecret'; print( - 'Constructed NWC connection URI (as per explicit instructions): $constructedNwcUri'); + 'Constructed NWC connection URI (as per explicit instructions): $constructedNwcUri', + ); setState(() { uri.text = constructedNwcUri; @@ -259,32 +266,29 @@ class NwcPageState extends State with WidgetsBindingObserver { doGetInfoMethod: true, ); - if (establishedConn != null) { - setState(() { - connection = establishedConn; - balance = null; - _resetInvoiceStates(); - // If make_hold_invoice is not permitted, ensure isHoldInvoice is false. - if (!(connection!.info?.methods - .contains(NwcMethod.MAKE_HOLD_INVOICE.name) ?? - false)) { - isHoldInvoice = false; - } - }); - print( - 'Successfully connected to NWC wallet: $walletNwcServicePubkey via $nwcRelayForConnectionUri'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text( - // Try using 'alias' as per potential GetInfoResponse field, fallback to pubkey - 'NWC Connected to: ${connection?.info?.alias ?? walletNwcServicePubkey.substring(0, 10)}...')), - ); - } else { - print('Failed to connect to NWC wallet using the constructed URI.'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to establish NWC connection.')), - ); - } + setState(() { + connection = establishedConn; + balance = null; + _resetInvoiceStates(); + // If make_hold_invoice is not permitted, ensure isHoldInvoice is false. + if (!(connection!.info?.methods.contains( + NwcMethod.MAKE_HOLD_INVOICE.name, + ) ?? + false)) { + isHoldInvoice = false; + } + }); + print( + 'Successfully connected to NWC wallet: $walletNwcServicePubkey via $nwcRelayForConnectionUri', + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + // Try using 'alias' as per potential GetInfoResponse field, fallback to pubkey + 'NWC Connected to: ${connection?.info?.alias ?? walletNwcServicePubkey.substring(0, 10)}...', + ), + ), + ); } catch (e) { print('Error during NIP-47 callback processing: $e'); ScaffoldMessenger.of(context).showSnackBar( @@ -293,7 +297,8 @@ class NwcPageState extends State with WidgetsBindingObserver { } } else { print( - 'Received unhandled protocol URL or missing pending NIP-47 auth state: $url'); + 'Received unhandled protocol URL or missing pending NIP-47 auth state: $url', + ); } } @@ -307,7 +312,8 @@ class NwcPageState extends State with WidgetsBindingObserver { // Always generate a new nwcAppKey for NIP-47 auth when this button is pressed. nwcAppKey = Bip340.generatePrivateKey(); print( - "Generated new, fresh nwcAppKey for NIP-47 auth: ${nwcAppKey!.publicKey}"); + "Generated new, fresh nwcAppKey for NIP-47 auth: ${nwcAppKey!.publicKey}", + ); // No need to call setState here as nwcAppKey is used immediately for URI construction. // This is the URI the button currently attempts to launch. @@ -332,18 +338,19 @@ class NwcPageState extends State with WidgetsBindingObserver { // Construct the URI that will be launched. // The host is our app's pubkey. final Uri launchUri = Uri( - scheme: 'nostr+walletauth', - host: nwcAppKey!.publicKey, // Our app's pubkey - queryParameters: { - 'relay': - discoveryRelay, // Relay for discovering the kind 13194 event - 'name': appName, - 'request_methods': methods, - 'icon': appIcon, - 'return_to': returnTo, - // NIP-47 also suggests a 'pubkey' param for the app's pubkey, but host is also used. - // Let's ensure our _pendingAppPubkeyForAuth is nwcAppKey!.publicKey - }); + scheme: 'nostr+walletauth', + host: nwcAppKey!.publicKey, // Our app's pubkey + queryParameters: { + 'relay': + discoveryRelay, // Relay for discovering the kind 13194 event + 'name': appName, + 'request_methods': methods, + 'icon': appIcon, + 'return_to': returnTo, + // NIP-47 also suggests a 'pubkey' param for the app's pubkey, but host is also used. + // Let's ensure our _pendingAppPubkeyForAuth is nwcAppKey!.publicKey + }, + ); // Store the context needed for when onProtocolUrlReceived is called. _pendingDiscoveryRelayUrl = discoveryRelay; @@ -351,16 +358,19 @@ class NwcPageState extends State with WidgetsBindingObserver { .publicKey; // This is the pubkey our app uses for this auth flow. print( - "Attempting to launch NIP-47 Auth URI: ${launchUri.toString()}"); + "Attempting to launch NIP-47 Auth URI: ${launchUri.toString()}", + ); print( - " _pendingDiscoveryRelayUrl set to: $_pendingDiscoveryRelayUrl"); + " _pendingDiscoveryRelayUrl set to: $_pendingDiscoveryRelayUrl", + ); print( - " _pendingAppPubkeyForAuth set to: $_pendingAppPubkeyForAuth"); + " _pendingAppPubkeyForAuth set to: $_pendingAppPubkeyForAuth", + ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: - Text('Redirecting to wallet for NWC authorization...')), + content: Text('Redirecting to wallet for NWC authorization...'), + ), ); try { await launchUrl(launchUri, mode: LaunchMode.externalApplication); @@ -378,7 +388,8 @@ class NwcPageState extends State with WidgetsBindingObserver { ), ); } - widgets.add(Container( + widgets.add( + Container( padding: const EdgeInsets.all(20), width: 400, child: Row( @@ -392,39 +403,46 @@ class NwcPageState extends State with WidgetsBindingObserver { }, decoration: InputDecoration( prefixIcon: IconButton( - onPressed: () { - Clipboard.getData(Clipboard.kTextPlain) - .then((clipboardData) { - if (clipboardData != null && - clipboardData.text != null) { - setState(() { - uri.text = clipboardData.text!; - }); - } - }); - }, - icon: const Icon(Icons.paste)), + onPressed: () { + Clipboard.getData(Clipboard.kTextPlain).then(( + clipboardData, + ) { + if (clipboardData != null && + clipboardData.text != null) { + setState(() { + uri.text = clipboardData.text!; + }); + } + }); + }, + icon: const Icon(Icons.paste), + ), hintText: "nostr+wallet://... url", hintStyle: const TextStyle(color: Colors.grey), ), style: const TextStyle(fontSize: 14), ), - ) + ), ], - ))); + ), + ), + ); widgets.add( FilledButton( onPressed: uri.text.isNotEmpty ? () async { _resetInvoiceStates(); // Reset states before new connection - NwcConnection? newConnection = - await ndk.nwc.connect(uri.text, doGetInfoMethod: true); + NwcConnection? newConnection = await ndk.nwc.connect( + uri.text, + doGetInfoMethod: true, + ); setState(() { connection = newConnection; balance = null; // If make_hold_invoice is not permitted, ensure isHoldInvoice is false. - if (!(connection?.info?.methods - .contains(NwcMethod.MAKE_HOLD_INVOICE.name) ?? + if (!(connection?.info?.methods.contains( + NwcMethod.MAKE_HOLD_INVOICE.name, + ) ?? false)) { isHoldInvoice = false; } @@ -434,13 +452,13 @@ class NwcPageState extends State with WidgetsBindingObserver { child: const Text('Connect and get info'), ), ); - widgets.add(connection != null && connection!.info != null - ? Text("Methods ${connection!.info!.methods}") - : Container()); + widgets.add( + connection != null && connection!.info != null + ? Text("Methods ${connection!.info!.methods}") + : Container(), + ); - widgets.add(const SizedBox( - height: 20, - )); + widgets.add(const SizedBox(height: 20)); widgets.add( FilledButton( onPressed: connection != null @@ -454,28 +472,37 @@ class NwcPageState extends State with WidgetsBindingObserver { child: const Text('Get Balance'), ), ); - widgets.add(connection != null && balance != null - ? Text("Balance ${balance!.balanceSats} sats") - : Container()); + widgets.add( + connection != null && balance != null + ? Text("Balance ${balance!.balanceSats} sats") + : Container(), + ); widgets.add( - const Divider(height: 40, thickness: 1, indent: 20, endIndent: 20)); + const Divider(height: 40, thickness: 1, indent: 20, endIndent: 20), + ); // Determine if the "Make Invoice" button should be enabled - bool canSubmitMakeInvoice = connection != null && + bool canSubmitMakeInvoice = + connection != null && amount.text.isNotEmpty && (int.tryParse(amount.text) ?? 0) > 0 && (isHoldInvoice - ? connection!.info!.methods - .contains(NwcMethod.MAKE_HOLD_INVOICE.name) + ? connection!.info!.methods.contains( + NwcMethod.MAKE_HOLD_INVOICE.name, + ) : connection!.info!.methods.contains(NwcMethod.MAKE_INVOICE.name)); // --- Make Invoice Section --- - widgets.add(const Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text("Make Invoice", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - )); + widgets.add( + const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + "Make Invoice", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ); widgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -517,10 +544,12 @@ class NwcPageState extends State with WidgetsBindingObserver { title: const Text("Hold Invoice"), value: isHoldInvoice, // Disable checkbox if connection is null, info is null, or method is not permitted - onChanged: (connection != null && + onChanged: + (connection != null && connection!.info != null && - connection!.info!.methods - .contains(NwcMethod.MAKE_HOLD_INVOICE.name)) + connection!.info!.methods.contains( + NwcMethod.MAKE_HOLD_INVOICE.name, + )) ? (bool? value) { setState(() { isHoldInvoice = value ?? false; @@ -553,14 +582,16 @@ class NwcPageState extends State with WidgetsBindingObserver { final random = Random.secure(); final preimageBytes = Uint8List.fromList( - List.generate( - 32, (_) => random.nextInt(256))); + List.generate(32, (_) => random.nextInt(256)), + ); final preimageHex = convert.hex.encode(preimageBytes); - final paymentHashBytes = - crypto.sha256.convert(preimageBytes).bytes; - final paymentHashHex = - convert.hex.encode(paymentHashBytes); + final paymentHashBytes = crypto.sha256 + .convert(preimageBytes) + .bytes; + final paymentHashHex = convert.hex.encode( + paymentHashBytes, + ); setState(() { holdInvoicePreimage = preimageHex; @@ -571,12 +602,13 @@ class NwcPageState extends State with WidgetsBindingObserver { try { final response = await ndk.nwc.makeHoldInvoice( - connection!, - amountSats: sats, - description: description.text.isNotEmpty - ? description.text - : null, // Pass null if empty - paymentHash: paymentHashHex); + connection!, + amountSats: sats, + description: description.text.isNotEmpty + ? description.text + : null, // Pass null if empty + paymentHash: paymentHashHex, + ); setState(() { makeInvoice = response; // Store in the unified makeInvoice @@ -614,7 +646,8 @@ class NwcPageState extends State with WidgetsBindingObserver { regularInvoiceStatusMessage = "Regular invoice created. Waiting for payment..."; _listenForRegularInvoicePayment( - response.paymentHash); + response.paymentHash, + ); } else if (response.errorCode != null) { regularInvoiceStatusMessage = "Error creating regular invoice: ${response.errorMessage}"; @@ -630,8 +663,9 @@ class NwcPageState extends State with WidgetsBindingObserver { } } : null, - child: - Text(isHoldInvoice ? 'Make Hold Invoice' : 'Make Invoice'), + child: Text( + isHoldInvoice ? 'Make Hold Invoice' : 'Make Invoice', + ), ), ), ], @@ -643,8 +677,9 @@ class NwcPageState extends State with WidgetsBindingObserver { if (makeInvoice != null && makeInvoice!.errorCode == null) { widgets.add(SelectableText("Invoice: ${makeInvoice!.invoice}")); if (_currentInvoiceWasHold) { - widgets - .add(SelectableText("Payment Hash: ${makeInvoice!.paymentHash}")); + widgets.add( + SelectableText("Payment Hash: ${makeInvoice!.paymentHash}"), + ); } if (makeInvoice!.invoice.isNotEmpty) { widgets.add( @@ -652,16 +687,19 @@ class NwcPageState extends State with WidgetsBindingObserver { padding: const EdgeInsets.all(8.0), child: GestureDetector( onTap: () async { - final Uri launchUri = - Uri.parse('lightning:${makeInvoice!.invoice}'); + final Uri launchUri = Uri.parse( + 'lightning:${makeInvoice!.invoice}', + ); if (await canLaunchUrl(launchUri)) { await launchUrl(launchUri); } else { // Optionally, show a message if no app can handle the URI ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text( - 'Could not launch Lightning invoice. No app found to handle it.')), + content: Text( + 'Could not launch Lightning invoice. No app found to handle it.', + ), + ), ); print('Could not launch $launchUri'); } @@ -680,8 +718,12 @@ class NwcPageState extends State with WidgetsBindingObserver { // If there was an error creating the invoice (and it's not a hold-specific status message already handled) if (!_currentInvoiceWasHold) { // Only show generic error if not a hold invoice with its own status - widgets.add(Text("Error creating invoice: ${makeInvoice!.errorMessage}", - style: const TextStyle(color: Colors.red))); + widgets.add( + Text( + "Error creating invoice: ${makeInvoice!.errorMessage}", + style: const TextStyle(color: Colors.red), + ), + ); } } @@ -690,14 +732,20 @@ class NwcPageState extends State with WidgetsBindingObserver { makeInvoice != null && makeInvoice!.errorCode == null) { if (regularInvoiceStatusMessage != null) { - widgets.add(Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(regularInvoiceStatusMessage!, + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + regularInvoiceStatusMessage!, style: isRegularInvoicePaid ? const TextStyle( - color: Colors.green, fontWeight: FontWeight.bold) - : null), - )); + color: Colors.green, + fontWeight: FontWeight.bold, + ) + : null, + ), + ), + ); } } @@ -705,153 +753,185 @@ class NwcPageState extends State with WidgetsBindingObserver { // are now part of the "Make Invoice" section's output, below the QR code. if (_currentInvoiceWasHold) { if (holdInvoiceStatusMessage != null) { - widgets.add(Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(holdInvoiceStatusMessage!), - )); + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text(holdInvoiceStatusMessage!), + ), + ); } if (isHoldInvoiceAccepted) { - widgets.add(const Text("Hold invoice accepted!", - style: - TextStyle(color: Colors.green, fontWeight: FontWeight.bold))); - widgets.add(Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton( - onPressed: (connection != null && - holdInvoicePreimage != null && - !isSettlingOrCancelling()) - ? () async { - setState(() { - holdInvoiceStatusMessage = "Settling hold invoice..."; - settleHoldInvoiceResponse = null; - cancelHoldInvoiceResponse = null; - }); - try { - final response = await ndk.nwc.settleHoldInvoice( - connection!, - preimage: holdInvoicePreimage!); - setState(() { - settleHoldInvoiceResponse = response; - if (response.errorCode == null) { - holdInvoiceStatusMessage = - "Hold invoice settled successfully. Preimage: $holdInvoicePreimage"; - } else { - holdInvoiceStatusMessage = - "Error settling invoice: ${response.errorMessage}"; - } - }); - } catch (e) { + widgets.add( + const Text( + "Hold invoice accepted!", + style: TextStyle(color: Colors.green, fontWeight: FontWeight.bold), + ), + ); + widgets.add( + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + onPressed: + (connection != null && + holdInvoicePreimage != null && + !isSettlingOrCancelling()) + ? () async { setState(() { - holdInvoiceStatusMessage = - "Exception settling invoice: $e"; + holdInvoiceStatusMessage = "Settling hold invoice..."; + settleHoldInvoiceResponse = null; + cancelHoldInvoiceResponse = null; }); - } - } - : null, - child: const Text("Settle Invoice"), - ), - const SizedBox(width: 20), - FilledButton( - onPressed: (connection != null && - holdInvoicePaymentHash != null && - !isSettlingOrCancelling()) - ? () async { - setState(() { - holdInvoiceStatusMessage = "Cancelling hold invoice..."; - settleHoldInvoiceResponse = null; - cancelHoldInvoiceResponse = null; - }); - try { - final response = await ndk.nwc.cancelHoldInvoice( + try { + final response = await ndk.nwc.settleHoldInvoice( connection!, - paymentHash: holdInvoicePaymentHash!); - setState(() { - cancelHoldInvoiceResponse = response; - if (response.errorCode == null) { - holdInvoiceStatusMessage = - "Hold invoice cancelled successfully."; - } else { + preimage: holdInvoicePreimage!, + ); + setState(() { + settleHoldInvoiceResponse = response; + if (response.errorCode == null) { + holdInvoiceStatusMessage = + "Hold invoice settled successfully. Preimage: $holdInvoicePreimage"; + } else { + holdInvoiceStatusMessage = + "Error settling invoice: ${response.errorMessage}"; + } + }); + } catch (e) { + setState(() { holdInvoiceStatusMessage = - "Error cancelling invoice: ${response.errorMessage}"; - } - }); - } catch (e) { + "Exception settling invoice: $e"; + }); + } + } + : null, + child: const Text("Settle Invoice"), + ), + const SizedBox(width: 20), + FilledButton( + onPressed: + (connection != null && + holdInvoicePaymentHash != null && + !isSettlingOrCancelling()) + ? () async { setState(() { holdInvoiceStatusMessage = - "Exception cancelling invoice: $e"; + "Cancelling hold invoice..."; + settleHoldInvoiceResponse = null; + cancelHoldInvoiceResponse = null; }); + try { + final response = await ndk.nwc.cancelHoldInvoice( + connection!, + paymentHash: holdInvoicePaymentHash!, + ); + setState(() { + cancelHoldInvoiceResponse = response; + if (response.errorCode == null) { + holdInvoiceStatusMessage = + "Hold invoice cancelled successfully."; + } else { + holdInvoiceStatusMessage = + "Error cancelling invoice: ${response.errorMessage}"; + } + }); + } catch (e) { + setState(() { + holdInvoiceStatusMessage = + "Exception cancelling invoice: $e"; + }); + } } - } - : null, - child: const Text("Cancel Invoice"), - ), - ], - )); + : null, + child: const Text("Cancel Invoice"), + ), + ], + ), + ); } if (settleHoldInvoiceResponse != null) { - widgets.add(Text(settleHoldInvoiceResponse!.errorCode == null - ? "Settle successful! Preimage: $holdInvoicePreimage" - : "Settle failed: ${settleHoldInvoiceResponse!.errorMessage}")); + widgets.add( + Text( + settleHoldInvoiceResponse!.errorCode == null + ? "Settle successful! Preimage: $holdInvoicePreimage" + : "Settle failed: ${settleHoldInvoiceResponse!.errorMessage}", + ), + ); } if (cancelHoldInvoiceResponse != null) { - widgets.add(Text(cancelHoldInvoiceResponse!.errorCode == null - ? "Cancel successful!" - : "Cancel failed: ${cancelHoldInvoiceResponse!.errorMessage}")); + widgets.add( + Text( + cancelHoldInvoiceResponse!.errorCode == null + ? "Cancel successful!" + : "Cancel failed: ${cancelHoldInvoiceResponse!.errorMessage}", + ), + ); } } widgets.add( - const Divider(height: 40, thickness: 1, indent: 20, endIndent: 20)); + const Divider(height: 40, thickness: 1, indent: 20, endIndent: 20), + ); // --- Pay Invoice Section --- - widgets.add(const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), // Reduced top padding - child: Text("Pay Invoice", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - )); + widgets.add( + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), // Reduced top padding + child: Text( + "Pay Invoice", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ); - bool canPayInvoice = connection != null && + bool canPayInvoice = + connection != null && connection!.info!.methods.contains(NwcMethod.PAY_INVOICE.name) && invoice.text != ''; - widgets.add(Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - SizedBox( - width: 300, // Centered invoice input field - child: TextField( - controller: invoice, - textAlign: TextAlign.center, - decoration: const InputDecoration( - hintText: "Invoice to pay (bolt11)", - hintStyle: TextStyle(color: Colors.grey), + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + SizedBox( + width: 300, // Centered invoice input field + child: TextField( + controller: invoice, + textAlign: TextAlign.center, + decoration: const InputDecoration( + hintText: "Invoice to pay (bolt11)", + hintStyle: TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), ), - style: const TextStyle(fontSize: 14), ), - ), - const SizedBox(height: 10), - FilledButton( - onPressed: canPayInvoice - ? () async { - final p = await ndk.nwc - .payInvoice(connection!, invoice: invoice.text); - setState(() { - payInvoice = p; - }); - } - : null, - child: const Text('Pay Invoice'), - ), - ], + const SizedBox(height: 10), + FilledButton( + onPressed: canPayInvoice + ? () async { + final p = await ndk.nwc.payInvoice( + connection!, + invoice: invoice.text, + ); + setState(() { + payInvoice = p; + }); + } + : null, + child: const Text('Pay Invoice'), + ), + ], + ), ), - )); - widgets.add(payInvoice != null - ? SelectableText("Payment Preimage: ${payInvoice!.preimage}") - : Container()); + ); + widgets.add( + payInvoice != null + ? SelectableText("Payment Preimage: ${payInvoice!.preimage}") + : Container(), + ); widgets.add(const Divider(height: 40, thickness: 2)); widgets.add( @@ -878,7 +958,9 @@ class NwcPageState extends State with WidgetsBindingObserver { padding: const EdgeInsets.symmetric(vertical: 16.0), child: SingleChildScrollView( child: Column( - mainAxisAlignment: MainAxisAlignment.start, children: widgets), + mainAxisAlignment: MainAxisAlignment.start, + children: widgets, + ), ), ); } @@ -905,44 +987,49 @@ class NwcPageState extends State with WidgetsBindingObserver { // Use makeInvoice.expiresAt as it now holds the response for both normal and hold invoices final duration = makeInvoice?.expiresAt != null ? (makeInvoice!.expiresAt! - - DateTime.now().millisecondsSinceEpoch ~/ 1000) + DateTime.now().millisecondsSinceEpoch ~/ 1000) : 300; // Default timeout if expiresAt is not available holdInvoiceStateSubscription = stream .timeout( - Duration(seconds: duration.toInt() > 0 ? duration.toInt() : 300)) - .listen((notification) { - if (notification.notificationType == - NwcNotification.kHoldInvoiceAccepted && - notification.paymentHash == expectedPaymentHash) { - setState(() { - isHoldInvoiceAccepted = true; - holdInvoiceStatusMessage = "Hold invoice accepted by wallet!"; - }); - holdInvoiceStateSubscription?.cancel(); - } - }, onError: (error) { - if (mounted) { - setState(() { - if (error is TimeoutException) { - holdInvoiceStatusMessage = - "Timed out waiting for hold invoice acceptance."; - } else { - holdInvoiceStatusMessage = - "Error listening for hold invoice acceptance: $error"; - } - }); - } - }, onDone: () { - if (mounted && - !isHoldInvoiceAccepted && - settleHoldInvoiceResponse == null && - cancelHoldInvoiceResponse == null) { - // setState(() { - // holdInvoiceStatusMessage = "Notification stream closed without acceptance."; - // }); - } - }); + Duration(seconds: duration.toInt() > 0 ? duration.toInt() : 300), + ) + .listen( + (notification) { + if (notification.notificationType == + NwcNotification.kHoldInvoiceAccepted && + notification.paymentHash == expectedPaymentHash) { + setState(() { + isHoldInvoiceAccepted = true; + holdInvoiceStatusMessage = "Hold invoice accepted by wallet!"; + }); + holdInvoiceStateSubscription?.cancel(); + } + }, + onError: (error) { + if (mounted) { + setState(() { + if (error is TimeoutException) { + holdInvoiceStatusMessage = + "Timed out waiting for hold invoice acceptance."; + } else { + holdInvoiceStatusMessage = + "Error listening for hold invoice acceptance: $error"; + } + }); + } + }, + onDone: () { + if (mounted && + !isHoldInvoiceAccepted && + settleHoldInvoiceResponse == null && + cancelHoldInvoiceResponse == null) { + // setState(() { + // holdInvoiceStatusMessage = "Notification stream closed without acceptance."; + // }); + } + }, + ); } void _listenForRegularInvoicePayment(String expectedPaymentHash) { @@ -961,43 +1048,49 @@ class NwcPageState extends State with WidgetsBindingObserver { final duration = makeInvoice?.expiresAt != null ? (makeInvoice!.expiresAt! - - DateTime.now().millisecondsSinceEpoch ~/ 1000) + DateTime.now().millisecondsSinceEpoch ~/ 1000) : 300; // Default timeout regularInvoicePaymentSubscription = stream .timeout( - Duration(seconds: duration.toInt() > 0 ? duration.toInt() : 300)) - .listen((notification) { - if (notification.notificationType == NwcNotification.kPaymentReceived && - notification.paymentHash == expectedPaymentHash) { - if (mounted) { - setState(() { - isRegularInvoicePaid = true; - regularInvoiceStatusMessage = - "Invoice PAID! Preimage: ${notification.preimage}"; - }); - } - regularInvoicePaymentSubscription?.cancel(); - } - // We might also want to listen for kPaymentFailed if the NWC provider sends such for incoming payments that fail. - // Or, more commonly, the invoice just expires. - }, onError: (error) { - if (mounted) { - setState(() { - if (error is TimeoutException) { - regularInvoiceStatusMessage = - "Timed out waiting for invoice payment."; - } else { - regularInvoiceStatusMessage = - "Error listening for invoice payment: $error"; - } - }); - } - }, onDone: () { - if (mounted && !isRegularInvoicePaid) { - // If stream closes and invoice not paid, could update status. - // For now, timeout handles expiration. - } - }); + Duration(seconds: duration.toInt() > 0 ? duration.toInt() : 300), + ) + .listen( + (notification) { + if (notification.notificationType == + NwcNotification.kPaymentReceived && + notification.paymentHash == expectedPaymentHash) { + if (mounted) { + setState(() { + isRegularInvoicePaid = true; + regularInvoiceStatusMessage = + "Invoice PAID! Preimage: ${notification.preimage}"; + }); + } + regularInvoicePaymentSubscription?.cancel(); + } + // We might also want to listen for kPaymentFailed if the NWC provider sends such for incoming payments that fail. + // Or, more commonly, the invoice just expires. + }, + onError: (error) { + if (mounted) { + setState(() { + if (error is TimeoutException) { + regularInvoiceStatusMessage = + "Timed out waiting for invoice payment."; + } else { + regularInvoiceStatusMessage = + "Error listening for invoice payment: $error"; + } + }); + } + }, + onDone: () { + if (mounted && !isRegularInvoicePaid) { + // If stream closes and invoice not paid, could update status. + // For now, timeout handles expiration. + } + }, + ); } } diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index edafeed37..57811052a 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -106,10 +106,7 @@ class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { Expanded( child: Text( l10n.scanNwcQrCodeTitle, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - ), + style: const TextStyle(color: Colors.white, fontSize: 18), textAlign: TextAlign.center, ), ), diff --git a/packages/sample-app/lib/pending_requests_page.dart b/packages/sample-app/lib/pending_requests_page.dart index 7f750570b..0dc5e820b 100644 --- a/packages/sample-app/lib/pending_requests_page.dart +++ b/packages/sample-app/lib/pending_requests_page.dart @@ -194,7 +194,8 @@ class _PendingRequestsPageState extends State { ); _ciphertext = result; _showResult( - l10n.pendingEncryptedResult('${result?.substring(0, 30)}...')); + l10n.pendingEncryptedResult('${result?.substring(0, 30)}...'), + ); } catch (e) { _showError(l10n.pendingEncryptFailed(e.toString())); } @@ -364,9 +365,9 @@ class _PendingRequestsPageState extends State { const SizedBox(height: 8), Text( l10n.pendingDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), ), const SizedBox(height: 16), @@ -474,14 +475,9 @@ class _PendingRequestsPageState extends State { const SizedBox(height: 16), if (widget.embedded) - SizedBox( - height: 360, - child: pendingRequestsList, - ) + SizedBox(height: 360, child: pendingRequestsList) else - Expanded( - child: pendingRequestsList, - ), + Expanded(child: pendingRequestsList), ], ), ); @@ -492,10 +488,7 @@ class _PendingRequestCard extends StatelessWidget { final PendingSignerRequest request; final VoidCallback onCancel; - const _PendingRequestCard({ - required this.request, - required this.onCancel, - }); + const _PendingRequestCard({required this.request, required this.onCancel}); IconData _getIconForMethod(SignerMethod method) { switch (method) { @@ -583,8 +576,9 @@ class _PendingRequestCard extends StatelessWidget { Row( children: [ CircleAvatar( - backgroundColor: - _getColorForMethod(method).withValues(alpha: 0.2), + backgroundColor: _getColorForMethod( + method, + ).withValues(alpha: 0.2), child: Icon( _getIconForMethod(method), color: _getColorForMethod(method), @@ -604,10 +598,7 @@ class _PendingRequestCard extends StatelessWidget { ), Text( _formatDuration(context, duration), - style: TextStyle( - color: Colors.grey[600], - fontSize: 12, - ), + style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ], ), @@ -616,9 +607,7 @@ class _PendingRequestCard extends StatelessWidget { onPressed: onCancel, icon: const Icon(Icons.cancel, size: 18), label: Text(context.l10n.cancel), - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), + style: TextButton.styleFrom(foregroundColor: Colors.red), ), ], ), @@ -634,8 +623,9 @@ class _PendingRequestCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n - .pendingEventKind(request.event!.kind.toString()), + context.l10n.pendingEventKind( + request.event!.kind.toString(), + ), style: const TextStyle(fontFamily: 'monospace'), ), const SizedBox(height: 4), diff --git a/packages/sample-app/lib/profile_page.dart b/packages/sample-app/lib/profile_page.dart index 1a26c73fc..46a30e2b1 100644 --- a/packages/sample-app/lib/profile_page.dart +++ b/packages/sample-app/lib/profile_page.dart @@ -7,11 +7,13 @@ import 'package:ndk_flutter/ndk_flutter.dart'; class ProfilePage extends StatefulWidget { final NdkFlutter ndkFlutter; final String? Function() getLoggedPubkey; + final String? initialPubkey; const ProfilePage({ super.key, required this.ndkFlutter, required this.getLoggedPubkey, + this.initialPubkey, }); @override @@ -34,8 +36,9 @@ class _ProfilePageState extends State { Widget build(BuildContext context) { final l10n = context.l10n; final loggedPubkey = widget.getLoggedPubkey(); + final profilePubkey = widget.initialPubkey ?? loggedPubkey; - if (loggedPubkey == null) { + if (profilePubkey == null) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Card( @@ -119,15 +122,16 @@ class _ProfilePageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ NUserProfile( - key: ValueKey(loggedPubkey), + key: ValueKey(profilePubkey), ndkFlutter: widget.ndkFlutter, + pubkey: profilePubkey, onLogout: () { setState(() {}); }, ), const SizedBox(height: 16), FutureBuilder( - future: widget.ndkFlutter.ndk.metadata.loadMetadata(loggedPubkey), + future: widget.ndkFlutter.ndk.metadata.loadMetadata(profilePubkey), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/packages/sample-app/lib/quantum_secure_page.dart b/packages/sample-app/lib/quantum_secure_page.dart index 4286857e3..28470529c 100644 --- a/packages/sample-app/lib/quantum_secure_page.dart +++ b/packages/sample-app/lib/quantum_secure_page.dart @@ -1,3 +1,4 @@ +// ignore_for_file: use_build_context_synchronously import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -62,8 +63,10 @@ class _QuantumSecurePageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'Generated $_eventCount events for quantum secure signing')), + content: Text( + 'Generated $_eventCount events for quantum secure signing', + ), + ), ); } @@ -102,8 +105,9 @@ class _QuantumSecurePageState extends State { final lastSignedEvent = signedEvents.last; setState(() { // Manually construct JSON-like representation from Nip01Event fields - _lastEventJson = - jsonEncode(Nip01EventModel.fromEntity(lastSignedEvent).toJson()); + _lastEventJson = jsonEncode( + Nip01EventModel.fromEntity(lastSignedEvent).toJson(), + ); // Store all signed events globally for verification _signedEvents = List.from(signedEvents); }); @@ -111,23 +115,24 @@ class _QuantumSecurePageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Signed $_eventCount events in ${_signTimeMs!.toStringAsFixed(2)} ms'), + 'Signed $_eventCount events in ${_signTimeMs!.toStringAsFixed(2)} ms', + ), ), ); } catch (e) { setState(() { _signTimeMs = null; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error signing events: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error signing events: $e'))); } } Future _verifyEvents() async { if (_signer == null || _verifier == null || _signedEvents.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Please generate events first')), + const SnackBar(content: Text('Please generate events first')), ); return; } @@ -168,9 +173,9 @@ class _QuantumSecurePageState extends State { statusMessage += ' ($failedCount failed)'; } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(statusMessage)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(statusMessage))); if (!allValid) { ScaffoldMessenger.of(context).showSnackBar( @@ -182,18 +187,16 @@ class _QuantumSecurePageState extends State { _verifyTimeMs = null; _failedVerifications = null; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error verifying events: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error verifying events: $e'))); } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Quantum Secure Sign/Verify Demo'), - ), + appBar: AppBar(title: const Text('Quantum Secure Sign/Verify Demo')), body: kIsWeb ? const Center( child: Padding( @@ -201,13 +204,18 @@ class _QuantumSecurePageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warning_amber_rounded, - size: 64, color: Colors.orange), + Icon( + Icons.warning_amber_rounded, + size: 64, + color: Colors.orange, + ), SizedBox(height: 16), Text( 'Not supported on Web', - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), SizedBox(height: 8), Text( @@ -232,11 +240,8 @@ class _QuantumSecurePageState extends State { child: Text( 'This is an experiment to test the feasibility of Dilithium in Nostr.\n\n' 'Please note that the ID is still generated the conventional way.', - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[700], - height: 1.5, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.grey[700], height: 1.5), ), ), ), @@ -294,7 +299,8 @@ class _QuantumSecurePageState extends State { label: '$_eventCount events', onChanged: (value) { setState( - () => _eventCount = (value * 1000).toInt()); + () => _eventCount = (value * 1000).toInt(), + ); }, ), @@ -348,8 +354,9 @@ class _QuantumSecurePageState extends State { const Divider(), if (_signTimeMs != null) ...[ Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -368,8 +375,9 @@ class _QuantumSecurePageState extends State { ], if (_verifyTimeMs != null) ...[ Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -389,8 +397,9 @@ class _QuantumSecurePageState extends State { if (_failedVerifications != null && _failedVerifications! > 0) ...[ Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), child: Text( 'Failed Verifications: $_failedVerifications', style: const TextStyle( diff --git a/packages/sample-app/lib/query_performance.dart b/packages/sample-app/lib/query_performance.dart index 068ebecf1..4f65dc0ac 100644 --- a/packages/sample-app/lib/query_performance.dart +++ b/packages/sample-app/lib/query_performance.dart @@ -23,17 +23,21 @@ class _QueryPerformancePageState extends State { static const relays = ["ws://localhost:10547"]; - final ndkBip340 = Ndk(NdkConfig( - eventVerifier: MyVerifiers.bip340Verifier, - cache: MemCacheManager(), - bootstrapRelays: relays, - )); - - final ndkRust = Ndk(NdkConfig( - eventVerifier: MyVerifiers.rustVerifier, - cache: MemCacheManager(), - bootstrapRelays: relays, - )); + final ndkBip340 = Ndk( + NdkConfig( + eventVerifier: MyVerifiers.bip340Verifier, + cache: MemCacheManager(), + bootstrapRelays: relays, + ), + ); + + final ndkRust = Ndk( + NdkConfig( + eventVerifier: MyVerifiers.rustVerifier, + cache: MemCacheManager(), + bootstrapRelays: relays, + ), + ); Future _runBip340Query() async { setState(() { @@ -69,12 +73,7 @@ class _QueryPerformancePageState extends State { _runQuery(Ndk ndk) async { final query = ndk.requests.query( - filters: [ - Filter( - kinds: [1], - limit: _eventCount, - ) - ], + filter: Filter(kinds: [1], limit: _eventCount), cacheRead: false, cacheWrite: false, ); @@ -94,9 +93,7 @@ class _QueryPerformancePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Query Performance'), - ), + appBar: AppBar(title: const Text('Query Performance')), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( diff --git a/packages/sample-app/lib/relays_page.dart b/packages/sample-app/lib/relays_page.dart index ac64f7c22..43e813c7c 100644 --- a/packages/sample-app/lib/relays_page.dart +++ b/packages/sample-app/lib/relays_page.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'dart:async'; import 'package:flutter/material.dart'; @@ -54,10 +55,7 @@ class _RelaysPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - url, - style: Theme.of(context).textTheme.titleMedium, - ), + Text(url, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 6), Text( l10n.relayConnection(stateLabel), @@ -74,8 +72,10 @@ class _RelaysPageState extends State runSpacing: 8, children: [ Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: stateColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), @@ -90,8 +90,10 @@ class _RelaysPageState extends State ), if (marker.isRead) Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(999), @@ -103,8 +105,10 @@ class _RelaysPageState extends State ), if (marker.isWrite) Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(999), @@ -243,22 +247,22 @@ class _RelaysPageState extends State } final entry = entries[index - 1]; - final transport = ndk.relays - .getRelayConnectivity(entry.key) - ?.relayTransport as WebSocketClientNostrTransport?; + final transport = + ndk.relays.getRelayConnectivity(entry.key)?.relayTransport + as WebSocketClientNostrTransport?; final stateColor = transport != null ? transport.isConnecting() - ? Colors.orange - : transport.isOpen() - ? Colors.green - : Colors.red + ? Colors.orange + : transport.isOpen() + ? Colors.green + : Colors.red : Colors.grey; final stateLabel = transport != null ? transport.isConnecting() - ? l10n.relayStateConnecting - : transport.isOpen() - ? l10n.relayStateOnline - : l10n.relayStateOffline + ? l10n.relayStateConnecting + : transport.isOpen() + ? l10n.relayStateOnline + : l10n.relayStateOffline : l10n.relayStateUnknown; return _buildRelayCard( diff --git a/packages/sample-app/lib/router.dart b/packages/sample-app/lib/router.dart index bdc3ae29d..f6eea7da1 100644 --- a/packages/sample-app/lib/router.dart +++ b/packages/sample-app/lib/router.dart @@ -4,8 +4,10 @@ import 'package:ndk_demo/l10n/app_localizations_context.dart'; import 'accounts_page.dart'; import 'blossom_page.dart'; +import 'follows_page.dart'; import 'home_page.dart'; import 'main.dart'; +import 'dm_page.dart'; import 'profile_page.dart'; import 'quantum_secure_page.dart'; import 'relays_page.dart'; @@ -15,14 +17,8 @@ import 'widgets_demo_page.dart'; final appRouter = GoRouter( initialLocation: '/home', routes: [ - GoRoute( - path: '/', - redirect: (_, __) => '/home', - ), - GoRoute( - path: '/home', - builder: (context, state) => const HomePage(), - ), + GoRoute(path: '/', redirect: (_, __) => '/home'), + GoRoute(path: '/home', builder: (context, state) => const HomePage()), GoRoute( path: '/accounts', builder: (context, state) => Scaffold( @@ -37,6 +33,8 @@ final appRouter = GoRouter( body: ProfilePage( ndkFlutter: ndkFlutter, getLoggedPubkey: () => ndk.accounts.getPublicKey(), + initialPubkey: + state.extra as String? ?? state.uri.queryParameters['pubkey'], ), ), ), @@ -53,14 +51,27 @@ final appRouter = GoRouter( ), GoRoute( path: '/wallets', - builder: (context, state) => WalletsPage( - initialUrl: state.extra as String?, - ), + builder: (context, state) => + WalletsPage(initialUrl: state.extra as String?), ), GoRoute( path: '/widgets', builder: (context, state) => const WidgetsDemoPage(), ), + GoRoute(path: '/dm', builder: (context, state) => const DmInboxPage()), + GoRoute( + path: '/dm/conversation/:pubkey', + builder: (context, state) => + DmConversationPage(peerPubKey: state.pathParameters['pubkey']!), + ), + GoRoute( + path: '/dm/compose', + builder: (context, state) => DmComposePage( + initialRecipientPubKey: + state.extra as String? ?? state.uri.queryParameters['recipient'], + ), + ), + GoRoute(path: '/follows', builder: (context, state) => const FollowsPage()), GoRoute( path: '/quantum', builder: (context, state) => const QuantumSecurePage(), diff --git a/packages/sample-app/lib/verifiers_performance.dart b/packages/sample-app/lib/verifiers_performance.dart index eeb021990..9312597be 100644 --- a/packages/sample-app/lib/verifiers_performance.dart +++ b/packages/sample-app/lib/verifiers_performance.dart @@ -52,11 +52,11 @@ class _VerifiersPerformancePageState extends State { return signedList; } - _verifyEventsWaiting({required EventVerifier verifier}) async { - for (final event in _events) { - await verifier.verify(event); - } - } + // _verifyEventsWaiting({required EventVerifier verifier}) async { + // for (final event in _events) { + // await verifier.verify(event); + // } + // } _verifyEventsParallel({required EventVerifier verifier}) async { final futures = []; @@ -160,9 +160,7 @@ class _VerifiersPerformancePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Verifiers Performance'), - ), + appBar: AppBar(title: const Text('Verifiers Performance')), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -192,8 +190,9 @@ class _VerifiersPerformancePageState extends State { ), const SizedBox(height: 16), ElevatedButton( - onPressed: - hasPubkey && !_isGenerating ? _handleGenerateEvents : null, + onPressed: hasPubkey && !_isGenerating + ? _handleGenerateEvents + : null, child: _isGenerating ? const SizedBox( height: 20, diff --git a/packages/sample-app/lib/widgets_demo_page.dart b/packages/sample-app/lib/widgets_demo_page.dart index 8aa794b5b..84c735c85 100644 --- a/packages/sample-app/lib/widgets_demo_page.dart +++ b/packages/sample-app/lib/widgets_demo_page.dart @@ -22,9 +22,7 @@ class _WidgetsDemoPageState extends State { final isLoggedIn = loggedPubkey != null; return Scaffold( - appBar: AppBar( - title: Text(l10n.widgetsPageTitle), - ), + appBar: AppBar(title: Text(l10n.widgetsPageTitle)), body: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( @@ -272,16 +270,16 @@ class _WidgetsDemoPageState extends State { children: [ Text( title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Text( description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[600], - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), ), const SizedBox(height: 12), child, @@ -320,10 +318,7 @@ class _WidgetsDemoPageState extends State { ), ), const SizedBox(height: 4), - Text( - fakePubkey.substring(0, 10), - style: const TextStyle(fontSize: 10), - ), + Text(fakePubkey.substring(0, 10), style: const TextStyle(fontSize: 10)), ], ); } diff --git a/packages/sample-app/lib/zaps_page.dart b/packages/sample-app/lib/zaps_page.dart index fb4f7127b..3686a44fd 100644 --- a/packages/sample-app/lib/zaps_page.dart +++ b/packages/sample-app/lib/zaps_page.dart @@ -1,6 +1,6 @@ +// ignore_for_file: experimental_member_use import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk/ndk.dart'; import 'main.dart'; @@ -42,7 +42,8 @@ class _ZapsPageState extends State { @override Widget build(BuildContext context) { List widgets = []; - widgets.add(Container( + widgets.add( + Container( padding: const EdgeInsets.all(20), width: 400, child: Row( @@ -56,32 +57,38 @@ class _ZapsPageState extends State { }, decoration: InputDecoration( prefixIcon: IconButton( - onPressed: () { - Clipboard.getData(Clipboard.kTextPlain) - .then((clipboardData) { - if (clipboardData != null && - clipboardData.text != null) { - setState(() { - uri.text = clipboardData.text!; - }); - } - }); - }, - icon: const Icon(Icons.paste)), + onPressed: () { + Clipboard.getData(Clipboard.kTextPlain).then(( + clipboardData, + ) { + if (clipboardData != null && + clipboardData.text != null) { + setState(() { + uri.text = clipboardData.text!; + }); + } + }); + }, + icon: const Icon(Icons.paste), + ), hintText: "nostr+wallet://... url", hintStyle: const TextStyle(color: Colors.grey), ), style: const TextStyle(fontSize: 14), ), - ) + ), ], - ))); + ), + ), + ); widgets.add( FilledButton( onPressed: uri.text.isNotEmpty ? () async { - connection = - await ndk.nwc.connect(uri.text, doGetInfoMethod: true); + connection = await ndk.nwc.connect( + uri.text, + doGetInfoMethod: true, + ); setState(() { balance = null; }); @@ -90,9 +97,11 @@ class _ZapsPageState extends State { child: const Text('Connect and get info'), ), ); - widgets.add(connection != null && connection!.info != null - ? Text("Methods ${connection!.info!.methods}") - : Container()); + widgets.add( + connection != null && connection!.info != null + ? Text("Methods ${connection!.info!.methods}") + : Container(), + ); widgets.add( FilledButton( @@ -107,80 +116,96 @@ class _ZapsPageState extends State { child: const Text('Get Balance'), ), ); - widgets.add(connection != null && balance != null - ? Text("Balance ${balance!.balanceSats} sats") - : Container()); + widgets.add( + connection != null && balance != null + ? Text("Balance ${balance!.balanceSats} sats") + : Container(), + ); - bool canMakeInvoice = connection != null && + bool canMakeInvoice = + connection != null && connection!.info!.methods.contains(NwcMethod.MAKE_INVOICE.name) && amount.text != '' && (int.tryParse(amount.text) ?? 0) > 0; - widgets.add(Row( - children: [ - const SizedBox(width: 30), - SizedBox( - width: 200, - child: TextField( - controller: amount, - decoration: const InputDecoration( - hintText: "amount in sats", - hintStyle: TextStyle(color: Colors.grey), + widgets.add( + Row( + children: [ + const SizedBox(width: 30), + SizedBox( + width: 200, + child: TextField( + controller: amount, + decoration: const InputDecoration( + hintText: "amount in sats", + hintStyle: TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), ), - style: const TextStyle(fontSize: 14), ), - ), - FilledButton( - onPressed: canMakeInvoice - ? () async { - final invoice = await ndk.nwc.makeInvoice(connection!, - amountSats: int.tryParse(amount.text) ?? 0); - setState(() { - makeInvoice = invoice; - }); - } - : null, - child: const Text('Make invoice'), - ), - ], - )); - widgets.add(makeInvoice != null - ? SelectableText("bolt11 invoice: ${makeInvoice!.invoice}") - : Container()); + FilledButton( + onPressed: canMakeInvoice + ? () async { + final invoice = await ndk.nwc.makeInvoice( + connection!, + amountSats: int.tryParse(amount.text) ?? 0, + ); + setState(() { + makeInvoice = invoice; + }); + } + : null, + child: const Text('Make invoice'), + ), + ], + ), + ); + widgets.add( + makeInvoice != null + ? SelectableText("bolt11 invoice: ${makeInvoice!.invoice}") + : Container(), + ); - bool canPayInvoice = connection != null && + bool canPayInvoice = + connection != null && connection!.info!.methods.contains(NwcMethod.PAY_INVOICE.name) && invoice.text != ''; - widgets.add(Row( - children: [ - const SizedBox(width: 30), - SizedBox( - width: 200, - child: TextField( - controller: invoice, - decoration: const InputDecoration( - hintText: "invoice to pay", - hintStyle: TextStyle(color: Colors.grey), + widgets.add( + Row( + children: [ + const SizedBox(width: 30), + SizedBox( + width: 200, + child: TextField( + controller: invoice, + decoration: const InputDecoration( + hintText: "invoice to pay", + hintStyle: TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), ), - style: const TextStyle(fontSize: 14), ), - ), - FilledButton( - onPressed: canPayInvoice - ? () async { - final p = await ndk.nwc - .payInvoice(connection!, invoice: invoice.text); - setState(() { - payInvoice = p; - }); - } - : null, - child: const Text('Pay invoice'), - ), - ], - )); - widgets.add(payInvoice != null - ? SelectableText("preimage: ${payInvoice!.preimage}") - : Container()); + FilledButton( + onPressed: canPayInvoice + ? () async { + final p = await ndk.nwc.payInvoice( + connection!, + invoice: invoice.text, + ); + setState(() { + payInvoice = p; + }); + } + : null, + child: const Text('Pay invoice'), + ), + ], + ), + ); + widgets.add( + payInvoice != null + ? SelectableText("preimage: ${payInvoice!.preimage}") + : Container(), + ); widgets.add( FilledButton( @@ -202,7 +227,9 @@ class _ZapsPageState extends State { padding: const EdgeInsets.symmetric(vertical: 16.0), child: SingleChildScrollView( child: Column( - mainAxisAlignment: MainAxisAlignment.start, children: widgets), + mainAxisAlignment: MainAxisAlignment.start, + children: widgets, + ), ), ); } diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 586a98915..cfd5aec14 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -122,7 +122,7 @@ packages: source: hosted version: "1.19.1" convert: - dependency: transitive + dependency: "direct main" description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 @@ -138,7 +138,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -553,10 +553,10 @@ packages: dependency: "direct main" description: name: mobile_scanner - sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + sha256: "30f7dc342094eb257ead933572f7e442dfd9d8aa6f3fa5506c3206f7837d1615" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "7.2.1" native_toolchain_c: dependency: transitive description: @@ -579,7 +579,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.8.4-dev.5" + version: "0.8.4-dev.10" ndk_bip32_keys: dependency: transitive description: @@ -594,14 +594,14 @@ packages: path: "../drift" relative: true source: path - version: "0.1.1-dev.7" + version: "0.1.1-dev.12" ndk_flutter: dependency: "direct main" description: path: "../ndk_flutter" relative: true source: path - version: "0.8.4-dev.7" + version: "0.8.4-dev.13" nested: dependency: transitive description: @@ -610,13 +610,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - nip07_event_signer: - dependency: "direct overridden" - description: - path: "../nip07_event_signer" - relative: true - source: path - version: "1.1.0-dev.5" objective_c: dependency: transitive description: diff --git a/packages/sample-app/pubspec.yaml b/packages/sample-app/pubspec.yaml index 980cf6d9d..ba671b7ae 100644 --- a/packages/sample-app/pubspec.yaml +++ b/packages/sample-app/pubspec.yaml @@ -52,7 +52,9 @@ dependencies: protocol_handler: ^0.2.0 http: ^1.2.0 qr_flutter: ^4.1.0 - mobile_scanner: ^7.2.0 + mobile_scanner: ^7.2.1 + convert: ^3.1.2 + crypto: ^3.0.7 dev_dependencies: flutter_test: diff --git a/packages/sample-app/web/index.html b/packages/sample-app/web/index.html index eaef530fa..61542c0f7 100644 --- a/packages/sample-app/web/index.html +++ b/packages/sample-app/web/index.html @@ -33,6 +33,50 @@ + + +
+ + Loading… +