From 7bdddc4138dbe1c1f2eb4a83ae44e08e8976aa82 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 29 Nov 2024 20:19:24 +0530 Subject: [PATCH 1/6] feat: add nip 44 and versioning support --- src/NWCClient.ts | 104 ++++++++++++++++++++++++++-- src/nip44/index.ts | 167 +++++++++++++++++++++++++++++++++++++++++++++ src/nip44/utils.ts | 130 +++++++++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 src/nip44/index.ts create mode 100644 src/nip44/utils.ts diff --git a/src/NWCClient.ts b/src/NWCClient.ts index ec378d4..dc69d93 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -11,6 +11,8 @@ import { finishEvent, Sub, } from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils"; +import * as nip44 from "./nip44"; import { NWCAuthorizationUrlOptions } from "./types"; type WithDTag = { @@ -194,6 +196,7 @@ export class Nip47ResponseDecodingError extends Nip47Error {} export class Nip47ResponseValidationError extends Nip47Error {} export class Nip47UnexpectedResponseError extends Nip47Error {} export class Nip47NetworkError extends Nip47Error {} +export class Nip47UnsupportedVersionError extends Nip47Error {} export const NWCs: Record = { alby: { @@ -220,6 +223,9 @@ export class NWCClient { lud16: string | undefined; walletPubkey: string; options: NWCOptions; + version: string | undefined; + + static SUPPORTED_VERSIONS = ["0.0", "1.0"]; static parseWalletConnectUrl(walletConnectUrl: string): NWCOptions { // makes it possible to parse with URL in the different environments (browser/node/...) @@ -316,6 +322,13 @@ export class NWCClient { return getPublicKey(this.secret); } + get supportedVersion(): string { + if (!this.version) { + throw new Error("Missing version"); + } + return this.version; + } + getPublicKey(): Promise { return Promise.resolve(this.publicKey); } @@ -340,7 +353,13 @@ export class NWCClient { if (!this.secret) { throw new Error("Missing secret"); } - const encrypted = await nip04.encrypt(this.secret, pubkey, content); + let encrypted; + if (this.supportedVersion === "0.0") { + encrypted = await nip04.encrypt(this.secret, pubkey, content); + } else { + const key = nip44.getConversationKey(hexToBytes(this.secret), pubkey); + encrypted = nip44.encrypt(content, key); + } return encrypted; } @@ -348,7 +367,13 @@ export class NWCClient { if (!this.secret) { throw new Error("Missing secret"); } - const decrypted = await nip04.decrypt(this.secret, pubkey, content); + let decrypted; + if (this.supportedVersion === "0.0") { + decrypted = await nip04.decrypt(this.secret, pubkey, content); + } else { + const key = nip44.getConversationKey(hexToBytes(this.secret), pubkey); + decrypted = nip44.decrypt(content, key); + } return decrypted; } @@ -455,6 +480,7 @@ export class NWCClient { } async getWalletServiceInfo(): Promise<{ + versions: string[]; capabilities: Nip47Capability[]; notifications: Nip47NotificationType[]; }> { @@ -480,7 +506,9 @@ export class NWCClient { const notificationsTag = events[0].tags.find( (t) => t[0] === "notifications", ); + const versionsTag = events[0].tags.find((t) => t[0] === "v"); return { + versions: versionsTag ? versionsTag[1]?.split(" ") : ["0.0"], // delimiter is " " per spec, but Alby NWC originally returned "," capabilities: content.split(/[ |,]/g) as Nip47Method[], notifications: (notificationsTag?.[1]?.split(" ") || @@ -687,14 +715,14 @@ export class NWCClient { let subscribed = true; let endPromise: (() => void) | undefined; let onRelayDisconnect: (() => void) | undefined; - let sub: Sub<23196> | undefined; + let sub: Sub | undefined; (async () => { while (subscribed) { try { await this._checkConnected(); sub = this.relay.sub([ { - kinds: [23196], + kinds: [...(this.supportedVersion ? [23196] : [23197])], authors: [this.walletPubkey], "#p": [this.publicKey], }, @@ -765,6 +793,7 @@ export class NWCClient { resultValidator: (result: T) => boolean, ): Promise { await this._checkConnected(); + await this._checkCompatibility(); return new Promise((resolve, reject) => { (async () => { const command = { @@ -778,7 +807,10 @@ export class NWCClient { const unsignedEvent: UnsignedEvent = { kind: 23194, created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], + tags: [ + ["p", this.walletPubkey], + ["v", this.supportedVersion], + ], content: encryptedCommand, pubkey: this.publicKey, }; @@ -895,6 +927,7 @@ export class NWCClient { resultValidator: (result: T) => boolean, ): Promise<(T & { dTag: string })[]> { await this._checkConnected(); + await this._checkCompatibility(); const results: (T & { dTag: string })[] = []; return new Promise<(T & { dTag: string })[]>((resolve, reject) => { (async () => { @@ -909,7 +942,10 @@ export class NWCClient { const unsignedEvent: UnsignedEvent = { kind: 23194, created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], + tags: [ + ["p", this.walletPubkey], + ["v", this.supportedVersion], + ], content: encryptedCommand, pubkey: this.publicKey, }; @@ -1034,6 +1070,7 @@ export class NWCClient { })(); }); } + private async _checkConnected() { if (!this.secret) { throw new Error("Missing secret key"); @@ -1048,4 +1085,59 @@ export class NWCClient { ); } } + + private async _checkCompatibility() { + if (!this.version) { + const walletServiceInfo = await this.getWalletServiceInfo(); + const compatibleVersion = this.selectHighestCompatibleVersion( + walletServiceInfo.versions, + ); + if (!compatibleVersion) { + throw new Nip47UnsupportedVersionError( + `no compatible version found between wallet and client`, + "UNSUPPORTED_VERSION", + ); + } + this.version = compatibleVersion; + } + } + + private selectHighestCompatibleVersion( + walletVersions: string[], + ): string | null { + const parseVersions = (versions: string[]) => + versions.map((v) => v.split(".").map(Number)); + + const walletParsed = parseVersions(walletVersions); + const clientParsed = parseVersions(NWCClient.SUPPORTED_VERSIONS); + + const walletMajors: number[] = walletParsed + .map(([major]) => major) + .filter((value, index, self) => self.indexOf(value) === index); + + const clientMajors: number[] = clientParsed + .map(([major]) => major) + .filter((value, index, self) => self.indexOf(value) === index); + + const commonMajors = walletMajors + .filter((major) => clientMajors.includes(major)) + .sort((a, b) => b - a); + + for (const major of commonMajors) { + const walletMinors = walletParsed + .filter(([m]) => m === major) + .map(([, minor]) => minor); + const clientMinors = clientParsed + .filter(([m]) => m === major) + .map(([, minor]) => minor); + + const highestMinor = Math.min( + Math.max(...walletMinors), + Math.max(...clientMinors), + ); + + return `${major}.${highestMinor}`; + } + return null; + } } diff --git a/src/nip44/index.ts b/src/nip44/index.ts new file mode 100644 index 0000000..ce114ef --- /dev/null +++ b/src/nip44/index.ts @@ -0,0 +1,167 @@ +import { chacha20 } from "@noble/ciphers/chacha"; +import { equalBytes } from "@noble/ciphers/utils"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { + extract as hkdf_extract, + expand as hkdf_expand, +} from "@noble/hashes/hkdf"; +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha256"; +import { concatBytes, randomBytes } from "@noble/hashes/utils"; +import { base64 } from "@scure/base"; + +import { utf8Decoder, utf8Encoder } from "./utils"; + +const minPlaintextSize = 0x0001; // 1b msg => padded to 32b +const maxPlaintextSize = 0xffff; // 65535 (64kb-1) => padded to 64kb + +export function getConversationKey( + privkeyA: Uint8Array, + pubkeyB: string, +): Uint8Array { + const sharedX = secp256k1 + .getSharedSecret(privkeyA, "02" + pubkeyB) + .subarray(1, 33); + return hkdf_extract(sha256, sharedX, "nip44-v2"); +} + +function getMessageKeys( + conversationKey: Uint8Array, + nonce: Uint8Array, +): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } { + const keys = hkdf_expand(sha256, conversationKey, nonce, 76); + return { + chacha_key: keys.subarray(0, 32), + chacha_nonce: keys.subarray(32, 44), + hmac_key: keys.subarray(44, 76), + }; +} + +function calcPaddedLen(len: number): number { + if (!Number.isSafeInteger(len) || len < 1) + throw new Error("expected positive integer"); + if (len <= 32) return 32; + const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1); + const chunk = nextPower <= 256 ? 32 : nextPower / 8; + return chunk * (Math.floor((len - 1) / chunk) + 1); +} + +function writeU16BE(num: number): Uint8Array { + if ( + !Number.isSafeInteger(num) || + num < minPlaintextSize || + num > maxPlaintextSize + ) + throw new Error( + "invalid plaintext size: must be between 1 and 65535 bytes", + ); + const arr = new Uint8Array(2); + new DataView(arr.buffer).setUint16(0, num, false); + return arr; +} + +function pad(plaintext: string): Uint8Array { + const unpadded = utf8Encoder.encode(plaintext); + const unpaddedLen = unpadded.length; + const prefix = writeU16BE(unpaddedLen); + const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen); + return concatBytes(prefix, unpadded, suffix); +} + +function unpad(padded: Uint8Array): string { + const unpaddedLen = new DataView(padded.buffer).getUint16(0); + const unpadded = padded.subarray(2, 2 + unpaddedLen); + if ( + unpaddedLen < minPlaintextSize || + unpaddedLen > maxPlaintextSize || + unpadded.length !== unpaddedLen || + padded.length !== 2 + calcPaddedLen(unpaddedLen) + ) + throw new Error("invalid padding"); + return utf8Decoder.decode(unpadded); +} + +function hmacAad( + key: Uint8Array, + message: Uint8Array, + aad: Uint8Array, +): Uint8Array { + if (aad.length !== 32) + throw new Error("AAD associated data must be 32 bytes"); + const combined = concatBytes(aad, message); + return hmac(sha256, key, combined); +} + +// metadata: always 65b (version: 1b, nonce: 32b, max: 32b) +// plaintext: 1b to 0xffff +// padded plaintext: 32b to 0xffff +// ciphertext: 32b+2 to 0xffff+2 +// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) +// compressed payload (base64): 132b to 87472b +function decodePayload(payload: string): { + nonce: Uint8Array; + ciphertext: Uint8Array; + mac: Uint8Array; +} { + if (typeof payload !== "string") + throw new Error("payload must be a valid string"); + const plen = payload.length; + if (plen < 132 || plen > 87472) + throw new Error("invalid payload length: " + plen); + if (payload[0] === "#") throw new Error("unknown encryption version"); + let data: Uint8Array; + try { + data = base64.decode(payload); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new Error("invalid base64: " + (error as any).message); + } + const dlen = data.length; + if (dlen < 99 || dlen > 65603) + throw new Error("invalid data length: " + dlen); + const vers = data[0]; + if (vers !== 2) throw new Error("unknown encryption version " + vers); + return { + nonce: data.subarray(1, 33), + ciphertext: data.subarray(33, -32), + mac: data.subarray(-32), + }; +} + +export function encrypt( + plaintext: string, + conversationKey: Uint8Array, + nonce: Uint8Array = randomBytes(32), +): string { + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( + conversationKey, + nonce, + ); + const padded = pad(plaintext); + const ciphertext = chacha20(chacha_key, chacha_nonce, padded); + const mac = hmacAad(hmac_key, ciphertext, nonce); + return base64.encode( + concatBytes(new Uint8Array([2]), nonce, ciphertext, mac), + ); +} + +export function decrypt(payload: string, conversationKey: Uint8Array): string { + const { nonce, ciphertext, mac } = decodePayload(payload); + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( + conversationKey, + nonce, + ); + const calculatedMac = hmacAad(hmac_key, ciphertext, nonce); + if (!equalBytes(calculatedMac, mac)) throw new Error("invalid MAC"); + const padded = chacha20(chacha_key, chacha_nonce, ciphertext); + return unpad(padded); +} + +export const v2 = { + utils: { + getConversationKey, + calcPaddedLen, + }, + encrypt, + decrypt, +}; diff --git a/src/nip44/utils.ts b/src/nip44/utils.ts new file mode 100644 index 0000000..64cb959 --- /dev/null +++ b/src/nip44/utils.ts @@ -0,0 +1,130 @@ +import type { Event } from "nostr-tools"; + +export const utf8Decoder: TextDecoder = new TextDecoder("utf-8"); +export const utf8Encoder: TextEncoder = new TextEncoder(); + +export function normalizeURL(url: string): string { + if (url.indexOf("://") === -1) url = "wss://" + url; + const p = new URL(url); + p.pathname = p.pathname.replace(/\/+/g, "/"); + if (p.pathname.endsWith("/")) p.pathname = p.pathname.slice(0, -1); + if ( + (p.port === "80" && p.protocol === "ws:") || + (p.port === "443" && p.protocol === "wss:") + ) + p.port = ""; + p.searchParams.sort(); + p.hash = ""; + return p.toString(); +} + +export function insertEventIntoDescendingList( + sortedArray: Event[], + event: Event, +): Event[] { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) return 0; + if (event.created_at === b.created_at) return -1; + return b.created_at - event.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} + +export function insertEventIntoAscendingList( + sortedArray: Event[], + event: Event, +): Event[] { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) return 0; + if (event.created_at === b.created_at) return -1; + return event.created_at - b.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} + +export function binarySearch( + arr: T[], + compare: (b: T) => number, +): [number, boolean] { + let start = 0; + let end = arr.length - 1; + + while (start <= end) { + const mid = Math.floor((start + end) / 2); + const cmp = compare(arr[mid]); + + if (cmp === 0) { + return [mid, true]; + } + + if (cmp < 0) { + end = mid - 1; + } else { + start = mid + 1; + } + } + + return [start, false]; +} + +export class QueueNode { + public value: V; + public next: QueueNode | null = null; + public prev: QueueNode | null = null; + + constructor(message: V) { + this.value = message; + } +} + +export class Queue { + public first: QueueNode | null; + public last: QueueNode | null; + + constructor() { + this.first = null; + this.last = null; + } + + enqueue(value: V): boolean { + const newNode = new QueueNode(value); + if (!this.last) { + // list is empty + this.first = newNode; + this.last = newNode; + } else if (this.last === this.first) { + // list has a single element + this.last = newNode; + this.last.prev = this.first; + this.first.next = newNode; + } else { + // list has elements, add as last + newNode.prev = this.last; + this.last.next = newNode; + this.last = newNode; + } + return true; + } + + dequeue(): V | null { + if (!this.first) return null; + + if (this.first === this.last) { + const target = this.first; + this.first = null; + this.last = null; + return target.value; + } + + const target = this.first; + this.first = target.next; + + return target.value; + } +} From e85f79c50357c1dea3a536391f13a5b96220c9bb Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 13 Dec 2024 16:52:33 +0530 Subject: [PATCH 2/6] fix: use version in subscriptions --- src/NWCClient.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NWCClient.ts b/src/NWCClient.ts index 622aaea..80ed94a 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -742,11 +742,13 @@ export class NWCClient { while (subscribed) { try { await this._checkConnected(); - + await this._checkCompatibility(); sub = this.relay.subscribe( [ { - kinds: [...(this.supportedVersion ? [23196] : [23197])], + kinds: [ + ...(this.supportedVersion === "0.0" ? [23196] : [23197]), + ], authors: [this.walletPubkey], "#p": [this.publicKey], }, From 8dae9c0b7ddaedd8a54e76043a2e79f4a0d367c4 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 13 Dec 2024 16:54:06 +0530 Subject: [PATCH 3/6] chore: use nip44 from nostr-tools --- src/NWCClient.ts | 2 +- src/nip44/index.ts | 167 --------------------------------------------- src/nip44/utils.ts | 130 ----------------------------------- 3 files changed, 1 insertion(+), 298 deletions(-) delete mode 100644 src/nip44/index.ts delete mode 100644 src/nip44/utils.ts diff --git a/src/NWCClient.ts b/src/NWCClient.ts index 80ed94a..86c00fa 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -1,6 +1,7 @@ import { nip04, nip19, + nip44, finalizeEvent, generateSecretKey, getEventHash, @@ -9,7 +10,6 @@ import { EventTemplate, Relay, } from "nostr-tools"; -import * as nip44 from "./nip44"; import { NWCAuthorizationUrlOptions } from "./types"; import { hexToBytes, bytesToHex } from "@noble/hashes/utils"; import { Subscription } from "nostr-tools/lib/types/abstract-relay"; diff --git a/src/nip44/index.ts b/src/nip44/index.ts deleted file mode 100644 index ce114ef..0000000 --- a/src/nip44/index.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { chacha20 } from "@noble/ciphers/chacha"; -import { equalBytes } from "@noble/ciphers/utils"; -import { secp256k1 } from "@noble/curves/secp256k1"; -import { - extract as hkdf_extract, - expand as hkdf_expand, -} from "@noble/hashes/hkdf"; -import { hmac } from "@noble/hashes/hmac"; -import { sha256 } from "@noble/hashes/sha256"; -import { concatBytes, randomBytes } from "@noble/hashes/utils"; -import { base64 } from "@scure/base"; - -import { utf8Decoder, utf8Encoder } from "./utils"; - -const minPlaintextSize = 0x0001; // 1b msg => padded to 32b -const maxPlaintextSize = 0xffff; // 65535 (64kb-1) => padded to 64kb - -export function getConversationKey( - privkeyA: Uint8Array, - pubkeyB: string, -): Uint8Array { - const sharedX = secp256k1 - .getSharedSecret(privkeyA, "02" + pubkeyB) - .subarray(1, 33); - return hkdf_extract(sha256, sharedX, "nip44-v2"); -} - -function getMessageKeys( - conversationKey: Uint8Array, - nonce: Uint8Array, -): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } { - const keys = hkdf_expand(sha256, conversationKey, nonce, 76); - return { - chacha_key: keys.subarray(0, 32), - chacha_nonce: keys.subarray(32, 44), - hmac_key: keys.subarray(44, 76), - }; -} - -function calcPaddedLen(len: number): number { - if (!Number.isSafeInteger(len) || len < 1) - throw new Error("expected positive integer"); - if (len <= 32) return 32; - const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1); - const chunk = nextPower <= 256 ? 32 : nextPower / 8; - return chunk * (Math.floor((len - 1) / chunk) + 1); -} - -function writeU16BE(num: number): Uint8Array { - if ( - !Number.isSafeInteger(num) || - num < minPlaintextSize || - num > maxPlaintextSize - ) - throw new Error( - "invalid plaintext size: must be between 1 and 65535 bytes", - ); - const arr = new Uint8Array(2); - new DataView(arr.buffer).setUint16(0, num, false); - return arr; -} - -function pad(plaintext: string): Uint8Array { - const unpadded = utf8Encoder.encode(plaintext); - const unpaddedLen = unpadded.length; - const prefix = writeU16BE(unpaddedLen); - const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen); - return concatBytes(prefix, unpadded, suffix); -} - -function unpad(padded: Uint8Array): string { - const unpaddedLen = new DataView(padded.buffer).getUint16(0); - const unpadded = padded.subarray(2, 2 + unpaddedLen); - if ( - unpaddedLen < minPlaintextSize || - unpaddedLen > maxPlaintextSize || - unpadded.length !== unpaddedLen || - padded.length !== 2 + calcPaddedLen(unpaddedLen) - ) - throw new Error("invalid padding"); - return utf8Decoder.decode(unpadded); -} - -function hmacAad( - key: Uint8Array, - message: Uint8Array, - aad: Uint8Array, -): Uint8Array { - if (aad.length !== 32) - throw new Error("AAD associated data must be 32 bytes"); - const combined = concatBytes(aad, message); - return hmac(sha256, key, combined); -} - -// metadata: always 65b (version: 1b, nonce: 32b, max: 32b) -// plaintext: 1b to 0xffff -// padded plaintext: 32b to 0xffff -// ciphertext: 32b+2 to 0xffff+2 -// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) -// compressed payload (base64): 132b to 87472b -function decodePayload(payload: string): { - nonce: Uint8Array; - ciphertext: Uint8Array; - mac: Uint8Array; -} { - if (typeof payload !== "string") - throw new Error("payload must be a valid string"); - const plen = payload.length; - if (plen < 132 || plen > 87472) - throw new Error("invalid payload length: " + plen); - if (payload[0] === "#") throw new Error("unknown encryption version"); - let data: Uint8Array; - try { - data = base64.decode(payload); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - throw new Error("invalid base64: " + (error as any).message); - } - const dlen = data.length; - if (dlen < 99 || dlen > 65603) - throw new Error("invalid data length: " + dlen); - const vers = data[0]; - if (vers !== 2) throw new Error("unknown encryption version " + vers); - return { - nonce: data.subarray(1, 33), - ciphertext: data.subarray(33, -32), - mac: data.subarray(-32), - }; -} - -export function encrypt( - plaintext: string, - conversationKey: Uint8Array, - nonce: Uint8Array = randomBytes(32), -): string { - const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( - conversationKey, - nonce, - ); - const padded = pad(plaintext); - const ciphertext = chacha20(chacha_key, chacha_nonce, padded); - const mac = hmacAad(hmac_key, ciphertext, nonce); - return base64.encode( - concatBytes(new Uint8Array([2]), nonce, ciphertext, mac), - ); -} - -export function decrypt(payload: string, conversationKey: Uint8Array): string { - const { nonce, ciphertext, mac } = decodePayload(payload); - const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( - conversationKey, - nonce, - ); - const calculatedMac = hmacAad(hmac_key, ciphertext, nonce); - if (!equalBytes(calculatedMac, mac)) throw new Error("invalid MAC"); - const padded = chacha20(chacha_key, chacha_nonce, ciphertext); - return unpad(padded); -} - -export const v2 = { - utils: { - getConversationKey, - calcPaddedLen, - }, - encrypt, - decrypt, -}; diff --git a/src/nip44/utils.ts b/src/nip44/utils.ts deleted file mode 100644 index 64cb959..0000000 --- a/src/nip44/utils.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Event } from "nostr-tools"; - -export const utf8Decoder: TextDecoder = new TextDecoder("utf-8"); -export const utf8Encoder: TextEncoder = new TextEncoder(); - -export function normalizeURL(url: string): string { - if (url.indexOf("://") === -1) url = "wss://" + url; - const p = new URL(url); - p.pathname = p.pathname.replace(/\/+/g, "/"); - if (p.pathname.endsWith("/")) p.pathname = p.pathname.slice(0, -1); - if ( - (p.port === "80" && p.protocol === "ws:") || - (p.port === "443" && p.protocol === "wss:") - ) - p.port = ""; - p.searchParams.sort(); - p.hash = ""; - return p.toString(); -} - -export function insertEventIntoDescendingList( - sortedArray: Event[], - event: Event, -): Event[] { - const [idx, found] = binarySearch(sortedArray, (b) => { - if (event.id === b.id) return 0; - if (event.created_at === b.created_at) return -1; - return b.created_at - event.created_at; - }); - if (!found) { - sortedArray.splice(idx, 0, event); - } - return sortedArray; -} - -export function insertEventIntoAscendingList( - sortedArray: Event[], - event: Event, -): Event[] { - const [idx, found] = binarySearch(sortedArray, (b) => { - if (event.id === b.id) return 0; - if (event.created_at === b.created_at) return -1; - return event.created_at - b.created_at; - }); - if (!found) { - sortedArray.splice(idx, 0, event); - } - return sortedArray; -} - -export function binarySearch( - arr: T[], - compare: (b: T) => number, -): [number, boolean] { - let start = 0; - let end = arr.length - 1; - - while (start <= end) { - const mid = Math.floor((start + end) / 2); - const cmp = compare(arr[mid]); - - if (cmp === 0) { - return [mid, true]; - } - - if (cmp < 0) { - end = mid - 1; - } else { - start = mid + 1; - } - } - - return [start, false]; -} - -export class QueueNode { - public value: V; - public next: QueueNode | null = null; - public prev: QueueNode | null = null; - - constructor(message: V) { - this.value = message; - } -} - -export class Queue { - public first: QueueNode | null; - public last: QueueNode | null; - - constructor() { - this.first = null; - this.last = null; - } - - enqueue(value: V): boolean { - const newNode = new QueueNode(value); - if (!this.last) { - // list is empty - this.first = newNode; - this.last = newNode; - } else if (this.last === this.first) { - // list has a single element - this.last = newNode; - this.last.prev = this.first; - this.first.next = newNode; - } else { - // list has elements, add as last - newNode.prev = this.last; - this.last.next = newNode; - this.last = newNode; - } - return true; - } - - dequeue(): V | null { - if (!this.first) return null; - - if (this.first === this.last) { - const target = this.first; - this.first = null; - this.last = null; - return target.value; - } - - const target = this.first; - this.first = target.next; - - return target.value; - } -} From e3b76a95e28437b129207b7c2c1044fb35c5694e Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 9 Jan 2025 15:19:06 +0530 Subject: [PATCH 4/6] chore: add deprecation warning if version is 0.0 --- src/NWCClient.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/NWCClient.ts b/src/NWCClient.ts index 86c00fa..5c5ecf1 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -1135,6 +1135,11 @@ export class NWCClient { "UNSUPPORTED_VERSION", ); } + if (compatibleVersion === "0.0") { + console.warn( + "NIP-04 encryption is about to be deprecated. Please upgrade your wallet service to use NIP-44 instead.", + ); + } this.version = compatibleVersion; } } From 9665bc18bcd6b337574b0e4729db2297330c29b2 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 9 Jan 2025 15:19:30 +0530 Subject: [PATCH 5/6] chore: add tests for selectHighestCompatibleVersion function --- src/NWCClient.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/NWCClient.test.ts b/src/NWCClient.test.ts index 10206a6..b191e21 100644 --- a/src/NWCClient.test.ts +++ b/src/NWCClient.test.ts @@ -56,3 +56,85 @@ describe("NWCClient", () => { expect(nwcClient.options.lud16).toBe("hello@getalby.com"); }); }); + +describe("selectHighestCompatibleVersion", () => { + let nwcClient: NWCClient; + let selectVersion: (walletVersions: string[]) => string | null; + const ORIGINAL_SUPPORTED_VERSIONS = NWCClient.SUPPORTED_VERSIONS; + + beforeEach(() => { + nwcClient = new NWCClient(); + // Access the private method using type assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectVersion = (nwcClient as any).selectHighestCompatibleVersion; + }); + + afterEach(() => { + // Restore the original SUPPORTED_VERSIONS + NWCClient.SUPPORTED_VERSIONS = [...ORIGINAL_SUPPORTED_VERSIONS]; + }); + + test("both client and wallet support version 1.0", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; + const walletVersions = ["0.0", "1.0"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("1.0"); + }); + + test("client supports version 1.0 but wallet does not", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; + const walletVersions = ["0.0"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("0.0"); + }); + + test("wallet supports version 1.0 but client does not", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0"]; + const walletVersions = ["0.0", "1.0"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("0.0"); + }); + + test("wallet and client do not have overlapping versions", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0"]; + const walletVersions = ["1.0"]; + const selected = selectVersion(walletVersions); + expect(selected).toBeNull(); + }); + + // Tests for future + test("client supports more versions than wallet", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.3"]; + const walletVersions = ["1.2"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("1.2"); + }); + + test("wallet supports more versions than client", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.4"]; + const walletVersions = ["1.6"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("1.4"); + }); + + test("wallet and client have no overlapping major versions", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; + const walletVersions = ["2.0"]; + const selected = selectVersion(walletVersions); + expect(selected).toBeNull(); + }); + + test("both client and wallet support multiple versions with different majors", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0", "2.4"]; + const walletVersions = ["1.0", "2.3"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("2.3"); + }); + + test("wallet has duplicate versions", () => { + NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.2"]; + const walletVersions = ["1.1", "1.1"]; + const selected = selectVersion(walletVersions); + expect(selected).toBe("1.1"); + }); +}); From 13a8a0b7fc6c970e657fca45a27e7b5523ab0706 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 10 Jan 2025 15:18:04 +0530 Subject: [PATCH 6/6] chore: simplify selectHighestCompatibleVersion --- src/NWCClient.test.ts | 82 ------------------------------------------- src/NWCClient.ts | 37 +++---------------- 2 files changed, 5 insertions(+), 114 deletions(-) diff --git a/src/NWCClient.test.ts b/src/NWCClient.test.ts index b191e21..10206a6 100644 --- a/src/NWCClient.test.ts +++ b/src/NWCClient.test.ts @@ -56,85 +56,3 @@ describe("NWCClient", () => { expect(nwcClient.options.lud16).toBe("hello@getalby.com"); }); }); - -describe("selectHighestCompatibleVersion", () => { - let nwcClient: NWCClient; - let selectVersion: (walletVersions: string[]) => string | null; - const ORIGINAL_SUPPORTED_VERSIONS = NWCClient.SUPPORTED_VERSIONS; - - beforeEach(() => { - nwcClient = new NWCClient(); - // Access the private method using type assertion - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectVersion = (nwcClient as any).selectHighestCompatibleVersion; - }); - - afterEach(() => { - // Restore the original SUPPORTED_VERSIONS - NWCClient.SUPPORTED_VERSIONS = [...ORIGINAL_SUPPORTED_VERSIONS]; - }); - - test("both client and wallet support version 1.0", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; - const walletVersions = ["0.0", "1.0"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("1.0"); - }); - - test("client supports version 1.0 but wallet does not", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; - const walletVersions = ["0.0"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("0.0"); - }); - - test("wallet supports version 1.0 but client does not", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0"]; - const walletVersions = ["0.0", "1.0"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("0.0"); - }); - - test("wallet and client do not have overlapping versions", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0"]; - const walletVersions = ["1.0"]; - const selected = selectVersion(walletVersions); - expect(selected).toBeNull(); - }); - - // Tests for future - test("client supports more versions than wallet", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.3"]; - const walletVersions = ["1.2"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("1.2"); - }); - - test("wallet supports more versions than client", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.4"]; - const walletVersions = ["1.6"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("1.4"); - }); - - test("wallet and client have no overlapping major versions", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0"]; - const walletVersions = ["2.0"]; - const selected = selectVersion(walletVersions); - expect(selected).toBeNull(); - }); - - test("both client and wallet support multiple versions with different majors", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.0", "2.4"]; - const walletVersions = ["1.0", "2.3"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("2.3"); - }); - - test("wallet has duplicate versions", () => { - NWCClient.SUPPORTED_VERSIONS = ["0.0", "1.2"]; - const walletVersions = ["1.1", "1.1"]; - const selected = selectVersion(walletVersions); - expect(selected).toBe("1.1"); - }); -}); diff --git a/src/NWCClient.ts b/src/NWCClient.ts index 5c5ecf1..68cfb29 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -1147,38 +1147,11 @@ export class NWCClient { private selectHighestCompatibleVersion( walletVersions: string[], ): string | null { - const parseVersions = (versions: string[]) => - versions.map((v) => v.split(".").map(Number)); - - const walletParsed = parseVersions(walletVersions); - const clientParsed = parseVersions(NWCClient.SUPPORTED_VERSIONS); - - const walletMajors: number[] = walletParsed - .map(([major]) => major) - .filter((value, index, self) => self.indexOf(value) === index); - - const clientMajors: number[] = clientParsed - .map(([major]) => major) - .filter((value, index, self) => self.indexOf(value) === index); - - const commonMajors = walletMajors - .filter((major) => clientMajors.includes(major)) - .sort((a, b) => b - a); - - for (const major of commonMajors) { - const walletMinors = walletParsed - .filter(([m]) => m === major) - .map(([, minor]) => minor); - const clientMinors = clientParsed - .filter(([m]) => m === major) - .map(([, minor]) => minor); - - const highestMinor = Math.min( - Math.max(...walletMinors), - Math.max(...clientMinors), - ); - - return `${major}.${highestMinor}`; + if (walletVersions.includes("1.0")) { + return "1.0"; + } + if (walletVersions.includes("0.0")) { + return "0.0"; } return null; }