From 6fce9b47cb157d00f99d2529a8a207aba6ae387a Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Thu, 26 Feb 2026 11:19:47 +0100 Subject: [PATCH] First commit for WizardConnect --- .gitignore | 5 + CLAUDE.md | 83 + LICENSE.txt | 165 + docs/connection-uri.md | 174 + docs/dapp.md | 214 + docs/index.md | 79 + docs/protocol.md | 347 ++ docs/pubkey-derivation.md | 142 + docs/transport.md | 193 + docs/wallet.md | 212 + eslint.config.cjs | 37 + linters/copyright_check.mjs | 59 + package-lock.json | 4164 +++++++++++++++++ package.json | 32 + packages/core/package.json | 34 + packages/core/src/connection-manager.ts | 228 + packages/core/src/dapp-relay.ts | 189 + packages/core/src/index.ts | 37 + packages/core/src/key-exchange.test.ts | 374 ++ packages/core/src/key-exchange.ts | 216 + packages/core/src/log.ts | 32 + packages/core/src/message-queue.test.ts | 221 + packages/core/src/message-queue.ts | 91 + packages/core/src/primitives.ts | 23 + packages/core/src/protocols/base.ts | 95 + packages/core/src/protocols/hdwalletv1.ts | 169 + packages/core/src/relay-client.ts | 406 ++ packages/core/src/relay-handler.ts | 167 + packages/core/src/utilnostr.test.ts | 104 + packages/core/src/utilnostr.ts | 21 + packages/core/src/wallet-relay.ts | 111 + packages/core/tsconfig.json | 9 + packages/core/vitest.config.ts | 11 + packages/dapp/package.json | 29 + .../dapp/src/.pubkey-state-manager.ts.swp | Bin 0 -> 16384 bytes packages/dapp/src/dapp-connection-manager.ts | 340 ++ packages/dapp/src/index.ts | 7 + .../dapp/src/pubkey-state-manager.test.ts | 27 + packages/dapp/src/pubkey-state-manager.ts | 43 + packages/dapp/tsconfig.json | 9 + packages/dapp/vitest.config.ts | 11 + packages/test-cli/package.json | 35 + packages/test-cli/src/cli.ts | 60 + packages/test-cli/src/dapp.ts | 297 ++ packages/test-cli/src/wallet.ts | 151 + packages/test-cli/tsconfig.json | 8 + packages/wallet/package.json | 31 + packages/wallet/src/derivation-path.ts | 33 + packages/wallet/src/index.ts | 16 + .../wallet/src/integration/disconnect.test.ts | 148 + packages/wallet/src/integration/helpers.ts | 233 + .../src/integration/integration.test.ts | 126 + .../src/integration/reconnection.test.ts | 156 + .../src/integration/sign-cancel.test.ts | 144 + packages/wallet/src/wallet-adapter.ts | 48 + .../wallet/src/wallet-connection-manager.ts | 457 ++ packages/wallet/tsconfig.json | 15 + packages/wallet/vitest.config.ts | 12 + packages/wallet/vitest.integration.config.ts | 18 + tsconfig.base.json | 18 + zensical.toml | 39 + 61 files changed, 10955 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE.txt create mode 100644 docs/connection-uri.md create mode 100644 docs/dapp.md create mode 100644 docs/index.md create mode 100644 docs/protocol.md create mode 100644 docs/pubkey-derivation.md create mode 100644 docs/transport.md create mode 100644 docs/wallet.md create mode 100644 eslint.config.cjs create mode 100644 linters/copyright_check.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/connection-manager.ts create mode 100644 packages/core/src/dapp-relay.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/key-exchange.test.ts create mode 100644 packages/core/src/key-exchange.ts create mode 100644 packages/core/src/log.ts create mode 100644 packages/core/src/message-queue.test.ts create mode 100644 packages/core/src/message-queue.ts create mode 100644 packages/core/src/primitives.ts create mode 100644 packages/core/src/protocols/base.ts create mode 100644 packages/core/src/protocols/hdwalletv1.ts create mode 100644 packages/core/src/relay-client.ts create mode 100644 packages/core/src/relay-handler.ts create mode 100644 packages/core/src/utilnostr.test.ts create mode 100644 packages/core/src/utilnostr.ts create mode 100644 packages/core/src/wallet-relay.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/dapp/package.json create mode 100644 packages/dapp/src/.pubkey-state-manager.ts.swp create mode 100644 packages/dapp/src/dapp-connection-manager.ts create mode 100644 packages/dapp/src/index.ts create mode 100644 packages/dapp/src/pubkey-state-manager.test.ts create mode 100644 packages/dapp/src/pubkey-state-manager.ts create mode 100644 packages/dapp/tsconfig.json create mode 100644 packages/dapp/vitest.config.ts create mode 100644 packages/test-cli/package.json create mode 100644 packages/test-cli/src/cli.ts create mode 100644 packages/test-cli/src/dapp.ts create mode 100644 packages/test-cli/src/wallet.ts create mode 100644 packages/test-cli/tsconfig.json create mode 100644 packages/wallet/package.json create mode 100644 packages/wallet/src/derivation-path.ts create mode 100644 packages/wallet/src/index.ts create mode 100644 packages/wallet/src/integration/disconnect.test.ts create mode 100644 packages/wallet/src/integration/helpers.ts create mode 100644 packages/wallet/src/integration/integration.test.ts create mode 100644 packages/wallet/src/integration/reconnection.test.ts create mode 100644 packages/wallet/src/integration/sign-cancel.test.ts create mode 100644 packages/wallet/src/wallet-adapter.ts create mode 100644 packages/wallet/src/wallet-connection-manager.ts create mode 100644 packages/wallet/tsconfig.json create mode 100644 packages/wallet/vitest.config.ts create mode 100644 packages/wallet/vitest.integration.config.ts create mode 100644 tsconfig.base.json create mode 100644 zensical.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e7f755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.js.map +site/ +.venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3f84030 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# wizardconnect + +Relay-based protocol library for connecting dapps to HD wallets over WebSocket (Nostr NIP-17 gift-wrapped messages). + +## Testing philosophy + +Tests are more important than the code itself. A correct implementation that lacks tests is worse than a slightly imperfect implementation that is well-tested, because untested code will silently break and the breakage will only surface in production — often as subtle protocol bugs that are hard to reproduce. + +This codebase communicates over a live relay with timing-sensitive handshakes and stateful reconnection logic. These bugs cannot be caught by reading the code. They only appear in integration tests that exercise the real relay. + +**Always write tests before or alongside any non-trivial change.** If you add a feature, add a test. If you fix a bug, add a regression test that would have caught it. + +### Test types + +**Unit tests** (`npm test` in a package): +- Fast, no network, run on every change +- Test individual functions and classes in isolation +- Use vitest, mock external deps +- Should cover all edge cases in pure logic (state managers, message builders, URI encoding, etc.) + +**Integration tests** (`npm run test:integration` in a package): +- Hit the real relay at `wss://relay.cauldron.quest:443` +- Test the full protocol handshake end-to-end +- Located in `src/__tests__/*.integration.test.ts` +- Run with generous timeouts (60s per test) via `vitest.integration.config.ts` +- Must pass before any release + +### Running tests + +```bash +# All packages, unit tests only (fast): +npm run test + +# Wallet package integration tests (requires network): +cd packages/wallet && npm run test:integration + +# Custom relay: +TEST_RELAY_URL=wss://your-relay:443 npm run test:integration +``` + +### Test CLI (manual/exploratory testing) + +```bash +# Start a dapp session and watch the protocol: +npm run dapp + +# Connect a test wallet to a dapp URI: +npm run wallet -- --uri wiz://... + +# Test the approval flow: +npm run dapp -- --sign +``` + +The test CLI (`packages/test-cli/`) is not a substitute for automated tests — it is a debugging tool. + +## Documentation + +Protocol and architecture documentation lives in `docs/`. Keep it up to date when making changes: + +| File | Update when… | +|------|-------------| +| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change | +| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes | +| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes | +| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes | +| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes | +| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes | +| `docs/index.md` | New top-level docs files are added | + +## Packages + +- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types) +- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`) +- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes) + +## Build + +```bash +npm install +npm run build # builds all packages in dependency order +``` + +Packages must be built before integration tests run (tests import from `dist/`). diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/docs/connection-uri.md b/docs/connection-uri.md new file mode 100644 index 0000000..3194f4e --- /dev/null +++ b/docs/connection-uri.md @@ -0,0 +1,174 @@ +# Connection URI and key exchange + +A WizardConnect session starts with the dapp generating a connection URI. The wallet scans +this URI (typically as a QR code) and both sides use it to establish an encrypted channel. + +## URI format + +``` +wiz://?p=&s= +``` + +When using a non-default relay: + +``` +wiz://:?p=&s=&pr= +``` + +| Parameter | Encoding | Size | Meaning | +|-----------|----------|------|---------| +| `p` | bech32-padded | 32 bytes | Dapp's Nostr public key (x-only secp256k1) | +| `s` | bech32-padded | 8 bytes | Shared secret for key exchange verification | +| `hostname` | URL authority | — | Relay hostname (default: `relay.cauldron.quest`) | +| `port` | URL authority | — | Relay port (default: `443`) | +| `pr` | query param | — | `ws` or `wss` (default: `wss`, omitted when default) | + +The URI omits the authority entirely when relay defaults are used, keeping QR codes compact. + +### QR code encoding — alphanumeric mode + +QR codes support several character encodings. **Alphanumeric mode** encodes each character in +5.5 bits instead of 8 bits (byte mode), producing ~30 % smaller codes for the same data. + +The alphanumeric charset covers: `A–Z`, `0–9`, and the symbols `$`, `%`, `*`, `+`, `-`, `.`, +`/`, `:`, and space. + +The bech32 alphabet (`a–z`, `2–7`) maps entirely into this charset when uppercased — every +bech32 character is either a letter or a digit. The URI scheme characters (`WIZ`, `://`) and +port numbers are also fully alphanumeric-safe. + +However, the standard URI separators `?`, `=`, and `&` are **not** in the alphanumeric charset. +Their percent-encoded forms are safe: + +| Char | Encoding | All chars alphanumeric? | +|------|----------|------------------------| +| `?` | `%3F` | `%` ✅ `3` ✅ `F` ✅ | +| `=` | `%3D` | `%` ✅ `3` ✅ `D` ✅ | +| `&` | `%26` | `%` ✅ `2` ✅ `6` ✅ | + +`encodeKeyExchangeURI()` returns both a standard URI and a QR-safe URI: + +```typescript +const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret); +// uri → "wiz://?p=qpzry9x8...&s=qpzry9x8" (standard, for copy-paste) +// qrUri → "WIZ://%3FP%3DQPZRY9X8...%26S%3DQPZRY9X8" (QR alphanumeric-safe) +``` + +Use `uri` for display and clipboard copy. Use `qrUri` for QR code generation. + +`decodeKeyExchangeURI()` accepts both formats — it detects the QR encoding by looking for +`%3f` without a `?` and reverses the substitutions before parsing. Both uppercase and +lowercase variants are accepted. + +### Example URIs + +Default relay, standard format (as produced by `encodeKeyExchangeURI`): +``` +wiz://?p=qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce6mua7l&s=qpzry9x8 +``` + +QR-alphanumeric-safe format (use this for QR code generation): +``` +WIZ://%3FP%3DQPZRY9X8GF2TVD...%26S%3DQPZRY9X8 +``` + +Custom relay: +``` +wiz://my-relay.example.com:8443?p=qpzry9x8...&s=qpzry9x8&pr=wss +``` + +## Credentials + +The dapp generates a fresh keypair and a random 8-byte secret for each new connection: + +```typescript +interface KeyExchangeCredentials { + privateKey: string; // hex, 32 bytes — dapp's Nostr private key + publicKey: string; // hex, 32 bytes — derived from privateKey (x-only) + secret: string; // hex, 8 bytes — shared secret for MITM prevention +} +``` + +`generateKeyExchangeCredentials()` creates a fresh set. `encodeKeyExchangeURI(publicKey, secret)` +produces the scannable URI from them. + +### Reconnection + +A dapp can reconnect to an existing session by passing `existingCredentials: { privateKey, secret }` +to `initiateDappRelay()`. The wallet's public key is re-exchanged on reconnect. + +## Key exchange flow + +Once the wallet scans the URI, the following happens over the relay: + +``` +1. Wallet sends: wallet_ready { ..., public_key: , + secret: } + (wallet_ready carries both the application handshake data and the key exchange fields) + +2. Dapp receives wallet_ready: + - Verifies secret matches its own credentials.secret. + - Calls client.setPairedPublicKey(walletPublicKey). + - Fires keyexchangecomplete event. + - Processes the session data (xpubs) as usual. + - From this point, all outbound messages are encrypted to the wallet's pubkey. + +3. Both sides now have each other's Nostr public keys. + Subsequent messages are encrypted and filtered to the paired peer. +``` + +The key exchange data (`public_key`, `secret`) is embedded directly in `wallet_ready`. The wallet +sends a single message and the dapp learns the wallet's pubkey atomically with the session data. + +### Why a shared secret? + +The secret prevents a MITM or a curious third party from completing the key exchange with a +wallet using a stolen URI. The wallet must echo back the correct 8-byte secret; if it doesn't +match, the dapp ignores the `wallet_ready`. Eight bytes (64-bit) is enough entropy for +a short-lived pairing code — it is not a long-term secret. + +### Why Nostr keys? + +Nostr keys are secp256k1 keys, the same curve used in Bitcoin/BCH. Using a Nostr relay and NIP-17 +gift wrap gets us: + +- A widely-deployed, censorship-resistant message bus. +- End-to-end encryption with a simple, audited encryption scheme. +- No WizardConnect-operated server in the critical path — anyone can run a relay. + +The "Nostr x-only pubkey" format is just the 32-byte x-coordinate of the secp256k1 public key +(no 02/03 prefix). `deriveNostrPublicKey(privateKey)` computes this from a 32-byte private key. + +## Peer filtering + +After key exchange, `RelayClient` compares the `pubkey` field of every decrypted Nostr event +against the paired public key. Messages from any other pubkey are dropped with a warning log. +`wallet_ready` bypasses this filter because the dapp does not yet know the wallet's pubkey when +it first arrives — it is the message that *establishes* the pairing. + +## API surface + +```typescript +// Generate fresh credentials (called internally by initiateDappRelay) +generateKeyExchangeCredentials(): KeyExchangeCredentials + +// Encode a URI from an existing keypair — returns both standard and QR-safe forms +encodeKeyExchangeURI(publicKey: string, secret: string, options?): KeyExchangeURIResult + +interface KeyExchangeURIResult { + uri: string; // standard URI for copy-paste: wiz://?p=...&s=... + qrUri: string; // QR-alphanumeric-safe URI: WIZ://%3FP%3D...%26S%3D... +} + +// Decode and validate a wiz:// URI +// Accepts standard format, QR format (%3F/%3D/%26), uppercase, and mixed-case +decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI + +interface DecodedKeyExchangeURI { + publicKey: string; // hex, 32 bytes + secret: string; // hex, 8 bytes + hostname: string; + port: number; + protocol: "ws" | "wss"; +} +``` diff --git a/docs/dapp.md b/docs/dapp.md new file mode 100644 index 0000000..c7244c0 --- /dev/null +++ b/docs/dapp.md @@ -0,0 +1,214 @@ +# Dapp integration + +Dapp integration uses `@wizardconnect/dapp` (for session management and pubkey state) together +with `@wizardconnect/core` (for the relay connection and URI generation). + +## DappConnectionManager + +Manages a single dapp–wallet session. Handles the handshake, xpub storage, and sign request +round-trips. Most dapps only ever have one active session at a time. + +```typescript +class DappConnectionManager extends EventEmitter { + readonly pubkeyState: DappPubkeyStateManager; + + walletName: string | null; + walletIcon: string | null; + /** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */ + protocol: string | null; + + constructor(dappName?: string, dappIcon?: string) + + /** Call from the RelayStatusCallback each time the relay status changes. + * Attaches the message listener exactly once (on first client seen). + * Triggers onConnected() on each "connected" event. */ + updateConnection(client: RelayClient | null, status: RelayStatus): void + + isWalletDiscovered(): boolean + + /** Get the next sequence number for a SignTransactionRequest. */ + nextSequence(): number + + /** Send a sign request and wait for the wallet's response. + * Rejects if the wallet returns an error or if the connection drops. */ + sendSignRequest(request: SignTransactionRequest): Promise + + /** Send a UserDisconnect courtesy message to the wallet. + * Caller is responsible for calling dappRelay.cleanup() afterwards. */ + sendDisconnect(message?: string): Promise + + // Events + on("walletready", (msg: WalletReadyMessage) => void) + on("messagesent", (msg: ProtocolMessage) => void) + on("messagereceived", (msg: ProtocolMessage) => void) + on("disconnect", (reason: DisconnectReason, message: string | undefined) => void) +} +``` + +## Pubkey state — convenience delegation + +`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods +are also available directly on the manager: + +```typescript +// Get a pubkey (derives on demand from xpub if not cached) +getPubkey(childIndex: number, index: bigint): Uint8Array | undefined + +// Get all cached pubkeys for a path +getPubkeys(childIndex: number): Map + +// Get/set the current address index for a path +getAddressIndex(childIndex: number): bigint +setAddressIndex(childIndex: number, index: bigint): void + +// Get a smart "next index to use" (see pubkey-derivation.md) +getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint + +// Get the min/max indices seen for a path +getIndexRange(childIndex: number): { min?: bigint; max?: bigint } + +// Remove a used change address from the gap-fill queue +removeFromChangeQueue(index: bigint): void + +// Get the stored xpub node (after wallet_ready) +getXpubNode(childIndex: number): HdPublicNodeValid | undefined +``` + +Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are +internal to the dapp layer; use `childIndexOfPathName()` to convert from `PathName` if needed. + +## Session lifecycle + +### Initial connect + +```typescript +import { initiateDappRelay } from "@wizardconnect/core"; +import { DappConnectionManager } from "@wizardconnect/dapp"; + +const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png"); + +const relay = initiateDappRelay( + (payload) => { + dappMgr.updateConnection(payload.client, payload.status); + // also update your own UI state here (connected/disconnected indicator) + }, + { explicitRelayUrls: ["wss://relay.cauldron.quest:443"] }, +); + +// Show relay.uri as a QR code for the wallet to scan. +console.log("Scan this URI:", relay.uri); +``` + +### After wallet connects + +```typescript +dappMgr.on("walletready", (msg) => { + console.log("Wallet:", msg.wallet_name); + // pubkeyState is now populated with xpub nodes. + // You can start deriving addresses. +}); +``` + +### Deriving addresses + +```typescript +// Get the first receive address pubkey: +const RECEIVE = 0; +const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed +``` + +See [pubkey-derivation.md](pubkey-derivation.md) for full details. + +### Sending a sign request + +```typescript +const seq = dappMgr.nextSequence(); +const request: SignTransactionRequest = { + action: RelayMsgAction.SignTransactionRequest, + sequence: seq, + time: Math.floor(Date.now() / 1000), + transaction: { + transaction: { inputs, outputs, version: 2, locktime: 0 }, + sourceOutputs, + userPrompt: "Confirm swap", + broadcast: true, + }, +}; + +try { + const response = await dappMgr.sendSignRequest(request); + console.log("Signed tx:", response.signedTransaction); +} catch (err) { + console.error("Signing failed:", err.message); +} +``` + +`sendSignRequest` returns a Promise that resolves when the wallet sends back a +`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an +error response. + +### Disconnecting + +```typescript +// Dapp-initiated: send courtesy message, then tear down the relay +await dappMgr.sendDisconnect("user closed the tab"); +relay.cleanup(); + +// Listen for wallet-initiated disconnect or protocol mismatch: +dappMgr.on("disconnect", (reason, message) => { + if (reason === DisconnectReason.ProtocolMismatch) { + console.error("Protocol mismatch:", message); + } else { + console.log("Wallet disconnected:", reason, message); + } + relay.cleanup(); +}); +``` + +The `disconnect` event fires in two cases: +1. **Remote disconnect**: the wallet sent a `disconnect` message (any reason). +2. **Protocol mismatch**: `handleWalletReady` found no overlap between the dapp's and wallet's + `supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the + wallet before emitting the event. + +### Reconnection + +`updateConnection()` is called on every relay status change. When `status.status === "connected"`, +it calls `onConnected()` which waits for key exchange and then sends a fresh `dapp_ready`. The +`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager), +so the correct `wallet_discovered` value is sent on each reconnect. + +## Using initiateDappRelay without DappConnectionManager + +If you need lower-level control (e.g., in the test-cli), you can work directly with the +`RelayClient` and handle `wallet_ready` manually: + +```typescript +const relay = initiateDappRelay(statusCallback, options); + +relay.events.on("keyexchangecomplete", async (walletPubkey) => { + // Key exchange done — wait for relay client to be fully ready + while (!relay.client.isKeyExchangeComplete()) { + await sleep(50); + } + + relay.client.on("message", (msg) => { + if (isDappReadyMessage(msg)) { /* ... */ } + if (isWalletReadyMessage(msg)) { /* ... */ } + }); + + await relay.client.relay({ action: RelayMsgAction.DappReady, ... }); +}); +``` + +## Cauldron (cauldron-beta) implementation notes + +Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`. +This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface. + +Key design points: +- `DappConnectionManager` is created per connection session (not a singleton). +- `updateConnection()` is called from the relay status callback. +- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns. +- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName + when processing `wallet_ready` data. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8dfba94 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,79 @@ +# WizardConnect + +WizardConnect is a protocol and library for connecting dapps to HD cryptocurrency wallets over +an encrypted relay. A wallet shows a QR code; the dapp scans it. After that, the dapp can derive +any number of the wallet's public keys locally and request transaction signatures — no seed phrase +exposure, no trusted intermediary, minimal round-trips. + +## Design goals + +- **Private**: all relay messages are end-to-end encrypted (Nostr NIP-17 gift wrap). The relay sees + only ciphertext addressed to the recipient's Nostr public key. +- **Reconnection-proof**: both sides can disconnect and reconnect independently (mobile app switch, + browser refresh, relay drop) and converge back to a live session without user action. +- **Low-latency pubkey delivery**: the wallet sends BIP32 xpubs in the handshake; the dapp derives + all addresses locally with no further round-trips. +- **Protocol-agnostic paths**: the protocol identifies derivation paths by name (`receive`, `change`, + `defi`) rather than numeric child indices. The wallet chooses how it actually derives those xpubs. + +## Package structure + +``` +libwizardconnect/ +├── packages/core — @wizardconnect/core +│ Transport (Nostr relay, key exchange, encryption), +│ protocol message types and type guards. +│ +├── packages/wallet — @wizardconnect/wallet +│ WalletConnectionManager, WalletAdapter interface. +│ Multi-connection management, sign-request queuing. +│ +├── packages/dapp — @wizardconnect/dapp +│ DappConnectionManager, DappPubkeyStateManager. +│ Single-session dapp helper, on-demand xpub derivation. +│ +└── packages/test-cli — @wizardconnect/test-cli (private) + CLI for manual and exploratory testing. +``` + +The dependency graph is strictly one-directional: + +``` +test-cli → wallet, dapp → core +``` + +`core` has no knowledge of wallets or dapps; `wallet` and `dapp` have no knowledge of each other. + +## License + +WizardConnect is licensed under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.html) (LGPL-3.0-or-later). + +You may use, link against, and distribute WizardConnect in both open-source and proprietary +applications. Modifications to the library itself must be released under the same license. + +Every source file must carry the copyright header: + +```ts +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html +``` + +A linter enforces this: `npm run lint:copyright`. + +### Contributing + +All contributors must sign a Contributor License Agreement (CLA) before their contributions can be +accepted. The CLA assigns copyright to Whiterun LLC so that the project can be relicensed in the +future if needed. You will be prompted to sign the CLA when you open your first pull request. + +## Quick navigation + +| Topic | File | +|-------|------| +| Protocol messages and handshake | [protocol.md](protocol.md) | +| Connection URI and key exchange | [connection-uri.md](connection-uri.md) | +| Relay transport and encryption | [transport.md](transport.md) | +| Wallet integration guide | [wallet.md](wallet.md) | +| Dapp integration guide | [dapp.md](dapp.md) | +| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) | diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..0cad3ac --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,347 @@ +# Protocol + +The application-level protocol has two layers: + +- **Base protocol** — the handshake messages (`dapp_ready`, `wallet_ready`, `disconnect`) that are + shared across all application protocols and live in `@wizardconnect/core/protocols/base.ts`. +- **hdwalletv1** — the BCH HD-wallet protocol, carrying session data (xpubs) and + sign request round-trips. Defined in `@wizardconnect/core/protocols/hdwalletv1.ts`. + +Both layers use the same encrypted relay channel (see [transport.md](transport.md)). + +Protocol selection happens during the handshake via `supported_protocols` lists, not via a +hard-coded field. This allows forward-compatible negotiation when future protocol versions are added. + +## Message envelope + +Every message shares a base shape: + +```typescript +interface ProtocolMessage { + action: string; // one of the RelayMsgAction values below + time: number; // Unix timestamp (seconds). Used for replay filtering. +} +``` + +The `time` field is checked by the relay client: messages older than the last-processed timestamp +are silently dropped. This prevents stale messages buffered at the relay from being re-delivered +after a reconnect. + +## Actions + +``` +dapp_ready — dapp → wallet, signals dapp is alive + lists supported protocols +wallet_ready — wallet → dapp, signals wallet is alive + delivers session data + key exchange +sign_transaction_request — dapp → wallet, asks wallet to sign a transaction +sign_transaction_response — wallet → dapp, returns signed tx or error +sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request +disconnect — either → either, courtesy notification before tearing down +``` + +--- + +## Base protocol + +### dapp_ready + +```typescript +interface DappReadyMessage { + action: "dapp_ready"; + supported_protocols: string[]; // protocols this dapp supports, in preference order + selected_protocol?: string; // set only on the reactive dapp_ready (after seeing wallet_ready) + wallet_discovered: boolean; // true if dapp already saw this wallet this session + dapp_name?: string; // optional, sent on first message for wallet UI + dapp_icon?: string; // optional icon URL or data-URI + time: number; +} +``` + +`dapp_name` and `dapp_icon` are captured by the wallet on the first `dapp_ready` that includes +them. The wallet shows these in its connections list. + +`selected_protocol` is absent on the proactive `dapp_ready` (sent before the dapp has seen +the wallet). Once the dapp receives `wallet_ready` and picks a protocol, the reactive `dapp_ready` +carries `selected_protocol` so the wallet can confirm the agreed protocol. + +### wallet_ready + +```typescript +interface WalletReadyMessage { + action: "wallet_ready"; + supported_protocols: string[]; // protocols this wallet supports + wallet_name: string; + wallet_icon: string; + dapp_discovered: boolean; // true if wallet already saw this dapp this session + session: Record; // keyed by protocol name; each value is protocol-specific + public_key: string; // wallet's Nostr x-only pubkey (hex, 32 bytes) — key exchange + secret: string; // echo of the shared secret from the URI — MITM prevention + time: number; +} +``` + +`wallet_ready` is the most important message in the protocol. It serves two purposes: + +1. **Key exchange** — `public_key` is the wallet's Nostr pubkey; `secret` is echoed from the + connection URI for MITM prevention. The dapp verifies the secret and calls + `setPairedPublicKey(public_key)` before processing the rest of the message. This is why + `wallet_ready` bypasses the relay-client peer filter. + +2. **Application handshake** — the wallet populates `session` for every protocol it supports. + The dapp picks the first protocol from its own `supported_protocols` list that also appears + in the wallet's list, then reads `session[selectedProtocol]` for the protocol-specific data. + +### Protocol negotiation + +1. The dapp sends its `supported_protocols` list in the proactive `dapp_ready`. +2. The wallet replies with its own `supported_protocols` and the `session` map. +3. The dapp selects `agreed = dapp.supported_protocols.find(p => wallet.supported_protocols.includes(p))`. +4. If no overlap: the dapp sends `disconnect(reason: "protocol_mismatch")` and emits a `disconnect` + event. No further communication happens. +5. If agreed: the dapp reads `session[agreed]` and sends a reactive `dapp_ready` with + `selected_protocol = agreed`. + +### Handshake + +The handshake uses a **mutual-discovery** pattern. The goal is for both sides to converge to a +live session regardless of who reconnects first. "Discovered" means "I have received and processed +a ready message from the other side in this runtime session." + +#### Rules + +1. On every connect/reconnect, each side sends its own "ready" message proactively. The wallet + sends `wallet_ready` immediately (it already knows the dapp's pubkey from the URI). The dapp + sends `dapp_ready` once the relay is connected and key exchange resolves. +2. Each "ready" message carries a boolean indicating whether the sender has already seen the + other party (`wallet_discovered` in `dapp_ready`, `dapp_discovered` in `wallet_ready`). +3. On receiving a "ready" with the discovery flag `false`, the receiver must send back its own + "ready" — *even if it already sent one* — because the other side has lost state and needs + a fresh delivery. +4. The wallet guards against duplicate `wallet_ready` messages within a single connection cycle + via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect. + Receiving `dapp_ready(wallet_discovered=false)` also resets this flag. + +#### Scenarios + +**Initial connect (neither has seen the other):** + +``` +Dapp ──dapp_ready(supported=["hdwalletv1"], wallet_discovered=false)──▶ Wallet (proactive) +Dapp ◀──wallet_ready(supported=["hdwalletv1"], session={...}, dapp_discovered=false)── Wallet +Dapp ──dapp_ready(supported=["hdwalletv1"], selected="hdwalletv1", wallet_discovered=true)──▶ Wallet +``` + +After step 3 the wallet sets `dappDiscovered = true`. No more ready messages unless a reconnect. + +**Wallet reconnects (dapp still running, walletDiscovered=true):** + +``` +Dapp ──dapp_ready(wallet_discovered=true)──────────────────────────────▶ Wallet (proactive) +Dapp ◀──wallet_ready(dapp_discovered=false, session={...})────────────── Wallet (proactive) +Dapp ──dapp_ready(selected="hdwalletv1", wallet_discovered=true)────────▶ Wallet (reactive) +``` + +**Dapp reconnects (browser refresh, wallet still running):** + +``` +Dapp ──dapp_ready(wallet_discovered=false)──────────────────────────────▶ Wallet (proactive) +Dapp ◀──wallet_ready(dapp_discovered=true, session={...})─────────────── Wallet (reactive) +``` +(No third message: `dapp_discovered=true` means the dapp does not need to send a reactive reply.) + +#### Design decision: why mutual discovery? + +An alternative is a fixed initiator/responder role (only the dapp initiates). That breaks when the +wallet reconnects while the dapp is still alive — the wallet would wait for a dapp message that +never comes because the dapp thinks the session is live. Mutual discovery means each side sends a +"hello" on reconnect without depending on the other side's state. + +--- + +## hdwalletv1 protocol + +The `hdwalletv1` session data is carried in `wallet_ready.session["hdwalletv1"]`. It delivers +everything the dapp needs to derive an unlimited number of addresses without further contact with +the wallet. + +### Hdwalletv1Session + +```typescript +interface Hdwalletv1Session { + paths: PathXpub[]; // BIP32 xpubs for receive/change/defi +} +``` + +Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`. + +See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story. + +### PathXpub + +```typescript +interface PathXpub { + name: PathName; // "receive" | "change" | "defi" + xpub: string; // BIP32 base58-encoded extended public key +} +``` + +`name` is the protocol-level identifier. The dapp uses the name to know what kind of addresses to +derive from the xpub; it does not need to know (or care) where the wallet derived the xpub from. + +**Highly recommended derivation paths.** To ensure addresses are recognised by other wallets and +blockchain explorers, wallets should derive xpubs from the standard BIP44 paths for BCH: + +| Name | Recommended derivation path | Purpose | +|------|-----------------------------|---------| +| `receive` | `m/44'/145'/0'/0` | External receive addresses | +| `change` | `m/44'/145'/0'/1` | Internal change addresses | +| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses | + +Using these paths means the same addresses will appear in any BIP44-compatible wallet that holds +the same seed, making fund recovery straightforward. + +**Privacy-first alternative: any path per session.** The protocol does not enforce the recommended +paths. A wallet that prioritises privacy may derive xpubs from non-standard or randomly-chosen +paths, and may even rotate them each session. The dapp derives addresses correctly regardless — +it never sees the path, only the xpub. The trade-off is that funds sent to session-specific paths +will not be found by standard wallet recovery tools without additional metadata. + +**Design decision: names instead of child indices.** The protocol uses human-readable names rather +than numeric child indices because the derivation path is a wallet-internal detail. A name like +`"receive"` is stable and meaningful; the corresponding BIP44 index is an implementation concern +that only the wallet (and internal dapp state) need to know. + +### PathName + +```typescript +type PathName = "receive" | "change" | "defi"; +``` + +| Name | Recommended BIP44 path | Purpose | +|------|------------------------|---------| +| `receive` | `m/44'/145'/0'/0` | External receive addresses | +| `change` | `m/44'/145'/0'/1` | Internal change addresses | +| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses | + +### sign_transaction_request + +```typescript +interface SignTransactionRequest { + action: "sign_transaction_request"; + transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces + sequence: number; + time: number; +} +``` + +`sequence` is a unique number generated by `RelayClient.nextSequence()`. It starts at a random +offset (to avoid collisions across sessions) and increments by 2 per call. The dapp uses `sequence` +to match responses to requests. + +`WcSignTransactionRequest` describes a Bitcoin Cash transaction: inputs, outputs, source outputs +(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the +wallet UI. + +### sign_transaction_response + +```typescript +interface SignTransactionResponse { + action: "sign_transaction_response"; + sequence: number; + signedTransaction: string; // hex-encoded fully signed transaction + error?: string; // if present, signing failed; signedTransaction is "" + time: number; +} +``` + +The wallet either returns the signed transaction hex or an error string. The dapp rejects the +pending Promise associated with the `sequence` in the error case. + +### sign_cancel + +```typescript +interface SignCancelMessage { + action: "sign_cancel"; + sequence: number; // must match the sequence of the sign_transaction_request being cancelled + reason?: string; // optional human-readable explanation + time: number; +} +``` + +Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should +dismiss the corresponding sign dialog immediately upon receipt. + +Use cases: +- User presses cancel on the dapp side while waiting for the wallet to sign. +- Dapp replaces a stale request with a new one (e.g., trade price has changed). + +**Dapp side** (`DappConnectionManager`): +- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence, + then sends `sign_cancel` to the wallet. + +**Wallet side** (`WalletConnectionManager`): +- Incoming `sign_cancel` emits a `signCancelled` event (`connectionId`, `sequence`, `reason`). + The host app is responsible for dismissing the sign dialog. + +--- + +## disconnect + +Either side may send a `disconnect` message before tearing down the relay connection. This is a +courtesy notification — the remote side treats the connection as closed immediately upon receipt +(no acknowledgement). + +```typescript +enum DisconnectReason { + ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake + UserDisconnect = "user_disconnect", // explicit user or application action +} + +interface DisconnectMessage { + action: "disconnect"; + reason: DisconnectReason; + message?: string; // optional human-readable detail + time: number; +} +``` + +**Wallet side** (`WalletConnectionManager`): +- `disconnect(id)` sends `UserDisconnect` before cleaning up. +- Incoming `disconnect` emits a `remoteDisconnect` event + (`connectionId`, `reason`, `message`) and removes the connection. + +**Dapp side** (`DappConnectionManager`): +- `sendDisconnect(message?)` sends `UserDisconnect`. Caller then calls `dappRelay.cleanup()`. +- Protocol mismatch during `handleWalletReady` sends `ProtocolMismatch` and emits a `disconnect` + event (`reason`, `message`). +- Incoming `disconnect` emits a `disconnect` event. + +--- + +## Type guards + +`@wizardconnect/core` exports runtime type guards for all protocol messages: + +```typescript +isProtocolMessage(obj) → ProtocolMessage +isDappReadyMessage(obj) → DappReadyMessage +isWalletReadyMessage(obj) → WalletReadyMessage +isDisconnectMessage(obj) → DisconnectMessage +isHdwalletv1Session(obj) → Hdwalletv1Session +isPathXpub(obj) → PathXpub +isErrorMessage(obj) → ErrorMessage +isSignTransactionRequest(obj) → SignTransactionRequest +isSignCancelMessage(obj) → SignCancelMessage +``` + +These are used internally to validate incoming messages before dispatch. + +## Helper: childIndexOfPathName + +```typescript +function childIndexOfPathName(name: PathName): number +// "receive" → 0, "change" → 1, "defi" → 7 +``` + +The protocol uses string names for paths, but code that manages key state internally (such as +`DappPubkeyStateManager`) keys its maps by numeric child index. This helper converts between the +two representations. It is not a protocol concern — the numeric indices never appear on the wire. diff --git a/docs/pubkey-derivation.md b/docs/pubkey-derivation.md new file mode 100644 index 0000000..65db280 --- /dev/null +++ b/docs/pubkey-derivation.md @@ -0,0 +1,142 @@ +# xpub delivery and pubkey derivation + +One of the main design choices in `hdwalletv1` is that the wallet delivers BIP32 extended +public keys (xpubs) in `wallet_ready` and the dapp derives all addresses locally. This page +explains the design, the on-demand derivation model, and the change-address gap-fill logic. + +## Why xpubs instead of individual pubkeys + +### The old model (pre-xpub) + +Earlier relay protocols required the dapp to ask the wallet for pubkeys by sending `pubkey_batch` +requests, waiting for `pubkey_batch_response`, and tracking "subscribe_paths" subscriptions. This: + +- Added round-trips before the dapp could construct a transaction. +- Required the wallet to be online and responsive during transaction construction. +- Created complex state management for "have I received pubkeys for indices N…M?". + +### The xpub model + +The wallet sends one xpub per named path in `wallet_ready`. The dapp can then derive any child +pubkey `m//n` locally for any index `n` in O(1) with no network. BIP32 public-child +derivation is deterministic and requires no private key material. + +**Design decision: wallet chooses derivation path.** The xpub is delivered with a name +(`receive`, `change`, `defi`) but no information about the wallet's internal derivation path. +The dapp derives addresses correctly regardless — it never sees the path, only the xpub node. + +**Highly recommended: standard BIP44 paths.** For maximum interoperability — so that funds are +found by any BIP44-compatible wallet during seed recovery — wallets should use: + +| Name | Recommended path | +|------|-----------------| +| `receive` | `m/44'/145'/0'/0` | +| `change` | `m/44'/145'/0'/1` | +| `defi` | `m/44'/145'/0'/7` | + +**Privacy alternative: non-standard or rotating paths.** A wallet that prioritises privacy may +derive xpubs from arbitrary paths, or choose a fresh random derivation path each session. The +protocol carries only the xpub, so the dapp is unaffected. The trade-off is that standard +recovery tools won't find funds at non-standard paths without extra metadata. + +## DappPubkeyStateManager + +`DappPubkeyStateManager` (in `@wizardconnect/dapp`) is the dapp's local address book. + +### Storage + +``` +xpubNodes: Map — decoded xpub node per path +pubkeys: Map> — cached 33-byte compressed pubkeys +addressIndices: Map — current "next index" per path +changeAddressQueue: Set — gap-fill queue for change path +``` + +### On-demand derivation + +`getPubkey(childIndex, index)` checks the cache first. On a miss it derives the child key from +the xpub node: + +```typescript +const child = deriveHdPublicNodeChild(xpubNode, Number(index)); +// child.publicKey is the 33-byte compressed secp256k1 public key +``` + +The derived key is added to the cache so subsequent calls are instant. This means the dapp +never pre-fetches — it derives exactly the indices it needs, when it needs them. + +### getIndexToUse + +```typescript +getIndexToUse(childIndex, options?): bigint +``` + +Returns the "right" index for the next address on a path, with three cases in priority order: + +1. If `options.index` is provided, use it directly (caller knows exactly what they want). +2. If the path is change (`childIndex === 1`) and there are gap-fill entries, return the lowest + available gap address (see below). +3. Otherwise return `addressIndex + 1` (the next fresh index). + +With `options.reuseLast = true`, returns `addressIndex` instead of `+1` (for reusing the last +issued address, e.g., when re-displaying an invoice). + +### getIndexRange + +Returns the min and max indices currently cached for a path. Useful for knowing the spread of +addresses the dapp has worked with. + +## Change address gap-filling + +Change addresses are spend-once by convention. When a transaction is constructed but not yet +broadcast, the dapp allocates a change address. If the transaction is later abandoned, that +change index becomes a "gap" — it was never actually used on-chain, but the dapp has already +handed it out. Requesting a new fresh index from `addressIndex + 1` would skip the gap, wasting +an address and potentially causing the wallet to over-scan. + +### How it works + +`addPubkey(1, index, pubkey)` checks whether `index < currentAddressIndex`. If so, the address +is in the "past" — it was issued earlier but not yet used (a gap). It gets added to +`changeAddressQueue`. + +`getIndexToUse(1)` checks `changeAddressQueue` first and returns a gap address if one exists +and its pubkey is cached. The caller calls `removeFromChangeQueue(index)` once the address is +actually used in a broadcast transaction. + +This is primarily useful for dapps that optimistically allocate change addresses before confirming +broadcast. If the user cancels mid-flow, the gap address goes back into the queue. + +## BIP32 derivation depth + +The xpub delivered in `wallet_ready` is the path node — for example, the node at +`m/44'/145'/0'/0` for the receive path. Deriving child index `n` from it gives the same key as +deriving `m/44'/145'/0'/0/n` from the master key. The `/n` level is the address level (unhardened). + +BIP32 requires unhardened derivation for xpub child derivation (hardened derivation requires +the private key). Standard BIP44 address indices are always unhardened, so this works correctly. + +## Example: deriving receive addresses + +```typescript +// After wallet_ready: +const RECEIVE = 0; // childIndex for receive + +// Derive address at index 0: +const pubkey = dappMgr.getPubkey(RECEIVE, 0n); +// pubkey is a 33-byte Uint8Array (compressed secp256k1 public key) + +// Derive several addresses for display (no network needed): +for (let i = 0n; i < 5n; i++) { + const pk = dappMgr.getPubkey(RECEIVE, i); + // convert to cashaddr, P2PKH, etc. +} +``` + +## Integration test coverage + +`integration.test.ts` and `pubkeybatch.test.ts` (in `packages/wallet/src/integration/`) verify: + +- `wallet_ready` carries paths for all three named paths (receive, change, defi). +- Each xpub is valid base58 and decodes as a valid BIP32 node. +- Deriving child indices from the xpub matches `adapter.getPublicKey(path, index)` exactly. diff --git a/docs/transport.md b/docs/transport.md new file mode 100644 index 0000000..be40075 --- /dev/null +++ b/docs/transport.md @@ -0,0 +1,193 @@ +# Relay transport + +The relay layer handles everything below the application protocol: WebSocket connectivity, +message encryption, reconnection, and message queuing. Application code (wallet, dapp) should +never touch NDK or Nostr directly. + +## Stack + +``` +Application messages (hdwalletv1 JSON) + ↓ JSON.stringify +NDK PrivateDirectMessage (kind 14, rumor) + ↓ NIP-17 gift wrap (kind 1059, encrypted to recipient) +NDK → Nostr relay (WebSocket) + ↓ stored as kind 1059 event, tagged with recipient pubkey +NDK ← Nostr relay subscription (filter: kind=1059, #p=) + ↓ giftUnwrap → PrivateDirectMessage → JSON.parse +Application message handler +``` + +## RelayClient + +`RelayClient` is the low-level building block. One instance per peer connection. + +### Responsibilities + +- Owns an NDK instance configured with explicit relay URLs. +- Subscribes to `kind:1059` (GiftWrap) events tagged with its own pubkey. +- Decrypts and unwraps incoming events using NIP-17 `giftUnwrap`. +- Publishes outbound messages using `giftWrap` addressed to the paired peer. +- Filters incoming messages by peer pubkey (rejects messages from unknown senders). +- Discards messages older than `lastProcessedTimestamp` (replay protection). +- Queues outbound messages until at least one relay is ready. + +### Construction + +```typescript +new RelayClient({ + explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"] + signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity) + pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange) + logNetworkActivity?: boolean; // default true +}) +``` + +### Key methods + +```typescript +connect(): Promise + // NDK connect, subscribe to GiftWrap events, start waiting for relays. + +disconnect(): Promise + // Stop subscription, mark queue not-ready, update lastProcessedTimestamp. + +relay(message: ProtocolMessage): Promise + // Send a message. Enqueues if relays not ready. Throws if paired key not set. + +setPairedPublicKey(key: Uint8Array): void + // Called after key exchange. Enables outbound messages and incoming peer filtering. + +isKeyExchangeComplete(): boolean + // true once pairedPublicKey is set and non-zero. + +nextSequence(): number + // Returns a unique sequence number (starts at random offset, increments by 2). + +getLastProcessedTimestamp() / setLastProcessedTimestamp(ts): void + // Persist/restore across reconnects to avoid re-delivering buffered messages. +``` + +### Message queue + +Before any relay is confirmed as connected, outbound `relay()` calls are enqueued in a +`MessageQueue`. The client polls every 100 ms for up to 5 seconds for at least one relay with +status 1 (connected). After that timeout it assumes the relay is ready and flushes the queue +regardless (avoiding silent message loss on slow connections). + +On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are +held rather than dropped. + +### Replay protection + +`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated +to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimestamp` is silently +dropped. This prevents the relay from re-delivering messages that were already handled before a +disconnect. + +### Sequence numbers + +`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This +means two instances are unlikely to collide (they start at different offsets), and the step of 2 +leaves room for error responses at odd offsets if needed in the future. + +## initiateRelay + +`initiateRelay()` (in `relay-handler.ts`) wraps `RelayClient` with a reconnect loop: + +```typescript +initiateRelay( + statusCallback: RelayStatusCallback, + privateKey: Uint8Array, + initialPairedKey: Uint8Array, + options: { explicitRelayUrls, reconnectInterval?, maxReconnectAttempts? } +): () => void // returns cleanup function +``` + +It calls `statusCallback` with a `RelayUpdatePayload` on every state change: + +```typescript +interface RelayUpdatePayload { + client: RelayClient; + status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" } +} +``` + +The reconnect loop is simple: on a disconnect event from the client it waits +`reconnectInterval` (default 5000 ms) and calls `client.connect()` again. No exponential backoff +currently — connections are expected to be stable (relay and mobile network) and fast to re-establish. + +## initiateDappRelay + +Higher-level helper that bundles credential generation, URI encoding, key exchange handling, +and reconnection into one call: + +```typescript +initiateDappRelay( + statusCallback: RelayStatusCallback, + options?: { + explicitRelayUrls?: string[]; + reconnectInterval?: number; + maxReconnectAttempts?: number; + existingCredentials?: { privateKey: string; secret: string }; + } +): DappRelayResult + +interface DappRelayResult { + client: RelayClient; + uri: string; // wiz:// URI to encode into QR + credentials: KeyExchangeCredentials; + events: EventEmitter<{ keyexchangecomplete: [walletPublicKey: Uint8Array] }>; + cleanup: () => void; +} +``` + +Internally it wraps the caller's `statusCallback` to intercept `wallet_ready` messages, +verify the secret, and call `client.setPairedPublicKey()` before the message is processed by +`DappConnectionManager`. On reconnect, if `walletPublicKeyNostr` is already known it immediately +restores the paired key so outbound messages can be sent before the next `wallet_ready` arrives. + +## initiateWalletRelay + +```typescript +initiateWalletRelay( + statusCallback: RelayStatusCallback, + options: { + uri: string; // wiz:// URI from QR scan + walletPrivateKey: Uint8Array; // wallet's relay identity key (may be ephemeral) + } +): { cleanup: () => void } +``` + +Decodes the URI, sets the dapp's public key as the initial paired key, and starts the relay. +On each connect/reconnect, the wallet sends `wallet_ready` which carries the key exchange fields +(`public_key`, `secret`) alongside the session data. This gives the dapp the wallet's pubkey +atomically with the application handshake data. + +## Encryption: NIP-17 gift wrap + +NIP-17 gift wrap works like a sealed envelope: + +1. **Rumor** (kind 14, `PrivateDirectMessage`): the plaintext message, signed by the sender. + Content is the JSON payload. Tags include `["p", recipient_pubkey_hex]`. +2. **Seal** (kind 13): the rumor encrypted with a shared ECDH secret derived from sender's + private key and recipient's public key. No `p` tag — unlinkable to sender/recipient. +3. **Wrap** (kind 1059, `GiftWrap`): the seal encrypted to the recipient's public key using a + freshly generated throwaway key. Has a `["p", recipient_pubkey_hex]` tag so the relay can + route it to the right subscription. + +The relay sees only kind 1059 with a recipient tag. It cannot read content or link messages to +senders. The throwaway outer key means even the relay cannot correlate multiple messages from +the same sender. + +NDK handles all three layers in `giftWrap()` / `giftUnwrap()`. + +## Default relay + +``` +wss://relay.cauldron.quest:443 +``` + +This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other +standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always +use the same relay without out-of-band coordination. diff --git a/docs/wallet.md b/docs/wallet.md new file mode 100644 index 0000000..84fd6bc --- /dev/null +++ b/docs/wallet.md @@ -0,0 +1,212 @@ +# Wallet integration + +Wallet integration uses `@wizardconnect/wallet`. The wallet implements the `WalletAdapter` +interface and hands it to `WalletConnectionManager`, which handles everything else. + +## WalletAdapter + +```typescript +interface WalletAdapter { + walletName: string; // shown in the dapp's connection UI + walletIcon: string; // URL or data-URI, shown in the dapp's connection UI + + /** + * Return the relay identity private key for this session. + * May be ephemeral (random per session) or stable (HD-derived) — both work. + * The dapp learns the wallet's public key from the wallet_ready message, + * so stability across restarts is not required. + */ + getRelayPrivateKey(): Uint8Array; + + /** Returns the compressed 33-byte secp256k1 public key at path/index. */ + getPublicKey(path: DerivationPath, index: bigint): Uint8Array; + + /** Returns the BIP32 base58-encoded xpub for the given derivation path. + * The dapp derives all addresses from this — no further pubkey requests needed. */ + getXpub(path: DerivationPath): string; + + /** Sign the transaction. May show approval UI to the user. + * Called when the wallet has received and validated a sign_transaction_request. */ + signTransaction(request: SignTransactionRequest): Promise; +} +``` + +### DerivationPath + +```typescript +enum DerivationPath { + Receive = 0, // m/44'/145'/0'/0 — external (receive) addresses + Change = 1, // m/44'/145'/0'/1 — internal (change) addresses + Cauldron = 7, // m/44'/145'/0'/7 — DeFi/Cauldron addresses +} +``` + +The numeric values are wallet-internal; the protocol uses names (`receive`, `change`, `defi`). +`childIndexOfPath(path)` and `pathOfChildIndex(index)` convert between them. + +### SignTransactionResult + +```typescript +interface SignTransactionResult { + signedTransactionHex: string; +} +``` + +## WalletConnectionManager + +```typescript +class WalletConnectionManager extends EventEmitter { + constructor(adapter: WalletAdapter) + + // Connect to a dapp. Returns a stable connection ID. + // If a connection for this URI already exists, returns the existing ID. + connect(uri: string): string + + // Tear down a specific connection, sending a UserDisconnect courtesy message. + disconnect(connectionId: string): void + + // Tear down all connections. + disconnectAll(): void + + // Snapshot of all connections for UI rendering. + getConnections(): Record + + // Send the signed transaction back to the dapp. + sendSignResponse(connectionId: string, sequence: number, signedTx: string): Promise + + // Send an error back to the dapp (user rejected, signing failed, etc.) + sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise + + // Events + on("connectionStatusChanged", (id: string, status: RelayStatus) => void) + on("pendingSignRequest", (req: PendingSignRequest) => void) + on("connectionsChanged", () => void) + on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void) +} +``` + +### RelayConnectionState + +```typescript +interface RelayConnectionState { + id: string; + uri: string; + status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" } + label: string; // dapp name once known, otherwise "Connecting..." + dappName: string | null; + dappIcon: string | null; + connectedAt: number; // Unix ms +} +``` + +### PendingSignRequest + +```typescript +interface PendingSignRequest { + connectionId: string; + request: SignTransactionRequest; +} +``` + +## Connection lifecycle + +### connect() + +1. A unique `connectionId` is generated. +2. `initiateWalletRelay(statusCallback, { uri, walletPrivateKey })` is called. +3. The relay decodes the URI, extracts the dapp's public key and secret, and connects. +4. On the first `"connected"` status, `onConnected()` is called. + +### onConnected() + +1. `walletReadySentThisCycle` is reset to `false`. +2. A notification processor interval is started (1 second, for retry on send errors). +3. The wallet polls until `client.isKeyExchangeComplete()` (key exchange with dapp done). +4. `pushWalletReady()` is called. + +### pushWalletReady() + +Sends `wallet_ready` with: +- `supported_protocols: ["hdwalletv1"]` +- `wallet_name`, `wallet_icon` from the adapter. +- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi). +- `dapp_discovered`: whether the dapp was seen in this runtime session. + +The message is pushed to a per-connection `notificationQueue` and flushed immediately. Retry +is handled by the interval processor — if `relay()` throws (e.g. network drop), the message +stays in the queue and is retried on the next tick. + +### Receiving dapp_ready + +``` +wallet_discovered=false → reset walletReadySentThisCycle, call pushWalletReady() again +wallet_discovered=true → set dappDiscovered=true, no further action +``` + +`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them. + +### Receiving disconnect + +When a `disconnect` message arrives from the dapp: +1. The `remoteDisconnect` event is emitted with `(connectionId, reason, message)`. +2. The connection is cleaned up without sending a reply disconnect. + +### Receiving sign_transaction_request + +The wallet emits `pendingSignRequest` with the `connectionId` and the full request. The host +application is responsible for: + +1. Queueing or displaying the request. +2. Getting user approval. +3. Calling `sendSignResponse(connectionId, sequence, signedTxHex)` or + `sendSignError(connectionId, sequence, errorMessage)`. + +The wallet library does not auto-sign or auto-reject anything. + +## Minimal example + +```typescript +import { WalletConnectionManager } from "@wizardconnect/wallet"; +import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet"; + +class MyAdapter implements WalletAdapter { + walletName = "My Wallet"; + walletIcon = ""; + + getRelayPrivateKey() { return crypto.getRandomValues(new Uint8Array(32)); } + getPublicKey(path: DerivationPath, index: bigint) { /* ... */ } + getXpub(path: DerivationPath) { /* ... */ } + + async signTransaction(request) { + // show approval UI, sign, return hex + return { signedTransactionHex: "..." }; + } +} + +const manager = new WalletConnectionManager(new MyAdapter()); + +// When user scans a QR code: +const connId = manager.connect("wiz://?p=...&s=..."); + +// When a sign request arrives: +manager.on("pendingSignRequest", async ({ connectionId, request }) => { + try { + const result = await showApprovalUI(request); + await manager.sendSignResponse(connectionId, request.sequence, result.signedTransactionHex); + } catch { + await manager.sendSignError(connectionId, request.sequence, "User rejected"); + } +}); + +// When the dapp disconnects: +manager.on("remoteDisconnect", (id, reason, message) => { + console.log(`Dapp disconnected: ${reason}`, message); + store.dispatch(setConnections(manager.getConnections())); +}); + +// For Redux / UI updates: +manager.on("connectionsChanged", () => { + store.dispatch(setConnections(manager.getConnections())); +}); +``` + diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..9bde924 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,37 @@ +const js = require("@eslint/js"); +const globals = require("globals"); +const parser = require("@typescript-eslint/parser"); +const plugin = require("@typescript-eslint/eslint-plugin"); + +module.exports = [ + { + ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + globals: { + ...globals.browser, + ...globals.node, + ...globals.es2022, + }, + }, + plugins: { + "@typescript-eslint": plugin, + }, + rules: { + ...js.configs.recommended.rules, + "no-console": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_" }, + ], + }, + }, +]; diff --git a/linters/copyright_check.mjs b/linters/copyright_check.mjs new file mode 100644 index 0000000..225addc --- /dev/null +++ b/linters/copyright_check.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, dirname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const OUR_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(OUR_DIR, ".."); + +const REQUIRED_LINES = [ + "This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.", + "A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html", +]; + +// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC" +const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/; + +const EXTENSIONS = [".ts", ".mjs"]; +const DIRECTORIES = ["packages", "linters"]; + +function walk(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + if (entry === "node_modules" || entry === "dist") continue; + results.push(...walk(full)); + } else if (EXTENSIONS.some((ext) => entry.endsWith(ext))) { + results.push(full); + } + } + return results; +} + +let missing = 0; + +for (const directory of DIRECTORIES) { + const dirPath = join(ROOT, directory); + for (const filePath of walk(dirPath)) { + const content = readFileSync(filePath, "utf-8"); + const hasRequired = REQUIRED_LINES.every((line) => content.includes(line)); + const hasCopyright = COPYRIGHT_PATTERN.test(content); + if (!hasRequired || !hasCopyright) { + console.log(`${relative(ROOT, filePath)}: Missing`); + missing++; + } + } +} + +if (missing) { + console.log(`${missing} file(s) are missing copyright headers`); + process.exit(1); +} else { + console.log("OK"); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4b255a3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4164 @@ +{ + "name": "wizardconnect", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wizardconnect", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^10.0.1", + "globals": "^17.3.0", + "prettier": "^3.2.5", + "typescript-eslint": "^8.56.0" + } + }, + "node_modules/@bch-wc2/interfaces": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@bch-wc2/interfaces/-/interfaces-0.0.8.tgz", + "integrity": "sha512-gRt95azHVqoViGI4X3F1ObgTpYBh3//KaCHRBWSMJbAKNM09T+TA/h9kx9u9SZmE8otMtpFSy4awSHWHUQZmbg==", + "license": "MIT", + "peerDependencies": { + "@bitauth/libauth": "^3.1.0-next.2" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@codesandbox/nodebox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@codesandbox/nodebox/-/nodebox-0.1.8.tgz", + "integrity": "sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==", + "license": "SEE LICENSE IN ./LICENSE", + "dependencies": { + "outvariant": "^1.4.0", + "strict-event-emitter": "^0.4.3" + } + }, + "node_modules/@codesandbox/sandpack-client": { + "version": "2.19.8", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.19.8.tgz", + "integrity": "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==", + "license": "Apache-2.0", + "dependencies": { + "@codesandbox/nodebox": "0.1.8", + "buffer": "^6.0.3", + "dequal": "^2.0.2", + "mime-db": "^1.52.0", + "outvariant": "1.4.0", + "static-browser-server": "1.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", + "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.2", + "debug": "^4.3.1", + "minimatch": "^10.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz", + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.18.1.tgz", + "integrity": "sha512-LTXXheGfmyN1y8x+8v/Dmkx8YX7LqaoVk0DTSaigETB5RZsxw7dLBKK++kZd4DVIxtj0tRfmSOsTr1E+M4653Q==", + "license": "MIT", + "dependencies": { + "@codesandbox/sandpack-client": "^2.19.8", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.7", + "light-bolt11-decoder": "^3.2.0", + "shiki": "^3.13.0", + "tseep": "^1.3.1", + "typescript-lru-cache": "^2.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "nostr-tools": "^2.17.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wizardconnect/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@wizardconnect/dapp": { + "resolved": "packages/dapp", + "link": true + }, + "node_modules/@wizardconnect/test-cli": { + "resolved": "packages/test-cli", + "link": true + }, + "node_modules/@wizardconnect/wallet": { + "resolved": "packages/wallet", + "link": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.1", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-bolt11-decoder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", + "dependencies": { + "@scure/base": "1.1.1" + } + }, + "node_modules/light-bolt11-decoder/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lossless-json": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", + "integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nostr-tools": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.1.tgz", + "integrity": "sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==", + "license": "Unlicense", + "peer": true, + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT", + "peer": true + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/static-browser-server": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz", + "integrity": "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==", + "license": "Apache-2.0", + "dependencies": { + "@open-draft/deferred-promise": "^2.1.0", + "dotenv": "^16.0.3", + "mime-db": "^1.52.0", + "outvariant": "^1.3.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-lru-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz", + "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/core": { + "name": "@wizardconnect/core", + "version": "0.1.0", + "dependencies": { + "@bch-wc2/interfaces": "^0.0.8", + "@bitauth/libauth": "^3.1.0-next.2", + "@nostr-dev-kit/ndk": "^2.18.1", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "lossless-json": "^4.3.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } + }, + "packages/dapp": { + "name": "@wizardconnect/dapp", + "version": "0.1.0", + "dependencies": { + "@wizardconnect/core": "*", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } + }, + "packages/test-cli": { + "name": "@wizardconnect/test-cli", + "version": "0.1.0", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.2", + "@wizardconnect/core": "*", + "@wizardconnect/wallet": "*", + "chalk": "^5.3.0", + "commander": "^12.0.0", + "ora": "^8.0.0" + }, + "bin": { + "wiz-test": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.2" + } + }, + "packages/wallet": { + "name": "@wizardconnect/wallet", + "version": "0.1.0", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.2", + "@wizardconnect/core": "*", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a128a7a --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "wizardconnect", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "npm run build --workspaces", + "test": "npm run test --workspaces --if-present", + "test:integration": "npm run test:integration --workspaces --if-present", + "dapp": "npm run dapp --workspace @wizardconnect/test-cli", + "wallet": "npm run wallet --workspace @wizardconnect/test-cli", + "docs:serve": "zensical serve", + "docs:build": "zensical build", + "lint:copyright": "node linters/copyright_check.mjs", + "lint:prettier": "npm run lint:prettier --workspaces --if-present", + "lint:eslint": "npm run lint:eslint --workspaces --if-present", + "lint": "npm run lint:eslint && npm run lint:prettier", + "fix": "npm run fix --workspaces --if-present", + "fix:prettier": "npm run fix:prettier --workspaces --if-present", + "fix:eslint": "npm run fix:eslint --workspaces --if-present" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^10.0.1", + "globals": "^17.3.0", + "prettier": "^3.2.5", + "typescript-eslint": "^8.56.0" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..051b124 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wizardconnect/core", + "version": "0.1.0", + "type": "module", + "description": "Transport and protocol primitives for WizardConnect", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsc", + "test": "vitest --config vitest.config.ts --run --passWithNoTests", + "lint:prettier": "prettier . --list-different", + "lint:eslint": "eslint .", + "lint": "npm run lint:eslint && npm run lint:prettier", + "fix": "npm run fix:eslint && npm run fix:prettier", + "fix:prettier": "prettier . --write", + "fix:eslint": "npm run lint:eslint -- --fix" + }, + "dependencies": { + "@bch-wc2/interfaces": "^0.0.8", + "@nostr-dev-kit/ndk": "^2.18.1", + "@bitauth/libauth": "^3.1.0-next.2", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "lossless-json": "^4.3.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } +} diff --git a/packages/core/src/connection-manager.ts b/packages/core/src/connection-manager.ts new file mode 100644 index 0000000..58ce2cb --- /dev/null +++ b/packages/core/src/connection-manager.ts @@ -0,0 +1,228 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { debug, Scope, LogScope } from "./log.js"; +import { EventEmitter } from "eventemitter3"; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface VisibilityChangeContext { + client: TClient; + state: "hidden" | "visible"; + setPaused: (_value: boolean) => void; + isPaused: () => boolean; + startConnectionLoop: () => void; +} + +export interface ConnectionManagerOptions< + TClient extends ConnectionClient = ConnectionClient, +> { + reconnectInterval?: number; + maxReconnectAttempts?: number; + enableVisibilityHandling?: boolean; + scope?: LogScope; + onVisibilityChange?: ( + _context: VisibilityChangeContext, + ) => void | Promise; +} + +export interface ConnectionClient extends EventEmitter { + connect(): Promise; + disconnect(..._args: any[]): Promise; +} + +export interface ConnectionManagerResult { + cleanup: () => Promise; + startConnectionLoop: () => void; +} + +export const createConnectionManager = ( + client: TClient, + callbacks: { + onConnected: (_client: TClient) => void; + onReconnecting: (_client: TClient, _reason: string | null) => void; + onDisconnected: (_client: TClient) => void; + onError?: (_client: TClient, _error: any) => void; + }, + events: { + connected: string; + disconnected: string; + error?: string; + }, + options: ConnectionManagerOptions = {}, +): ConnectionManagerResult => { + const { + reconnectInterval = 5000, + maxReconnectAttempts = Infinity, + enableVisibilityHandling = true, + scope = Scope.Network, + onVisibilityChange, + } = options; + + let isPaused = false; + let reconnectLoop: Promise | null = null; + let visibilityChangeHandler: (() => void) | null = null; + + const triggerReconnect = (reason: string | null) => { + if (!isPaused) { + callbacks.onReconnecting(client, reason); + reconnectLoop = null; + startConnectionLoop(); + } + }; + + const onConnected = () => callbacks.onConnected(client); + client.on(events.connected, onConnected); + + const onDisconnected = (...args: any[]) => { + const err = args[0] instanceof Error ? args[0].message : null; + debug(scope, "Disconnected event received", err); + triggerReconnect(err); + }; + + const onError = (err: any) => { + const errorMsg = err?.message || String(err); + debug(scope, "Error event received", errorMsg); + triggerReconnect(errorMsg); + if (callbacks.onError) { + callbacks.onError(client, err); + } + }; + + client.on(events.disconnected, onDisconnected); + if (events.error) { + client.on(events.error, onError); + } + + const setupVisibilityHandling = () => { + if (typeof document === "undefined") { + return; + } + + visibilityChangeHandler = () => { + const state = document.visibilityState as "hidden" | "visible"; + + if (onVisibilityChange) { + const context: VisibilityChangeContext = { + client, + state, + setPaused: (value: boolean) => { + isPaused = value; + }, + isPaused: () => isPaused, + startConnectionLoop, + }; + (async () => { + try { + await onVisibilityChange(context); + } catch (err) { + debug(scope, "Error in custom visibility change handler:", err); + } + })(); + return; + } + + if (state === "hidden") { + isPaused = true; + (async () => { + try { + await client.disconnect(); + callbacks.onDisconnected(client); + } catch (err) { + debug(scope, "Error disconnecting on visibility change:", err); + } + })(); + } else if (state === "visible") { + if (isPaused) { + isPaused = false; + startConnectionLoop(); + } + } + }; + + document.addEventListener("visibilitychange", visibilityChangeHandler); + }; + + const startConnectionLoop = () => { + if (reconnectLoop) { + return; + } + + reconnectLoop = (async () => { + let reconnectAttempts = 0; + let wasConnected = false; + + while (true) { + if (isPaused) { + await sleep(1000); + continue; + } + + try { + await client.connect(); + // onConnected is fired via the "connection" event emitted inside client.connect() + // — do NOT call callbacks.onConnected here too, that would double-fire it. + reconnectAttempts = 0; + wasConnected = true; + reconnectLoop = null; + return; + } catch (e) { + reconnectAttempts++; + + if (wasConnected || reconnectAttempts === 1) { + callbacks.onReconnecting(client, `${e}`); + wasConnected = false; + } + + if (reconnectAttempts > maxReconnectAttempts) { + callbacks.onDisconnected(client); + reconnectLoop = null; + return; + } + + try { + await client.disconnect(); + } catch (disconnectError) { + debug(scope, "Failed to disconnect client", disconnectError); + } + + await sleep(reconnectInterval); + } + } + })(); + }; + + if (enableVisibilityHandling) { + setupVisibilityHandling(); + } + + const cleanup = async () => { + isPaused = true; + + if (typeof document !== "undefined" && visibilityChangeHandler !== null) { + document.removeEventListener("visibilitychange", visibilityChangeHandler); + } + + client.off(events.connected, onConnected); + client.off(events.disconnected, onDisconnected); + if (events.error) { + client.off(events.error, onError); + } + + callbacks.onDisconnected(client); + + try { + await client.disconnect(); + } catch (e) { + debug(scope, "Failed to disconnect client during cleanup", e); + } + }; + + return { + cleanup, + startConnectionLoop, + }; +}; diff --git a/packages/core/src/dapp-relay.ts b/packages/core/src/dapp-relay.ts new file mode 100644 index 0000000..9dc92ce --- /dev/null +++ b/packages/core/src/dapp-relay.ts @@ -0,0 +1,189 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { RelayClient } from "./relay-client.js"; +import { + RelayUpdatePayload, + RelayStatusCallback, + initiateRelay, +} from "./relay-handler.js"; +import { RelayMsgAction, ProtocolMessage } from "./protocols/hdwalletv1.js"; +import { WalletReadyMessage } from "./protocols/base.js"; +import { + generateKeyExchangeCredentials, + encodeKeyExchangeURI, + KeyExchangeCredentials, + DEFAULT_RELAY_HOSTNAME, + DEFAULT_RELAY_PORT, + DEFAULT_RELAY_PROTOCOL, +} from "./key-exchange.js"; +import { hexToBin, binToHex } from "@bitauth/libauth"; +import { EventEmitter } from "eventemitter3"; +import { deriveNostrPublicKey } from "./utilnostr.js"; +import { error as logError, warn, Scope } from "./log.js"; + +export interface DappRelayOptions { + explicitRelayUrls?: string[]; + reconnectInterval?: number; + maxReconnectAttempts?: number; + existingCredentials?: { + privateKey: string; + secret: string; + }; +} + +export interface DappRelayResult { + client: RelayClient; + uri: string; + qrUri: string; + credentials: KeyExchangeCredentials; + events: EventEmitter<{ + keyexchangecomplete: [walletPublicKey: Uint8Array]; + }>; + cleanup: () => void; +} + +export function initiateDappRelay( + statusCallback: RelayStatusCallback, + options: DappRelayOptions = {}, +): DappRelayResult { + const events = new EventEmitter<{ + keyexchangecomplete: [walletPublicKey: Uint8Array]; + }>(); + + let credentials: KeyExchangeCredentials; + let dappPrivateKey: Uint8Array; + + if (options.existingCredentials) { + const privateKeyHex = options.existingCredentials.privateKey; + if (privateKeyHex.length !== 64) { + throw new Error("Private key must be 64 hex characters (32 bytes)"); + } + dappPrivateKey = hexToBin(privateKeyHex); + const dappPublicKeyHex = deriveNostrPublicKey(dappPrivateKey); + + credentials = { + privateKey: privateKeyHex, + publicKey: dappPublicKeyHex, + secret: options.existingCredentials.secret, + }; + } else { + credentials = generateKeyExchangeCredentials(); + dappPrivateKey = hexToBin(credentials.privateKey); + } + + let uriOptions: { + hostname?: string; + port?: number; + protocol?: "ws" | "wss"; + } = {}; + if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) { + const relayUrl = options.explicitRelayUrls[0]; + const hostMatch = relayUrl.match(/^wss?:\/\/([^:/]+)/); + const portMatch = relayUrl.match(/:(\d+)/); + if (hostMatch) { + uriOptions.hostname = hostMatch[1]; + } + if (portMatch) { + uriOptions.port = parseInt(portMatch[1], 10); + } + if (relayUrl.startsWith("wss://")) { + uriOptions.protocol = "wss"; + } else if (relayUrl.startsWith("ws://")) { + uriOptions.protocol = "ws"; + } + } + const { uri, qrUri } = encodeKeyExchangeURI( + credentials.publicKey, + credentials.secret, + uriOptions, + ); + + let relayClient: RelayClient | null = null; + let keyExchanged = false; + let walletPublicKeyNostr: Uint8Array | null = null; + + const wrappedCallback: RelayStatusCallback = ( + payload: RelayUpdatePayload, + ) => { + if (!relayClient) { + relayClient = payload.client; + + relayClient.on("message", async (message: ProtocolMessage) => { + if (message.action === RelayMsgAction.WalletReady) { + const walletReady = message as WalletReadyMessage; + + if (walletReady.secret !== credentials.secret) { + if (!keyExchanged) { + logError(Scope.Relay, "Key exchange failed: secret mismatch"); + } + return; + } + + const receivedWalletKey = hexToBin(walletReady.public_key); + if (receivedWalletKey.length !== 32) { + logError(Scope.Relay, "Invalid wallet public key length"); + return; + } + + if (keyExchanged && walletPublicKeyNostr) { + if ( + binToHex(receivedWalletKey) !== binToHex(walletPublicKeyNostr) + ) { + warn( + Scope.Relay, + "Different wallet connected (different public key)", + ); + } + } + + walletPublicKeyNostr = receivedWalletKey; + relayClient!.setPairedPublicKey(receivedWalletKey); + + if (!keyExchanged) { + keyExchanged = true; + events.emit("keyexchangecomplete", receivedWalletKey); + } + // NOTE: do NOT return — message also propagates to DappConnectionManager's listener + } + }); + } + + if (payload.status.status === "connected") { + if (walletPublicKeyNostr && relayClient) { + relayClient.setPairedPublicKey(walletPublicKeyNostr); + keyExchanged = true; + } + } + + statusCallback(payload); + }; + + const relayUrls = + options.explicitRelayUrls && options.explicitRelayUrls.length > 0 + ? options.explicitRelayUrls + : [ + `${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`, + ]; + + const cleanup = initiateRelay( + wrappedCallback, + dappPrivateKey, + new Uint8Array(33), + { + explicitRelayUrls: relayUrls, + reconnectInterval: options.reconnectInterval, + maxReconnectAttempts: options.maxReconnectAttempts, + }, + ); + + return { + client: relayClient!, + uri, + qrUri, + credentials, + events, + cleanup, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..0bab2ee --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +export { RelayClient } from "./relay-client.js"; +export type { RelayClientConfig } from "./relay-client.js"; +export { RelayStatus, initiateRelay } from "./relay-handler.js"; +export type { + RelayUpdatePayload, + RelayStatusCallback, +} from "./relay-handler.js"; +export { initiateDappRelay } from "./dapp-relay.js"; +export type { DappRelayOptions, DappRelayResult } from "./dapp-relay.js"; +export { initiateWalletRelay } from "./wallet-relay.js"; +export type { WalletRelayOptions, WalletRelayResult } from "./wallet-relay.js"; +export { + generateKeyExchangeCredentials, + encodeKeyExchangeURI, + decodeKeyExchangeURI, + DEFAULT_RELAY_HOSTNAME, + DEFAULT_RELAY_PORT, + DEFAULT_RELAY_PROTOCOL, +} from "./key-exchange.js"; +export type { + KeyExchangeCredentials, + DecodedKeyExchangeURI, + KeyExchangeURIOptions, + KeyExchangeURIResult, +} from "./key-exchange.js"; +export * from "./protocols/hdwalletv1.js"; +export * from "./protocols/base.js"; +export { + binToHex, + hexToBin, + binToBech32Padded, + bech32PaddedToBin, +} from "@bitauth/libauth"; diff --git a/packages/core/src/key-exchange.test.ts b/packages/core/src/key-exchange.test.ts new file mode 100644 index 0000000..2f6d362 --- /dev/null +++ b/packages/core/src/key-exchange.test.ts @@ -0,0 +1,374 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { describe, it, expect } from "vitest"; +import { + encodeKeyExchangeURI, + decodeKeyExchangeURI, + generateKeyExchangeCredentials, +} from "./key-exchange.js"; + +describe("key-exchange", () => { + describe("generateKeyExchangeCredentials", () => { + it("should generate valid credentials", () => { + const credentials = generateKeyExchangeCredentials(); + + expect(credentials.privateKey).toHaveLength(64); // 32 bytes in hex + expect(credentials.publicKey).toHaveLength(64); // 32 bytes in hex + expect(credentials.secret).toHaveLength(16); // 8 bytes in hex + + // Verify all are valid hex strings + expect(/^[0-9a-f]+$/.test(credentials.privateKey)).toBe(true); + expect(/^[0-9a-f]+$/.test(credentials.publicKey)).toBe(true); + expect(/^[0-9a-f]+$/.test(credentials.secret)).toBe(true); + }); + + it("should generate different credentials on each call", () => { + const cred1 = generateKeyExchangeCredentials(); + const cred2 = generateKeyExchangeCredentials(); + + expect(cred1.privateKey).not.toBe(cred2.privateKey); + expect(cred1.publicKey).not.toBe(cred2.publicKey); + expect(cred1.secret).not.toBe(cred2.secret); + }); + }); + + describe("encodeKeyExchangeURI", () => { + it("should encode valid public key and secret to URI", () => { + const publicKey = "a".repeat(64); // 32 bytes in hex + const secret = "b".repeat(16); // 8 bytes in hex + + const { uri } = encodeKeyExchangeURI(publicKey, secret); + + // With defaults, should use minimal URI format + expect(uri).toMatch( + /^wiz:\/\/\?p=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+&s=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/, + ); + expect(uri.startsWith("wiz://?")).toBe(true); + expect(uri.includes("?p=")).toBe(true); + expect(uri.includes("&s=")).toBe(true); + }); + + it("should always produce lowercase bech32 encoding", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + + const { uri } = encodeKeyExchangeURI(publicKey, secret); + + // Extract the bech32 parts (minimal URI format) + const parts = uri.match(/^wiz:\/\/\?p=([^&]+)&s=(.+)$/); + expect(parts).not.toBeNull(); + if (parts) { + expect(parts[1]).toBe(parts[1].toLowerCase()); + expect(parts[2]).toBe(parts[2].toLowerCase()); + } + }); + + it("should throw error for invalid public key length", () => { + // Use a hex string that decodes to wrong length (odd length hex = invalid) + const invalidPublicKey = "a".repeat(62); // 31 bytes when decoded + const secret = "b".repeat(16); + + expect(() => encodeKeyExchangeURI(invalidPublicKey, secret)).toThrow( + "Invalid public key length", + ); + }); + + it("should throw error for invalid secret length", () => { + const publicKey = "a".repeat(64); + // Use a hex string that decodes to wrong length (odd length hex = invalid) + const invalidSecret = "b".repeat(14); // 7 bytes when decoded + + expect(() => encodeKeyExchangeURI(publicKey, invalidSecret)).toThrow( + "Invalid secret length", + ); + }); + + it("should handle invalid hex gracefully", () => { + // hexToBin from libauth might be lenient with invalid hex + // This test verifies the function doesn't crash, but the actual behavior + // depends on libauth's hexToBin implementation + const invalidPublicKey = "g".repeat(64); // 'g' is not valid hex + const secret = "b".repeat(16); + + // The function should either throw or produce a result + // If it doesn't throw, it means libauth's hexToBin is lenient + // In that case, it will likely fail at length validation + try { + encodeKeyExchangeURI(invalidPublicKey, secret); + // If it doesn't throw, that's also acceptable behavior + } catch (error) { + // If it throws, that's expected + expect(error).toBeDefined(); + } + }); + }); + + describe("encodeKeyExchangeURI — qrUri", () => { + it("qrUri is fully uppercase", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret); + expect(qrUri).toBe(qrUri.toUpperCase()); + }); + + it("qrUri contains no ?, =, or &", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret); + expect(qrUri).not.toContain("?"); + expect(qrUri).not.toContain("="); + expect(qrUri).not.toContain("&"); + }); + + it("qrUri passes QR alphanumeric charset regex", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret); + expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true); + }); + + it("qrUri contains %3F, %3D, %26 for default relay", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret); + expect(qrUri).toContain("%3F"); + expect(qrUri).toContain("%3D"); + expect(qrUri).toContain("%26"); + }); + + it("custom relay encodes all separators", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret, { + hostname: "example.com", + port: 8080, + protocol: "ws", + }); + expect(qrUri).not.toContain("?"); + expect(qrUri).not.toContain("="); + expect(qrUri).not.toContain("&"); + expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true); + }); + }); + + describe("decodeKeyExchangeURI", () => { + it("should decode valid URI to public key and secret", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { uri } = encodeKeyExchangeURI(publicKey, secret); + + const decoded = decodeKeyExchangeURI(uri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + }); + + it("should handle case-insensitive URIs", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { uri } = encodeKeyExchangeURI(publicKey, secret); + + // Test uppercase URI (but keep scheme lowercase as URL parsing requires it) + const upperUri = uri.replace(/wiz:\/\/([^?]+)/, (match, host) => { + return `wiz://${host.toUpperCase()}`; + }); + const decoded = decodeKeyExchangeURI(upperUri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + }); + + it("should handle mixed case URIs", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { uri } = encodeKeyExchangeURI(publicKey, secret); + + // Mix case + const mixedUri = uri.replace(/r/i, "R").replace(/c/i, "C"); + const decoded = decodeKeyExchangeURI(mixedUri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + }); + + it("should throw error for invalid URI format", () => { + const invalidUri = "invalid://uri"; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); + }); + + it("should throw error for wrong scheme", () => { + const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def"; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( + "Invalid URI scheme", + ); + }); + + it("should handle URIs with custom hostname and port", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + + const { uri } = encodeKeyExchangeURI(publicKey, secret, { + hostname: "example.com", + port: 8080, + protocol: "ws", + }); + + expect(uri).toMatch(/^wiz:\/\/example\.com:8080\?p=/); + const decoded = decodeKeyExchangeURI(uri); + expect(decoded.hostname).toBe("example.com"); + expect(decoded.port).toBe(8080); + expect(decoded.protocol).toBe("ws"); + }); + + it("should throw error for missing parameters", () => { + const invalidUri = "wiz://relay.cauldron.quest?p=abc"; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( + "Invalid URI format", + ); + }); + + it("should throw error for invalid bech32 encoding", () => { + // Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l) + // This will fail at bech32 decoding + const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars"; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( + "Invalid bech32 encoding", + ); + }); + + it("should throw error for wrong public key length after decoding", () => { + // Create a bech32 string that decodes to wrong length + const shortBech32 = "q"; + const { uri: validSecret } = encodeKeyExchangeURI( + "a".repeat(64), + "b".repeat(16), + ); + const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || ""; + const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); + }); + + it("should throw error for wrong secret length after decoding", () => { + const { uri: validPublicKey } = encodeKeyExchangeURI( + "a".repeat(64), + "b".repeat(16), + ); + const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || ""; + const shortBech32 = "q"; + const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`; + + expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); + }); + }); + + describe("decodeKeyExchangeURI — QR format", () => { + it("decodes qrUri to same values as standard uri", () => { + const publicKey = "0123456789abcdef".repeat(4); + const secret = "fedcba9876543210"; + const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret); + + const fromUri = decodeKeyExchangeURI(uri); + const fromQr = decodeKeyExchangeURI(qrUri); + + expect(fromQr.publicKey).toBe(fromUri.publicKey); + expect(fromQr.secret).toBe(fromUri.secret); + expect(fromQr.hostname).toBe(fromUri.hostname); + expect(fromQr.port).toBe(fromUri.port); + expect(fromQr.protocol).toBe(fromUri.protocol); + }); + + it("accepts lowercase version of qrUri", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret); + + const fromUri = decodeKeyExchangeURI(uri); + const fromLowerQr = decodeKeyExchangeURI(qrUri.toLowerCase()); + + expect(fromLowerQr.publicKey).toBe(fromUri.publicKey); + expect(fromLowerQr.secret).toBe(fromUri.secret); + }); + + it("round-trip: encodeKeyExchangeURI → qrUri → decodeKeyExchangeURI", () => { + const credentials = generateKeyExchangeCredentials(); + const { qrUri } = encodeKeyExchangeURI( + credentials.publicKey, + credentials.secret, + ); + const decoded = decodeKeyExchangeURI(qrUri); + + expect(decoded.publicKey).toBe(credentials.publicKey); + expect(decoded.secret).toBe(credentials.secret); + }); + + it("round-trip with custom relay via qrUri", () => { + const publicKey = "a".repeat(64); + const secret = "b".repeat(16); + const { qrUri } = encodeKeyExchangeURI(publicKey, secret, { + hostname: "example.com", + port: 8080, + protocol: "ws", + }); + const decoded = decodeKeyExchangeURI(qrUri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + expect(decoded.hostname).toBe("example.com"); + expect(decoded.port).toBe(8080); + expect(decoded.protocol).toBe("ws"); + }); + }); + + describe("round-trip encoding/decoding", () => { + it("should round-trip encode and decode correctly", () => { + const publicKey = "0123456789abcdef".repeat(4); // 32 bytes + const secret = "fedcba9876543210"; // 8 bytes + + const { uri } = encodeKeyExchangeURI(publicKey, secret); + const decoded = decodeKeyExchangeURI(uri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + }); + + it("should round-trip with generated credentials", () => { + const credentials = generateKeyExchangeCredentials(); + + const { uri } = encodeKeyExchangeURI( + credentials.publicKey, + credentials.secret, + ); + const decoded = decodeKeyExchangeURI(uri); + + expect(decoded.publicKey).toBe(credentials.publicKey); + expect(decoded.secret).toBe(credentials.secret); + }); + + it("should round-trip with random hex values", () => { + const randomHex = (length: number) => { + const chars = "0123456789abcdef"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; + }; + + const publicKey = randomHex(64); + const secret = randomHex(16); + + const { uri } = encodeKeyExchangeURI(publicKey, secret); + const decoded = decodeKeyExchangeURI(uri); + + expect(decoded.publicKey).toBe(publicKey); + expect(decoded.secret).toBe(secret); + }); + }); +}); diff --git a/packages/core/src/key-exchange.ts b/packages/core/src/key-exchange.ts new file mode 100644 index 0000000..630d0f6 --- /dev/null +++ b/packages/core/src/key-exchange.ts @@ -0,0 +1,216 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { + generatePrivateKey, + binToHex, + hexToBin, + binToBech32Padded, + bech32PaddedToBin, +} from "@bitauth/libauth"; +import { deriveNostrPublicKey } from "./utilnostr.js"; + +export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest"; +export const DEFAULT_RELAY_PORT = 443; +export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss"; + +export interface KeyExchangeCredentials { + privateKey: string; + publicKey: string; + secret: string; +} + +export function generateKeyExchangeCredentials(): KeyExchangeCredentials { + const privateKey = generatePrivateKey(); + const privateKeyHex = binToHex(privateKey); + const publicKeyHex = deriveNostrPublicKey(privateKey); + + const secretBytes = generatePrivateKey(); + const secretShort = secretBytes.slice(0, 8); + const secret = binToHex(secretShort); + + return { + privateKey: privateKeyHex, + publicKey: publicKeyHex, + secret: secret, + }; +} + +export interface DecodedKeyExchangeURI { + publicKey: string; + secret: string; + hostname: string; + port: number; + protocol: "ws" | "wss"; +} + +export interface KeyExchangeURIOptions { + hostname?: string; + port?: number; + protocol?: "ws" | "wss"; +} + +export interface KeyExchangeURIResult { + /** Standard URI for copy-paste and display: wiz://?p=...&s=... */ + uri: string; + /** Fully QR-alphanumeric-safe URI for QR code generation: WIZ://%3FP%3D... */ + qrUri: string; +} + +export function encodeKeyExchangeURI( + publicKey: string, + secret: string, + options: KeyExchangeURIOptions = {}, +): KeyExchangeURIResult { + const publicKeyBin = hexToBin(publicKey); + const secretBin = hexToBin(secret); + + if (publicKeyBin.length !== 32) { + throw new Error( + `Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`, + ); + } + if (secretBin.length !== 8) { + throw new Error( + `Invalid secret length: expected 8 bytes, got ${secretBin.length}`, + ); + } + + const publicKeyBech32 = binToBech32Padded(publicKeyBin).toLowerCase(); + const secretBech32 = binToBech32Padded(secretBin).toLowerCase(); + + const hostname = options.hostname || DEFAULT_RELAY_HOSTNAME; + const port = options.port ?? DEFAULT_RELAY_PORT; + const protocol = options.protocol || DEFAULT_RELAY_PROTOCOL; + + const isDefaultHostname = hostname === DEFAULT_RELAY_HOSTNAME; + const defaultPort = protocol === "wss" ? 443 : 80; + const isDefaultPort = port === defaultPort; + const isDefaultProtocol = protocol === DEFAULT_RELAY_PROTOCOL; + + let uri: string; + if (isDefaultHostname && isDefaultPort && isDefaultProtocol) { + uri = `wiz://?p=${publicKeyBech32}&s=${secretBech32}`; + } else { + const portPart = isDefaultPort ? "" : `:${port}`; + const authority = `${hostname}${portPart}`; + + uri = `wiz://${authority}?p=${publicKeyBech32}&s=${secretBech32}`; + + if (!isDefaultProtocol) { + uri += `&pr=${protocol}`; + } + } + + const qrUri = uri + .toUpperCase() + .replace("?", "%3F") + .replace(/=/g, "%3D") + .replace(/&/g, "%26"); + + return { uri, qrUri }; +} + +export function decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI { + let url: URL; + try { + const lower = uri.toLowerCase(); + const isQr = lower.includes("%3f") && !lower.includes("?"); + const toParse = isQr + ? lower.replace("%3f", "?").replace(/%3d/g, "=").replace(/%26/g, "&") + : lower; + url = new URL(toParse); + } catch (error) { + throw new Error( + `Invalid URI format: ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + + if (url.protocol !== "wiz:") { + throw new Error("Invalid URI scheme. Expected: wiz://"); + } + + let hostname = url.hostname || DEFAULT_RELAY_HOSTNAME; + let port: number; + if (url.port) { + const parsedPort = parseInt(url.port, 10); + if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + throw new Error(`Invalid port number: ${url.port}`); + } + port = parsedPort; + } else { + port = DEFAULT_RELAY_PORT; + } + + const publicKeyBech32 = url.searchParams.get("p"); + const secretBech32 = url.searchParams.get("s"); + + if (!publicKeyBech32 || !secretBech32) { + throw new Error( + "Invalid URI format. Missing required parameters: p (public key) or s (secret)", + ); + } + + const protocol = url.searchParams.get("pr") as "ws" | "wss" | null; + + const publicKeyBech32Normalized = publicKeyBech32.toLowerCase(); + const secretBech32Normalized = secretBech32.toLowerCase(); + + let publicKeyBin: Uint8Array; + let secretBin: Uint8Array; + + try { + const pubkeyResult = bech32PaddedToBin(publicKeyBech32Normalized); + const secretResult = bech32PaddedToBin(secretBech32Normalized); + + if ( + pubkeyResult instanceof Uint8Array && + secretResult instanceof Uint8Array + ) { + publicKeyBin = pubkeyResult; + secretBin = secretResult; + } else { + const pubkeyError = + typeof pubkeyResult === "string" ? pubkeyResult : "Unknown error"; + const secretError = + typeof secretResult === "string" ? secretResult : "Unknown error"; + throw new Error( + `Bech32 decoding failed: pubkey=${pubkeyError}, secret=${secretError}`, + ); + } + } catch (error) { + throw new Error( + `Invalid bech32 encoding: ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + + if (publicKeyBin.length !== 32) { + throw new Error( + `Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`, + ); + } + if (secretBin.length !== 8) { + throw new Error( + `Invalid secret length: expected 8 bytes, got ${secretBin.length}`, + ); + } + + if (protocol && protocol !== "ws" && protocol !== "wss") { + throw new Error(`Invalid protocol: ${protocol}. Must be 'ws' or 'wss'`); + } + + if (!url.port && protocol === "ws") { + port = 80; + } + + return { + publicKey: binToHex(publicKeyBin), + secret: binToHex(secretBin), + hostname: hostname, + port: port, + protocol: protocol || DEFAULT_RELAY_PROTOCOL, + }; +} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 0000000..3df6743 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Simple console-based logger shim. + * Replaces @riftenlabs/log without any external dependencies. + */ + +export enum Scope { + Relay = "relay", + Network = "network", + Misc = "misc", +} + +export type LogScope = Scope | string; + +export function debug(scope: LogScope, ...args: unknown[]): void { + console.log(`[${scope}]`, ...args); +} + +export function info(scope: LogScope, ...args: unknown[]): void { + console.info(`[${scope}]`, ...args); +} + +export function warn(scope: LogScope, ...args: unknown[]): void { + console.warn(`[${scope}]`, ...args); +} + +export function error(scope: LogScope, ...args: unknown[]): void { + console.error(`[${scope}]`, ...args); +} diff --git a/packages/core/src/message-queue.test.ts b/packages/core/src/message-queue.test.ts new file mode 100644 index 0000000..fb2b2c8 --- /dev/null +++ b/packages/core/src/message-queue.test.ts @@ -0,0 +1,221 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { MessageQueue } from "./message-queue.js"; +import { ProtocolMessage, RelayMsgAction } from "./protocols/hdwalletv1.js"; + +describe("MessageQueue", () => { + let queue: MessageQueue; + let publishFn: ReturnType; + + beforeEach(() => { + queue = new MessageQueue({ logActivity: false }); + publishFn = vi.fn().mockResolvedValue(undefined); + }); + + describe("initialization", () => { + it("should start with ready state false", () => { + expect(queue.getReady()).toBe(false); + }); + + it("should start with empty queue", () => { + expect(queue.getQueueLength()).toBe(0); + }); + }); + + describe("enqueue", () => { + it("should queue messages when not ready", async () => { + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise = queue.enqueue(message); + + expect(queue.getQueueLength()).toBe(1); + expect(queue.getReady()).toBe(false); + + // Message should be queued, promise should not resolve yet + await expect( + Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve("timeout"), 10)), + ]), + ).resolves.toBe("timeout"); + }); + + it("should return resolved promise when already ready", async () => { + queue.setReady(publishFn); + + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + + await expect(queue.enqueue(message)).resolves.toBeUndefined(); + expect(queue.getQueueLength()).toBe(0); + }); + }); + + describe("setReady", () => { + it("should mark queue as ready", () => { + queue.setReady(publishFn); + expect(queue.getReady()).toBe(true); + }); + + it("should process queued messages when set to ready", async () => { + const message1: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + const message2: ProtocolMessage = { + action: RelayMsgAction.WalletReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise1 = queue.enqueue(message1); + const promise2 = queue.enqueue(message2); + + expect(queue.getQueueLength()).toBe(2); + + await queue.setReady(publishFn); + + expect(publishFn).toHaveBeenCalledTimes(2); + expect(publishFn).toHaveBeenCalledWith(message1); + expect(publishFn).toHaveBeenCalledWith(message2); + expect(queue.getQueueLength()).toBe(0); + + // Promises should resolve + await expect(promise1).resolves.toBeUndefined(); + await expect(promise2).resolves.toBeUndefined(); + }); + + it("should reject queued messages if publish fails", async () => { + const error = new Error("Publish failed"); + publishFn.mockRejectedValueOnce(error); + + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise = queue.enqueue(message); + await queue.setReady(publishFn); + + await expect(promise).rejects.toThrow("Publish failed"); + }); + + it("should process messages in order", async () => { + const messages: ProtocolMessage[] = []; + const callOrder: number[] = []; + + publishFn.mockImplementation(async (msg: ProtocolMessage) => { + const index = messages.length; + messages.push(msg); + callOrder.push(index); + }); + + for (let i = 0; i < 5; i++) { + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + queue.enqueue(message); + } + + await queue.setReady(publishFn); + + expect(callOrder).toEqual([0, 1, 2, 3, 4]); + expect(messages).toHaveLength(5); + }); + }); + + describe("setNotReady", () => { + it("should mark queue as not ready", () => { + queue.setReady(publishFn); + expect(queue.getReady()).toBe(true); + + queue.setNotReady(); + expect(queue.getReady()).toBe(false); + }); + + it("should reject all queued messages", async () => { + const message1: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + const message2: ProtocolMessage = { + action: RelayMsgAction.WalletReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise1 = queue.enqueue(message1); + const promise2 = queue.enqueue(message2); + + queue.setNotReady("Custom error message"); + + await expect(promise1).rejects.toThrow("Custom error message"); + await expect(promise2).rejects.toThrow("Custom error message"); + expect(queue.getQueueLength()).toBe(0); + }); + + it("should use default error message if not provided", async () => { + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise = queue.enqueue(message); + queue.setNotReady(); + + await expect(promise).rejects.toThrow( + "Connection closed before message could be sent", + ); + }); + }); + + describe("clear", () => { + it("should clear all queued messages", async () => { + const message: ProtocolMessage = { + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }; + + const promise = queue.enqueue(message); + expect(queue.getQueueLength()).toBe(1); + + queue.clear(); + + expect(queue.getQueueLength()).toBe(0); + await expect(promise).rejects.toThrow("Queue cleared"); + }); + + it("should not affect ready state", () => { + queue.setReady(publishFn); + expect(queue.getReady()).toBe(true); + + queue.clear(); + expect(queue.getReady()).toBe(true); + }); + }); + + describe("getQueueLength", () => { + it("should return correct queue length", () => { + expect(queue.getQueueLength()).toBe(0); + + queue.enqueue({ + action: RelayMsgAction.DappReady, + time: Math.floor(Date.now() / 1000), + }); + expect(queue.getQueueLength()).toBe(1); + + queue.enqueue({ + action: RelayMsgAction.WalletReady, + time: Math.floor(Date.now() / 1000), + }); + expect(queue.getQueueLength()).toBe(2); + }); + }); +}); diff --git a/packages/core/src/message-queue.ts b/packages/core/src/message-queue.ts new file mode 100644 index 0000000..1226d40 --- /dev/null +++ b/packages/core/src/message-queue.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { ProtocolMessage } from "./protocols/hdwalletv1.js"; +import { debug, Scope } from "./log.js"; + +interface QueuedMessage { + message: ProtocolMessage; + resolve: () => void; + reject: (_error: Error) => void; +} + +export class MessageQueue { + private queue: QueuedMessage[] = []; + private isReady: boolean = false; + private logActivity: boolean = false; + + constructor(options?: { logActivity?: boolean }) { + this.logActivity = options?.logActivity ?? false; + } + + getReady(): boolean { + return this.isReady; + } + + getQueueLength(): number { + return this.queue.length; + } + + enqueue(message: ProtocolMessage): Promise { + if (this.isReady) { + return Promise.resolve(); + } + + if (this.logActivity) { + debug( + Scope.Relay, + `net: Relays not ready, queuing message ${message.action}`, + ); + } + + return new Promise((resolve, reject) => { + this.queue.push({ message, resolve, reject }); + }); + } + + async setReady( + publishFn: (_message: ProtocolMessage) => Promise, + ): Promise { + this.isReady = true; + + const queuedMessages = [...this.queue]; + this.queue = []; + + if (queuedMessages.length > 0 && this.logActivity) { + debug(Scope.Relay, `Processing ${queuedMessages.length} queued messages`); + } + + for (const queued of queuedMessages) { + try { + await publishFn(queued.message); + queued.resolve(); + } catch (error) { + queued.reject(error as Error); + } + } + } + + setNotReady( + errorMessage: string = "Connection closed before message could be sent", + ): void { + this.isReady = false; + + const queuedMessages = [...this.queue]; + this.queue = []; + + for (const queued of queuedMessages) { + queued.reject(new Error(errorMessage)); + } + } + + clear(): void { + const queuedMessages = [...this.queue]; + this.queue = []; + + for (const queued of queuedMessages) { + queued.reject(new Error("Queue cleared")); + } + } +} diff --git a/packages/core/src/primitives.ts b/packages/core/src/primitives.ts new file mode 100644 index 0000000..1e1f0b6 --- /dev/null +++ b/packages/core/src/primitives.ts @@ -0,0 +1,23 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Minimal primitives shim — replaces @riftenlabs/primitives. + */ + +export const throwUnless = (x: boolean, what: string): void => { + if (!x) { + throw Error(`Internal application error: ${what}`); + } +}; + +export function unwrap(value: string | Error | T): T { + if (typeof value === "string") { + throw new Error(`unwrap: ${value}`); + } + if (value instanceof Error) { + throw value; + } + return value as T; +} diff --git a/packages/core/src/protocols/base.ts b/packages/core/src/protocols/base.ts new file mode 100644 index 0000000..70dcf45 --- /dev/null +++ b/packages/core/src/protocols/base.ts @@ -0,0 +1,95 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/// Base protocol — DappReadyMessage and WalletReadyMessage +/// +/// These are protocol-agnostic handshake messages shared across all +/// application-level protocols (hdwalletv1, future hdwalletv2, etc.). +/// +/// The wallet populates `session` for every protocol it supports, keyed by +/// protocol name. The dapp selects the first protocol from its own +/// `supported_protocols` list that is also present in the wallet's list, +/// then reads `session[selectedProtocol]` for protocol-specific data. + +import { + RelayMsgAction, + ProtocolMessage, + isProtocolMessage, +} from "./hdwalletv1.js"; + +export interface DappReadyMessage extends ProtocolMessage { + action: RelayMsgAction.DappReady; + /// Protocols this dapp supports, in preference order. + supported_protocols: string[]; + /// Set on the reactive dapp_ready (after seeing wallet_ready). + /// Absent on the proactive send (before the dapp has seen the wallet). + selected_protocol?: string; + /// True if the dapp has already seen this wallet in the current runtime session. + wallet_discovered: boolean; + dapp_name?: string; + dapp_icon?: string; +} + +export interface WalletReadyMessage extends ProtocolMessage { + action: RelayMsgAction.WalletReady; + wallet_name: string; + wallet_icon: string; + /// True if the wallet has already seen this dapp in the current runtime session. + /// The dapp uses this to skip sending a reactive dapp_ready when already known. + dapp_discovered: boolean; + /// Protocols this wallet supports. + supported_protocols: string[]; + /// Session data keyed by protocol name. + /// e.g. { "hdwalletv1": { paths: PathXpub[], next_indices: NextIndex[] } } + /// The dapp picks the first compatible protocol and reads session[selectedProtocol]. + session: Record; + /// Wallet's Nostr x-only public key (hex, 32 bytes). Replaces the old key_exchange_response. + public_key: string; + /// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention. + secret: string; +} + +export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage { + return ( + isProtocolMessage(msg) && + msg.action === RelayMsgAction.DappReady && + Array.isArray((msg as DappReadyMessage).supported_protocols) && + typeof (msg as DappReadyMessage).wallet_discovered === "boolean" + ); +} + +export function isWalletReadyMessage(msg: unknown): msg is WalletReadyMessage { + const m = msg as WalletReadyMessage; + return ( + isProtocolMessage(msg) && + msg.action === RelayMsgAction.WalletReady && + typeof m.wallet_name === "string" && + typeof m.wallet_icon === "string" && + typeof m.dapp_discovered === "boolean" && + Array.isArray(m.supported_protocols) && + m.session !== null && + typeof m.session === "object" && + typeof m.public_key === "string" && + typeof m.secret === "string" + ); +} + +export enum DisconnectReason { + ProtocolMismatch = "protocol_mismatch", + UserDisconnect = "user_disconnect", +} + +export interface DisconnectMessage extends ProtocolMessage { + action: RelayMsgAction.Disconnect; + reason: DisconnectReason; + message?: string; +} + +export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage { + return ( + isProtocolMessage(msg) && + (msg as DisconnectMessage).action === RelayMsgAction.Disconnect && + typeof (msg as DisconnectMessage).reason === "string" + ); +} diff --git a/packages/core/src/protocols/hdwalletv1.ts b/packages/core/src/protocols/hdwalletv1.ts new file mode 100644 index 0000000..b1726c1 --- /dev/null +++ b/packages/core/src/protocols/hdwalletv1.ts @@ -0,0 +1,169 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/// This is a protocol within the relayed messages (which is protocol agnostic). + +import { WcSignTransactionRequest } from "@bch-wc2/interfaces"; + +/// Dapp <-> Wallet messages + +export const PROTOCOL_NAME = "hdwalletv1" as const; + +/// What this message is about + +export enum RelayMsgAction { + /// Notification: Dapp has joined the session and is ready to receive relayed messages. + DappReady = "dapp_ready", + /// Notification: Wallet has joined the session and is ready to send relayed messages. + /// Also carries key exchange data (public_key, secret) embedded in the message. + WalletReady = "wallet_ready", + /// Request: The dapp wants wallet to sign a transaction. + SignTransactionRequest = "sign_transaction_request", + SignTransactionResponse = "sign_transaction_response", + /// Dapp-only: cancels an in-flight sign_transaction_request. + SignCancel = "sign_cancel", + /// Courtesy notification: one side is closing the connection. + Disconnect = "disconnect", +} + +export interface ProtocolMessage { + action: RelayMsgAction; + time: number; +} + +export type PathName = "receive" | "change" | "defi"; + +export interface PathXpub { + name: PathName; // "receive" | "change" | "defi" + xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally) +} + +export function isPathXpub(obj: any): obj is PathXpub { + return ( + obj && + typeof obj === "object" && + typeof obj.name === "string" && + typeof obj.xpub === "string" + ); +} + +/** Returns the numeric BIP44 child index for the given named path. + * Use only for internal HD derivation — not a protocol concern. */ +export function childIndexOfPathName(name: PathName): number { + switch (name) { + case "receive": + return 0; + case "change": + return 1; + case "defi": + return 7; + } +} + +/// Handshake Protocol +/// +/// Either side may connect or reconnect at any time (mobile app switch, browser +/// refresh, relay drop). The handshake uses a **mutual-discovery** pattern so +/// both sides converge regardless of who connects first: +/// +/// 1. On every connect/reconnect, both sides independently send their "ready" +/// message once key exchange completes. +/// 2. Each "ready" carries a boolean indicating whether the sender has already +/// seen the other party before (in this runtime session). +/// 3. On receiving a "ready" with `_discovered=false`, the receiver sends back +/// their own "ready" — **only if they have not already sent one this cycle**. +/// 4. Once both sides have exchanged wallet_ready/dapp_ready, the wallet sends +/// its xpubs and the dapp derives all needed pubkeys locally. +/// +/// INITIAL CONNECT (neither has seen the other): +/// +/// Dapp --dapp_ready(supported=["v1"])--> Wallet (proactive) +/// Dapp <--wallet_ready(supported=["v1"], session={v1:{paths,...}})-- Wallet +/// Dapp --dapp_ready(supported=["v1"], selected="hdwalletv1")--> Wallet (reactive) +/// +/// WALLET RECONNECTS (dapp still running, has walletDiscovered=true): +/// +/// Dapp --dapp_ready(supported=["v1"], wallet_discovered=true)--> Wallet +/// Dapp <--wallet_ready(supported=["v1"], session={...})-- Wallet +/// Dapp --dapp_ready(selected="hdwalletv1", wallet_discovered=true)--> Wallet +/// +/// See base.ts for DappReadyMessage / WalletReadyMessage definitions. + +/// Session data for the hdwalletv1 protocol. +/// Carried inside WalletReadyMessage.session["hdwalletv1"]. +export interface Hdwalletv1Session { + /// BIP32 xpubs for each named derivation path. + paths: PathXpub[]; +} + +export function isHdwalletv1Session(obj: unknown): obj is Hdwalletv1Session { + return ( + obj !== null && + typeof obj === "object" && + Array.isArray((obj as Hdwalletv1Session).paths) && + (obj as Hdwalletv1Session).paths.every((p) => isPathXpub(p)) + ); +} + +export interface ErrorMessage extends ProtocolMessage { + error: string; +} + +export interface SignTransactionRequest extends ProtocolMessage { + action: RelayMsgAction.SignTransactionRequest; + transaction: WcSignTransactionRequest; + sequence: number; +} + +export interface SignTransactionResponse extends ProtocolMessage { + action: RelayMsgAction.SignTransactionResponse; + sequence: number; + signedTransaction: string; + error?: string; +} + +export interface SignCancelMessage extends ProtocolMessage { + action: RelayMsgAction.SignCancel; + sequence: number; + reason?: string; +} + +// Type guard functions + +export function isProtocolMessage(payload: any): payload is ProtocolMessage { + return ( + payload && + typeof payload === "object" && + typeof payload.action === "string" && + typeof payload.time === "number" + ); +} + +export function isErrorMessage(payload: any): payload is ErrorMessage { + return ( + payload && typeof payload === "object" && typeof payload.error === "string" + ); +} + +export function isSignTransactionRequest( + msg: any, +): msg is SignTransactionRequest { + return ( + msg && + typeof msg === "object" && + msg.action === RelayMsgAction.SignTransactionRequest && + msg.transaction && + typeof msg.transaction === "object" && + typeof msg.sequence === "number" + ); +} + +export function isSignCancelMessage(msg: any): msg is SignCancelMessage { + return ( + msg && + typeof msg === "object" && + msg.action === RelayMsgAction.SignCancel && + typeof msg.sequence === "number" + ); +} diff --git a/packages/core/src/relay-client.ts b/packages/core/src/relay-client.ts new file mode 100644 index 0000000..c0c43e9 --- /dev/null +++ b/packages/core/src/relay-client.ts @@ -0,0 +1,406 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { throwUnless, unwrap } from "./primitives.js"; +import NDK, { + NDKPrivateKeySigner, + NDKUser, + NDKEvent, + NDKKind, + giftWrap, + giftUnwrap, + NDKSubscription, +} from "@nostr-dev-kit/ndk"; +import { binToHex, hash256, secp256k1 } from "@bitauth/libauth"; +import { EventEmitter } from "eventemitter3"; +import { + isProtocolMessage, + ProtocolMessage, + RelayMsgAction, +} from "./protocols/hdwalletv1.js"; +import { deriveNostrPublicKey } from "./utilnostr.js"; +import { MessageQueue } from "./message-queue.js"; +import { debug, error as logError, Scope } from "./log.js"; + +export interface RelayClientConfig { + explicitRelayUrls: string[]; + signerPrivateKey: Uint8Array; + pairedPublicKey?: Uint8Array; + logNetworkActivity?: boolean; +} + +export class RelayClient extends EventEmitter { + private ndk: NDK; + private paired: NDKUser; + private config: RelayClientConfig; + private messageSubscription: NDKSubscription | null = null; + private myPubkey: Uint8Array; + private myPubkeyHex: string; + private lastProcessedTimestamp: number = 0; + private messageQueue: MessageQueue; + + private sequence: number = Math.floor( + Math.random() * (Number.MAX_SAFE_INTEGER - 500_000), + ); + + private pendingCalls = new Map< + number, + { + resolve: (_value: any) => void; + reject: (_error: Error) => void; + } + >(); + + private pendingDeliveries = new Map< + number, + { + resolve: (_value: any) => void; + reject: (_error: Error) => void; + } + >(); + + constructor(config: RelayClientConfig) { + super(); + this.config = { + logNetworkActivity: true, + ...config, + }; + this.ndk = new NDK({ + explicitRelayUrls: this.config.explicitRelayUrls, + signer: new NDKPrivateKeySigner(this.config.signerPrivateKey), + enableOutboxModel: false, + autoConnectUserRelays: false, + }); + + this.messageQueue = new MessageQueue({ + logActivity: this.config.logNetworkActivity, + }); + + this.myPubkey = unwrap( + secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey), + ); + this.myPubkeyHex = deriveNostrPublicKey(this.config.signerPrivateKey); + + if (this.config.pairedPublicKey) { + const pairedNostrPubkey = + this.config.pairedPublicKey.length === 33 + ? this.config.pairedPublicKey.slice(1) + : this.config.pairedPublicKey; + this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) }); + } else { + this.paired = new NDKUser({ pubkey: "" }); + } + } + + setPairedPublicKey(pairedPublicKey: Uint8Array): void { + this.config.pairedPublicKey = pairedPublicKey; + const pairedNostrPubkey = + pairedPublicKey.length === 33 + ? pairedPublicKey.slice(1) + : pairedPublicKey; + this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) }); + this.emit("paired"); + } + + getPublicKey(): Uint8Array { + return this.myPubkey; + } + + getPublicKeyHex(): string { + return this.myPubkeyHex; + } + + isKeyExchangeComplete(): boolean { + if (!this.config.pairedPublicKey) { + return false; + } + return !this.config.pairedPublicKey.every((byte) => byte === 0); + } + + async connect(): Promise { + if (this.config.logNetworkActivity) { + debug(Scope.Relay, `Connecting to relay...`); + } + + if (this.lastProcessedTimestamp === 0) { + this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2; + } + + try { + await this.ndk.connect(); + + if (this.config.logNetworkActivity) { + debug(Scope.Relay, `NDK connect() resolved`); + } + + this.messageSubscription = this.ndk.subscribe( + { + kinds: [NDKKind.GiftWrap], + "#p": [this.myPubkeyHex], + }, + { closeOnEose: false }, + ); + + this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => { + try { + const signer = this.ndk.signer; + if (!signer) { + throw new Error("No signer available"); + } + + const rumor = await giftUnwrap(wrappedEvent, undefined, signer); + + if (rumor.kind === NDKKind.PrivateDirectMessage) { + let payload: ProtocolMessage; + try { + payload = JSON.parse(rumor.content); + } catch (e) { + if (this.config.logNetworkActivity) { + logError( + Scope.Relay, + "Failed to parse message content as JSON:", + e, + ); + } + return; + } + + if ( + !payload.time || + (this.lastProcessedTimestamp > 0 && + payload.time < this.lastProcessedTimestamp) + ) { + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`, + ); + } + return; + } + + // wallet_ready carries the key exchange data (public_key + secret) so it + // must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet. + const isKeyExchangeMessage = + payload.action === RelayMsgAction.WalletReady; + + if (!isKeyExchangeMessage && this.config.pairedPublicKey) { + const pairedNostrPubkey = + this.config.pairedPublicKey.length === 33 + ? binToHex(this.config.pairedPublicKey.slice(1)) + : binToHex(this.config.pairedPublicKey); + if (rumor.pubkey !== pairedNostrPubkey) { + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`, + ); + } + return; + } + } + + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Received message ${payload.action} from relay`, + ); + } + this.handleRelayMessage(payload); + } else { + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`, + ); + } + } + } catch (error) { + if (this.config.logNetworkActivity) { + logError(Scope.Relay, "Error handling incoming message:", error); + } + this.emitError(error as Error); + } + }); + + this.messageSubscription.on("close", () => { + if (this.config.logNetworkActivity) { + debug(Scope.Relay, "Subscription closed, connection may be stale"); + } + this.emit("disconnect", new Error("Subscription closed")); + }); + + this.waitForRelaysReady(); + + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Subscription created and handler set up, emitting connection event`, + ); + } + + this.emit("connection"); + } catch (error) { + if (this.config.logNetworkActivity) { + logError(Scope.Relay, `Connection failed:`, error); + } + throw error; + } + } + + async disconnect(): Promise { + this.lastProcessedTimestamp = Math.floor(Date.now() / 1000); + this.messageQueue.setNotReady(); + + if (this.messageSubscription) { + this.messageSubscription.stop(); + this.messageSubscription = null; + } + } + + getLastProcessedTimestamp(): number { + return this.lastProcessedTimestamp; + } + + setLastProcessedTimestamp(timestamp: number): void { + this.lastProcessedTimestamp = timestamp; + } + + async relay(message: ProtocolMessage): Promise { + if (!this.config.pairedPublicKey) { + throw new Error( + "Cannot relay message: paired public key not set. Call setPairedPublicKey() first.", + ); + } + + if (!this.messageQueue.getReady()) { + return this.messageQueue.enqueue(message); + } + + return this.publishMessage(message); + } + + private async publishMessage(message: ProtocolMessage): Promise { + this.netlog("send", message.action); + + const signer = this.ndk.signer; + if (!signer) { + throw new Error("No signer available"); + } + + const rumor = new NDKEvent(this.ndk); + rumor.kind = NDKKind.PrivateDirectMessage; + rumor.content = JSON.stringify(message); + rumor.created_at = Math.floor(Date.now() / 1000); + rumor.tags = [["p", this.paired.pubkey]]; + + const wrappedEvent = await giftWrap(rumor, this.paired, signer); + + try { + await wrappedEvent.publish(); + if (this.config.logNetworkActivity) { + debug(Scope.Relay, `Published message ${message.action} to relay`); + } + } catch (error) { + if (this.config.logNetworkActivity) { + logError( + Scope.Relay, + `Failed to publish message ${message.action}:`, + error, + ); + } + throw error; + } + } + + private async waitForRelaysReady(): Promise { + const maxWaitTime = 5000; + const checkInterval = 100; + const startTime = Date.now(); + + const checkRelays = async (): Promise => { + const pool = (this.ndk as any).pool; + if (pool) { + const relays = pool.relays || []; + // NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8 + const connectedRelays = Array.from(relays.values()).filter( + (relay: any) => relay.status >= 5, + ); + + if (connectedRelays.length > 0) { + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + `Relays ready (${connectedRelays.length} connected), processing queued messages`, + ); + } + await this.messageQueue.setReady((msg) => this.publishMessage(msg)); + return; + } + } + + if (Date.now() - startTime < maxWaitTime) { + setTimeout(checkRelays, checkInterval); + } else { + if (this.config.logNetworkActivity) { + debug( + Scope.Relay, + "Relay ready check timeout, assuming ready and processing queued messages", + ); + } + await this.messageQueue.setReady((msg) => this.publishMessage(msg)); + } + }; + + setTimeout(checkRelays, 200); + } + + isConnected(): boolean { + return true; + } + + private netlog( + direction: "recv" | "send", + what: string, + sequence?: number, + ): void { + if (!this.config.logNetworkActivity) { + return; + } + const us = binToHex(hash256(this.config.signerPrivateKey)).slice(-6); + const them = this.config.pairedPublicKey + ? binToHex(this.config.pairedPublicKey).slice(-6) + : "??????"; + const pending = `c${this.pendingCalls.size} d${this.pendingDeliveries.size}`; + if (direction === "send") { + debug( + Scope.Relay, + `net [${sequence ?? "?"} ${pending}] ${us} -> ${them}: ${what}`, + ); + } else { + debug( + Scope.Relay, + `net [${sequence ?? "?"} ${pending}] ${us} <- ${them}: ${what}`, + ); + } + } + + private async handleRelayMessage(message: ProtocolMessage): Promise { + throwUnless( + isProtocolMessage(message), + `Invalid protocol message: ${message}`, + ); + this.emit("message", message); + } + + public nextSequence(): number { + const current = this.sequence; + this.sequence += 2; + return current; + } + + private emitError(error: Error): void { + this.emit("error", error); + } +} diff --git a/packages/core/src/relay-handler.ts b/packages/core/src/relay-handler.ts new file mode 100644 index 0000000..3071d20 --- /dev/null +++ b/packages/core/src/relay-handler.ts @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { RelayClient } from "./relay-client.js"; +import { + createConnectionManager, + VisibilityChangeContext, +} from "./connection-manager.js"; +import { debug, error as logError, Scope } from "./log.js"; + +type RelayStatusCode = + | "connected" + | "reconnecting" + | "disconnected" + | "session_deleted"; + +export class RelayStatus { + status: RelayStatusCode; + error: string | null; + sessionId: string | null; + + private constructor( + status: RelayStatusCode, + error: string | null, + sessionId: string | null = null, + ) { + this.status = status; + this.error = error; + this.sessionId = sessionId; + } + + static connected(sessionId?: string): RelayStatus { + return new RelayStatus("connected", null, sessionId || null); + } + + static reconnecting(reason: string | null, sessionId?: string): RelayStatus { + return new RelayStatus("reconnecting", reason, sessionId || null); + } + + static disconnected(): RelayStatus { + return new RelayStatus("disconnected", null, null); + } + + static sessionDeleted(): RelayStatus { + return new RelayStatus("session_deleted", null, null); + } +} + +export interface RelayUpdatePayload { + client: RelayClient; + status: RelayStatus; +} + +export type RelayStatusCallback = (_payload: RelayUpdatePayload) => void; + +export const initiateRelay = ( + dispatchCallback: RelayStatusCallback, + signerPrivateKey: Uint8Array, + pairPublicKey: Uint8Array, + options?: { + explicitRelayUrls: string[]; + reconnectInterval?: number; + maxReconnectAttempts?: number; + enableVisibilityHandling?: boolean; + }, +) => { + const client = new RelayClient({ + explicitRelayUrls: options?.explicitRelayUrls ?? [], + signerPrivateKey: signerPrivateKey, + pairedPublicKey: pairPublicKey, + }); + + let lastProcessedTimestamp: number = 0; + + const connectionManager = createConnectionManager( + client, + { + onConnected: () => { + if (lastProcessedTimestamp > 0) { + client.setLastProcessedTimestamp(lastProcessedTimestamp); + } else { + lastProcessedTimestamp = client.getLastProcessedTimestamp(); + } + dispatchCallback({ + client, + status: RelayStatus.connected(), + }); + }, + onReconnecting: (_client: RelayClient, reason: string | null) => { + dispatchCallback({ + client, + status: RelayStatus.reconnecting(reason), + }); + }, + onDisconnected: () => { + dispatchCallback({ + client, + status: RelayStatus.disconnected(), + }); + }, + onError: (_client: RelayClient, _error: any) => { + // Error handling is already done in onReconnecting + }, + }, + { + connected: "connection", + disconnected: "disconnect", + error: "error", + }, + { + reconnectInterval: options?.reconnectInterval, + maxReconnectAttempts: options?.maxReconnectAttempts, + enableVisibilityHandling: options?.enableVisibilityHandling ?? true, + scope: Scope.Relay, + onVisibilityChange: async ( + context: VisibilityChangeContext, + ) => { + if (context.state === "hidden") { + debug(Scope.Relay, "Page hidden, disconnecting relay"); + context.setPaused(true); + try { + await client.disconnect(); + lastProcessedTimestamp = client.getLastProcessedTimestamp(); + debug( + Scope.Relay, + `Disconnected, last processed timestamp: ${lastProcessedTimestamp}`, + ); + dispatchCallback({ + client, + status: RelayStatus.disconnected(), + }); + } catch (error) { + logError( + Scope.Relay, + "Error disconnecting on visibility change:", + error, + ); + } + } else if (context.state === "visible") { + if (context.isPaused()) { + debug(Scope.Relay, "Page visible, reconnecting relay"); + context.setPaused(false); + context.startConnectionLoop(); + } + } + }, + }, + ); + + connectionManager.startConnectionLoop(); + + return () => { + dispatchCallback({ + client, + status: RelayStatus.disconnected(), + }); + + (async () => { + try { + await connectionManager.cleanup(); + } catch { + // Ignore disconnect errors during cleanup + } + })(); + }; +}; diff --git a/packages/core/src/utilnostr.test.ts b/packages/core/src/utilnostr.test.ts new file mode 100644 index 0000000..1cc9de6 --- /dev/null +++ b/packages/core/src/utilnostr.test.ts @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { describe, it, expect } from "vitest"; +import { + deriveNostrPublicKey, + deriveNostrPublicKeyBytes, +} from "./utilnostr.js"; +import { + generatePrivateKey, + hexToBin, + binToHex, + secp256k1, +} from "@bitauth/libauth"; + +describe("utilnostr", () => { + describe("deriveNostrPublicKey", () => { + it("should derive a valid Nostr public key from a private key", () => { + const privateKey = generatePrivateKey(); + const publicKey = deriveNostrPublicKey(privateKey); + + // Nostr public key should be 64 hex characters (32 bytes) + expect(publicKey).toHaveLength(64); + expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true); + }); + + it("should produce the same public key for the same private key", () => { + const privateKey = generatePrivateKey(); + const publicKey1 = deriveNostrPublicKey(privateKey); + const publicKey2 = deriveNostrPublicKey(privateKey); + + expect(publicKey1).toBe(publicKey2); + }); + + it("should produce different public keys for different private keys", () => { + const privateKey1 = generatePrivateKey(); + const privateKey2 = generatePrivateKey(); + const publicKey1 = deriveNostrPublicKey(privateKey1); + const publicKey2 = deriveNostrPublicKey(privateKey2); + + expect(publicKey1).not.toBe(publicKey2); + }); + + it("should handle private key from hex string", () => { + const privateKeyHex = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const privateKey = hexToBin(privateKeyHex); + const publicKey = deriveNostrPublicKey(privateKey); + + expect(publicKey).toHaveLength(64); + expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true); + }); + + it("should match the pattern used in key-exchange", () => { + const privateKey = generatePrivateKey(); + const publicKey = deriveNostrPublicKey(privateKey); + + // Verify it's the same as the manual pattern + const result = secp256k1.derivePublicKeyCompressed(privateKey); + if (typeof result === "string") throw new Error(result); + const publicKeyNostr = result.slice(1); + const publicKeyHex = binToHex(publicKeyNostr); + + expect(publicKey).toBe(publicKeyHex); + }); + }); + + describe("deriveNostrPublicKeyBytes", () => { + it("should derive a valid Nostr public key as bytes", () => { + const privateKey = generatePrivateKey(); + const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey); + + // Nostr public key should be 32 bytes + expect(publicKeyBytes).toHaveLength(32); + expect(publicKeyBytes).toBeInstanceOf(Uint8Array); + }); + + it("should match the hex version when converted", () => { + const privateKey = generatePrivateKey(); + const publicKeyHex = deriveNostrPublicKey(privateKey); + const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey); + + expect(binToHex(publicKeyBytes)).toBe(publicKeyHex); + }); + + it("should produce the same bytes for the same private key", () => { + const privateKey = generatePrivateKey(); + const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey); + const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey); + + expect(publicKeyBytes1).toEqual(publicKeyBytes2); + }); + + it("should produce different bytes for different private keys", () => { + const privateKey1 = generatePrivateKey(); + const privateKey2 = generatePrivateKey(); + const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey1); + const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey2); + + expect(publicKeyBytes1).not.toEqual(publicKeyBytes2); + }); + }); +}); diff --git a/packages/core/src/utilnostr.ts b/packages/core/src/utilnostr.ts new file mode 100644 index 0000000..b4735c5 --- /dev/null +++ b/packages/core/src/utilnostr.ts @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { binToHex, secp256k1 } from "@bitauth/libauth"; +import { unwrap } from "./primitives.js"; + +export function deriveNostrPublicKey(privateKey: Uint8Array): string { + const publicKeyCompressed = unwrap( + secp256k1.derivePublicKeyCompressed(privateKey), + ); + const publicKeyNostr = publicKeyCompressed.slice(1); + return binToHex(publicKeyNostr); +} + +export function deriveNostrPublicKeyBytes(privateKey: Uint8Array): Uint8Array { + const publicKeyCompressed = unwrap( + secp256k1.derivePublicKeyCompressed(privateKey), + ); + return publicKeyCompressed.slice(1); +} diff --git a/packages/core/src/wallet-relay.ts b/packages/core/src/wallet-relay.ts new file mode 100644 index 0000000..2b26664 --- /dev/null +++ b/packages/core/src/wallet-relay.ts @@ -0,0 +1,111 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { RelayClient } from "./relay-client.js"; +import { + RelayUpdatePayload, + RelayStatusCallback, + initiateRelay, +} from "./relay-handler.js"; +import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js"; +import { hexToBin } from "@bitauth/libauth"; +import { deriveNostrPublicKeyBytes } from "./utilnostr.js"; + +export interface WalletRelayOptions { + uri: string; + walletPrivateKey: Uint8Array; + explicitRelayUrls?: string[]; + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export interface WalletRelayResult { + client: RelayClient; + dappPublicKey: Uint8Array; + walletPublicKey: Uint8Array; + secret: string; + cleanup: () => void; +} + +export function initiateWalletRelay( + statusCallback: RelayStatusCallback, + options: WalletRelayOptions, +): WalletRelayResult { + let decoded; + try { + decoded = decodeKeyExchangeURI(options.uri); + } catch (err: any) { + throw new Error(`Failed to decode connection URI: ${err.message}`, { + cause: err, + }); + } + + const dappPublicKeyHex = decoded.publicKey; + const secret = decoded.secret; + const dappPublicKeyNostr = hexToBin(dappPublicKeyHex); + + const hostname = decoded.hostname; + const protocol = decoded.protocol; + const port = decoded.port; + const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`; + + const walletPublicKeyNostr = deriveNostrPublicKeyBytes( + options.walletPrivateKey, + ); + + let relayClient: RelayClient | null = null; + + const wrappedCallback: RelayStatusCallback = ( + payload: RelayUpdatePayload, + ) => { + if (!relayClient) { + relayClient = payload.client; + } + + if (payload.status.status === "connected") { + // Set dapp's pubkey so outbound messages are addressed correctly. + // The wallet's own pubkey + secret are delivered via wallet_ready. + relayClient!.setPairedPublicKey(dappPublicKeyNostr); + } + + statusCallback(payload); + }; + + const relayUrls: string[] = [relayUrl]; + if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) { + for (const explicitUrl of options.explicitRelayUrls) { + if (!relayUrls.includes(explicitUrl)) { + relayUrls.push(explicitUrl); + } + } + } + + const cleanup = initiateRelay( + wrappedCallback, + options.walletPrivateKey, + dappPublicKeyNostr, + { + explicitRelayUrls: relayUrls, + reconnectInterval: options.reconnectInterval, + maxReconnectAttempts: options.maxReconnectAttempts, + }, + ); + + const result: WalletRelayResult = { + get client() { + if (!relayClient) { + throw new Error( + "Relay client not yet initialized. Wait for connection status callback.", + ); + } + return relayClient; + }, + dappPublicKey: dappPublicKeyNostr, + walletPublicKey: walletPublicKeyNostr, + secret, + cleanup, + }; + + return result; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..a67526c --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..cb55e7b --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/*.integration.test.ts", "**/node_modules/**"], + }, +}); diff --git a/packages/dapp/package.json b/packages/dapp/package.json new file mode 100644 index 0000000..e7b10e7 --- /dev/null +++ b/packages/dapp/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wizardconnect/dapp", + "version": "0.1.0", + "type": "module", + "description": "Dapp-side integration helpers for WizardConnect", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsc", + "test": "vitest --config vitest.config.ts --run --passWithNoTests", + "lint:prettier": "prettier . --list-different", + "lint:eslint": "eslint .", + "lint": "npm run lint:eslint && npm run lint:prettier", + "fix": "npm run fix:eslint && npm run fix:prettier", + "fix:prettier": "prettier . --write", + "fix:eslint": "npm run lint:eslint -- --fix" + }, + "dependencies": { + "@wizardconnect/core": "*", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } +} diff --git a/packages/dapp/src/.pubkey-state-manager.ts.swp b/packages/dapp/src/.pubkey-state-manager.ts.swp new file mode 100644 index 0000000000000000000000000000000000000000..0d2b25994c5e77701f31bc936a55088c7211b996 GIT binary patch literal 16384 zcmeHNON<;x8EyjPVJ8rg6XNpkBJ7^cJa!=n*!ApU?>g~{*mAsaaYL*{zp|;S7-UD?Thr8=8A{UgP!-pZ+<^|;`x_ObYAqlQ0xw}eGzlhyw61u zoJ-T2jmufu_+&FqgBe4fwE|ml)XAc5A(J3Y5*ZY&z6frLU75E+(eJnNENJxyotyHo zkr$$njh;x*nl+1jo*u5vG2j@O$G`*LCqBNi%soE3xIhnm=<2-AuFWyv7;p?Y1{?#9 z0mp!2z%k$$_&;Ny=-=f%2b=CPcJ<8nd&a)sYyN(~d?#b==1=$K7;p?Y1{?#90mp!2 zz%k$$a11yG90QI4$H4y}10wXik3g?G{J@X*|MvO+Umx_m-vYk@z6x9et^y~3pMSvf zeg^y$H~`wf8Q>ip4*UXm9r!NL0Zs#_fZyHkdCvn+1J?i_xO39;-U7Y>WWXlS2JQjw z27YwH^S%OX0gJ#t?(@7q18)Ow0)GU)4m<;_18v|w;Mezh-j9LXzz%Q{_|rY;5Bvf6 zJ@6XvD)1BF%fL2p9@qlz1%7!qYyn;eUI2E10O$Z81OEPg&-*Lz7vLq}OTcr$v%nhg zAaFl$68Pu)Jns$Q2fzz}0B!)MfH!ck@;yKRPXjx^72qM@S2&b;5%>=9Z6E_412%zk zz=wf%;J3E{_TO9V!x>%SW@cr{$|Db^9u5 z!I)(~jl!yUqj@mMGMN-~hL#iB;D1)h{`N4fB&<16)FnUd7g3tz8_i4(a{0K(i<&jx z4wxcDkH@`~yco)Dld?@{hPIP13S`~{KYp+m#UXxxWwgQCtT7*!k@8z>q@?VL{Ia^+ zQW;#Q&ok{>&``_rH?0&=H_gb`x|NNTcC~&}{0IVB9j0Spk`Fp{ib@Np-EPz7g|n9) zJAZxi!uI1^*S9ZioxfHyGFmNq6w~WTs4t2=5Wgpbo0weEuIM+qQ5;9fuC|4`QI;2) zjS+9CIG_d0H2`njD3RgNVxwYH^@SU6rDV|S$c%r70~O;>rdM<6mw{{|O?m48_mD27;iS7+I|#UD+AGeqQ$Y6%AzU3t z0ZVvF#0cmwlvY=Rdu*LW0lr-mr9D^|1d8m8b%0QGE(J~jc^byl1)ulu7Vdu-&ZEWnQ#_&onjqkK}S>d z>U0&g@6%_qOdO6>z^a8arrl^F|4l9AiqREuE;*K_l?59~lib?)u9ROh`7o(wRwC0; zvTzFuOU$RO;nMq_`C;XR+)GR|#JStA-BzTFMVS{$$Jl8BVxi%^S>ZDWK1fL~!Wb}9; z9Tu8O#4e1C;HpqsUemO~kclLm5dc=J)kArZs*t!^L7Ox!4(mcI#4hza>e;nH1q6L>tnP)(5)pI1D5=p>%k!e1(u&JG_G8tutv5Tzx_T~twi6X%Q0(!&Z~k6f zvqO&%GXXX?b}Sa6`m9xe=7{rHA6lY3RW%K&tNY}=Y@U!%UIM%fM6%5j*M@vO^YiDmb=L*z{8g zZ@-j!eVG{rI8GXQ6iOqE)Ma|Im+3hkJbwQ7@$6mz{QQqcb@kzA|5t!-0bc|5fCb=h z=ywNr8TcY_8`uYUd=9veW56-s7;p?Y1{?#90mp!2z%k$$a11yGECYOGrjN~z?WuE5 z9ITQ$rq5(J2&C_eI(2H)UdLe&ty2fD`UKweb!s*JMu(^uC3A+HnH*xyy^k=vJ+0&! zaWJ$sp8zC8|RPx{ozum1IF4_}B91k9O{|1+yl6e3C literal 0 HcmV?d00001 diff --git a/packages/dapp/src/dapp-connection-manager.ts b/packages/dapp/src/dapp-connection-manager.ts new file mode 100644 index 0000000..0fc2218 --- /dev/null +++ b/packages/dapp/src/dapp-connection-manager.ts @@ -0,0 +1,340 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { EventEmitter } from "eventemitter3"; +import { decodeHdPublicKey } from "@bitauth/libauth"; +import type { HdPublicNodeValid } from "@bitauth/libauth"; +import { + RelayClient, + RelayStatus, + RelayMsgAction, + DappReadyMessage, + WalletReadyMessage, + DisconnectMessage, + DisconnectReason, + SignTransactionRequest, + SignTransactionResponse, + SignCancelMessage, + ProtocolMessage, + PROTOCOL_NAME, + PathName, + childIndexOfPathName, + isHdwalletv1Session, +} from "@wizardconnect/core"; +import { DappPubkeyStateManager } from "./pubkey-state-manager.js"; + +export interface DappConnectionManagerEvents { + /** Fired after wallet_ready is received and state is updated. */ + walletready: [msg: WalletReadyMessage]; + /** Fired for every protocol message sent by the dapp. */ + messagesent: [msg: ProtocolMessage]; + /** Fired for every protocol message received from the wallet. */ + messagereceived: [msg: ProtocolMessage]; + /** Fired on disconnect — either remote-initiated or protocol mismatch. */ + disconnect: [reason: DisconnectReason, message: string | undefined]; +} + +/** + * Manages the dapp side of a single WizardConnect session. + * + * Protocol responsibilities: + * - Sends dapp_ready after key exchange completes (and on reconnect) + * - Receives wallet_ready, sign_transaction_response + * - Tracks pubkeys and address indices (via xpub on-demand derivation) + * + * No chain-specific logic — child indices are plain numbers: + * 0 = Receive, 1 = Change, 7 = Cauldron (BCH convention) + */ +export class DappConnectionManager extends EventEmitter { + private conn: RelayClient | null = null; + private listenerAttached = false; + + /** Pubkey state — exposed for callers that need to query by index. */ + readonly pubkeyState: DappPubkeyStateManager; + + walletName: string | null = null; + walletIcon: string | null = null; + protocol: string | null = null; + + /** Protocols this dapp supports, in preference order. */ + private readonly supportedProtocols: string[] = [PROTOCOL_NAME]; + + private walletDiscovered = false; + private pendingSignatureRequests = new Map< + number, + { + resolve: (r: SignTransactionResponse) => void; + reject: (e: Error) => void; + } + >(); + + /** + * @param dappName Optional display name of the dapp (sent in dapp_ready). + * @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready). + */ + constructor( + private dappName?: string, + private dappIcon?: string, + ) { + super(); + this.pubkeyState = new DappPubkeyStateManager(); + } + + /** + * Call this from the RelayStatusCallback passed to `initiateDappRelay`. + * Attaches the message listener exactly once and re-sends dapp_ready + * each time the connection is established (handles reconnects). + */ + updateConnection( + client: RelayClient | null | undefined, + status: RelayStatus, + ): void { + if (client) { + // Attach message listener once (same RelayClient object is reused across reconnects) + if (!this.listenerAttached) { + this.listenerAttached = true; + client.on("message", (msg: ProtocolMessage) => this.handleMessage(msg)); + } + this.conn = client; + } + + if (status.status === "connected" && this.conn) { + this.onConnected(); + } + } + + isWalletDiscovered(): boolean { + return this.walletDiscovered; + } + + /** + * Get a sequence number from the relay client. + * Use this to populate the `sequence` field of a SignTransactionRequest. + */ + nextSequence(): number { + if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected"); + return this.conn.nextSequence(); + } + + /** + * Send a sign transaction request and wait for the wallet's response. + * The caller is responsible for creating the full SignTransactionRequest + * (including sequence from `nextSequence()`). + */ + async sendSignRequest( + request: SignTransactionRequest, + ): Promise { + if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected"); + + return new Promise((resolve, reject) => { + this.pendingSignatureRequests.set(request.sequence, { resolve, reject }); + + this.conn!.relay(request) + .then(() => { + this.emit("messagesent", request); + }) + .catch((err) => { + this.pendingSignatureRequests.delete(request.sequence); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + } + + /** + * Cancel an in-flight sign request. + * Immediately rejects the pending Promise and sends sign_cancel to the wallet. + */ + async sendSignCancel(sequence: number, reason?: string): Promise { + // Reject pending promise immediately — no response will come + const handlers = this.pendingSignatureRequests.get(sequence); + if (handlers) { + this.pendingSignatureRequests.delete(sequence); + handlers.reject(new Error(reason ?? "Sign request cancelled")); + } + if (!this.conn) return; + const msg: SignCancelMessage = { + action: RelayMsgAction.SignCancel, + sequence, + ...(reason !== undefined && { reason }), + time: Math.floor(Date.now() / 1000), + }; + await this.conn.relay(msg); + this.emit("messagesent", msg); + } + + /** + * Send a disconnect message to the wallet (courtesy notification). + * The caller is responsible for calling dappRelay.cleanup() afterwards. + */ + async sendDisconnect(message?: string): Promise { + if (!this.conn) return; + const msg: DisconnectMessage = { + action: RelayMsgAction.Disconnect, + reason: DisconnectReason.UserDisconnect, + time: Math.floor(Date.now() / 1000), + ...(message !== undefined && { message }), + }; + await this.conn.relay(msg); + this.emit("messagesent", msg); + } + + // --- Pubkey state delegation ------------------------------------------------ + // Convenience methods that forward to pubkeyState. + + getPubkey(childIndex: number, index: bigint): Uint8Array | undefined { + return this.pubkeyState.getPubkey(childIndex, index); + } + + /** Returns true if an xpub node is available for this child index. */ + hasPath(childIndex: number): boolean { + return this.pubkeyState.hasPath(childIndex); + } + + /** + * Returns the stored xpub node for the given child index. + * Available after wallet_ready is received. + */ + getXpubNode(childIndex: number): HdPublicNodeValid | undefined { + return this.pubkeyState.getXpubNode(childIndex); + } + + // --- Private protocol handling ------------------------------------------- + + private onConnected(): void { + (async () => { + // Wait until key exchange is complete before sending dapp_ready + while (this.conn && !this.conn.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 100)); + } + if (this.conn) { + await this.pushDappReady(); + } + })().catch((e) => + console.error("[wizardconnect/dapp] Error in onConnected:", e), + ); + } + + private async pushDappReady(): Promise { + if (!this.conn) return; + + const msg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: this.supportedProtocols, + wallet_discovered: this.walletDiscovered, + time: Math.floor(Date.now() / 1000), + // Include selected_protocol on the reactive send (after the dapp has seen the wallet) + ...(this.walletDiscovered && + this.protocol && { selected_protocol: this.protocol }), + ...(this.dappName !== undefined && { dapp_name: this.dappName }), + ...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }), + }; + + await this.conn.relay(msg); + this.emit("messagesent", msg); + } + + private handleMessage(msg: ProtocolMessage): void { + this.emit("messagereceived", msg); + switch (msg.action) { + case RelayMsgAction.WalletReady: + this.handleWalletReady(msg as WalletReadyMessage); + break; + case RelayMsgAction.SignTransactionResponse: + this.handleSignTransactionResponse(msg as SignTransactionResponse); + break; + case RelayMsgAction.Disconnect: + this.handleRemoteDisconnect(msg as DisconnectMessage); + break; + case RelayMsgAction.DappReady: + // Not expected on dapp side — silently ignore + break; + default: + console.warn( + "[wizardconnect/dapp] Unknown message action:", + msg.action, + ); + } + } + + private handleRemoteDisconnect(msg: DisconnectMessage): void { + this.emit("disconnect", msg.reason, msg.message); + } + + private handleWalletReady(msg: WalletReadyMessage): void { + this.walletDiscovered = true; + this.walletName = msg.wallet_name; + this.walletIcon = msg.wallet_icon; + + // Protocol selection: first match in dapp's preference order + const agreed = this.supportedProtocols.find((p) => + msg.supported_protocols.includes(p), + ); + if (!agreed) { + const detail = `No protocol overlap. Wallet: [${msg.supported_protocols}], Dapp: [${this.supportedProtocols}]`; + const disconnectMsg: DisconnectMessage = { + action: RelayMsgAction.Disconnect, + reason: DisconnectReason.ProtocolMismatch, + message: detail, + time: Math.floor(Date.now() / 1000), + }; + this.conn?.relay(disconnectMsg).catch(() => {}); + this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail); + return; + } + this.protocol = agreed; + + // Extract and validate protocol-specific session data + const sessionData = msg.session[agreed]; + if (!isHdwalletv1Session(sessionData)) { + console.error( + "[wizardconnect/dapp] Invalid hdwalletv1 session data:", + sessionData, + ); + return; + } + + // Store xpub nodes — no eager derivation; consumer drives it + for (const pathInfo of sessionData.paths) { + const decoded = decodeHdPublicKey(pathInfo.xpub); + if (typeof decoded === "string") { + console.warn( + "[wizardconnect/dapp] Bad xpub for path", + pathInfo.name, + decoded, + ); + continue; + } + const ci = childIndexOfPathName(pathInfo.name as PathName); + this.pubkeyState.setXpubNode(ci, decoded.node); + } + + if (!msg.dapp_discovered) { + this.pushDappReady().catch((e) => + console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e), + ); + } + + this.emit("walletready", msg); + } + + private handleSignTransactionResponse( + response: SignTransactionResponse, + ): void { + const handlers = this.pendingSignatureRequests.get(response.sequence); + if (!handlers) { + console.warn( + "[wizardconnect/dapp] No pending request for sequence:", + response.sequence, + ); + return; + } + this.pendingSignatureRequests.delete(response.sequence); + + if (response.error) { + handlers.reject(new Error(response.error)); + } else { + handlers.resolve(response); + } + } +} diff --git a/packages/dapp/src/index.ts b/packages/dapp/src/index.ts new file mode 100644 index 0000000..d85c7b6 --- /dev/null +++ b/packages/dapp/src/index.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +export { DappConnectionManager } from "./dapp-connection-manager.js"; +export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js"; +export { DappPubkeyStateManager } from "./pubkey-state-manager.js"; diff --git a/packages/dapp/src/pubkey-state-manager.test.ts b/packages/dapp/src/pubkey-state-manager.test.ts new file mode 100644 index 0000000..2ca8375 --- /dev/null +++ b/packages/dapp/src/pubkey-state-manager.test.ts @@ -0,0 +1,27 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Dapp-side PubkeyStateManager tests. + * Uses plain number child indices (0=Receive, 1=Change, 7=Cauldron) + * instead of DerivationPath enum. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { DappPubkeyStateManager } from "./pubkey-state-manager.js"; + +const Receive = 0; + +describe("DappPubkeyStateManager", () => { + let manager: DappPubkeyStateManager; + + beforeEach(() => { + manager = new DappPubkeyStateManager(); + }); + + describe("hasPath", () => { + it("should return false when no xpub node is set", () => { + expect(manager.hasPath(Receive)).toBe(false); + }); + }); +}); diff --git a/packages/dapp/src/pubkey-state-manager.ts b/packages/dapp/src/pubkey-state-manager.ts new file mode 100644 index 0000000..2332606 --- /dev/null +++ b/packages/dapp/src/pubkey-state-manager.ts @@ -0,0 +1,43 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Dapp-side pubkey state manager. + * + * Stores xpub nodes received from the wallet and derives pubkeys on demand. + * + * Uses plain `number` for child indices (0=Receive, 1=Change, 7=Cauldron) + * so this package has no dependency on chain-specific types. + */ + +import { deriveHdPublicNodeChild } from "@bitauth/libauth"; +import type { HdPublicNodeValid } from "@bitauth/libauth"; + +export class DappPubkeyStateManager { + // xpub nodes for on-demand pubkey derivation + private xpubNodes: Map = new Map(); + + /** Derive a pubkey on demand from the stored xpub node. */ + getPubkey(childIndex: number, index: bigint): Uint8Array | undefined { + const xpubNode = this.xpubNodes.get(childIndex); + if (!xpubNode) return undefined; + + const child = deriveHdPublicNodeChild(xpubNode, Number(index)); + if (typeof child === "string") return undefined; + return child.publicKey; + } + + /** Returns true if an xpub node is available for this child index. */ + hasPath(childIndex: number): boolean { + return this.xpubNodes.has(childIndex); + } + + setXpubNode(childIndex: number, node: HdPublicNodeValid): void { + this.xpubNodes.set(childIndex, node); + } + + getXpubNode(childIndex: number): HdPublicNodeValid | undefined { + return this.xpubNodes.get(childIndex); + } +} diff --git a/packages/dapp/tsconfig.json b/packages/dapp/tsconfig.json new file mode 100644 index 0000000..a67526c --- /dev/null +++ b/packages/dapp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/dapp/vitest.config.ts b/packages/dapp/vitest.config.ts new file mode 100644 index 0000000..cb55e7b --- /dev/null +++ b/packages/dapp/vitest.config.ts @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/*.integration.test.ts", "**/node_modules/**"], + }, +}); diff --git a/packages/test-cli/package.json b/packages/test-cli/package.json new file mode 100644 index 0000000..778794d --- /dev/null +++ b/packages/test-cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wizardconnect/test-cli", + "version": "0.1.0", + "description": "CLI for testing WizardConnect protocol", + "type": "module", + "private": true, + "bin": { + "wiz-test": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "dapp": "tsx src/cli.ts dapp", + "wallet": "tsx src/cli.ts wallet", + "lint:prettier": "prettier . --list-different", + "lint:eslint": "eslint .", + "lint": "npm run lint:eslint && npm run lint:prettier", + "fix": "npm run fix:eslint && npm run fix:prettier", + "fix:prettier": "prettier . --write", + "fix:eslint": "npm run lint:eslint -- --fix" + }, + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.2", + "@wizardconnect/core": "*", + "@wizardconnect/wallet": "*", + "chalk": "^5.3.0", + "commander": "^12.0.0", + "ora": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.2" + } +} diff --git a/packages/test-cli/src/cli.ts b/packages/test-cli/src/cli.ts new file mode 100644 index 0000000..05e82e3 --- /dev/null +++ b/packages/test-cli/src/cli.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { Command } from "commander"; +import { runDappMode } from "./dapp.js"; +import { runWalletMode } from "./wallet.js"; + +const program = new Command(); + +program + .name("wiz-test") + .description("WizardConnect protocol test CLI") + .version("0.1.0"); + +program + .command("dapp") + .description( + "Run as a dapp — generates URI, waits for wallet connection, logs all messages", + ) + .option( + "-r, --relay ", + "Nostr relay WebSocket URL", + "wss://relay.cauldron.quest:443", + ) + .option( + "-k, --private-key ", + "Existing dapp private key (64 hex chars) — for reconnection testing", + ) + .option("--secret ", "Existing secret (hex) — for reconnection testing") + .option( + "--sign", + "Send a dummy sign request after wallet is ready (tests approval flow)", + ) + .action(async (options) => { + await runDappMode(options); + }); + +program + .command("wallet") + .description("Run as a wallet — connects to a dapp URI, pushes test pubkeys") + .option( + "-r, --relay ", + "Nostr relay WebSocket URL", + "wss://relay.cauldron.quest:443", + ) + .requiredOption("-u, --uri ", "wiz:// URI from dapp") + .option( + "-k, --private-key ", + "Wallet private key (64 hex chars) — random if omitted", + ) + .action(async (options) => { + await runWalletMode(options); + }); + +// Strip bare "--" that npm inserts when forwarding arguments through +// multiple script layers (e.g. `npm run wallet -- --uri ...`). +const argv = process.argv.filter((arg) => arg !== "--"); +program.parse(argv); diff --git a/packages/test-cli/src/dapp.ts b/packages/test-cli/src/dapp.ts new file mode 100644 index 0000000..8f19377 --- /dev/null +++ b/packages/test-cli/src/dapp.ts @@ -0,0 +1,297 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import chalk from "chalk"; +import ora from "ora"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + type RelayUpdatePayload, + type DappReadyMessage, + type WalletReadyMessage, + type Hdwalletv1Session, + type SignTransactionResponse, + type ProtocolMessage, + type RelayClient, +} from "@wizardconnect/core"; + +// ---- State ---- + +interface DappState { + walletDiscovered: boolean; + walletName: string | null; + walletIcon: string | null; + // Whether we've received at least one wallet_ready + walletReady: boolean; + sequence: number; +} + +function makeState(): DappState { + return { + walletDiscovered: false, + walletName: null, + walletIcon: null, + walletReady: false, + sequence: 1, + }; +} + +// ---- Send dapp_ready ---- + +async function sendDappReady( + client: RelayClient, + state: DappState, +): Promise { + const msg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: state.walletDiscovered, + time: Math.floor(Date.now() / 1000), + }; + + console.log( + chalk.blue(`→ dapp_ready`) + + chalk.dim(` wallet_discovered=${msg.wallet_discovered}`), + ); + + try { + await client.relay(msg); + } catch (err: any) { + console.error(chalk.red(" send error:"), err.message); + } +} + +// ---- Send dummy sign request ---- + +async function sendSignRequest( + client: RelayClient, + state: DappState, +): Promise { + const sequence = state.sequence++; + + // Minimal dummy transaction for approval flow testing + const msg = { + action: RelayMsgAction.SignTransactionRequest, + sequence, + transaction: { + transaction: { + inputs: [], + outputs: [], + version: 2, + locktime: 0, + }, + sourceOutputs: [], + userPrompt: "Test sign request from wiz-test CLI", + broadcast: false, + }, + time: Math.floor(Date.now() / 1000), + }; + + console.log( + chalk.yellow(`→ sign_transaction_request`) + chalk.dim(` seq=${sequence}`), + ); + + try { + await (client as any).relay(msg); + } catch (err: any) { + console.error(chalk.red(" send error:"), err.message); + } +} + +// ---- Handle incoming messages ---- + +function handleMessage( + message: ProtocolMessage, + client: RelayClient, + state: DappState, + sendSign: boolean, +): void { + const now = chalk.dim(new Date().toISOString().slice(11, 23)); + + switch (message.action) { + case RelayMsgAction.WalletReady: { + const msg = message as WalletReadyMessage; + state.walletDiscovered = true; + state.walletName = msg.wallet_name; + state.walletIcon = msg.wallet_icon; + state.walletReady = true; + + const hdwv1 = msg.session?.[PROTOCOL_NAME] as + | Hdwalletv1Session + | undefined; + const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none"; + + console.log( + `${now} ` + + chalk.green("← wallet_ready") + + ` wallet="${chalk.bold(msg.wallet_name)}"` + + ` dapp_discovered=${msg.dapp_discovered}` + + ` protocols=[${msg.supported_protocols.join(",")}]` + + ` paths=[${pathsSummary}]`, + ); + + if (!msg.dapp_discovered) { + // Wallet hasn't seen us — reply with dapp_ready(wallet_discovered: true) + sendDappReady(client, state).catch(() => {}); + } + + // If --sign flag set, schedule a sign request after wallet_ready + if (sendSign && state.walletReady) { + console.log( + chalk.dim(" (--sign: scheduling sign request in 500ms...)"), + ); + setTimeout(() => { + sendSignRequest(client, state).catch(() => {}); + }, 500); + } + break; + } + + case RelayMsgAction.SignTransactionResponse: { + const msg = message as SignTransactionResponse; + if (msg.error) { + console.log( + chalk.red("← sign_response") + + ` seq=${msg.sequence} error="${msg.error}"`, + ); + } else { + console.log( + chalk.green("← sign_response") + + ` seq=${msg.sequence} tx=${msg.signedTransaction.slice(0, 32)}...`, + ); + } + break; + } + + case RelayMsgAction.DappReady: + console.log(chalk.dim("← dapp_ready (unexpected as dapp, ignoring)")); + break; + + default: + console.log(chalk.dim(`← unknown action: ${message.action}`)); + } +} + +// ---- Main ---- + +export async function runDappMode(options: { + relay: string; + privateKey?: string; + secret?: string; + sign?: boolean; +}): Promise { + const state = makeState(); + + console.log(chalk.bold("\nwiz-test dapp mode")); + console.log(chalk.dim(`relay: ${options.relay}`)); + if (options.sign) + console.log( + chalk.yellow( + "--sign: will send dummy sign request after first pubkey batch", + ), + ); + console.log(); + + let connected = false; + let capturedClient: RelayClient | null = null; + + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + // Capture the client reference here — dappRelay.client is null at creation time + if (payload.client) capturedClient = payload.client; + + const s = payload.status.status; + if (s === "connected" && !connected) { + connected = true; + console.log(chalk.green("relay: connected")); + } else if (s === "reconnecting") { + console.log(chalk.yellow("relay: reconnecting...")); + connected = false; + } else if (s === "disconnected") { + console.log(chalk.red("relay: disconnected")); + connected = false; + } + }, + { + explicitRelayUrls: [options.relay], + existingCredentials: + options.privateKey && options.secret + ? { privateKey: options.privateKey, secret: options.secret } + : undefined, + }, + ); + + // ---- Print URI ---- + console.log(chalk.bgCyan(chalk.black(" CONNECTION URI "))); + console.log(); + console.log(" " + chalk.cyan(chalk.bold(dappRelay.uri))); + console.log(); + console.log( + chalk.dim( + "Dev shortcut:\n npm run dev -- --wiz=" + + encodeURIComponent(dappRelay.uri), + ), + ); + if (options.privateKey) { + console.log( + chalk.dim( + `\nReconnect:\n wiz-test dapp --private-key ${options.privateKey} --secret ${dappRelay.credentials.secret}`, + ), + ); + } else { + console.log( + chalk.dim( + `\nReconnect:\n wiz-test dapp --private-key ${dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret}`, + ), + ); + } + console.log(); + + const keySpinner = ora("Waiting for wallet to connect...").start(); + + // ---- Key exchange ---- + dappRelay.events.on("keyexchangecomplete", async (walletPubkey) => { + const hex = Buffer.from(walletPubkey).toString("hex"); + keySpinner.succeed( + chalk.green("Key exchange complete!") + + chalk.dim(` wallet nostr key: ${hex.slice(0, 16)}...`), + ); + + // Wait for client to be fully ready + await new Promise((r) => setTimeout(r, 50)); + + const client = capturedClient; + if (!client) { + console.error(chalk.red("No relay client after key exchange")); + return; + } + + // Wait for key exchange to complete on the client side + while (!client.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + // Register message handler + client.on("message", (message: ProtocolMessage) => { + handleMessage(message, client, state, options.sign ?? false); + }); + + // Send initial dapp_ready + await sendDappReady(client, state); + }); + + // ---- Keep alive ---- + console.log(chalk.dim("Press Ctrl+C to quit\n")); + + process.on("SIGINT", () => { + keySpinner.stop(); + console.log(chalk.yellow("\nShutting down...")); + dappRelay.cleanup(); + process.exit(0); + }); + + // Keep process alive + await new Promise(() => {}); +} diff --git a/packages/test-cli/src/wallet.ts b/packages/test-cli/src/wallet.ts new file mode 100644 index 0000000..8ac1e94 --- /dev/null +++ b/packages/test-cli/src/wallet.ts @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import chalk from "chalk"; +import ora from "ora"; +import { + WalletConnectionManager, + type WalletAdapter, + DerivationPath, +} from "@wizardconnect/wallet"; +import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core"; +import { + deriveHdPrivateNodeFromSeed, + deriveHdPrivateNodeChild, + deriveHdPath, + deriveHdPublicNode, + encodeHdPublicKey, + secp256k1, +} from "@bitauth/libauth"; + +// ---- Build WalletAdapter ---- + +async function buildAdapter( + relayPrivateKey: Uint8Array, +): Promise { + // Use relay private key as HD seed for deterministic derivation + const hdMaster = deriveHdPrivateNodeFromSeed(relayPrivateKey); + + const hdMain = deriveHdPath(hdMaster, "m/0") as any; + + const hdChange = deriveHdPath(hdMaster, "m/1") as any; + + const hdDefi = deriveHdPath(hdMaster, "m/7") as any; + + return { + walletName: "wiz-test CLI wallet", + walletIcon: "", + + getRelayPrivateKey(): Uint8Array { + return relayPrivateKey; + }, + + getPublicKey(path: DerivationPath, index: bigint): Uint8Array { + const hdChain = + (path as number) === 1 + ? hdChange + : (path as number) === 7 + ? hdDefi + : hdMain; + const child = deriveHdPrivateNodeChild(hdChain, Number(index)); + const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey); + if (typeof pubKey === "string") throw new Error(`secp256k1: ${pubKey}`); + return pubKey; + }, + + getXpub(path: DerivationPath): string { + const hdChain = + (path as number) === 1 + ? hdChange + : (path as number) === 7 + ? hdDefi + : hdMain; + const result = encodeHdPublicKey({ + node: deriveHdPublicNode(hdChain), + network: "mainnet", + }); + if (typeof result === "string") + throw new Error(`encodeHdPublicKey: ${result}`); + return result.hdPublicKey; + }, + + async signTransaction(_request): Promise { + throw new Error("signTransaction not implemented in test wallet"); + }, + }; +} + +// ---- Main ---- + +export async function runWalletMode(options: { + relay: string; + uri: string; + privateKey?: string; +}): Promise { + console.log(chalk.bold("\nwiz-test wallet mode")); + console.log(chalk.dim(`relay: ${options.relay}`)); + console.log(chalk.dim(`uri: ${options.uri}`)); + console.log(); + + // Derive relay private key + let relayPrivKey: Uint8Array; + if (options.privateKey) { + if (options.privateKey.length !== 64) { + console.error(chalk.red("--private-key must be 64 hex chars")); + process.exit(1); + } + relayPrivKey = hexToBin(options.privateKey); + } else { + const creds = generateKeyExchangeCredentials(); + relayPrivKey = hexToBin(creds.privateKey); + console.log(chalk.dim(`generated relay key: ${creds.privateKey}`)); + } + + const buildSpinner = ora("Building test wallet adapter...").start(); + const adapter = await buildAdapter(relayPrivKey); + buildSpinner.succeed("Wallet adapter ready"); + + const manager = new WalletConnectionManager(adapter); + + manager.on("connectionStatusChanged", (connectionId, status) => { + const s = status.status; + if (s === "connected") { + console.log(chalk.green(`connection ${connectionId}: connected`)); + } else if (s === "reconnecting") { + console.log(chalk.yellow(`connection ${connectionId}: reconnecting...`)); + } else if (s === "disconnected") { + console.log(chalk.red(`connection ${connectionId}: disconnected`)); + } + }); + + manager.on("pendingSignRequest", (request) => { + console.log( + chalk.yellow("← sign_request") + + chalk.dim( + ` conn=${request.connectionId} seq=${request.request.sequence}`, + ), + ); + console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)")); + manager + .sendSignError( + request.connectionId, + request.request.sequence, + "Test wallet cannot sign", + ) + .catch(() => {}); + }); + + console.log(chalk.dim("\nConnecting to dapp...")); + const connectionId = manager.connect(options.uri); + console.log(chalk.dim(`connection id: ${connectionId}`)); + console.log(chalk.dim("Press Ctrl+C to quit\n")); + + process.on("SIGINT", () => { + console.log(chalk.yellow("\nShutting down...")); + manager.disconnectAll(); + process.exit(0); + }); + + await new Promise(() => {}); +} diff --git a/packages/test-cli/tsconfig.json b/packages/test-cli/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/test-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/wallet/package.json b/packages/wallet/package.json new file mode 100644 index 0000000..80b4399 --- /dev/null +++ b/packages/wallet/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wizardconnect/wallet", + "version": "0.1.0", + "type": "module", + "description": "Wallet-side integration helpers for WizardConnect", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsc", + "test": "vitest --config vitest.config.ts --run --passWithNoTests", + "test:integration": "vitest --config vitest.integration.config.ts --run", + "lint:prettier": "prettier . --list-different", + "lint:eslint": "eslint .", + "lint": "npm run lint:eslint && npm run lint:prettier", + "fix": "npm run fix:eslint && npm run fix:prettier", + "fix:prettier": "prettier . --write", + "fix:eslint": "npm run lint:eslint -- --fix" + }, + "dependencies": { + "@wizardconnect/core": "*", + "@bitauth/libauth": "^3.1.0-next.2", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^3.2.3" + } +} diff --git a/packages/wallet/src/derivation-path.ts b/packages/wallet/src/derivation-path.ts new file mode 100644 index 0000000..a5b7045 --- /dev/null +++ b/packages/wallet/src/derivation-path.ts @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * BCH HD wallet derivation paths. + * child_index values as used in hdwalletv1 protocol: + * 0 = Receive (m/44'/145'/0'/0) + * 1 = Change (m/44'/145'/0'/1) + * 7 = Cauldron (m/44'/145'/0'/7) + */ +export enum DerivationPath { + Receive = 0, + Change = 1, + Cauldron = 7, +} + +export function childIndexOfPath(path: DerivationPath): number { + return path as number; +} + +export function pathOfChildIndex(child: number): DerivationPath | undefined { + switch (child) { + case 0: + return DerivationPath.Receive; + case 1: + return DerivationPath.Change; + case 7: + return DerivationPath.Cauldron; + default: + return undefined; + } +} diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts new file mode 100644 index 0000000..719d65a --- /dev/null +++ b/packages/wallet/src/index.ts @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +export { + DerivationPath, + childIndexOfPath, + pathOfChildIndex, +} from "./derivation-path.js"; +export type { WalletAdapter, SignTransactionResult } from "./wallet-adapter.js"; +export { WalletConnectionManager } from "./wallet-connection-manager.js"; +export type { + RelayConnectionState, + PendingSignRequest, + WalletConnectionManagerEvents, +} from "./wallet-connection-manager.js"; diff --git a/packages/wallet/src/integration/disconnect.test.ts b/packages/wallet/src/integration/disconnect.test.ts new file mode 100644 index 0000000..ce4f76b --- /dev/null +++ b/packages/wallet/src/integration/disconnect.test.ts @@ -0,0 +1,148 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Disconnect tests — verifies the disconnect message flow. + * + * Test: user_disconnect — dapp sends a raw DisconnectMessage; wallet emits remoteDisconnect. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + DisconnectReason, + type RelayUpdatePayload, + type RelayClient, + type DappReadyMessage, + type WalletReadyMessage, + type DisconnectMessage, + type ProtocolMessage, +} from "@wizardconnect/core"; +import { WalletConnectionManager } from "@wizardconnect/wallet"; +import { makeTestAdapter, waitFor } from "./helpers.js"; + +const TEST_RELAY_URL = + process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + +describe("WalletConnectionManager — disconnect", () => { + let dappCleanup: () => void; + let dappClient: RelayClient | null = null; + let dappUri: string; + + let manager: WalletConnectionManager; + let connectionId: string; + + let walletReadyReceived = false; + + const remoteDisconnectEvents: Array<{ + connectionId: string; + reason: DisconnectReason; + message: string | undefined; + }> = []; + + beforeAll(async () => { + // ---- Dapp setup ---- + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: [TEST_RELAY_URL] }, + ); + + dappUri = dappRelay.uri; + dappCleanup = dappRelay.cleanup; + + // Wait for the dapp relay client to be available + await waitFor(() => dappClient !== null, { + timeoutMs: 10000, + what: "dapp relay client", + }); + + // Register keyexchange handler BEFORE wallet connects to avoid race condition + dappRelay.events.on("keyexchangecomplete", async () => { + await new Promise((r) => setTimeout(r, 50)); + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + dappClient!.on("message", (msg: ProtocolMessage) => { + if (msg.action === RelayMsgAction.WalletReady) { + walletReadyReceived = true; + const wr = msg as WalletReadyMessage; + if (!wr.dapp_discovered) { + const reply: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: true, + time: Math.floor(Date.now() / 1000), + }; + dappClient!.relay(reply).catch(() => {}); + } + } + }); + + const initMsg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: false, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(initMsg); + }); + + // ---- Wallet side (connect AFTER registering dapp handlers) ---- + const adapter = makeTestAdapter(); + manager = new WalletConnectionManager(adapter); + + manager.on("remoteDisconnect", (id, reason, message) => { + remoteDisconnectEvents.push({ connectionId: id, reason, message }); + }); + + connectionId = manager.connect(dappUri); + + // Wait for full handshake (wallet_ready received on dapp side) + await waitFor(() => walletReadyReceived, { + timeoutMs: 15000, + what: "wallet_ready received on dapp side", + }); + + // Give a moment for the reactive dapp_ready to be processed + await new Promise((r) => setTimeout(r, 300)); + }, 30000); + + afterAll(() => { + dappCleanup?.(); + manager?.disconnectAll(); + }); + + it("user_disconnect: dapp sends disconnect, wallet emits remoteDisconnect and removes connection", async () => { + expect(Object.keys(manager.getConnections())).toContain(connectionId); + + // Dapp sends a raw disconnect message + const disconnectMsg: DisconnectMessage = { + action: RelayMsgAction.Disconnect, + reason: DisconnectReason.UserDisconnect, + message: "test disconnect", + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(disconnectMsg); + + // Wallet should emit remoteDisconnect + await waitFor(() => remoteDisconnectEvents.length > 0, { + timeoutMs: 5000, + what: "remoteDisconnect event on wallet", + }); + + expect(remoteDisconnectEvents[0].connectionId).toBe(connectionId); + expect(remoteDisconnectEvents[0].reason).toBe( + DisconnectReason.UserDisconnect, + ); + expect(remoteDisconnectEvents[0].message).toBe("test disconnect"); + + // Connection should be removed from manager + expect(Object.keys(manager.getConnections())).not.toContain(connectionId); + }, 15000); +}); diff --git a/packages/wallet/src/integration/helpers.ts b/packages/wallet/src/integration/helpers.ts new file mode 100644 index 0000000..007bf69 --- /dev/null +++ b/packages/wallet/src/integration/helpers.ts @@ -0,0 +1,233 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Shared test helpers for integration tests. + * + * Architecture: the wallet side uses WalletConnectionManager (our package). + * The dapp side is implemented manually using initiateDappRelay from core. + */ + +import { + generateRandomBytes, + deriveHdPrivateNodeFromSeed, + deriveHdPrivateNodeChild, + deriveHdPath, + deriveHdPublicNode, + encodeHdPublicKey, + secp256k1, +} from "@bitauth/libauth"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + type RelayClient, + type RelayUpdatePayload, + type DappReadyMessage, + type WalletReadyMessage, + type ProtocolMessage, +} from "@wizardconnect/core"; +import { + WalletConnectionManager, + DerivationPath, + type WalletAdapter, +} from "@wizardconnect/wallet"; + +// ---- waitFor ---------------------------------------------------------------- + +export async function waitFor( + condition: () => boolean | Promise, + options: { timeoutMs?: number; intervalMs?: number; what?: string } = {}, +): Promise { + const { timeoutMs = 15000, intervalMs = 100, what = "condition" } = options; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Timed out (${timeoutMs}ms) waiting for: ${what}`); +} + +// ---- TestWalletAdapter ------------------------------------------------------ + +export function makeTestAdapter(seed?: Uint8Array): WalletAdapter { + const seedBytes = seed ?? generateRandomBytes(32); + const hdMaster = deriveHdPrivateNodeFromSeed(seedBytes); + + const hdMain = deriveHdPath(hdMaster, "m/0") as any; + + const hdChange = deriveHdPath(hdMaster, "m/1") as any; + + const hdDefi = deriveHdPath(hdMaster, "m/7") as any; + + return { + walletName: "Test Wallet", + walletIcon: "", + + getRelayPrivateKey(): Uint8Array { + return hdMaster.privateKey; + }, + + getPublicKey(path: DerivationPath, index: bigint): Uint8Array { + const hdChain = + (path as number) === 1 + ? hdChange + : (path as number) === 7 + ? hdDefi + : hdMain; + const child = deriveHdPrivateNodeChild(hdChain, Number(index)); + const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey); + if (typeof pubKey === "string") throw new Error(pubKey); + return pubKey; + }, + + getXpub(path: DerivationPath): string { + const hdChain = + (path as number) === 1 + ? hdChange + : (path as number) === 7 + ? hdDefi + : hdMain; + const result = encodeHdPublicKey({ + node: deriveHdPublicNode(hdChain), + network: "mainnet", + }); + if (typeof result === "string") throw new Error(result); + return result.hdPublicKey; + }, + + async signTransaction() { + throw new Error("signTransaction not implemented in test adapter"); + }, + }; +} + +// ---- DappHandle ------------------------------------------------------------- + +export interface DappHandle { + uri: string; + cleanup: () => void; + /** All wallet_ready messages received. */ + walletReadyMessages: WalletReadyMessage[]; +} + +// ---- WalletHandle ----------------------------------------------------------- + +export interface WalletHandle { + manager: WalletConnectionManager; + connectionId: string; + adapter: WalletAdapter; +} + +// ---- ConnectionHandles ------------------------------------------------------ + +export interface ConnectionHandles { + dapp: DappHandle; + wallet: WalletHandle; + cleanup(): void; +} + +// ---- setupConnection -------------------------------------------------------- + +/** + * Sets up a full dapp + wallet connection. + * + * Waits for: + * 1. Key exchange to complete + * 2. The initial dapp_ready / wallet_ready handshake + * 3. wallet_ready with paths + */ +export async function setupConnection( + relayUrl: string = process.env.TEST_RELAY_URL ?? + "wss://relay.cauldron.quest:443", + seed?: Uint8Array, +): Promise { + const adapter = makeTestAdapter(seed); + + // ---- Dapp side ---- + + const walletReadyMessages: WalletReadyMessage[] = []; + let dappClient: RelayClient | null = null; + let keyExchanged = false; + + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: [relayUrl] }, + ); + + async function sendDappReady(wd: boolean): Promise { + const msg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: wd, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(msg); + } + + dappRelay.events.on("keyexchangecomplete", async () => { + keyExchanged = true; + + // Wait for client to be ready + await new Promise((r) => setTimeout(r, 50)); + + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + // Register message handler + dappClient!.on("message", (message: ProtocolMessage) => { + if (message.action === RelayMsgAction.WalletReady) { + const msg = message as WalletReadyMessage; + walletReadyMessages.push(msg); + if (!msg.dapp_discovered) { + sendDappReady(true).catch(() => {}); + } + } + }); + + // Initial dapp_ready — tells wallet we're here (wallet not yet discovered) + await sendDappReady(false); + }); + + // ---- Wallet side ---- + + const manager = new WalletConnectionManager(adapter); + const connectionId = manager.connect(dappRelay.uri); + + // ---- Wait for key exchange ---- + + await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" }); + + // ---- Wait for wallet_ready with paths ---- + + await waitFor( + () => + walletReadyMessages.length > 0 && + (walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length > + 0, + { timeoutMs: 15000, what: "wallet_ready with paths" }, + ); + + // ---- Build handle ---- + + const dappHandle: DappHandle = { + uri: dappRelay.uri, + cleanup: dappRelay.cleanup, + walletReadyMessages, + }; + + const walletHandle: WalletHandle = { manager, connectionId, adapter }; + + return { + dapp: dappHandle, + wallet: walletHandle, + cleanup() { + dappRelay.cleanup(); + manager.disconnectAll(); + }, + }; +} diff --git a/packages/wallet/src/integration/integration.test.ts b/packages/wallet/src/integration/integration.test.ts new file mode 100644 index 0000000..0104e45 --- /dev/null +++ b/packages/wallet/src/integration/integration.test.ts @@ -0,0 +1,126 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Integration tests — basic connection, key exchange, and xpub delivery. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { decodeHdPublicKey, deriveHdPublicNodeChild } from "@bitauth/libauth"; +import { + setupConnection, + makeTestAdapter, + type ConnectionHandles, +} from "./helpers.js"; +import { DerivationPath } from "@wizardconnect/wallet"; +import type { Hdwalletv1Session } from "@wizardconnect/core"; + +const TEST_RELAY_URL = + process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + +const PATHS = [ + { childIndex: 0, name: "receive" }, + { childIndex: 1, name: "change" }, + { childIndex: 7, name: "defi" }, +]; + +// Fixed seed for deterministic tests +const TEST_SEED = new Uint8Array(32).fill(0xab); + +describe("WalletConnectionManager — integration", () => { + let conn: ConnectionHandles; + + beforeAll(async () => { + conn = await setupConnection(TEST_RELAY_URL, TEST_SEED); + }, 30000); + + afterAll(() => { + conn?.cleanup(); + }); + + it("should complete key exchange between dapp and wallet", () => { + expect(conn.dapp.walletReadyMessages.length).toBeGreaterThan(0); + }); + + it("should have received wallet name in wallet_ready", () => { + const msg = conn.dapp.walletReadyMessages[0]; + expect(msg.wallet_name).toBe("Test Wallet"); + expect(msg.supported_protocols).toContain("hdwalletv1"); + }); + + it("wallet_ready session.hdwalletv1 contains paths", () => { + const msg = conn.dapp.walletReadyMessages[0]; + const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session; + expect(hdwv1).toBeDefined(); + expect(Array.isArray(hdwv1.paths)).toBe(true); + expect(hdwv1.paths.length).toBeGreaterThanOrEqual(3); + }); + + it("wallet_ready contains paths for all three named paths", () => { + const msg = conn.dapp.walletReadyMessages[0]; + const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session; + expect(Array.isArray(hdwv1.paths)).toBe(true); + const names = hdwv1.paths.map((p) => p.name); + for (const { name } of PATHS) { + expect(names, `Expected path for ${name}`).toContain(name); + } + }); + + it("paths have all named paths", () => { + const msg = conn.dapp.walletReadyMessages[0]; + const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session; + for (const { name } of PATHS) { + const pathInfo = hdwv1.paths.find((p) => p.name === name); + expect(pathInfo, `Expected path info for ${name}`).toBeDefined(); + } + }); + + it("xpubs are valid BIP32 base58", () => { + const msg = conn.dapp.walletReadyMessages[0]; + const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session; + const xpubRe = /^[xt]pub[1-9A-HJ-NP-Za-km-z]{107,108}$/; + for (const pathInfo of hdwv1.paths) { + expect(pathInfo.xpub, `xpub for ${pathInfo.name}`).toMatch(xpubRe); + } + }); + + it("xpubs decode and derive pubkeys consistent with adapter", () => { + const adapter = makeTestAdapter(TEST_SEED); + const msg = conn.dapp.walletReadyMessages[0]; + const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session; + + for (const { childIndex, name } of PATHS) { + const pathInfo = hdwv1.paths.find((p) => p.name === name)!; + expect(pathInfo, `Expected path info for ${name}`).toBeDefined(); + + const decoded = decodeHdPublicKey(pathInfo.xpub); + expect( + typeof decoded, + `xpub decode failed for ${name}: ${decoded}`, + ).not.toBe("string"); + if (typeof decoded === "string") continue; + + // Derive index 0 and 3 from xpub, compare with adapter + for (const i of [0, 3]) { + const child = deriveHdPublicNodeChild(decoded.node, i); + expect( + typeof child, + `child derivation failed for ${name}[${i}]`, + ).not.toBe("string"); + if (typeof child === "string") continue; + + const fromXpub = child.publicKey; + const fromAdapter = adapter.getPublicKey( + childIndex as DerivationPath, + BigInt(i), + ); + + expect( + Array.from(fromXpub), + `${name}[${i}]: xpub-derived pubkey should match adapter`, + ).toEqual(Array.from(fromAdapter)); + } + } + }); +}); diff --git a/packages/wallet/src/integration/reconnection.test.ts b/packages/wallet/src/integration/reconnection.test.ts new file mode 100644 index 0000000..0033ae2 --- /dev/null +++ b/packages/wallet/src/integration/reconnection.test.ts @@ -0,0 +1,156 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Reconnection tests — verifies that a second wallet with the same private key + * can reconnect to the same dapp session and receive wallet_ready without interference + * from old messages. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + type RelayUpdatePayload, + type RelayClient, + type DappReadyMessage, + type WalletReadyMessage, + type ProtocolMessage, +} from "@wizardconnect/core"; +import { WalletConnectionManager } from "@wizardconnect/wallet"; +import { makeTestAdapter, waitFor } from "./helpers.js"; +import { generateRandomBytes } from "@bitauth/libauth"; + +const TEST_RELAY_URL = + process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + +describe("WalletConnectionManager — reconnection", () => { + // Shared dapp that stays alive across both wallet connections + let dappCleanup: () => void; + let dappClient: RelayClient | null = null; + let dappUri: string; + + let walletReadyMessages: WalletReadyMessage[] = []; + let walletDiscovered = false; + + // Shared wallet relay private key — same key used for both connections + const walletSeed = generateRandomBytes(32); + + beforeAll(async () => { + // ---- Dapp setup ---- + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: [TEST_RELAY_URL] }, + ); + + dappUri = dappRelay.uri; + dappCleanup = dappRelay.cleanup; + + // Wait for dapp relay to connect + await waitFor(() => dappClient !== null, { + timeoutMs: 10000, + what: "dapp relay connection", + }); + + // Register message handler — stays alive for both wallet connections + dappClient!.on("message", (message: ProtocolMessage) => { + if (message.action === RelayMsgAction.WalletReady) { + const msg = message as WalletReadyMessage; + walletDiscovered = true; + walletReadyMessages.push(msg); + if (!msg.dapp_discovered) { + sendDappReady(true).catch(() => {}); + } + } + }); + + dappRelay.events.on("keyexchangecomplete", async () => { + await new Promise((r) => setTimeout(r, 50)); + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + await sendDappReady(false); + }); + }, 15000); + + afterAll(() => { + dappCleanup?.(); + }); + + async function sendDappReady(wd: boolean): Promise { + const msg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: wd, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(msg); + } + + it("first wallet connects and sends wallet_ready with paths", async () => { + const adapter1 = makeTestAdapter(walletSeed); + const manager1 = new WalletConnectionManager(adapter1); + manager1.connect(dappUri); + + // Wait for first wallet_ready with paths + await waitFor( + () => + walletReadyMessages.length > 0 && + (walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length > + 0, + { + timeoutMs: 15000, + what: "wallet_ready with paths from first connection", + }, + ); + + expect(walletReadyMessages.length).toBeGreaterThan(0); + expect( + (walletReadyMessages[0].session["hdwalletv1"] as any).paths.length, + ).toBeGreaterThan(0); + expect(walletDiscovered).toBe(true); + + // Disconnect first wallet + manager1.disconnectAll(); + + // Short pause to let disconnect propagate + await new Promise((r) => setTimeout(r, 1000)); + + // Store count for second connection comparison + (globalThis as any).__walletReadyCountAfterFirst = + walletReadyMessages.length; + }, 30000); + + it("second wallet with same private key reconnects and sends wallet_ready", async () => { + const countBefore: number = + (globalThis as any).__walletReadyCountAfterFirst ?? 0; + + // Reset walletDiscovered to verify second handshake completes + walletDiscovered = false; + + const adapter2 = makeTestAdapter(walletSeed); // same seed = same relay private key + const manager2 = new WalletConnectionManager(adapter2); + manager2.connect(dappUri); + + // Wait for second connection to deliver wallet_ready + await waitFor(() => walletReadyMessages.length > countBefore, { + timeoutMs: 15000, + what: "wallet_ready from second connection", + }); + + expect(walletReadyMessages.length).toBeGreaterThan(countBefore); + expect(walletDiscovered).toBe(true); + + // Verify the new wallet_ready has paths + const newMsg = walletReadyMessages[walletReadyMessages.length - 1]; + expect((newMsg.session["hdwalletv1"] as any).paths.length).toBeGreaterThan( + 0, + ); + + manager2.disconnectAll(); + }, 30000); +}); diff --git a/packages/wallet/src/integration/sign-cancel.test.ts b/packages/wallet/src/integration/sign-cancel.test.ts new file mode 100644 index 0000000..7c773c4 --- /dev/null +++ b/packages/wallet/src/integration/sign-cancel.test.ts @@ -0,0 +1,144 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * sign_cancel tests — verifies that an incoming sign_cancel from the dapp + * causes the WalletConnectionManager to emit a signCancelled event. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + type RelayUpdatePayload, + type RelayClient, + type DappReadyMessage, + type WalletReadyMessage, + type ProtocolMessage, + type SignCancelMessage, +} from "@wizardconnect/core"; +import { WalletConnectionManager } from "@wizardconnect/wallet"; +import { makeTestAdapter, waitFor } from "./helpers.js"; + +const TEST_RELAY_URL = + process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + +describe("WalletConnectionManager — sign_cancel", () => { + let dappCleanup: () => void; + let dappClient: RelayClient | null = null; + + let manager: WalletConnectionManager; + let connectionId: string; + + let walletReadyReceived = false; + + const signCancelledEvents: Array<{ + connectionId: string; + sequence: number; + reason: string | undefined; + }> = []; + + beforeAll(async () => { + // ---- Dapp setup ---- + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: [TEST_RELAY_URL] }, + ); + + dappCleanup = dappRelay.cleanup; + + dappRelay.events.on("keyexchangecomplete", async () => { + await new Promise((r) => setTimeout(r, 50)); + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + dappClient!.on("message", (msg: ProtocolMessage) => { + if (msg.action === RelayMsgAction.WalletReady) { + walletReadyReceived = true; + const wr = msg as WalletReadyMessage; + if (!wr.dapp_discovered) { + const reply: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: true, + time: Math.floor(Date.now() / 1000), + }; + dappClient!.relay(reply).catch(() => {}); + } + } + }); + + const initMsg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: false, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(initMsg); + }); + + // ---- Wallet side ---- + const adapter = makeTestAdapter(); + manager = new WalletConnectionManager(adapter); + + manager.on("signCancelled", (connId, sequence, reason) => { + signCancelledEvents.push({ connectionId: connId, sequence, reason }); + }); + + connectionId = manager.connect(dappRelay.uri); + + await waitFor(() => walletReadyReceived, { + timeoutMs: 15000, + what: "wallet_ready received on dapp side", + }); + + // Give time for reactive dapp_ready to be processed + await new Promise((r) => setTimeout(r, 300)); + }, 30000); + + afterAll(() => { + dappCleanup?.(); + manager?.disconnectAll(); + }); + + it("sign_cancel from dapp emits signCancelled on wallet with correct fields", async () => { + const cancelMsg: SignCancelMessage = { + action: RelayMsgAction.SignCancel, + sequence: 42, + reason: "user cancelled on dapp", + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(cancelMsg); + + await waitFor(() => signCancelledEvents.length > 0, { + timeoutMs: 5000, + what: "signCancelled event on wallet", + }); + + expect(signCancelledEvents[0].connectionId).toBe(connectionId); + expect(signCancelledEvents[0].sequence).toBe(42); + expect(signCancelledEvents[0].reason).toBe("user cancelled on dapp"); + }, 15000); + + it("sign_cancel without reason emits signCancelled with undefined reason", async () => { + const cancelMsg: SignCancelMessage = { + action: RelayMsgAction.SignCancel, + sequence: 99, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(cancelMsg); + + await waitFor(() => signCancelledEvents.length >= 2, { + timeoutMs: 5000, + what: "second signCancelled event on wallet", + }); + + expect(signCancelledEvents[1].sequence).toBe(99); + expect(signCancelledEvents[1].reason).toBeUndefined(); + }, 15000); +}); diff --git a/packages/wallet/src/wallet-adapter.ts b/packages/wallet/src/wallet-adapter.ts new file mode 100644 index 0000000..c8c5632 --- /dev/null +++ b/packages/wallet/src/wallet-adapter.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { DerivationPath } from "./derivation-path.js"; +import { SignTransactionRequest } from "@wizardconnect/core"; + +export interface SignTransactionResult { + signedTransaction: string; +} + +/** + * Abstract interface that wallets implement to integrate with WizardConnect. + */ +export interface WalletAdapter { + /** Human-readable wallet name shown to dapps */ + walletName: string; + /** URL to wallet icon (used in wallet_ready handshake) */ + walletIcon: string; + + /** + * Return the relay identity private key for this session. + * May be ephemeral (random per session) or stable (HD-derived) — both work. + * The dapp learns the wallet's public key dynamically via wallet_ready + * so stability across restarts is not required. + */ + getRelayPrivateKey(): Uint8Array; + + /** + * Derive the compressed public key (33 bytes) for a given HD path and index. + */ + getPublicKey(path: DerivationPath, index: bigint): Uint8Array; + + /** + * BIP32 xpub string for the given derivation path. + * Used to allow dapps to derive all needed pubkeys locally without round-trips. + */ + getXpub(path: DerivationPath): string; + + /** + * Sign a transaction request from a dapp. + * The wallet should verify the request and return the signed transaction hex, + * or throw if the user rejects or signing fails. + */ + signTransaction( + request: SignTransactionRequest, + ): Promise; +} diff --git a/packages/wallet/src/wallet-connection-manager.ts b/packages/wallet/src/wallet-connection-manager.ts new file mode 100644 index 0000000..d92f542 --- /dev/null +++ b/packages/wallet/src/wallet-connection-manager.ts @@ -0,0 +1,457 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { EventEmitter } from "eventemitter3"; +import { + RelayClient, + RelayStatus, + RelayUpdatePayload, + RelayStatusCallback, + initiateWalletRelay, + SignTransactionRequest, + SignTransactionResponse, + SignCancelMessage, + RelayMsgAction, + DappReadyMessage, + WalletReadyMessage, + DisconnectMessage, + DisconnectReason, + PathXpub, + Hdwalletv1Session, + ProtocolMessage, + PROTOCOL_NAME, + binToHex, +} from "@wizardconnect/core"; +import { WalletAdapter } from "./wallet-adapter.js"; +import { DerivationPath } from "./derivation-path.js"; + +export interface RelayConnectionState { + id: string; + uri: string; + status: RelayStatus; + label: string; + dappName: string | null; + dappIcon: string | null; + connectedAt: number; +} + +export interface PendingSignRequest { + connectionId: string; + request: SignTransactionRequest; +} + +interface ActiveConnection { + id: string; + uri: string; + cleanup: () => void; + client: RelayClient | null; + status: RelayStatus; + label: string; + dappName: string | null; + dappIcon: string | null; + connectedAt: number; + dappDiscovered: boolean; + /// Prevents duplicate wallet_ready messages within a single connection cycle. + /// Reset to false on each new connect/reconnect; set to true after sending. + walletReadySentThisCycle: boolean; + notificationQueue: ProtocolMessage[]; + notificationProcessor: ReturnType | null; + /// Key exchange data embedded in wallet_ready + walletPublicKeyHex: string; + keyExchangeSecret: string; +} + +export type WalletConnectionManagerEvents = { + connectionStatusChanged: [connectionId: string, status: RelayStatus]; + pendingSignRequest: [request: PendingSignRequest]; + connectionsChanged: []; + remoteDisconnect: [ + connectionId: string, + reason: DisconnectReason, + message: string | undefined, + ]; + signCancelled: [ + connectionId: string, + sequence: number, + reason: string | undefined, + ]; +}; + +/** + * Manages multiple simultaneous dapp connections for a wallet. + * Handles sign request queuing and reconnection. + */ +export class WalletConnectionManager extends EventEmitter { + private connections: Map = new Map(); + private adapter: WalletAdapter; + + constructor(adapter: WalletAdapter) { + super(); + this.adapter = adapter; + } + + /** + * Connect to a dapp using a wiz:// URI. + * Returns the connection ID. + */ + connect(uri: string): string { + // Return existing connection if one for this URI is already active + for (const conn of this.connections.values()) { + if (conn.uri === uri) { + return conn.id; + } + } + + const id = `rc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + const conn: ActiveConnection = { + id, + uri, + cleanup: () => {}, + client: null, + status: RelayStatus.disconnected(), + label: "Connecting...", + dappName: null, + dappIcon: null, + connectedAt: Date.now(), + dappDiscovered: false, + walletReadySentThisCycle: false, + notificationQueue: [], + notificationProcessor: null, + walletPublicKeyHex: "", + keyExchangeSecret: "", + }; + + this.connections.set(id, conn); + + const statusCallback: RelayStatusCallback = ( + payload: RelayUpdatePayload, + ) => { + // Attach message listener exactly once, the first time we receive the client. + if (!conn.client && payload.client) { + payload.client.on("message", (message: ProtocolMessage) => { + this.handleMessage(conn, message).catch((err) => { + console.error( + "[wizardconnect/wallet] Error handling message:", + err, + ); + }); + }); + } + + conn.client = payload.client; + conn.status = payload.status; + + if (payload.status.status === "connected") { + this.onConnected(conn); + } else if ( + payload.status.status === "disconnected" || + payload.status.status === "reconnecting" + ) { + if (conn.notificationProcessor) { + clearInterval(conn.notificationProcessor); + conn.notificationProcessor = null; + } + } + + this.emit("connectionStatusChanged", id, payload.status); + this.emit("connectionsChanged"); + }; + + const result = initiateWalletRelay(statusCallback, { + uri, + walletPrivateKey: this.adapter.getRelayPrivateKey(), + }); + + conn.cleanup = result.cleanup; + conn.walletPublicKeyHex = binToHex(result.walletPublicKey); + conn.keyExchangeSecret = result.secret; + + this.emit("connectionsChanged"); + return id; + } + + /** + * Disconnect a specific dapp connection, sending a courtesy UserDisconnect message. + */ + disconnect(connectionId: string): void { + this.doDisconnect(connectionId, true); + } + + /** + * Disconnect all connections. + */ + disconnectAll(): void { + for (const id of [...this.connections.keys()]) { + this.doDisconnect(id, true); + } + } + + private doDisconnect(connectionId: string, sendMessage: boolean): void { + const conn = this.connections.get(connectionId); + if (!conn) return; + + if (sendMessage && conn.client) { + const disconnectMsg: DisconnectMessage = { + action: RelayMsgAction.Disconnect, + reason: DisconnectReason.UserDisconnect, + time: Math.floor(Date.now() / 1000), + }; + conn.client.relay(disconnectMsg).catch(() => {}); + } + + clearInterval(conn.notificationProcessor ?? undefined); + conn.cleanup(); + this.connections.delete(connectionId); + this.emit("connectionsChanged"); + } + + /** + * Get all connection states (for UI/Redux). + */ + getConnections(): Record { + const result: Record = {}; + for (const [id, conn] of this.connections) { + result[id] = { + id: conn.id, + uri: conn.uri, + status: conn.status, + label: conn.label, + dappName: conn.dappName, + dappIcon: conn.dappIcon, + connectedAt: conn.connectedAt, + }; + } + return result; + } + + /** + * Send a sign transaction response back to the dapp. + */ + async sendSignResponse( + connectionId: string, + sequence: number, + signedTransactionHex: string, + ): Promise { + const conn = this.connections.get(connectionId); + if (!conn?.client) { + throw new Error(`Connection ${connectionId} not found or not connected`); + } + + const response: SignTransactionResponse = { + action: RelayMsgAction.SignTransactionResponse, + sequence, + signedTransaction: signedTransactionHex, + time: Math.floor(Date.now() / 1000), + }; + + await conn.client.relay(response); + } + + /** + * Send a sign error response back to the dapp. + */ + async sendSignError( + connectionId: string, + sequence: number, + errorMessage: string, + ): Promise { + const conn = this.connections.get(connectionId); + if (!conn?.client) { + return; // Already disconnected, nothing to do + } + + const response: SignTransactionResponse = { + action: RelayMsgAction.SignTransactionResponse, + sequence, + signedTransaction: "", + error: errorMessage, + time: Math.floor(Date.now() / 1000), + }; + + await conn.client.relay(response); + } + + // --- Private connection lifecycle --- + + /** Immediately attempt to flush the notification queue (fire-and-forget). */ + private flushNotificationQueue(conn: ActiveConnection): void { + this.processNotificationQueue(conn).catch((err) => { + console.error("[wizardconnect/wallet] Notification queue error:", err); + }); + } + + private onConnected(conn: ActiveConnection): void { + // New connection cycle: reset dedup flag so wallet_ready is sent fresh + conn.walletReadySentThisCycle = false; + + // Start notification processor as fallback for retries after send errors + if (conn.notificationProcessor) { + clearInterval(conn.notificationProcessor); + } + conn.notificationProcessor = setInterval(() => { + this.processNotificationQueue(conn).catch((err) => { + console.error("[wizardconnect/wallet] Notification queue error:", err); + }); + }, 1000); + + // Wait for key exchange, then send wallet_ready + (async () => { + while (conn.client && !conn.client.isKeyExchangeComplete()) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (conn.client) { + this.pushWalletReady(conn).catch((err) => { + console.error( + "[wizardconnect/wallet] Failed to send wallet_ready:", + err, + ); + }); + } + })(); + } + + private async handleMessage( + conn: ActiveConnection, + message: ProtocolMessage, + ): Promise { + switch (message.action) { + case RelayMsgAction.DappReady: + await this.handleDappReady(conn, message as DappReadyMessage); + break; + case RelayMsgAction.SignTransactionRequest: + this.handleSignRequest(conn, message as SignTransactionRequest); + break; + case RelayMsgAction.WalletReady: + console.warn( + "[wizardconnect/wallet] Got wallet_ready as wallet, ignoring", + ); + break; + case RelayMsgAction.Disconnect: + this.handleRemoteDisconnect(conn, message as DisconnectMessage); + break; + case RelayMsgAction.SignCancel: + this.handleSignCancel(conn, message as SignCancelMessage); + break; + default: + console.warn( + "[wizardconnect/wallet] Unknown message action:", + message.action, + ); + } + } + + private handleRemoteDisconnect( + conn: ActiveConnection, + msg: DisconnectMessage, + ): void { + this.emit("remoteDisconnect", conn.id, msg.reason, msg.message); + this.doDisconnect(conn.id, false); + } + + private handleSignCancel( + conn: ActiveConnection, + msg: SignCancelMessage, + ): void { + this.emit("signCancelled", conn.id, msg.sequence, msg.reason); + } + + private async handleDappReady( + conn: ActiveConnection, + msg: DappReadyMessage, + ): Promise { + // Log the dapp's protocol selection for diagnostics + if (msg.selected_protocol) { + console.debug( + "[wizardconnect/wallet] Dapp selected protocol:", + msg.selected_protocol, + ); + } + + // Update dapp metadata from the first dapp_ready that carries it + if (msg.dapp_name && !conn.dappName) { + conn.dappName = msg.dapp_name; + conn.dappIcon = msg.dapp_icon ?? null; + conn.label = msg.dapp_name; + this.emit("connectionStatusChanged", conn.id, conn.status); + } + + if (!msg.wallet_discovered) { + // Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force + // re-introduction even if we already sent wallet_ready this cycle. + conn.walletReadySentThisCycle = false; + await this.pushWalletReady(conn); + return; + } + + conn.dappDiscovered = true; + } + + private async pushWalletReady(conn: ActiveConnection): Promise { + if (conn.walletReadySentThisCycle) return; + conn.walletReadySentThisCycle = true; + + const paths: PathXpub[] = [ + { name: "receive", xpub: this.adapter.getXpub(DerivationPath.Receive) }, + { name: "change", xpub: this.adapter.getXpub(DerivationPath.Change) }, + { name: "defi", xpub: this.adapter.getXpub(DerivationPath.Cauldron) }, + ]; + + const hdwv1Session: Hdwalletv1Session = { + paths, + }; + + const msg: WalletReadyMessage = { + action: RelayMsgAction.WalletReady, + supported_protocols: [PROTOCOL_NAME], + wallet_name: this.adapter.walletName, + wallet_icon: this.adapter.walletIcon, + time: Math.floor(Date.now() / 1000), + dapp_discovered: conn.dappDiscovered, + session: { + [PROTOCOL_NAME]: hdwv1Session, + }, + public_key: conn.walletPublicKeyHex, + secret: conn.keyExchangeSecret, + }; + + conn.notificationQueue.push(msg); + this.flushNotificationQueue(conn); + } + + private handleSignRequest( + conn: ActiveConnection, + msg: SignTransactionRequest, + ): void { + // Emit to host app for queuing/approval + const pendingRequest: PendingSignRequest = { + connectionId: conn.id, + request: msg, + }; + this.emit("pendingSignRequest", pendingRequest); + } + + private async processNotificationQueue( + conn: ActiveConnection, + ): Promise { + if (!conn.client || conn.notificationQueue.length === 0) { + return; + } + + const notifications = [...conn.notificationQueue]; + conn.notificationQueue = []; + + for (const notification of notifications) { + try { + await conn.client.relay(notification); + } catch (error) { + console.error( + "[wizardconnect/wallet] Failed to send notification:", + error, + ); + conn.notificationQueue.push(notification); + } + } + } +} diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json new file mode 100644 index 0000000..8394c71 --- /dev/null +++ b/packages/wallet/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "src/integration/**" + ] +} diff --git a/packages/wallet/vitest.config.ts b/packages/wallet/vitest.config.ts new file mode 100644 index 0000000..ed682d8 --- /dev/null +++ b/packages/wallet/vitest.config.ts @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Exclude integration tests from the normal fast test run + exclude: ["src/integration/**", "**/node_modules/**"], + }, +}); diff --git a/packages/wallet/vitest.integration.config.ts b/packages/wallet/vitest.integration.config.ts new file mode 100644 index 0000000..cbf19f8 --- /dev/null +++ b/packages/wallet/vitest.integration.config.ts @@ -0,0 +1,18 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/integration/**/*.test.ts"], + testTimeout: 60000, + hookTimeout: 60000, + // Run integration tests serially to avoid relay contention + pool: "forks", + poolOptions: { + forks: { singleFork: true }, + }, + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..69ed82e --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "sourceMap": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowJs": true, + "strict": true, + "lib": ["dom", "dom.iterable", "esnext", "es2020", "esnext.bigint"], + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true + } +} diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..53bc41b --- /dev/null +++ b/zensical.toml @@ -0,0 +1,39 @@ +[project] +site_name = "WizardConnect" +site_description = "Relay-based protocol for connecting dapps to HD wallets over encrypted Nostr channel." + +nav = [ + { "Home" = "index.md" }, + { "Protocol" = "protocol.md" }, + { "Connection URI" = "connection-uri.md" }, + { "Transport" = "transport.md" }, + { "Wallet" = "wallet.md" }, + { "Dapp" = "dapp.md" }, + { "Pubkey Derivation" = "pubkey-derivation.md" }, +] + +[project.theme] + +features = [ + "content.code.copy", + "content.code.select", + "content.tooltips", + "navigation.footer", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.sections", + "navigation.top", + "navigation.tracking", + "search.highlight", +] + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode"