@unicitylabs/uniclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts ADDED
@@ -0,0 +1,456 @@
1
+ /** Unicity channel plugin — Sphere SDK DMs over private Nostr relays. */
2
+
3
+ import type { Sphere } from "@unicitylabs/sphere-sdk";
4
+ import type { PluginRuntime, ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
5
+ import { waitForSphere, walletExists } from "./sphere.js";
6
+ import { runInteractiveSetup } from "./setup.js";
7
+ import { getCoinDecimals, toHumanReadable } from "./assets.js";
8
+ import { VALID_RECIPIENT } from "./validation.js";
9
+
10
+ const DEFAULT_ACCOUNT_ID = "default";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Account config shape (read from openclaw config under channels.uniclaw)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface UnicityAccountConfig {
17
+ enabled?: boolean;
18
+ name?: string;
19
+ nametag?: string;
20
+ network?: string;
21
+ additionalRelays?: string[];
22
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
23
+ allowFrom?: string[];
24
+ }
25
+
26
+ export interface ResolvedUnicityAccount {
27
+ accountId: string;
28
+ name?: string;
29
+ enabled: boolean;
30
+ configured: boolean;
31
+ publicKey: string;
32
+ nametag?: string;
33
+ config: UnicityAccountConfig;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Config helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function readChannelConfig(cfg: Record<string, unknown>): UnicityAccountConfig | undefined {
41
+ const channels = cfg.channels as Record<string, unknown> | undefined;
42
+ return channels?.uniclaw as UnicityAccountConfig | undefined;
43
+ }
44
+
45
+ export function listUnicityAccountIds(_cfg: Record<string, unknown>): string[] {
46
+ // We have an account once sphere has been initialized (pubkey present at runtime).
47
+ // Config-time: we always report a default account so the gateway tries to start it.
48
+ return [DEFAULT_ACCOUNT_ID];
49
+ }
50
+
51
+ export function resolveUnicityAccount(opts: {
52
+ cfg: Record<string, unknown>;
53
+ accountId?: string | null;
54
+ sphere?: Sphere | null;
55
+ }): ResolvedUnicityAccount {
56
+ const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
57
+ const ucfg = readChannelConfig(opts.cfg);
58
+ const enabled = ucfg?.enabled !== false;
59
+ const sphere = opts.sphere ?? null;
60
+
61
+ return {
62
+ accountId,
63
+ name: ucfg?.name?.trim() || undefined,
64
+ enabled,
65
+ configured: sphere?.identity?.chainPubkey != null,
66
+ publicKey: sphere?.identity?.chainPubkey ?? "",
67
+ nametag: sphere?.identity?.nametag ?? ucfg?.nametag,
68
+ config: ucfg ?? {},
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Channel plugin (full ChannelPlugin shape)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ let activeSphere: Sphere | null = null;
77
+ let pluginRuntime: PluginRuntime | null = null;
78
+ let ownerIdentity: string | null = null;
79
+
80
+ export function setUnicityRuntime(rt: PluginRuntime): void {
81
+ pluginRuntime = rt;
82
+ }
83
+ export function setOwnerIdentity(owner: string | undefined): void {
84
+ ownerIdentity = owner ?? null;
85
+ }
86
+ export function getOwnerIdentity(): string | null {
87
+ return ownerIdentity;
88
+ }
89
+ export function getUnicityRuntime(): PluginRuntime {
90
+ if (!pluginRuntime) throw new Error("Unicity runtime not initialized");
91
+ return pluginRuntime;
92
+ }
93
+ export function setActiveSphere(s: Sphere | null): void {
94
+ activeSphere = s;
95
+ }
96
+ export function getActiveSphere(): Sphere | null {
97
+ return activeSphere;
98
+ }
99
+
100
+ function isSenderOwner(senderPubkey: string, senderNametag?: string): boolean {
101
+ if (!ownerIdentity) return false;
102
+ const normalized = ownerIdentity.replace(/^@/, "").toLowerCase();
103
+ if (senderPubkey.toLowerCase() === normalized) return true;
104
+ if (senderNametag) {
105
+ const tag = senderNametag.replace(/^@/, "").toLowerCase();
106
+ if (tag === normalized) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ export const uniclawChannelPlugin = {
112
+ id: "uniclaw" as const,
113
+
114
+ meta: {
115
+ id: "uniclaw" as const,
116
+ label: "Unicity",
117
+ selectionLabel: "Unicity (Sphere DMs)",
118
+ docsPath: "/channels/uniclaw",
119
+ docsLabel: "uniclaw",
120
+ blurb: "Private Nostr DMs via Unicity Sphere SDK.",
121
+ order: 110,
122
+ },
123
+
124
+ capabilities: {
125
+ chatTypes: ["direct" as const],
126
+ media: false,
127
+ },
128
+
129
+ reload: { configPrefixes: ["channels.uniclaw"] },
130
+
131
+ // -- config adapter -------------------------------------------------------
132
+ config: {
133
+ listAccountIds: (cfg: Record<string, unknown>) => listUnicityAccountIds(cfg),
134
+ resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) =>
135
+ resolveUnicityAccount({ cfg, accountId, sphere: activeSphere }),
136
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
137
+ isConfigured: (_account: ResolvedUnicityAccount) => true,
138
+ describeAccount: (account: ResolvedUnicityAccount) => ({
139
+ accountId: account.accountId,
140
+ name: account.name,
141
+ enabled: account.enabled,
142
+ configured: account.configured,
143
+ publicKey: account.publicKey || undefined,
144
+ nametag: account.nametag,
145
+ }),
146
+ resolveAllowFrom: (params: { cfg: Record<string, unknown>; accountId?: string | null }) => {
147
+ const account = resolveUnicityAccount({ ...params, sphere: activeSphere });
148
+ return account.config.allowFrom ?? [];
149
+ },
150
+ },
151
+
152
+ // -- outbound adapter (send replies) --------------------------------------
153
+ outbound: {
154
+ deliveryMode: "direct" as const,
155
+ textChunkLimit: 4000,
156
+ sendText: async (ctx: {
157
+ cfg: Record<string, unknown>;
158
+ to: string;
159
+ text: string;
160
+ accountId?: string | null;
161
+ }) => {
162
+ const sphere = activeSphere ?? await waitForSphere();
163
+ if (!sphere) throw new Error("Unicity Sphere not initialized");
164
+ await sphere.communications.sendDM(ctx.to, ctx.text ?? "");
165
+ return { channel: "uniclaw", to: ctx.to };
166
+ },
167
+ },
168
+
169
+ // -- gateway adapter (inbound listener) -----------------------------------
170
+ gateway: {
171
+ startAccount: async (ctx: {
172
+ cfg: Record<string, unknown>;
173
+ accountId: string;
174
+ account: ResolvedUnicityAccount;
175
+ runtime: unknown;
176
+ abortSignal: AbortSignal;
177
+ log?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void; debug: (m: string) => void };
178
+ setStatus: (s: Record<string, unknown>) => void;
179
+ }) => {
180
+ const sphere = activeSphere ?? await waitForSphere();
181
+ if (!sphere) throw new Error("Unicity Sphere not initialized — run `openclaw uniclaw init`");
182
+
183
+ const runtime = getUnicityRuntime();
184
+
185
+ ctx.setStatus({
186
+ accountId: ctx.account.accountId,
187
+ publicKey: sphere.identity?.chainPubkey,
188
+ nametag: sphere.identity?.nametag,
189
+ running: true,
190
+ lastStartAt: Date.now(),
191
+ });
192
+
193
+ ctx.log?.info(
194
+ `[${ctx.account.accountId}] Starting Unicity channel (nametag: ${sphere.identity?.nametag ?? "none"}, pubkey: ${sphere.identity?.chainPubkey?.slice(0, 16)}...)`,
195
+ );
196
+
197
+ ctx.log?.info(`[${ctx.account.accountId}] Subscribing to DMs (pubkey: ${sphere.identity?.chainPubkey?.slice(0, 16)}...)`);
198
+
199
+ const unsub = sphere.communications.onDirectMessage((msg) => {
200
+ // Use @nametag if available, otherwise raw pubkey
201
+ const peerId = msg.senderNametag ? `@${msg.senderNametag}` : msg.senderPubkey;
202
+ ctx.log?.info(`[${ctx.account.accountId}] DM received from ${peerId}: ${msg.content.slice(0, 80)}`);
203
+
204
+ const isOwner = isSenderOwner(msg.senderPubkey, msg.senderNametag);
205
+
206
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
207
+ Body: msg.content,
208
+ RawBody: msg.content,
209
+ From: peerId,
210
+ To: sphere.identity?.nametag ?? sphere.identity?.chainPubkey ?? "agent",
211
+ SessionKey: `uniclaw:dm:${peerId}`,
212
+ ChatType: "direct",
213
+ Surface: "uniclaw",
214
+ Provider: "uniclaw",
215
+ AccountId: ctx.account.accountId,
216
+ SenderName: msg.senderNametag ?? msg.senderPubkey.slice(0, 12),
217
+ SenderId: msg.senderPubkey,
218
+ IsOwner: isOwner,
219
+ CommandAuthorized: isOwner,
220
+ });
221
+
222
+ runtime.channel.reply
223
+ .dispatchReplyWithBufferedBlockDispatcher({
224
+ ctx: inboundCtx,
225
+ cfg: ctx.cfg,
226
+ dispatcherOptions: {
227
+ deliver: async (payload: { text?: string }) => {
228
+ const text = payload.text;
229
+ if (!text) return;
230
+ try {
231
+ await sphere.communications.sendDM(peerId, text);
232
+ } catch (err) {
233
+ ctx.log?.error(`[${ctx.account.accountId}] Failed to send DM to ${peerId}: ${err}`);
234
+ }
235
+ },
236
+ },
237
+ })
238
+ .catch((err: unknown) => {
239
+ ctx.log?.error(`[${ctx.account.accountId}] Reply dispatch error: ${err}`);
240
+ });
241
+ });
242
+
243
+ ctx.log?.info(`[${ctx.account.accountId}] Unicity DM listener active`);
244
+
245
+ // Subscribe to incoming token transfers
246
+ const unsubTransfer = sphere.on("transfer:incoming", (transfer) => {
247
+ // Full address for DM replies; short form for display/logging only
248
+ const replyTo = transfer.senderNametag ? `@${transfer.senderNametag}` : transfer.senderPubkey;
249
+ const displayName = transfer.senderNametag ? `@${transfer.senderNametag}` : transfer.senderPubkey.slice(0, 12) + "…";
250
+ const totalAmount = transfer.tokens.map((t) => {
251
+ const decimals = getCoinDecimals(t.coinId) ?? 0;
252
+ const amount = toHumanReadable(t.amount, decimals);
253
+ return `${amount} ${t.symbol}`;
254
+ }).join(", ");
255
+ const memo = transfer.memo ? ` — "${transfer.memo}"` : "";
256
+ const body = `[Payment received] ${totalAmount} from ${displayName}${memo}`;
257
+
258
+ ctx.log?.info(`[${ctx.account.accountId}] ${body}`);
259
+
260
+ // Notify owner about the incoming transfer
261
+ const owner = getOwnerIdentity();
262
+ if (owner) {
263
+ sphere.communications.sendDM(`@${owner}`, body).catch((err) => {
264
+ ctx.log?.error(`[${ctx.account.accountId}] Failed to notify owner about transfer: ${err}`);
265
+ });
266
+ }
267
+
268
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
269
+ Body: body,
270
+ RawBody: body,
271
+ From: replyTo,
272
+ To: sphere.identity?.nametag ?? sphere.identity?.chainPubkey ?? "agent",
273
+ SessionKey: `uniclaw:transfer:${transfer.id}`,
274
+ ChatType: "direct",
275
+ Surface: "uniclaw",
276
+ Provider: "uniclaw",
277
+ AccountId: ctx.account.accountId,
278
+ SenderName: displayName,
279
+ SenderId: transfer.senderPubkey,
280
+ IsOwner: false,
281
+ CommandAuthorized: false,
282
+ });
283
+
284
+ runtime.channel.reply
285
+ .dispatchReplyWithBufferedBlockDispatcher({
286
+ ctx: inboundCtx,
287
+ cfg: ctx.cfg,
288
+ dispatcherOptions: {
289
+ deliver: async (payload: { text?: string }) => {
290
+ const text = payload.text;
291
+ if (!text) return;
292
+ try {
293
+ await sphere.communications.sendDM(replyTo, text);
294
+ } catch (err) {
295
+ ctx.log?.error(`[${ctx.account.accountId}] Failed to send DM to ${displayName}: ${err}`);
296
+ }
297
+ },
298
+ },
299
+ })
300
+ .catch((err: unknown) => {
301
+ ctx.log?.error(`[${ctx.account.accountId}] Transfer notification dispatch error: ${err}`);
302
+ });
303
+ });
304
+
305
+ // Subscribe to incoming payment requests
306
+ const unsubPaymentRequest = sphere.on("payment_request:incoming", (request) => {
307
+ const replyTo = request.senderNametag ? `@${request.senderNametag}` : request.senderPubkey;
308
+ const displayName = request.senderNametag ? `@${request.senderNametag}` : request.senderPubkey.slice(0, 12) + "…";
309
+ const decimals = getCoinDecimals(request.coinId) ?? 0;
310
+ const amount = toHumanReadable(request.amount, decimals);
311
+ const msg = request.message ? ` — "${request.message}"` : "";
312
+ const body = `[Payment request] ${displayName} is requesting ${amount} ${request.symbol}${msg} (request id: ${request.requestId})`;
313
+
314
+ ctx.log?.info(`[${ctx.account.accountId}] ${body}`);
315
+
316
+ // Notify owner about the incoming payment request
317
+ const owner = getOwnerIdentity();
318
+ if (owner) {
319
+ sphere.communications.sendDM(`@${owner}`, body).catch((err) => {
320
+ ctx.log?.error(`[${ctx.account.accountId}] Failed to notify owner about payment request: ${err}`);
321
+ });
322
+ }
323
+
324
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
325
+ Body: body,
326
+ RawBody: body,
327
+ From: replyTo,
328
+ To: sphere.identity?.nametag ?? sphere.identity?.chainPubkey ?? "agent",
329
+ SessionKey: `uniclaw:payreq:${request.requestId}`,
330
+ ChatType: "direct",
331
+ Surface: "uniclaw",
332
+ Provider: "uniclaw",
333
+ AccountId: ctx.account.accountId,
334
+ SenderName: displayName,
335
+ SenderId: request.senderPubkey,
336
+ IsOwner: false,
337
+ CommandAuthorized: false,
338
+ });
339
+
340
+ runtime.channel.reply
341
+ .dispatchReplyWithBufferedBlockDispatcher({
342
+ ctx: inboundCtx,
343
+ cfg: ctx.cfg,
344
+ dispatcherOptions: {
345
+ deliver: async (payload: { text?: string }) => {
346
+ const text = payload.text;
347
+ if (!text) return;
348
+ try {
349
+ await sphere.communications.sendDM(replyTo, text);
350
+ } catch (err) {
351
+ ctx.log?.error(`[${ctx.account.accountId}] Failed to send DM to ${displayName}: ${err}`);
352
+ }
353
+ },
354
+ },
355
+ })
356
+ .catch((err: unknown) => {
357
+ ctx.log?.error(`[${ctx.account.accountId}] Payment request dispatch error: ${err}`);
358
+ });
359
+ });
360
+
361
+ ctx.abortSignal.addEventListener("abort", () => {
362
+ unsub();
363
+ unsubTransfer();
364
+ unsubPaymentRequest();
365
+ }, { once: true });
366
+
367
+ return {
368
+ stop: () => {
369
+ unsub();
370
+ unsubTransfer();
371
+ unsubPaymentRequest();
372
+ ctx.log?.info(`[${ctx.account.accountId}] Unicity channel stopped`);
373
+ },
374
+ };
375
+ },
376
+ },
377
+
378
+ // -- status adapter -------------------------------------------------------
379
+ status: {
380
+ defaultRuntime: {
381
+ accountId: DEFAULT_ACCOUNT_ID,
382
+ running: false,
383
+ lastStartAt: null,
384
+ lastStopAt: null,
385
+ lastError: null,
386
+ },
387
+ buildChannelSummary: (params: { snapshot: Record<string, unknown> }) => ({
388
+ configured: params.snapshot.configured ?? false,
389
+ publicKey: params.snapshot.publicKey ?? null,
390
+ nametag: params.snapshot.nametag ?? null,
391
+ running: params.snapshot.running ?? false,
392
+ lastStartAt: params.snapshot.lastStartAt ?? null,
393
+ lastError: params.snapshot.lastError ?? null,
394
+ }),
395
+ buildAccountSnapshot: (params: {
396
+ account: ResolvedUnicityAccount;
397
+ runtime?: Record<string, unknown>;
398
+ }) => ({
399
+ accountId: params.account.accountId,
400
+ name: params.account.name,
401
+ enabled: params.account.enabled,
402
+ configured: params.account.configured,
403
+ publicKey: params.account.publicKey || undefined,
404
+ nametag: params.account.nametag,
405
+ running: (params.runtime?.running as boolean) ?? false,
406
+ lastStartAt: params.runtime?.lastStartAt ?? null,
407
+ lastStopAt: params.runtime?.lastStopAt ?? null,
408
+ lastError: params.runtime?.lastError ?? null,
409
+ }),
410
+ },
411
+
412
+ // -- messaging adapter (target normalization) -----------------------------
413
+ messaging: {
414
+ normalizeTarget: (target: string) => target.replace(/^@/, "").trim(),
415
+ targetResolver: {
416
+ looksLikeId: (input: string) => VALID_RECIPIENT.test(input.trim()),
417
+ hint: "<@nametag|hex pubkey>",
418
+ },
419
+ },
420
+
421
+ // -- security adapter (DM access control) ---------------------------------
422
+ security: {
423
+ resolveDmPolicy: (params: { account: ResolvedUnicityAccount }) => ({
424
+ policy: params.account.config.dmPolicy ?? "open",
425
+ allowFrom: params.account.config.allowFrom ?? [],
426
+ policyPath: "channels.uniclaw.dmPolicy",
427
+ allowFromPath: "channels.uniclaw.allowFrom",
428
+ approveHint: 'openclaw config set channels.uniclaw.allowFrom \'["<pubkey-or-nametag>"]\'',
429
+ }),
430
+ },
431
+
432
+ // -- onboarding adapter (interactive setup via `openclaw onboard`) ---------
433
+ onboarding: {
434
+ channel: "uniclaw",
435
+
436
+ getStatus: async (_ctx) => ({
437
+ channel: "uniclaw" as const,
438
+ configured: walletExists(),
439
+ statusLines: walletExists()
440
+ ? [`Nametag: ${activeSphere?.identity?.nametag ?? "pending"}`]
441
+ : ["Not configured — run setup to create wallet"],
442
+ quickstartScore: 80,
443
+ }),
444
+
445
+ configure: async (ctx) => {
446
+ const { prompter, cfg } = ctx;
447
+ await runInteractiveSetup(prompter, {
448
+ loadConfig: () => cfg as Record<string, unknown>,
449
+ writeConfigFile: async (updatedCfg) => {
450
+ Object.assign(cfg, updatedCfg);
451
+ },
452
+ });
453
+ return { cfg };
454
+ },
455
+ } satisfies ChannelOnboardingAdapter,
456
+ };
@@ -0,0 +1,66 @@
1
+ /** WizardPrompter adapter wrapping @clack/prompts for CLI use. */
2
+
3
+ import * as clack from "@clack/prompts";
4
+ import type { WizardPrompter } from "openclaw/plugin-sdk";
5
+
6
+ export function createCliPrompter(): WizardPrompter {
7
+ return {
8
+ async intro() { /* handled externally */ },
9
+ async outro() { /* handled externally */ },
10
+ async note(message: string, title?: string) {
11
+ clack.note(message, title);
12
+ },
13
+ async select<T>(params: { message: string; options: Array<{ value: T; label: string; hint?: string }>; initialValue?: T }): Promise<T> {
14
+ const result = await clack.select({
15
+ message: params.message,
16
+ options: params.options,
17
+ initialValue: params.initialValue,
18
+ });
19
+ if (clack.isCancel(result)) {
20
+ throw new Error("Setup cancelled");
21
+ }
22
+ return result as T;
23
+ },
24
+ async multiselect<T>(params: { message: string; options: Array<{ value: T; label: string }>; initialValues?: T[] }): Promise<T[]> {
25
+ const result = await clack.multiselect({
26
+ message: params.message,
27
+ options: params.options,
28
+ initialValues: params.initialValues,
29
+ });
30
+ if (clack.isCancel(result)) {
31
+ throw new Error("Setup cancelled");
32
+ }
33
+ return result as T[];
34
+ },
35
+ async text(params: { message: string; initialValue?: string; placeholder?: string; validate?: (value: string) => string | undefined }): Promise<string> {
36
+ const result = await clack.text({
37
+ message: params.message,
38
+ initialValue: params.initialValue,
39
+ placeholder: params.placeholder,
40
+ validate: params.validate,
41
+ });
42
+ if (clack.isCancel(result)) {
43
+ throw new Error("Setup cancelled");
44
+ }
45
+ return result as string;
46
+ },
47
+ async confirm(params: { message: string; initialValue?: boolean }): Promise<boolean> {
48
+ const result = await clack.confirm({
49
+ message: params.message,
50
+ initialValue: params.initialValue,
51
+ });
52
+ if (clack.isCancel(result)) {
53
+ throw new Error("Setup cancelled");
54
+ }
55
+ return result as boolean;
56
+ },
57
+ progress(label: string) {
58
+ const spinner = clack.spinner();
59
+ spinner.start(label);
60
+ return {
61
+ update(message: string) { spinner.message(message); },
62
+ stop(message?: string) { spinner.stop(message); },
63
+ };
64
+ },
65
+ };
66
+ }
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ /** Uniclaw plugin configuration schema and helpers. */
2
+
3
+ import { NAMETAG_REGEX } from "./validation.js";
4
+
5
+ export type UnicityNetwork = "testnet" | "mainnet" | "dev";
6
+
7
+ export type UniclawConfig = {
8
+ network?: UnicityNetwork;
9
+ nametag?: string;
10
+ owner?: string;
11
+ additionalRelays?: string[];
12
+ /** Aggregator API key (defaults to testnet key) */
13
+ apiKey?: string;
14
+ };
15
+
16
+ const VALID_NETWORKS = new Set<string>(["testnet", "mainnet", "dev"]);
17
+
18
+ export function resolveUniclawConfig(raw: Record<string, unknown> | undefined): UniclawConfig {
19
+ const cfg = (raw ?? {}) as Record<string, unknown>;
20
+ const network = typeof cfg.network === "string" && VALID_NETWORKS.has(cfg.network)
21
+ ? (cfg.network as UnicityNetwork)
22
+ : "testnet";
23
+ const rawNametag = typeof cfg.nametag === "string" ? cfg.nametag.replace(/^@/, "").trim() : undefined;
24
+ const nametag = rawNametag && NAMETAG_REGEX.test(rawNametag) ? rawNametag : undefined;
25
+ const rawOwner = typeof cfg.owner === "string" ? cfg.owner.replace(/^@/, "").trim() : undefined;
26
+ const owner = rawOwner && NAMETAG_REGEX.test(rawOwner) ? rawOwner : undefined;
27
+ const additionalRelays = Array.isArray(cfg.additionalRelays)
28
+ ? cfg.additionalRelays.filter((r): r is string => typeof r === "string")
29
+ : undefined;
30
+ const apiKey = typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
31
+ return { network, nametag, owner, additionalRelays, apiKey };
32
+ }