import assert from "node:assert/strict"; import { createHash, randomUUID } from "node:crypto"; import test from "node:test"; import { InviteReader, KEY_PACKAGE_KIND, KEY_PACKAGE_RELAY_LIST_KIND, GROUP_EVENT_KIND, WELCOME_EVENT_KIND, MarmotClient, KeyPackageStore, KeyValueGroupStateBackend, Proposals, createKeyPackageRelayListEvent, deserializeApplicationData, extractMarmotGroupData, getKeyPackageRelayList, } from "../../marmot-ts/dist/index.js"; import { PrivateKeyAccount } from "../../marmot-ts/node_modules/applesauce-accounts/dist/accounts/index.js"; const GIFT_WRAP_KIND = 1059; const relayPort = process.env.PARRHESIA_MARMOT_E2E_RELAY_PORT ?? process.env.PARRHESIA_E2E_RELAY_PORT ?? "4051"; const relayHttpBase = `http://127.0.0.1:${relayPort}`; const relayHttpUrl = `${relayHttpBase}/relay`; const relayWsUrl = relayHttpUrl.replace("http://", "ws://"); class MemoryBackend { #map = new Map(); async getItem(key) { return this.#map.get(key) ?? null; } async setItem(key, value) { this.#map.set(key, value); return value; } async removeItem(key) { this.#map.delete(key); } async clear() { this.#map.clear(); } async keys() { return Array.from(this.#map.keys()); } } class RelayNetwork { constructor(relayWs, relayHttp) { this.relayWs = relayWs; this.relayHttp = relayHttp; } async publish(relays, event) { const responses = await Promise.all( relays.map(async (relay) => { const frame = await publishEvent(relay, event); return [relay, { from: relay, ok: Boolean(frame[2]), message: frame[3] }]; }), ); return Object.fromEntries(responses); } async request(relays, filters) { const filtersArray = Array.isArray(filters) ? filters : [filters]; const eventsById = new Map(); for (const relay of relays) { const events = await requestEvents(relay, filtersArray); for (const event of events) { eventsById.set(event.id, event); } } return Array.from(eventsById.values()); } subscription(relays, filters) { return { subscribe: (observer) => { let unsubscribed = false; void this.request(relays, filters) .then((events) => { for (const event of events) { if (unsubscribed) { return; } observer.next?.(event); } if (!unsubscribed) { observer.complete?.(); } }) .catch((error) => { if (!unsubscribed) { observer.error?.(error); } }); return { unsubscribe: () => { unsubscribed = true; }, }; }, }; } async getUserInboxRelays(pubkey) { const relayListEvents = await this.request([this.relayWs], [ { kinds: [KEY_PACKAGE_RELAY_LIST_KIND], authors: [pubkey], limit: 1 }, ]); if (relayListEvents.length === 0) { return [this.relayWs]; } const relays = getKeyPackageRelayList(relayListEvents[0]); return relays.length > 0 ? relays : [this.relayWs]; } } function createClient(account, network) { return new MarmotClient({ signer: account.signer, network, keyPackageStore: new KeyPackageStore(new MemoryBackend()), groupStateBackend: new KeyValueGroupStateBackend(new MemoryBackend()), }); } /** * Bootstraps a group with one admin and `count` invited members. */ async function createGroupWithMembers(network, count) { const adminAccount = PrivateKeyAccount.generateNew(); const adminPubkey = await adminAccount.signer.getPublicKey(); const adminClient = createClient(adminAccount, network); const adminRelayList = await adminAccount.signer.signEvent( createKeyPackageRelayListEvent({ pubkey: adminPubkey, relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }), ); await network.publish([relayWsUrl], adminRelayList); const memberInfos = []; for (let i = 0; i < count; i++) { const account = PrivateKeyAccount.generateNew(); const pubkey = await account.signer.getPublicKey(); const client = createClient(account, network); const relayList = await account.signer.signEvent( createKeyPackageRelayListEvent({ pubkey, relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }), ); await network.publish([relayWsUrl], relayList); await client.keyPackages.create({ relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }); memberInfos.push({ account, pubkey, client }); } const adminGroup = await adminClient.createGroup( `E2E Group ${randomUUID()}`, { relays: [relayWsUrl], adminPubkeys: [adminPubkey] }, ); const members = []; for (const mi of memberInfos) { const keyPackageEvents = await network.request([relayWsUrl], [ { kinds: [KEY_PACKAGE_KIND], authors: [mi.pubkey], limit: 1 }, ]); assert.equal(keyPackageEvents.length, 1, `key package missing for ${mi.pubkey}`); await adminGroup.inviteByKeyPackageEvent(keyPackageEvents[0]); const giftWraps = await requestGiftWrapsWithAuth({ relayUrl: relayWsUrl, relayHttpUrl, signer: mi.account.signer, recipientPubkey: mi.pubkey, }); assert.ok(giftWraps.length >= 1, `gift wrap missing for ${mi.pubkey}`); const inviteReader = new InviteReader({ signer: mi.account.signer, store: { received: new MemoryBackend(), unread: new MemoryBackend(), seen: new MemoryBackend(), }, }); await inviteReader.ingestEvents(giftWraps); const invites = await inviteReader.decryptGiftWraps(); assert.equal(invites.length, 1); const { group } = await mi.client.joinGroupFromWelcome({ welcomeRumor: invites[0], }); members.push({ account: mi.account, pubkey: mi.pubkey, client: mi.client, group }); } return { admin: { account: adminAccount, pubkey: adminPubkey, client: adminClient, group: adminGroup }, members, }; } function getNostrGroupId(group) { const data = extractMarmotGroupData(group.state); assert.ok(data, "MarmotGroupData should exist on group"); return bytesToHex(data.nostrGroupId); } async function fetchGroupEvents(network, group) { const nostrGroupId = getNostrGroupId(group); return network.request([relayWsUrl], [ { kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 100 }, ]); } async function countEvents(relayUrl, filters) { const relay = await openRelay(relayUrl, 5_000); const subId = randomSubId("count"); const filtersArray = Array.isArray(filters) ? filters : [filters]; try { relay.send(["COUNT", subId, ...filtersArray]); while (true) { const frame = await relay.nextFrame(5_000); if (!Array.isArray(frame)) continue; if (frame[0] === "COUNT" && frame[1] === subId) { return frame[2]; } if (frame[0] === "CLOSED" && frame[1] === subId) { throw new Error(`COUNT closed: ${frame[2]}`); } } } finally { await relay.close(); } } function computeEventId(event) { const payload = [ 0, event.pubkey, event.created_at, event.kind, event.tags, event.content, ]; return createHash("sha256").update(JSON.stringify(payload)).digest("hex"); } function bytesToHex(bytes) { return Buffer.from(bytes).toString("hex"); } function unixNow() { return Math.floor(Date.now() / 1000); } function randomSubId(prefix) { return `${prefix}-${randomUUID()}`; } async function openRelay(relayUrl, timeoutMs = 5_000) { const ws = new WebSocket(relayUrl); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`WebSocket open timeout for ${relayUrl}`)); }, timeoutMs); ws.addEventListener( "open", () => { clearTimeout(timeout); resolve(); }, { once: true }, ); ws.addEventListener( "error", (error) => { clearTimeout(timeout); reject(error.error ?? error); }, { once: true }, ); }); const queue = []; let waiter = null; ws.addEventListener("message", (message) => { let frame; try { frame = JSON.parse(String(message.data)); } catch { return; } if (waiter) { const activeWaiter = waiter; waiter = null; activeWaiter.resolve(frame); } else { queue.push(frame); } }); ws.addEventListener("close", () => { if (waiter) { const activeWaiter = waiter; waiter = null; activeWaiter.reject(new Error(`WebSocket closed for ${relayUrl}`)); } }); return { send(frame) { ws.send(JSON.stringify(frame)); }, nextFrame(timeoutMs = 5_000) { if (queue.length > 0) { return Promise.resolve(queue.shift()); } if (waiter) { return Promise.reject(new Error("Only one pending frame wait is supported")); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { if (waiter) { waiter = null; } reject(new Error(`Timed out waiting for relay frame from ${relayUrl}`)); }, timeoutMs); waiter = { resolve: (frame) => { clearTimeout(timeout); resolve(frame); }, reject: (error) => { clearTimeout(timeout); reject(error); }, }; }); }, async close() { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(); } }, }; } async function publishEvent(relayUrl, event, timeoutMs = 5_000) { const relay = await openRelay(relayUrl, timeoutMs); try { relay.send(["EVENT", event]); while (true) { const frame = await relay.nextFrame(timeoutMs); if (Array.isArray(frame) && frame[0] === "OK" && frame[1] === event.id) { return frame; } } } finally { await relay.close(); } } async function requestEvents(relayUrl, filters, timeoutMs = 5_000) { const relay = await openRelay(relayUrl, timeoutMs); const subscriptionId = randomSubId("req"); const events = []; try { relay.send(["REQ", subscriptionId, ...filters]); while (true) { const frame = await relay.nextFrame(timeoutMs); if (!Array.isArray(frame)) { continue; } const [type, subId, payload] = frame; if (type === "EVENT" && subId === subscriptionId) { events.push(payload); } if (type === "EOSE" && subId === subscriptionId) { break; } if (type === "CLOSED" && subId === subscriptionId) { throw new Error(`relay closed request ${subscriptionId}: ${payload}`); } } relay.send(["CLOSE", subscriptionId]); return events; } finally { await relay.close(); } } async function requestGiftWrapsWithAuth({ relayUrl, relayHttpUrl, signer, recipientPubkey }) { const relay = await openRelay(relayUrl, 5_000); const probeSubId = randomSubId("auth-probe"); try { relay.send(["REQ", probeSubId, { kinds: [GIFT_WRAP_KIND], "#p": [recipientPubkey], limit: 1 }]); let challenge = null; for (;;) { const frame = await relay.nextFrame(5_000); if (!Array.isArray(frame)) { continue; } if (frame[0] === "AUTH" && typeof frame[1] === "string") { challenge = frame[1]; } if (frame[0] === "CLOSED" && frame[1] === probeSubId) { break; } } assert.ok(challenge, "relay should provide an AUTH challenge for restricted giftwrap queries"); const authEvent = await signer.signEvent({ kind: 22_242, created_at: unixNow(), tags: [ ["challenge", challenge], ["relay", relayUrl], ], content: "", }); relay.send(["AUTH", authEvent]); for (;;) { const frame = await relay.nextFrame(5_000); if (!Array.isArray(frame)) { continue; } if (frame[0] === "OK" && frame[1] === authEvent.id) { assert.equal(frame[2], true, `auth rejected: ${frame[3] ?? "unknown error"}`); break; } } const subscriptionId = randomSubId("giftwrap"); const giftWraps = []; relay.send([ "REQ", subscriptionId, { kinds: [GIFT_WRAP_KIND], "#p": [recipientPubkey], limit: 20 }, ]); for (;;) { const frame = await relay.nextFrame(5_000); if (!Array.isArray(frame)) { continue; } const [type, subId, payload] = frame; if (type === "EVENT" && subId === subscriptionId) { giftWraps.push(payload); } if (type === "EOSE" && subId === subscriptionId) { break; } if (type === "CLOSED" && subId === subscriptionId) { throw new Error(`giftwrap request closed unexpectedly: ${payload}`); } } relay.send(["CLOSE", subscriptionId]); return giftWraps; } finally { await relay.close(); } } test("relay returns NIP-11 metadata and advertises Marmot support", async () => { const response = await fetch(relayHttpUrl, { headers: { Accept: "application/nostr+json" }, }); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.name, "Parrhesia"); assert.ok(body.supported_nips.includes(59)); assert.ok(body.supported_nips.includes(77)); }); test("key package create + rotate works against relay storage", async () => { const account = PrivateKeyAccount.generateNew(); const pubkey = await account.signer.getPublicKey(); const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const client = createClient(account, network); const relayListEvent = await account.signer.signEvent( createKeyPackageRelayListEvent({ pubkey, relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }), ); await network.publish([relayWsUrl], relayListEvent); const keyPackage = await client.keyPackages.create({ relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }); const keyPackageRef = bytesToHex(keyPackage.keyPackageRef); const keyPackageEvents = await network.request([relayWsUrl], [ { kinds: [KEY_PACKAGE_KIND], authors: [pubkey], limit: 10 }, ]); assert.ok(keyPackageEvents.length >= 1); const matchingByRef = await network.request([relayWsUrl], [ { kinds: [KEY_PACKAGE_KIND], "#i": [keyPackageRef], limit: 10 }, ]); assert.ok(matchingByRef.length >= 1); await client.keyPackages.rotate(keyPackage.keyPackageRef, { relays: [relayWsUrl] }); const afterRotateOldRef = await network.request([relayWsUrl], [ { kinds: [KEY_PACKAGE_KIND], "#i": [keyPackageRef], limit: 10 }, ]); assert.equal(afterRotateOldRef.length, 0); }); test("kind 445 requests without #h are rejected by relay policy", async () => { const relay = await openRelay(relayWsUrl, 5_000); try { const subscriptionId = randomSubId("missing-h"); relay.send(["REQ", subscriptionId, { kinds: [GROUP_EVENT_KIND], limit: 5 }]); for (;;) { const frame = await relay.nextFrame(5_000); if (!Array.isArray(frame)) { continue; } if (frame[0] === "CLOSED" && frame[1] === subscriptionId) { assert.match(String(frame[2]), /kind 445 queries must include a #h tag/); break; } } } finally { await relay.close(); } }); test("admin invites user, user joins, and message round-trip decrypts", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const adminAccount = PrivateKeyAccount.generateNew(); const inviteeAccount = PrivateKeyAccount.generateNew(); const adminPubkey = await adminAccount.signer.getPublicKey(); const inviteePubkey = await inviteeAccount.signer.getPublicKey(); const adminClient = createClient(adminAccount, network); const inviteeClient = createClient(inviteeAccount, network); const adminRelayList = await adminAccount.signer.signEvent( createKeyPackageRelayListEvent({ pubkey: adminPubkey, relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }), ); const inviteeRelayList = await inviteeAccount.signer.signEvent( createKeyPackageRelayListEvent({ pubkey: inviteePubkey, relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }), ); await network.publish([relayWsUrl], adminRelayList); await network.publish([relayWsUrl], inviteeRelayList); await inviteeClient.keyPackages.create({ relays: [relayWsUrl], client: "parrhesia-marmot-e2e", }); const adminGroup = await adminClient.createGroup(`Parrhesia Group ${randomUUID()}`, { relays: [relayWsUrl], adminPubkeys: [adminPubkey], }); const inviteeKeyPackageEvents = await network.request([relayWsUrl], [ { kinds: [KEY_PACKAGE_KIND], authors: [inviteePubkey], limit: 1 }, ]); assert.equal(inviteeKeyPackageEvents.length, 1); await adminGroup.inviteByKeyPackageEvent(inviteeKeyPackageEvents[0]); const marmotGroupData = extractMarmotGroupData(adminGroup.state); assert.ok(marmotGroupData, "group should include Marmot extension data"); const nostrGroupId = bytesToHex(marmotGroupData.nostrGroupId); const commitEvents = await network.request([relayWsUrl], [ { kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 10 }, ]); assert.ok(commitEvents.length >= 1); const giftWraps = await requestGiftWrapsWithAuth({ relayUrl: relayWsUrl, relayHttpUrl, signer: inviteeAccount.signer, recipientPubkey: inviteePubkey, }); assert.equal(giftWraps.length, 1); const inviteReader = new InviteReader({ signer: inviteeAccount.signer, store: { received: new MemoryBackend(), unread: new MemoryBackend(), seen: new MemoryBackend(), }, }); await inviteReader.ingestEvents(giftWraps); const invites = await inviteReader.decryptGiftWraps(); assert.equal(invites.length, 1); assert.equal(invites[0].kind, WELCOME_EVENT_KIND); const { group: inviteeGroup } = await inviteeClient.joinGroupFromWelcome({ welcomeRumor: invites[0], }); const messageRumor = { kind: 9, pubkey: inviteePubkey, created_at: unixNow(), tags: [], content: `hello-from-invitee-${randomUUID()}`, }; messageRumor.id = computeEventId(messageRumor); await inviteeGroup.sendApplicationRumor(messageRumor); const groupEventsAfterMessage = await network.request([relayWsUrl], [ { kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 50 }, ]); const decryptedMessages = []; for await (const result of adminGroup.ingest(groupEventsAfterMessage)) { if (result.kind === "processed" && result.result.kind === "applicationMessage") { decryptedMessages.push(deserializeApplicationData(result.result.message)); } } assert.ok( decryptedMessages.some((rumor) => rumor.content === messageRumor.content), "admin should decrypt invitee application message", ); }); test("sendChatMessage convenience API works", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const { admin, members } = await createGroupWithMembers(network, 1); const invitee = members[0]; const content = `chat-msg-${randomUUID()}`; await invitee.group.sendChatMessage(content); const groupEvents = await fetchGroupEvents(network, admin.group); const decrypted = []; for await (const result of admin.group.ingest(groupEvents)) { if (result.kind === "processed" && result.result.kind === "applicationMessage") { decrypted.push(deserializeApplicationData(result.result.message)); } } assert.ok( decrypted.some((r) => r.content === content && r.kind === 9), "admin should decrypt kind 9 chat message from invitee", ); }); test("admin removes member from group", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const { admin, members } = await createGroupWithMembers(network, 2); const [user1, user2] = members; // proposeRemoveUser returns ProposalRemove[] — use propose() which handles arrays await admin.group.propose(Proposals.proposeRemoveUser(user2.pubkey)); await admin.group.commit(); // user1 ingests the removal commit const groupEvents = await fetchGroupEvents(network, admin.group); let user1Processed = false; for await (const result of user1.group.ingest(groupEvents)) { if (result.kind === "processed" && result.result.kind === "newState") { user1Processed = true; } } assert.ok(user1Processed, "user1 should process the removal commit"); // user1 can still send a message that admin decrypts const msg = `post-removal-${randomUUID()}`; await user1.group.sendChatMessage(msg); const updatedEvents = await fetchGroupEvents(network, admin.group); const decrypted = []; for await (const result of admin.group.ingest(updatedEvents)) { if (result.kind === "processed" && result.result.kind === "applicationMessage") { decrypted.push(deserializeApplicationData(result.result.message)); } } assert.ok( decrypted.some((r) => r.content === msg), "admin should decrypt message from user1 after user2 removal", ); }); test("group metadata update via proposal", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const { admin, members } = await createGroupWithMembers(network, 1); const invitee = members[0]; const updatedName = `Updated-${randomUUID()}`; await admin.group.commit({ extraProposals: [Proposals.proposeUpdateMetadata({ name: updatedName })], }); // Admin sees the updated name locally const adminGroupData = extractMarmotGroupData(admin.group.state); assert.equal(adminGroupData.name, updatedName); // Invitee ingests the commit and sees updated metadata const groupEvents = await fetchGroupEvents(network, admin.group); for await (const _result of invitee.group.ingest(groupEvents)) { // drain ingest } const inviteeGroupData = extractMarmotGroupData(invitee.group.state); assert.equal(inviteeGroupData.name, updatedName, "invitee should see updated group name"); }); test("member self-update rotates leaf keys (forward secrecy)", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const { admin, members } = await createGroupWithMembers(network, 1); const invitee = members[0]; const epochBefore = admin.group.state.groupContext.epoch; await invitee.group.selfUpdate(); // Admin ingests the self-update commit const groupEvents = await fetchGroupEvents(network, admin.group); for await (const result of admin.group.ingest(groupEvents)) { // drain } const epochAfter = admin.group.state.groupContext.epoch; assert.ok(epochAfter > epochBefore, "admin epoch should advance after self-update commit"); // Both parties can still exchange messages after key rotation const msg = `after-selfupdate-${randomUUID()}`; await invitee.group.sendChatMessage(msg); const updatedEvents = await fetchGroupEvents(network, admin.group); const decrypted = []; for await (const result of admin.group.ingest(updatedEvents)) { if (result.kind === "processed" && result.result.kind === "applicationMessage") { decrypted.push(deserializeApplicationData(result.result.message)); } } assert.ok( decrypted.some((r) => r.content === msg), "admin should decrypt message sent after self-update", ); }); test("member leaves group voluntarily", async () => { const network = new RelayNetwork(relayWsUrl, relayHttpUrl); const { admin, members } = await createGroupWithMembers(network, 2); const [stayer, leaver] = members; // Leaver publishes self-remove proposals and destroys local state await leaver.group.leave(); // Admin ingests the leave proposals (they become unapplied) const groupEventsForAdmin = await fetchGroupEvents(network, admin.group); for await (const _result of admin.group.ingest(groupEventsForAdmin)) { // drain } // Admin commits all unapplied proposals (the leave removals) await admin.group.commit(); // Stayer ingests everything and the group continues const allEvents = await fetchGroupEvents(network, admin.group); for await (const _result of stayer.group.ingest(allEvents)) { // drain } const msgAfterLeave = `after-leave-${randomUUID()}`; await stayer.group.sendChatMessage(msgAfterLeave); const latestEvents = await fetchGroupEvents(network, admin.group); const decrypted = []; for await (const result of admin.group.ingest(latestEvents)) { if (result.kind === "processed" && result.result.kind === "applicationMessage") { decrypted.push(deserializeApplicationData(result.result.message)); } } assert.ok( decrypted.some((r) => r.content === msgAfterLeave), "group should continue functioning after member departure", ); }); test("NIP-45 COUNT returns accurate event count", async () => { const account = PrivateKeyAccount.generateNew(); const pubkey = await account.signer.getPublicKey(); const tag = `count-test-${randomUUID()}`; for (let i = 0; i < 3; i++) { const event = { kind: 1, pubkey, created_at: unixNow() + i, tags: [["t", tag]], content: `count-event-${i}`, }; event.id = computeEventId(event); const signed = await account.signer.signEvent(event); const frame = await publishEvent(relayWsUrl, signed); assert.equal(frame[2], true, `publish of event ${i} failed: ${frame[3]}`); } const result = await countEvents(relayWsUrl, { kinds: [1], "#t": [tag] }); assert.equal(result.count, 3, "COUNT should report 3 events"); }); test("NIP-9 event deletion removes event from relay", async () => { const account = PrivateKeyAccount.generateNew(); const pubkey = await account.signer.getPublicKey(); const original = { kind: 1, pubkey, created_at: unixNow(), tags: [], content: `to-be-deleted-${randomUUID()}`, }; original.id = computeEventId(original); const signedOriginal = await account.signer.signEvent(original); const publishFrame = await publishEvent(relayWsUrl, signedOriginal); assert.equal(publishFrame[2], true, `original publish failed: ${publishFrame[3]}`); // Verify it exists const beforeDelete = await requestEvents(relayWsUrl, [{ ids: [signedOriginal.id] }]); assert.equal(beforeDelete.length, 1, "event should exist before deletion"); // Publish kind 5 deletion referencing the original const deletion = { kind: 5, pubkey, created_at: unixNow(), tags: [["e", signedOriginal.id]], content: "", }; deletion.id = computeEventId(deletion); const signedDeletion = await account.signer.signEvent(deletion); const deleteFrame = await publishEvent(relayWsUrl, signedDeletion); assert.equal(deleteFrame[2], true, `deletion publish failed: ${deleteFrame[3]}`); // Query again -- the original should be gone const afterDelete = await requestEvents(relayWsUrl, [{ ids: [signedOriginal.id] }]); assert.equal(afterDelete.length, 0, "event should be deleted after kind 5 request"); });