From 584cd4d52de31f9882c36231758e57557a6ae77b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 22 Oct 2025 11:17:00 -0500 Subject: [PATCH 1/2] clean up echo flow once more --- bun.lock | 4 +- package.json | 2 +- src/components/keyset/useShareEchoListener.ts | 4 +- src/keyset/awaitShareEchoCompat.ts | 160 ++++++++++++++++++ tests/awaitShareEchoCompat.test.ts | 25 +++ 5 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 src/keyset/awaitShareEchoCompat.ts create mode 100644 tests/awaitShareEchoCompat.test.ts diff --git a/bun.lock b/bun.lock index 5c050e4..4a8ed61 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "igloo-cli", "dependencies": { "@frostr/bifrost": "^1.0.7", - "@frostr/igloo-core": "^0.2.3", + "@frostr/igloo-core": "0.2.4", "@noble/ciphers": "^2.0.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", @@ -86,7 +86,7 @@ "@frostr/bifrost": ["@frostr/bifrost@1.0.7", "", { "dependencies": { "@cmdcode/buff": "^2.2.5", "@cmdcode/frost": "^1.1.3", "@cmdcode/nostr-p2p": "^2.0.11", "@noble/ciphers": "^1.2.1", "@noble/curves": "^1.8.1", "zod": "^3.24.1" } }, "sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow=="], - "@frostr/igloo-core": ["@frostr/igloo-core@0.2.3", "", { "dependencies": { "zod": "^3.24.2" }, "peerDependencies": { "@frostr/bifrost": "^1.0.6", "nostr-tools": "^2.10.0" } }, "sha512-PNH8lcxVe957EclKtu/zcpCekRoogX9rnMO5/OYcAiEp2Yyk0GKN35iy+M09de0JN7oGPgemzJmGA6TiJAcc+g=="], + "@frostr/igloo-core": ["@frostr/igloo-core@0.2.4", "", { "dependencies": { "zod": "^3.24.2" }, "peerDependencies": { "@frostr/bifrost": "^1.0.6", "nostr-tools": "^2.10.0" } }, "sha512-/tpYIyC0zpSuYdQPiSXtcpIYCoT3HZ82x4O0f7qYx/DVUp+RRLZAfltaJ4Ts1UkTdwN5yBqnlKk+nrTIqPMq/A=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], diff --git a/package.json b/package.json index 2879aca..6643204 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "homepage": "https://github.com/FROSTR-ORG/igloo-cli#readme", "dependencies": { "@frostr/bifrost": "^1.0.7", - "@frostr/igloo-core": "^0.2.4", + "@frostr/igloo-core": "0.2.4", "@noble/ciphers": "^2.0.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", diff --git a/src/components/keyset/useShareEchoListener.ts b/src/components/keyset/useShareEchoListener.ts index 03697c4..13c9eb4 100644 --- a/src/components/keyset/useShareEchoListener.ts +++ b/src/components/keyset/useShareEchoListener.ts @@ -1,6 +1,6 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {awaitShareEcho} from '@frostr/igloo-core'; import {computeEchoRelays} from '../../keyset/echoRelays.js'; +import {awaitShareEchoCompat} from '../../keyset/awaitShareEchoCompat.js'; export type EchoStatus = 'idle' | 'listening' | 'success'; @@ -131,7 +131,7 @@ export function useShareEchoListener( console.log('[echo-listen] INFO using relays', relays ?? 'default'); } catch {} } - const result = await awaitShareEcho( + const result = await awaitShareEchoCompat( groupCredential, shareCredential, { relays, timeout: timeoutMs, eventConfig: { enableLogging: debugEnabled, customLogger: debugLogger } } diff --git a/src/keyset/awaitShareEchoCompat.ts b/src/keyset/awaitShareEchoCompat.ts new file mode 100644 index 0000000..ba9bd70 --- /dev/null +++ b/src/keyset/awaitShareEchoCompat.ts @@ -0,0 +1,160 @@ +import { + createBifrostNode, + connectNode, + closeNode, + decodeShare, + decodeGroup, + DEFAULT_ECHO_RELAYS, + type NodeEventConfig +} from '@frostr/igloo-core'; + +export type AwaitShareEchoOptions = { + relays?: string[]; + timeout?: number; + eventConfig?: NodeEventConfig; +}; + +const HEX_CHALLENGE_REGEX = /^[0-9a-f]+$/i; + +function resolveEchoRelays(groupCredential: string, explicitRelays?: string[]): string[] { + if (Array.isArray(explicitRelays) && explicitRelays.length > 0) { + return explicitRelays; + } + try { + const decoded: any = decodeGroup(groupCredential); + const relays: unknown = decoded?.relays ?? decoded?.relayUrls ?? decoded?.relay_urls; + if (Array.isArray(relays) && relays.length > 0) { + return relays.filter((relay): relay is string => typeof relay === 'string' && relay.length > 0); + } + } catch { + // If group decoding fails we fall back to defaults. + } + return DEFAULT_ECHO_RELAYS; +} + +function isHexChallenge(input: unknown): boolean { + if (typeof input !== 'string') return false; + const trimmed = input.trim(); + if (trimmed.length === 0 || trimmed.length % 2 !== 0) { + return false; + } + return HEX_CHALLENGE_REGEX.test(trimmed); +} + +export function isEchoConfirmationPayload(data: unknown): boolean { + if (typeof data !== 'string') return false; + const trimmed = data.trim(); + if (trimmed.length === 0) return false; + if (trimmed.toLowerCase() === 'echo') return true; + return isHexChallenge(trimmed); +} + +export async function awaitShareEchoCompat( + groupCredential: string, + shareCredential: string, + {relays, timeout = 30_000, eventConfig = {}}: AwaitShareEchoOptions = {} +): Promise { + const shareDetails = decodeShare(shareCredential); + const resolvedRelays = resolveEchoRelays(groupCredential, relays); + + let node: any | null = null; + let timeoutId: NodeJS.Timeout | null = null; + let settled = false; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (node) { + try { + closeNode(node); + } catch { + // Ignore cleanup errors. + } + node = null; + } + }; + + const prefixLogger = (level: string, message: string, payload?: unknown) => { + const prefix = `[awaitShareEcho:${shareDetails.idx}] ${message}`; + if (eventConfig.customLogger) { + eventConfig.customLogger(level, prefix, payload); + } else if (eventConfig.enableLogging) { + // eslint-disable-next-line no-console + console.log(prefix, payload ?? ''); + } + }; + + return new Promise((resolve, reject) => { + const safeResolve = (value: boolean) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const safeReject = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + const err = error instanceof Error ? error : new Error(String(error)); + reject(err); + }; + + try { + const mergedEventConfig: NodeEventConfig = { + ...eventConfig, + customLogger: prefixLogger + }; + + node = createBifrostNode( + {group: groupCredential, share: shareCredential, relays: resolvedRelays}, + mergedEventConfig + ); + + const onMessage = (payload: any) => { + if (!payload || payload.tag !== '/echo/req') { + return; + } + if (!isEchoConfirmationPayload(payload.data)) { + return; + } + prefixLogger('info', 'Echo confirmation received', payload); + safeResolve(true); + }; + + const onError = (error: unknown) => { + prefixLogger('error', 'Node error while waiting for echo', error); + safeReject(error); + }; + + const onClosed = () => { + if (settled) return; + prefixLogger('warn', 'Connection closed before echo arrived'); + safeReject(new Error('Connection closed before echo confirmation was received.')); + }; + + node.on('message', onMessage); + node.on('error', onError); + node.on('closed', onClosed); + + timeoutId = setTimeout(() => { + safeReject(new Error(`No echo confirmation within ${timeout / 1000}s.`)); + }, timeout); + + void connectNode(node) + .then(() => { + if (settled) { + return; + } + prefixLogger('info', 'Listening for echo confirmation'); + }) + .catch(error => { + safeReject(error); + }); + } catch (error) { + safeReject(error); + } + }); +} diff --git a/tests/awaitShareEchoCompat.test.ts b/tests/awaitShareEchoCompat.test.ts new file mode 100644 index 0000000..75c3b25 --- /dev/null +++ b/tests/awaitShareEchoCompat.test.ts @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isEchoConfirmationPayload} from '../src/keyset/awaitShareEchoCompat.js'; + +test('isEchoConfirmationPayload accepts legacy "echo" token', () => { + assert.equal(isEchoConfirmationPayload('echo'), true); + assert.equal(isEchoConfirmationPayload(' ECHO '), true); +}); + +test('isEchoConfirmationPayload accepts even-length hex challenges', () => { + assert.equal( + isEchoConfirmationPayload('810907ac3915c5d4f50e6751ea476b708fe7178f53711d1a185bb3d49987b3d4'), + true + ); + assert.equal(isEchoConfirmationPayload('aaff00cc'), true); +}); + +test('isEchoConfirmationPayload rejects invalid payloads', () => { + assert.equal(isEchoConfirmationPayload(''), false); + assert.equal(isEchoConfirmationPayload(' '), false); + assert.equal(isEchoConfirmationPayload('abc'), false); // odd length + assert.equal(isEchoConfirmationPayload('xyz123'), false); // non-hex + assert.equal(isEchoConfirmationPayload(undefined), false); + assert.equal(isEchoConfirmationPayload(null), false); +}); From 12fa31900bdea9a1dd6e873217007ef90ae7a239 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 22 Oct 2025 16:23:41 -0500 Subject: [PATCH 2/2] use proper bifrost cleanup func --- src/keyset/awaitShareEchoCompat.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/keyset/awaitShareEchoCompat.ts b/src/keyset/awaitShareEchoCompat.ts index ba9bd70..f24947c 100644 --- a/src/keyset/awaitShareEchoCompat.ts +++ b/src/keyset/awaitShareEchoCompat.ts @@ -1,7 +1,7 @@ import { createBifrostNode, connectNode, - closeNode, + cleanupBifrostNode, decodeShare, decodeGroup, DEFAULT_ECHO_RELAYS, @@ -67,11 +67,7 @@ export async function awaitShareEchoCompat( timeoutId = null; } if (node) { - try { - closeNode(node); - } catch { - // Ignore cleanup errors. - } + cleanupBifrostNode(node); node = null; } };