From 27a1c23fecbd2f67e7fab0075bedaeebe6fad969 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 07:35:12 -0800 Subject: [PATCH 01/12] Refactor --- extensions/xmtp/src/accounts.test.ts | 33 +- extensions/xmtp/src/accounts.ts | 15 +- extensions/xmtp/src/actions.ts | 9 +- extensions/xmtp/src/channel.ts | 284 +++++------- extensions/xmtp/src/dm-policy.test.ts | 66 +-- extensions/xmtp/src/dm-policy.ts | 13 +- extensions/xmtp/src/gateway-lifecycle.test.ts | 84 +--- extensions/xmtp/src/gateway-lifecycle.ts | 416 ++++++++---------- extensions/xmtp/src/lib/access-control.ts | 74 ++++ extensions/xmtp/src/lib/ens-resolver.ts | 19 + extensions/xmtp/src/lib/identity.ts | 14 + extensions/xmtp/src/lib/index.ts | 33 ++ extensions/xmtp/src/lib/xmtp-client.ts | 20 +- extensions/xmtp/src/onboarding.ts | 13 +- extensions/xmtp/src/outbound.test.ts | 36 +- extensions/xmtp/src/outbound.ts | 17 +- extensions/xmtp/src/setup.ts | 10 +- .../xmtp/src/test-utils/unit-helpers.ts | 25 +- 18 files changed, 581 insertions(+), 600 deletions(-) create mode 100644 extensions/xmtp/src/lib/access-control.ts create mode 100644 extensions/xmtp/src/lib/index.ts diff --git a/extensions/xmtp/src/accounts.test.ts b/extensions/xmtp/src/accounts.test.ts index a732b302e2108..09876f76275a9 100644 --- a/extensions/xmtp/src/accounts.test.ts +++ b/extensions/xmtp/src/accounts.test.ts @@ -3,7 +3,6 @@ * No network access needed — tests pure config parsing. */ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; import { autoProvisionAccount, @@ -14,6 +13,7 @@ import { type CoreConfig, type ResolvedXmtpAccount, } from "./accounts.js"; +import { createMockRuntime } from "./test-utils/unit-helpers.js"; // Use a real 32-byte hex private key for tests that need address derivation const VALID_WALLET_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; @@ -373,17 +373,13 @@ function makeAccount(overrides?: Partial): ResolvedXmtpAcco }; } -function makeMockRuntime(): { runtime: PluginRuntime; writeConfigFile: ReturnType } { - const writeConfigFile = vi.fn(async () => {}); - const loadConfig = vi.fn(() => ({ channels: { xmtp: {} } })); - const runtime = { config: { loadConfig, writeConfigFile } } as unknown as PluginRuntime; - return { runtime, writeConfigFile }; -} - describe("autoProvisionAccount", () => { it("generates both keys when both are missing", async () => { const account = makeAccount(); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -407,7 +403,10 @@ describe("autoProvisionAccount", () => { walletKey: VALID_WALLET_KEY, publicAddress: VALID_ADDRESS, }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -427,7 +426,10 @@ describe("autoProvisionAccount", () => { it("generates only walletKey when dbEncryptionKey is present", async () => { const existingEncKey = "ab".repeat(32); const account = makeAccount({ dbEncryptionKey: existingEncKey }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -452,7 +454,10 @@ describe("autoProvisionAccount", () => { publicAddress: VALID_ADDRESS, configured: true, }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); @@ -462,7 +467,7 @@ describe("autoProvisionAccount", () => { it("generated wallet key produces a valid Ethereum address", async () => { const account = makeAccount(); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); @@ -475,7 +480,7 @@ describe("autoProvisionAccount", () => { it("generated encryption key is 32-byte hex", async () => { const account = makeAccount({ walletKey: VALID_WALLET_KEY, publicAddress: VALID_ADDRESS }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); diff --git a/extensions/xmtp/src/accounts.ts b/extensions/xmtp/src/accounts.ts index 4b3034c24cbf7..4df64958edc1c 100644 --- a/extensions/xmtp/src/accounts.ts +++ b/extensions/xmtp/src/accounts.ts @@ -9,6 +9,7 @@ import type { XMTPConfig } from "./config-types.js"; import { generateEncryptionKeyHex, generatePrivateKey, + generateXmtpIdentity, walletAddressFromPrivateKey, } from "./lib/identity.js"; @@ -173,14 +174,20 @@ export async function autoProvisionAccount( let dbEncryptionKey = account.dbEncryptionKey; let publicAddress = account.publicAddress; - if (needWalletKey) { + if (needWalletKey && needEncryptionKey) { + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; + update.walletKey = walletKey; + update.dbEncryptionKey = dbEncryptionKey; + update.publicAddress = publicAddress; + } else if (needWalletKey) { walletKey = generatePrivateKey(); publicAddress = walletAddressFromPrivateKey(walletKey); update.walletKey = walletKey; update.publicAddress = publicAddress; - } - - if (needEncryptionKey) { + } else if (needEncryptionKey) { dbEncryptionKey = generateEncryptionKeyHex(); update.dbEncryptionKey = dbEncryptionKey; } diff --git a/extensions/xmtp/src/actions.ts b/extensions/xmtp/src/actions.ts index 0681234316140..dc124511138ed 100644 --- a/extensions/xmtp/src/actions.ts +++ b/extensions/xmtp/src/actions.ts @@ -1,6 +1,7 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk"; import { jsonResult, readReactionParams, readStringParam } from "openclaw/plugin-sdk"; import { listXmtpAccountIds, resolveXmtpAccount, type CoreConfig } from "./accounts.js"; +import { getOrCreateConversation } from "./lib/xmtp-client.js"; import { getAgentOrThrow } from "./outbound.js"; export const xmtpMessageActions: ChannelMessageActionAdapter = { @@ -18,13 +19,7 @@ export const xmtpMessageActions: ChannelMessageActionAdapter = { if (action === "send") { const to = readStringParam(params, "to", { required: true }); const message = readStringParam(params, "message", { required: true, allowEmpty: true }); - let conversation = await agent.client.conversations.getConversationById(to); - if (!conversation && to.startsWith("0x")) { - conversation = await agent.createDmWithAddress(to as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${to.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, to); const messageId = await conversation.sendText(message ?? ""); return jsonResult({ ok: true, to, messageId }); } diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts index f938bd74f3164..ae57bb33fa50a 100644 --- a/extensions/xmtp/src/channel.ts +++ b/extensions/xmtp/src/channel.ts @@ -23,14 +23,10 @@ import { } from "./accounts.js"; import { xmtpMessageActions } from "./actions.js"; import { xmtpChannelConfigSchema } from "./config-schema.js"; -import { - evaluateDmAccess, - isGroupAllowed, - normalizeXmtpAddress, - sendPairingReply, -} from "./dm-policy.js"; +import { normalizeXmtpAddress } from "./dm-policy.js"; import { startAccount, stopAccountHandler } from "./gateway-lifecycle.js"; import { runInboundPipeline } from "./inbound-pipeline.js"; +import { enforceInboundAccessControl } from "./lib/access-control.js"; import { isEnsName } from "./lib/ens-resolver.js"; import { createAgentFromAccount } from "./lib/xmtp-client.js"; import { xmtpOnboardingAdapter } from "./onboarding.js"; @@ -59,6 +55,53 @@ function normalizeXmtpMessagingTarget(raw: string): string | undefined { // Re-export for existing test imports export { isGroupAllowed } from "./dm-policy.js"; +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function resolveTableMode(runtime: PluginRuntime, accountId: string) { + return runtime.channel.text.resolveMarkdownTableMode({ + cfg: runtime.config.loadConfig(), + channel: CHANNEL_ID, + accountId, + }); +} + +/** Save attachment buffers via the media API and return paths + filenames. */ +async function saveInboundMedia( + items: Array<{ content: Uint8Array; mimeType: string; filename?: string }>, + runtime: PluginRuntime, + accountId: string, + log?: RuntimeLogger, +): Promise<{ media: Array<{ path: string; contentType?: string }>; filenames: string[] }> { + const media: Array<{ path: string; contentType?: string }> = []; + const filenames: string[] = []; + + for (const item of items) { + try { + const saved = await runtime.channel.media.saveMediaBuffer( + Buffer.from(item.content), + item.mimeType, + "inbound", + undefined, + item.filename, + ); + media.push({ path: saved.path, contentType: saved.contentType }); + filenames.push(item.filename ?? "attachment"); + } catch (err) { + log?.error(`[${accountId}] Failed to save attachment: ${String(err)}`); + } + } + + return { media, filenames }; +} + +function formatAttachmentLabel(filenames: string[]): string { + return filenames.length === 1 + ? `[Attachment: ${filenames[0]}]` + : `[Attachments: ${filenames.join(", ")}]`; +} + // --------------------------------------------------------------------------- // Inbound message handler (thin orchestrator) // --------------------------------------------------------------------------- @@ -84,48 +127,18 @@ export async function handleInboundMessage(params: { ); } - // Group access control - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped message from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (decision.reason === "pairing" && decision.created && decision.code) { - await sendPairingReply({ - account, - sender, - conversationId, - code: decision.code, - runtime, - log, - }); - } else if (decision.reason === "blocked" && account.debug) { - log?.info( - `[${account.accountId}] Blocked DM from ${sender.slice(0, 12)} (dmPolicy=${decision.dmPolicy})`, - ); - } else if (decision.reason === "disabled" && account.debug) { - log?.info( - `[${account.accountId}] Dropped DM from ${sender.slice(0, 12)} (dmPolicy=disabled)`, - ); - } - return; - } - } - - // Pipeline: route -> envelope -> session -> dispatch - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "message", }); + if (!allowed) return; + + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -179,37 +192,21 @@ export async function handleInboundReaction(params: { ); } - // Group access control (same as handleInboundMessage) - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped reaction from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control (same as handleInboundMessage) - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped reaction from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "reaction", + }); + if (!allowed) return; // Format reaction as descriptive content for the inbound pipeline const content = `[Reaction: ${reaction.content} ${actionLabel} to message ${reaction.reference}]`; - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -256,66 +253,47 @@ export async function handleInboundAttachment(params: { const { account, sender, conversationId, remoteAttachments, messageId, isDirect, runtime, log } = params; - // Group access control (same as handleInboundMessage) - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped attachment from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control (same as handleInboundMessage) - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped attachment from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "attachment", + }); + if (!allowed) return; // Download, decrypt, and save each attachment - const media: Array<{ path: string; contentType?: string }> = []; - const filenames: string[] = []; - + const decryptedItems: Array<{ content: Uint8Array; mimeType: string; filename?: string }> = []; for (const ra of remoteAttachments) { try { const decrypted = await downloadRemoteAttachment(ra); - const saved = await runtime.channel.media.saveMediaBuffer( - Buffer.from(decrypted.content), - decrypted.mimeType, - "inbound", - undefined, - decrypted.filename, - ); - media.push({ path: saved.path, contentType: saved.contentType }); - filenames.push(decrypted.filename ?? ra.filename ?? "attachment"); + decryptedItems.push({ + content: decrypted.content, + mimeType: decrypted.mimeType, + filename: decrypted.filename ?? ra.filename, + }); } catch (err) { log?.error(`[${account.accountId}] Failed to download remote attachment: ${String(err)}`); } } + const { media, filenames } = await saveInboundMedia( + decryptedItems, + runtime, + account.accountId, + log, + ); if (media.length === 0) return; - const content = - filenames.length === 1 - ? `[Attachment: ${filenames[0]}]` - : `[Attachments: ${filenames.join(", ")}]`; + const content = formatAttachmentLabel(filenames); if (account.debug) { log?.info(`[${account.accountId}] Inbound attachment from ${sender.slice(0, 12)}: ${content}`); } - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -363,55 +341,31 @@ export async function handleInboundInlineAttachment(params: { const { account, sender, conversationId, attachments, messageId, isDirect, runtime, log } = params; - // Group access control - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped inline attachment from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped inline attachment from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "inline attachment", + }); + if (!allowed) return; // Save each inline attachment directly (no download needed) - const media: Array<{ path: string; contentType?: string }> = []; - const filenames: string[] = []; - - for (const att of attachments) { - try { - const saved = await runtime.channel.media.saveMediaBuffer( - Buffer.from(att.content), - att.mimeType, - "inbound", - undefined, - att.filename, - ); - media.push({ path: saved.path, contentType: saved.contentType }); - filenames.push(att.filename ?? "attachment"); - } catch (err) { - log?.error(`[${account.accountId}] Failed to save inline attachment: ${String(err)}`); - } - } - + const { media, filenames } = await saveInboundMedia( + attachments.map((att) => ({ + content: att.content, + mimeType: att.mimeType, + filename: att.filename, + })), + runtime, + account.accountId, + log, + ); if (media.length === 0) return; - const content = - filenames.length === 1 - ? `[Attachment: ${filenames[0]}]` - : `[Attachments: ${filenames.join(", ")}]`; + const content = formatAttachmentLabel(filenames); if (account.debug) { log?.info( @@ -419,11 +373,7 @@ export async function handleInboundInlineAttachment(params: { ); } - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, diff --git a/extensions/xmtp/src/dm-policy.test.ts b/extensions/xmtp/src/dm-policy.test.ts index 279273e44cb0f..ebcd35f53892d 100644 --- a/extensions/xmtp/src/dm-policy.test.ts +++ b/extensions/xmtp/src/dm-policy.test.ts @@ -13,6 +13,7 @@ import { import { setResolverForAccount, createEnsResolver } from "./lib/ens-resolver.js"; import { setClientForAccount } from "./outbound.js"; import { + createMockRuntime, createTestAccount, makeFakeAgent, TEST_OWNER_ADDRESS, @@ -105,32 +106,11 @@ describe("isGroupAllowed", () => { // evaluateDmAccess // --------------------------------------------------------------------------- -function makeMockRuntime(overrides?: { - storeAllowFrom?: string[]; - pairingResult?: { code: string; created: boolean }; -}) { - const readAllowFromStore = vi.fn(async () => overrides?.storeAllowFrom ?? []); - const upsertPairingRequest = vi.fn( - async () => overrides?.pairingResult ?? { code: "TESTCODE", created: true }, - ); - return { - runtime: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - }, - }, - } as any, - mocks: { readAllowFromStore, upsertPairingRequest }, - }; -} - describe("evaluateDmAccess", () => { describe("dmPolicy: open", () => { it("allows any sender", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -141,7 +121,7 @@ describe("evaluateDmAccess", () => { describe("dmPolicy: disabled", () => { it("blocks all senders", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "disabled" }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -156,7 +136,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [TEST_SENDER_ADDRESS], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -169,7 +149,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [], }); - const { runtime } = makeMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); + const { runtime } = createMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -182,7 +162,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["0xOther"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -195,7 +175,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["*"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -208,7 +188,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [TEST_SENDER_ADDRESS.toUpperCase()], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, @@ -225,7 +205,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [`xmtp:${TEST_SENDER_ADDRESS}`], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -241,7 +221,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -255,7 +235,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -268,7 +248,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "disabled", ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -282,7 +262,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS.toUpperCase(), }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, @@ -300,7 +280,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: `xmtp:${TEST_SENDER_ADDRESS}`, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -313,7 +293,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -334,7 +314,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -355,7 +335,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime } = makeMockRuntime({ + const { runtime } = createMockRuntime({ pairingResult: { code: "TESTCODE", created: false }, }); @@ -375,7 +355,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime } = makeMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); + const { runtime } = createMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -388,7 +368,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], }); account.config.dmPolicy = undefined; - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -437,7 +417,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "pairing", ownerAddress: "owner.eth", }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: true }); @@ -452,7 +432,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["friend.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: true }); @@ -467,7 +447,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["other.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: false, reason: "blocked", dmPolicy: "allowlist" }); @@ -480,7 +460,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["friend.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: false, reason: "blocked", dmPolicy: "allowlist" }); diff --git a/extensions/xmtp/src/dm-policy.ts b/extensions/xmtp/src/dm-policy.ts index f537abe587e60..2c45a61d4c366 100644 --- a/extensions/xmtp/src/dm-policy.ts +++ b/extensions/xmtp/src/dm-policy.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { ResolvedXmtpAccount } from "./accounts.js"; -import { getResolverForAccount, isEnsName } from "./lib/ens-resolver.js"; +import { getResolverForAccount, isEnsName, resolveOwnerAddress } from "./lib/ens-resolver.js"; import { getClientForAccount } from "./outbound.js"; const CHANNEL_ID = "xmtp"; @@ -68,12 +68,11 @@ export async function evaluateDmAccess(params: { // Owner is always allowed (unless DMs are fully disabled) if (account.ownerAddress) { - let ownerAddr = normalizeXmtpAddress(account.ownerAddress); - if (isEnsName(ownerAddr)) { - const resolver = getResolverForAccount(account.accountId); - const resolved = resolver ? await resolver.resolveEnsName(ownerAddr) : null; - if (resolved) ownerAddr = resolved; - } + const resolver = getResolverForAccount(account.accountId); + const ownerAddr = await resolveOwnerAddress( + normalizeXmtpAddress(account.ownerAddress), + resolver, + ); if (ownerAddr && normalizedSender.toLowerCase() === ownerAddr.toLowerCase()) { return { allowed: true }; } diff --git a/extensions/xmtp/src/gateway-lifecycle.test.ts b/extensions/xmtp/src/gateway-lifecycle.test.ts index feb3df9fa4a0b..f533a75cf2fb7 100644 --- a/extensions/xmtp/src/gateway-lifecycle.test.ts +++ b/extensions/xmtp/src/gateway-lifecycle.test.ts @@ -23,9 +23,10 @@ import { import { setClientForAccount, getClientForAccount } from "./outbound.js"; import { setXmtpRuntime } from "./runtime.js"; import { + createMockEnsResolver, + createMockRuntime, createTestAccount, makeFakeAgent, - createMockRuntime, TEST_OWNER_ADDRESS, TEST_SENDER_ADDRESS, } from "./test-utils/unit-helpers.js"; @@ -627,12 +628,7 @@ describe("owner DM creation with ENS resolution", () => { }); const resolvedAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; - // Create a mock ENS resolver - const ensResolver = { - resolveEnsName: vi.fn(async () => resolvedAddr), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver({ resolveEnsName: resolvedAddr }); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -672,12 +668,7 @@ describe("owner DM creation with ENS resolution", () => { ownerAddress: "nonexistent.eth", }); - // Create a mock ENS resolver that returns null (resolution failure) - const ensResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver(); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -717,12 +708,7 @@ describe("owner DM creation with ENS resolution", () => { ownerAddress: regularAddr, }); - // Create a mock ENS resolver (should not be called) - const ensResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver(); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -775,11 +761,7 @@ describe("resolveInboundEns", () => { }); it("resolves sender address to ENS name", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "vitalik.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "vitalik.eth" }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -794,11 +776,7 @@ describe("resolveInboundEns", () => { }); it("does not set senderName when resolveAddress returns null", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -815,11 +793,7 @@ describe("resolveInboundEns", () => { const resolved = new Map([ ["nick.eth", "0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5"], ]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -836,11 +810,7 @@ describe("resolveInboundEns", () => { it("resolves Ethereum addresses mentioned in message content", async () => { const addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; const resolved = new Map([[addr, "vitalik.eth"]]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -855,11 +825,7 @@ describe("resolveInboundEns", () => { }); it("does not set ensContext when no identifiers found in content", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -877,11 +843,7 @@ describe("resolveInboundEns", () => { it("resolves group members for non-DM conversations", async () => { const memberAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; const resolved = new Map([[memberAddr, "vitalik.eth"]]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const conversation = { @@ -905,11 +867,7 @@ describe("resolveInboundEns", () => { }); it("skips group member resolution for DM conversations", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const conversation = { @@ -928,11 +886,7 @@ describe("resolveInboundEns", () => { }); it("handles group member resolution failure gracefully", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const conversation = { @@ -978,11 +932,7 @@ describe("buildTextHandler ENS integration", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "sender.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "sender.eth" }); setResolverForAccount("default", mockResolver); const handler = buildTextHandler({ account, runtime }); @@ -1043,11 +993,7 @@ describe("buildReactionHandler ENS integration", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "reactor.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "reactor.eth" }); setResolverForAccount("default", mockResolver); const handler = buildReactionHandler({ account, runtime }); diff --git a/extensions/xmtp/src/gateway-lifecycle.ts b/extensions/xmtp/src/gateway-lifecycle.ts index 2b22eaa27c106..dfe01ec71c207 100644 --- a/extensions/xmtp/src/gateway-lifecycle.ts +++ b/extensions/xmtp/src/gateway-lifecycle.ts @@ -31,6 +31,7 @@ import { extractEthAddresses, formatEnsContext, formatGroupMembersWithEns, + resolveOwnerAddress, } from "./lib/ens-resolver.js"; import { createAgentFromAccount } from "./lib/xmtp-client.js"; import { getClientForAccount, setClientForAccount } from "./outbound.js"; @@ -109,19 +110,13 @@ export async function startAccount(ctx: { // Proactively open DM with owner so the channel is ready if (account.ownerAddress) { try { - let ownerAddr = account.ownerAddress; - if (isEnsName(ownerAddr)) { - const resolved = await ensResolver.resolveEnsName(ownerAddr); - if (resolved) { - ownerAddr = resolved; - log?.info( - `[${account.accountId}] Resolved owner ENS ${account.ownerAddress} → ${ownerAddr.slice(0, 12)}...`, - ); - } else { - log?.warn?.( - `[${account.accountId}] Could not resolve owner ENS: ${account.ownerAddress}`, - ); - } + const ownerAddr = await resolveOwnerAddress(account.ownerAddress, ensResolver); + if (ownerAddr !== account.ownerAddress) { + log?.info( + `[${account.accountId}] Resolved owner ENS ${account.ownerAddress} → ${ownerAddr.slice(0, 12)}...`, + ); + } else if (isEnsName(account.ownerAddress)) { + log?.warn?.(`[${account.accountId}] Could not resolve owner ENS: ${account.ownerAddress}`); } if (/^0x[0-9a-fA-F]{40}$/.test(ownerAddr)) { await agent.createDmWithAddress(ownerAddr as `0x${string}`); @@ -230,32 +225,52 @@ export async function resolveInboundEns(params: { return result; } -/** Build the reaction event handler for inbound reactions. */ -export function buildReactionHandler(params: { +// --------------------------------------------------------------------------- +// Generic inbound handler factory +// --------------------------------------------------------------------------- + +type HandlerBaseParams = { account: ResolvedXmtpAccount; runtime: PluginRuntime; log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { - const { account, runtime, log } = params; +}; - return async (msgCtx: MessageContext) => { +/** + * Create an inbound event handler with shared boilerplate: + * denied-check → content extraction → getSenderAddress → resolveInboundEns → dispatch. + */ +function createInboundHandler( + base: HandlerBaseParams, + config: { + label: string; + /** Extract and validate content from the message context. Return null to skip. */ + extractContent: (msgCtx: MessageContext) => T | null | undefined; + /** Return text to use for ENS resolution (empty string for non-text content). */ + ensText: (content: T) => string; + /** Dispatch the validated content to the appropriate channel handler. */ + dispatch: (ctx: { + content: T; + sender: string; + conversationId: string; + messageId: string | undefined; + isDirect: boolean; + ens: { senderName?: string; groupMembers?: string; ensContext?: string }; + }) => Promise; + }, +): (msgCtx: MessageContext) => Promise { + const { account, log } = base; + const { label, extractContent, ensText, dispatch } = config; + + return async (msgCtx: MessageContext) => { if (msgCtx.isDenied) { if (account.debug) { - log?.info(`[${account.accountId}] Skipped reaction from denied contact`); + log?.info(`[${account.accountId}] Skipped ${label} from denied contact`); } return; } - const reaction = msgCtx.message?.content; - if (!reaction) return; - - log?.info( - `[${account.accountId}] reaction event: ${JSON.stringify({ - content: reaction.content, - action: reaction.action, - reference: reaction.reference, - })}`, - ); + const content = extractContent(msgCtx); + if (content == null) return; const sender = await msgCtx.getSenderAddress(); if (!sender) return; @@ -263,235 +278,168 @@ export function buildReactionHandler(params: { const conversationId = msgCtx.conversation?.id as string; const isDirect = msgCtx.isDm(); - const reactionContent = `[Reaction: ${reaction.content}]`; const ens = await resolveInboundEns({ accountId: account.accountId, sender, - content: reactionContent, + content: ensText(content), isDirect, conversation: msgCtx.conversation as any, log, }); - handleInboundReaction({ - account, - sender, - conversationId, - reaction, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Reaction handling failed: ${String(err)}`); - }); + try { + await dispatch({ + content, + sender, + conversationId, + messageId: msgCtx.message.id, + isDirect, + ens, + }); + } catch (err) { + log?.error(`[${account.accountId}] ${label} handling failed: ${String(err)}`); + } }; } +// --------------------------------------------------------------------------- +// Concrete handler builders (thin wrappers over createInboundHandler) +// --------------------------------------------------------------------------- + /** Build the text/markdown event handler for inbound messages. */ -export function buildTextHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildTextHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; + return createInboundHandler(params, { + label: "message", + extractContent: (msgCtx) => { + const content = msgCtx.message?.content; + if (typeof content !== "string") return null; + log?.info( + `[${account.accountId}] text event: ${JSON.stringify({ content: content.slice(0, 50), id: msgCtx.message?.id })}`, + ); + return content; + }, + ensText: (content) => content, + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundMessage({ + account, + sender, + conversationId, + content, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); +} - return async (msgCtx: MessageContext) => { - // Skip messages from denied contacts - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped message from denied contact`); - } - return; - } - - const content = msgCtx.message?.content; - if (typeof content !== "string") return; - - log?.info( - `[${account.accountId}] text event: ${JSON.stringify({ content: content.slice(0, 50), id: msgCtx.message?.id })}`, - ); - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - const conversation = msgCtx.conversation; - const conversationId = conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content, - isDirect, - conversation: conversation as any, - log, - }); - - handleInboundMessage({ - account, - sender, - conversationId, - content, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Message handling failed: ${String(err)}`); - }); - }; +/** Build the reaction event handler for inbound reactions. */ +export function buildReactionHandler(params: HandlerBaseParams) { + const { account, runtime, log } = params; + return createInboundHandler(params, { + label: "reaction", + extractContent: (msgCtx) => { + const reaction = msgCtx.message?.content; + if (!reaction) return null; + log?.info( + `[${account.accountId}] reaction event: ${JSON.stringify({ + content: reaction.content, + action: reaction.action, + reference: reaction.reference, + })}`, + ); + return reaction; + }, + ensText: (reaction) => `[Reaction: ${reaction.content}]`, + dispatch: async ({ content: reaction, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundReaction({ + account, + sender, + conversationId, + reaction, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the attachment event handler for inbound RemoteAttachments. */ -export function buildAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped attachment from denied contact`); - } - return; - } - - const remoteAttachment = msgCtx.message?.content; - if (!remoteAttachment) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - handleInboundAttachment({ - account, - sender, - conversationId, - remoteAttachments: [remoteAttachment], - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "attachment", + extractContent: (msgCtx) => msgCtx.message?.content ?? null, + ensText: () => "", + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundAttachment({ + account, + sender, + conversationId, + remoteAttachments: [content], + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the inline-attachment event handler for inbound Attachments (raw bytes). */ -export function buildInlineAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildInlineAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped inline attachment from denied contact`); - } - return; - } - - const attachment = msgCtx.message?.content; - if (!attachment) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - handleInboundInlineAttachment({ - account, - sender, - conversationId, - attachments: [attachment], - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Inline attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "inline attachment", + extractContent: (msgCtx) => msgCtx.message?.content ?? null, + ensText: () => "", + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundInlineAttachment({ + account, + sender, + conversationId, + attachments: [content], + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the multi-attachment event handler for inbound MultiRemoteAttachments. */ -export function buildMultiAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildMultiAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped multi-attachment from denied contact`); - } - return; - } - - const multiAttachment = msgCtx.message?.content; - if (!multiAttachment?.attachments?.length) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - // RemoteAttachmentInfo is structurally compatible with RemoteAttachment - const remoteAttachments = multiAttachment.attachments as unknown as RemoteAttachment[]; - - handleInboundAttachment({ - account, - sender, - conversationId, - remoteAttachments, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Multi-attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "multi-attachment", + extractContent: (msgCtx) => { + const multi = msgCtx.message?.content; + return multi?.attachments?.length ? multi : null; + }, + ensText: () => "", + dispatch: async ({ content: multi, sender, conversationId, messageId, isDirect, ens }) => { + // RemoteAttachmentInfo is structurally compatible with RemoteAttachment + const remoteAttachments = multi.attachments as unknown as RemoteAttachment[]; + await handleInboundAttachment({ + account, + sender, + conversationId, + remoteAttachments, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } diff --git a/extensions/xmtp/src/lib/access-control.ts b/extensions/xmtp/src/lib/access-control.ts new file mode 100644 index 0000000000000..a593accbce1be --- /dev/null +++ b/extensions/xmtp/src/lib/access-control.ts @@ -0,0 +1,74 @@ +/** + * Inbound access control for XMTP messages. + * Consolidates the group + DM policy checks used by all inbound handlers. + */ + +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; +import type { ResolvedXmtpAccount } from "../accounts.js"; +import { evaluateDmAccess, isGroupAllowed, sendPairingReply } from "../dm-policy.js"; + +/** + * Enforce inbound access control for a message/reaction/attachment. + * + * Returns `true` if the message should be processed, `false` if it should be dropped. + * + * Handles: + * - Group allowlist filtering (for non-DM conversations) + * - DM policy evaluation (open / disabled / allowlist / pairing) + * - Sending pairing replies for first-time DM senders + */ +export async function enforceInboundAccessControl(params: { + account: ResolvedXmtpAccount; + sender: string; + conversationId: string; + isDirect: boolean; + runtime: PluginRuntime; + log?: RuntimeLogger; + /** Label for log messages (e.g. "message", "reaction", "attachment"). */ + label?: string; +}): Promise { + const { account, sender, conversationId, isDirect, runtime, log } = params; + const label = params.label ?? "message"; + + // Group access control + if (!isDirect && !isGroupAllowed({ account, conversationId })) { + if (account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from disallowed conversation ${conversationId.slice(0, 12)}`, + ); + } + return false; + } + + // DM access control + if (isDirect) { + const decision = await evaluateDmAccess({ account, sender, runtime }); + if (!decision.allowed) { + if (decision.reason === "pairing" && decision.created && decision.code) { + await sendPairingReply({ + account, + sender, + conversationId, + code: decision.code, + runtime, + log, + }); + } else if (decision.reason === "blocked" && account.debug) { + log?.info( + `[${account.accountId}] Blocked ${label} from ${sender.slice(0, 12)} (dmPolicy=${decision.dmPolicy})`, + ); + } else if (decision.reason === "disabled" && account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from ${sender.slice(0, 12)} (dmPolicy=disabled)`, + ); + } else if (account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from ${sender.slice(0, 12)} (dm access denied)`, + ); + } + return false; + } + } + + return true; +} diff --git a/extensions/xmtp/src/lib/ens-resolver.ts b/extensions/xmtp/src/lib/ens-resolver.ts index e4f595f4f9605..0f0218fb2901c 100644 --- a/extensions/xmtp/src/lib/ens-resolver.ts +++ b/extensions/xmtp/src/lib/ens-resolver.ts @@ -188,6 +188,25 @@ export function setResolverForAccount(accountId: string, resolver: EnsResolver | } } +// --------------------------------------------------------------------------- +// Owner address resolution +// --------------------------------------------------------------------------- + +/** + * Resolve an owner address that might be an ENS name to a raw Ethereum address. + * Returns the resolved address, or the original value if not an ENS name or + * resolution fails. + */ +export async function resolveOwnerAddress( + ownerAddress: string, + resolver: EnsResolver | null, +): Promise { + if (!isEnsName(ownerAddress)) return ownerAddress; + if (!resolver) return ownerAddress; + const resolved = await resolver.resolveEnsName(ownerAddress); + return resolved ?? ownerAddress; +} + // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- diff --git a/extensions/xmtp/src/lib/identity.ts b/extensions/xmtp/src/lib/identity.ts index 4622d2871e76b..7b20d373e7776 100644 --- a/extensions/xmtp/src/lib/identity.ts +++ b/extensions/xmtp/src/lib/identity.ts @@ -20,3 +20,17 @@ export function walletAddressFromPrivateKey(walletKey: string): string { const hexKey = walletKey.startsWith("0x") ? walletKey : `0x${walletKey}`; return privateKeyToAccount(hexKey as `0x${string}`).address; } + +/** + * Generate a complete XMTP identity: wallet key, encryption key, and public address. + */ +export function generateXmtpIdentity(): { + walletKey: string; + dbEncryptionKey: string; + publicAddress: string; +} { + const walletKey = generatePrivateKey(); + const dbEncryptionKey = generateEncryptionKeyHex(); + const publicAddress = walletAddressFromPrivateKey(walletKey); + return { walletKey, dbEncryptionKey, publicAddress }; +} diff --git a/extensions/xmtp/src/lib/index.ts b/extensions/xmtp/src/lib/index.ts new file mode 100644 index 0000000000000..20eb736fc2eec --- /dev/null +++ b/extensions/xmtp/src/lib/index.ts @@ -0,0 +1,33 @@ +/** + * Barrel file for lib/ — re-exports public API from sub-modules. + */ + +export { enforceInboundAccessControl } from "./access-control.js"; + +export { + isEnsName, + isEthAddress, + extractEnsNames, + extractEthAddresses, + createEnsResolver, + getResolverForAccount, + setResolverForAccount, + resolveOwnerAddress, + formatEnsContext, + formatGroupMembersWithEns, + type EnsResolver, +} from "./ens-resolver.js"; + +export { + generateEncryptionKeyHex, + generatePrivateKey, + walletAddressFromPrivateKey, + generateXmtpIdentity, +} from "./identity.js"; + +export { + runTemporaryXmtpClient, + createAgentFromAccount, + getOrCreateConversation, + ensureHexPrefix, +} from "./xmtp-client.js"; diff --git a/extensions/xmtp/src/lib/xmtp-client.ts b/extensions/xmtp/src/lib/xmtp-client.ts index 41bfeba816f20..fca8c250d8720 100644 --- a/extensions/xmtp/src/lib/xmtp-client.ts +++ b/extensions/xmtp/src/lib/xmtp-client.ts @@ -11,10 +11,28 @@ import type { ResolvedXmtpAccount } from "../accounts.js"; import { getXmtpRuntime } from "../runtime.js"; /** Ensure a hex string has the `0x` prefix required by the SDK. */ -function ensureHexPrefix(hex: string): HexString { +export function ensureHexPrefix(hex: string): HexString { return (hex.startsWith("0x") ? hex : `0x${hex}`) as HexString; } +/** + * Look up a conversation by ID, or create a DM if the target looks like + * an Ethereum address (starts with 0x). Throws if neither succeeds. + */ +export async function getOrCreateConversation( + agent: Pick, + target: string, +) { + let conversation = await agent.client.conversations.getConversationById(target); + if (!conversation && target.startsWith("0x")) { + conversation = await agent.createDmWithAddress(target as `0x${string}`); + } + if (!conversation) { + throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); + } + return conversation; +} + export async function createAgentFromAccount( account: ResolvedXmtpAccount, stateDir: string, diff --git a/extensions/xmtp/src/onboarding.ts b/extensions/xmtp/src/onboarding.ts index d6f403428dd4f..9ddc6b363d973 100644 --- a/extensions/xmtp/src/onboarding.ts +++ b/extensions/xmtp/src/onboarding.ts @@ -12,11 +12,7 @@ import { type CoreConfig, } from "./accounts.js"; import { isEnsName } from "./lib/ens-resolver.js"; -import { - generateEncryptionKeyHex, - generatePrivateKey, - walletAddressFromPrivateKey, -} from "./lib/identity.js"; +import { generateXmtpIdentity, walletAddressFromPrivateKey } from "./lib/identity.js"; import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; const channel = "xmtp" as const; @@ -119,9 +115,10 @@ export const xmtpOnboardingAdapter: ChannelOnboardingAdapter = { let publicAddress: string; if (keySource === "random") { - walletKey = generatePrivateKey(); - dbEncryptionKey = generateEncryptionKeyHex(); - publicAddress = walletAddressFromPrivateKey(walletKey); + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; } else { walletKey = await prompter.text({ message: "Wallet key (private key)", diff --git a/extensions/xmtp/src/outbound.test.ts b/extensions/xmtp/src/outbound.test.ts index e4021560dec0e..7adf8d5a74678 100644 --- a/extensions/xmtp/src/outbound.test.ts +++ b/extensions/xmtp/src/outbound.test.ts @@ -15,7 +15,7 @@ import { getAgentOrThrow, } from "./outbound.js"; import { getXmtpRuntime, setXmtpRuntime } from "./runtime.js"; -import { makeFakeAgent } from "./test-utils/unit-helpers.js"; +import { createMockEnsResolver, makeFakeAgent } from "./test-utils/unit-helpers.js"; vi.mock("@xmtp/agent-sdk", () => ({ encryptAttachment: vi.fn( @@ -467,11 +467,10 @@ describe("XMTP outbound adapter", () => { it("sendText resolves ENS name to address before sending", async () => { const { agent, fakeConversation } = makeFakeAgent(); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => RESOLVED_ADDRESS), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount( + ACCOUNT_ID, + createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }), + ); const cfg = makeCfg(); await xmtpOutbound.sendText!({ @@ -488,11 +487,10 @@ describe("XMTP outbound adapter", () => { it("sendMedia resolves ENS name to address before sending", async () => { const { agent, fakeConversation } = makeFakeAgent(); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => RESOLVED_ADDRESS), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount( + ACCOUNT_ID, + createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }), + ); const cfg = makeCfg(); await xmtpOutbound.sendMedia!({ @@ -509,11 +507,7 @@ describe("XMTP outbound adapter", () => { it("falls back to original value when ENS resolution returns null", async () => { const { agent } = makeFakeAgent({ conversationId: CONVERSATION_ID }); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount(ACCOUNT_ID, createMockEnsResolver()); const cfg = makeCfg(); // nick.eth will be unresolved (null) — should use original "nick.eth" as target @@ -534,12 +528,8 @@ describe("XMTP outbound adapter", () => { it("passes non-ENS targets through without resolution", async () => { const { agent, fakeConversation } = makeFakeAgent({ conversationId: CONVERSATION_ID }); setClientForAccount(ACCOUNT_ID, agent as any); - const mockResolveEnsName = vi.fn(async () => RESOLVED_ADDRESS); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: mockResolveEnsName, - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + const mockResolver = createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }); + setResolverForAccount(ACCOUNT_ID, mockResolver); const cfg = makeCfg(); await xmtpOutbound.sendText!({ @@ -550,7 +540,7 @@ describe("XMTP outbound adapter", () => { }); // Should NOT have called resolveEnsName since CONVERSATION_ID is not an ENS name - expect(mockResolveEnsName).not.toHaveBeenCalled(); + expect(mockResolver.resolveEnsName).not.toHaveBeenCalled(); expect(agent.client.conversations.getConversationById).toHaveBeenCalledWith(CONVERSATION_ID); }); }); diff --git a/extensions/xmtp/src/outbound.ts b/extensions/xmtp/src/outbound.ts index df18df3a8939f..39ee003bac5c2 100644 --- a/extensions/xmtp/src/outbound.ts +++ b/extensions/xmtp/src/outbound.ts @@ -4,6 +4,7 @@ import { createRemoteAttachment, encryptAttachment } from "@xmtp/agent-sdk"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import { resolveXmtpAccount, type CoreConfig } from "./accounts.js"; import { getResolverForAccount, isEnsName } from "./lib/ens-resolver.js"; +import { getOrCreateConversation } from "./lib/xmtp-client.js"; import { getXmtpRuntime } from "./runtime.js"; const MAX_MEDIA_BYTES = 25 * 1024 * 1024; // 25 MB @@ -140,13 +141,7 @@ export const xmtpOutbound: ChannelOutboundAdapter = { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); const agent = getAgentOrThrow(account.accountId); const target = await resolveOutboundTarget(to, account.accountId); - let conversation = await agent.client.conversations.getConversationById(target); - if (!conversation && target.startsWith("0x")) { - conversation = await agent.createDmWithAddress(target as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, target); const messageId = await conversation.sendText(text); return { channel: CHANNEL_ID, messageId }; }, @@ -155,13 +150,7 @@ export const xmtpOutbound: ChannelOutboundAdapter = { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); const agent = getAgentOrThrow(account.accountId); const target = await resolveOutboundTarget(to, account.accountId); - let conversation = await agent.client.conversations.getConversationById(target); - if (!conversation && target.startsWith("0x")) { - conversation = await agent.createDmWithAddress(target as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, target); // Send caption text first if provided alongside media if (text && mediaUrl) { diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts index b5f8414c21965..b1095c03c3fe1 100644 --- a/extensions/xmtp/src/setup.ts +++ b/extensions/xmtp/src/setup.ts @@ -6,11 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; import { resolveXmtpAccount, updateXmtpSection, type CoreConfig } from "./accounts.js"; -import { - generateEncryptionKeyHex, - generatePrivateKey, - walletAddressFromPrivateKey, -} from "./lib/identity.js"; +import { generateXmtpIdentity } from "./lib/identity.js"; import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; import { getXmtpRuntime } from "./runtime.js"; @@ -29,9 +25,7 @@ export async function handleSetup(params: { }): Promise<{ publicAddress: string }> { const env = params.env === "dev" ? "dev" : "production"; - const walletKey = generatePrivateKey(); - const dbEncryptionKey = generateEncryptionKeyHex(); - const publicAddress = walletAddressFromPrivateKey(walletKey); + const { walletKey, dbEncryptionKey, publicAddress } = generateXmtpIdentity(); await runTemporaryXmtpClient({ walletKey, diff --git a/extensions/xmtp/src/test-utils/unit-helpers.ts b/extensions/xmtp/src/test-utils/unit-helpers.ts index 8c4c9adaa8d78..0a5f1e641b70a 100644 --- a/extensions/xmtp/src/test-utils/unit-helpers.ts +++ b/extensions/xmtp/src/test-utils/unit-helpers.ts @@ -105,6 +105,7 @@ export function createMockRuntime(overrides?: { dmPolicy?: DmPolicy; allowFrom?: Array; storeAllowFrom?: string[]; + pairingResult?: { code: string; created: boolean }; }): MockRuntime { const cfg: CoreConfig = { channels: { @@ -138,7 +139,9 @@ export function createMockRuntime(overrides?: { ({ code }: { channel: string; idLine: string; code: string }) => `Pairing code: ${code}`, ); const readAllowFromStore = vi.fn(async () => overrides?.storeAllowFrom ?? []); - const upsertPairingRequest = vi.fn(async () => ({ code: "TESTCODE", created: true })); + const upsertPairingRequest = vi.fn( + async () => overrides?.pairingResult ?? { code: "TESTCODE", created: true }, + ); const runtime = { config: { loadConfig, writeConfigFile }, @@ -193,6 +196,26 @@ export function createMockRuntime(overrides?: { }; } +// --------------------------------------------------------------------------- +// Mock ENS resolver +// --------------------------------------------------------------------------- + +/** + * Create a mock EnsResolver for unit tests. + * Each method returns the provided default value (or null/empty Map). + */ +export function createMockEnsResolver(overrides?: { + resolveEnsName?: string | null; + resolveAddress?: string | null; + resolveAll?: Map; +}) { + return { + resolveEnsName: vi.fn(async () => overrides?.resolveEnsName ?? null), + resolveAddress: vi.fn(async () => overrides?.resolveAddress ?? null), + resolveAll: vi.fn(async () => overrides?.resolveAll ?? new Map()), + }; +} + // --------------------------------------------------------------------------- // Fake XMTP agent (consolidated from access-control + outbound tests) // --------------------------------------------------------------------------- From 5d2c64198a4810f7bca65ac00f407d7b989323a9 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 07:59:46 -0800 Subject: [PATCH 02/12] Make table based tests --- extensions/xmtp/src/channel.messaging.test.ts | 82 +--- extensions/xmtp/src/dm-policy.test.ts | 78 +--- extensions/xmtp/src/gateway-lifecycle.test.ts | 375 +++--------------- extensions/xmtp/src/lib/ens-resolver.test.ts | 113 ++---- 4 files changed, 126 insertions(+), 522 deletions(-) diff --git a/extensions/xmtp/src/channel.messaging.test.ts b/extensions/xmtp/src/channel.messaging.test.ts index e4ed32c6ca397..48af4febda71b 100644 --- a/extensions/xmtp/src/channel.messaging.test.ts +++ b/extensions/xmtp/src/channel.messaging.test.ts @@ -164,52 +164,19 @@ describe("XMTP message flow", () => { }); describe("group policy enforcement", () => { - it("groupPolicy 'open' allows any group", () => { + it.each([ + ["open allows any group", "open", undefined, "any-group", true], + ["disabled blocks all groups", "disabled", undefined, "any-group", false], + ["allowlist allows listed group", "allowlist", ["group-123"], "group-123", true], + ["allowlist blocks unlisted group", "allowlist", ["group-123"], "group-456", false], + ["allowlist wildcard allows all", "allowlist", ["*"], "any-group", true], + ] as const)("%s", (_desc, groupPolicy, groups, conversationId, expected) => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, - groupPolicy: "open", + groupPolicy: groupPolicy as any, + groups: groups as any, }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); - }); - - it("groupPolicy 'disabled' blocks all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "disabled", - }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(false); - }); - - it("groupPolicy 'allowlist' allows listed group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - - expect(isGroupAllowed({ account, conversationId: "group-123" })).toBe(true); - }); - - it("groupPolicy 'allowlist' blocks unlisted group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - - expect(isGroupAllowed({ account, conversationId: "group-456" })).toBe(false); - }); - - it("groupPolicy 'allowlist' with '*' allows all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["*"], - }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); + expect(isGroupAllowed({ account, conversationId })).toBe(expected); }); it("drops message from disabled group conversation", async () => { @@ -263,24 +230,17 @@ describe("XMTP message flow", () => { describe("ENS-aware target resolution", () => { const looksLikeId = xmtpPlugin.messaging!.targetResolver!.looksLikeId!; - it("recognizes Ethereum addresses", () => { - expect(looksLikeId("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")).toBe(true); - }); - - it("recognizes ENS names", () => { - expect(looksLikeId("nick.eth")).toBe(true); - expect(looksLikeId("pay.nick.eth")).toBe(true); - expect(looksLikeId("vitalik.eth")).toBe(true); - }); - - it("rejects empty strings", () => { - expect(looksLikeId("")).toBe(false); - expect(looksLikeId(" ")).toBe(false); - }); - - it("rejects non-ENS non-address strings", () => { - expect(looksLikeId("hello")).toBe(false); - expect(looksLikeId("not-an-address")).toBe(false); + it.each([ + ["Ethereum address", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", true], + ["simple ENS name", "nick.eth", true], + ["subdomain ENS name", "pay.nick.eth", true], + ["well-known ENS name", "vitalik.eth", true], + ["empty string", "", false], + ["whitespace only", " ", false], + ["plain word", "hello", false], + ["hyphenated string", "not-an-address", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(looksLikeId(input)).toBe(expected); }); it("hint mentions ENS name", () => { diff --git a/extensions/xmtp/src/dm-policy.test.ts b/extensions/xmtp/src/dm-policy.test.ts index ebcd35f53892d..4b500be175b66 100644 --- a/extensions/xmtp/src/dm-policy.test.ts +++ b/extensions/xmtp/src/dm-policy.test.ts @@ -25,30 +25,19 @@ import { // --------------------------------------------------------------------------- describe("normalizeXmtpAddress", () => { - it("strips xmtp: prefix", () => { - expect(normalizeXmtpAddress("xmtp:0xABC")).toBe("0xABC"); - }); - - it("strips xmtp: prefix case-insensitively", () => { - expect(normalizeXmtpAddress("XMTP:0xABC")).toBe("0xABC"); - }); - - it("trims whitespace", () => { - expect(normalizeXmtpAddress(" 0xABC ")).toBe("0xABC"); - }); - - it("handles combined prefix and whitespace", () => { - expect(normalizeXmtpAddress(" xmtp: 0xABC ")).toBe("0xABC"); - }); - - it("returns empty string for empty input", () => { - expect(normalizeXmtpAddress("")).toBe(""); - }); - - it("passes through normal addresses unchanged", () => { - expect(normalizeXmtpAddress("0xAbCdEf1234567890abcdef1234567890AbCdEf12")).toBe( + it.each([ + ["strips xmtp: prefix", "xmtp:0xABC", "0xABC"], + ["strips prefix case-insensitively", "XMTP:0xABC", "0xABC"], + ["trims whitespace", " 0xABC ", "0xABC"], + ["handles combined prefix and whitespace", " xmtp: 0xABC ", "0xABC"], + ["returns empty for empty input", "", ""], + [ + "passes through normal addresses", "0xAbCdEf1234567890abcdef1234567890AbCdEf12", - ); + "0xAbCdEf1234567890abcdef1234567890AbCdEf12", + ], + ])("%s", (_desc, input, expected) => { + expect(normalizeXmtpAddress(input)).toBe(expected); }); }); @@ -57,46 +46,23 @@ describe("normalizeXmtpAddress", () => { // --------------------------------------------------------------------------- describe("isGroupAllowed", () => { - it("open policy allows any group", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, groupPolicy: "open" }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); - }); - - it("disabled policy blocks all groups", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, groupPolicy: "disabled" }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(false); - }); - - it("allowlist policy allows listed group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - expect(isGroupAllowed({ account, conversationId: "group-123" })).toBe(true); - }); - - it("allowlist policy blocks unlisted group", () => { + it.each([ + ["open allows any group", "open", undefined, "any-group", true], + ["disabled blocks all groups", "disabled", undefined, "any-group", false], + ["allowlist allows listed group", "allowlist", ["group-123"], "group-123", true], + ["allowlist blocks unlisted group", "allowlist", ["group-123"], "group-456", false], + ["allowlist wildcard allows all", "allowlist", ["*"], "any-group", true], + ] as const)("%s", (_desc, groupPolicy, groups, conversationId, expected) => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], + groupPolicy: groupPolicy as any, + groups: groups as any, }); - expect(isGroupAllowed({ account, conversationId: "group-456" })).toBe(false); - }); - - it("allowlist with wildcard allows all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["*"], - }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); + expect(isGroupAllowed({ account, conversationId })).toBe(expected); }); it("defaults to open when groupPolicy not set", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS }); - // groupPolicy defaults to "open" in createTestAccountConfig account.config.groupPolicy = undefined; expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); }); diff --git a/extensions/xmtp/src/gateway-lifecycle.test.ts b/extensions/xmtp/src/gateway-lifecycle.test.ts index f533a75cf2fb7..98ca10206a7ad 100644 --- a/extensions/xmtp/src/gateway-lifecycle.test.ts +++ b/extensions/xmtp/src/gateway-lifecycle.test.ts @@ -136,10 +136,46 @@ describe("backfillPublicAddress", () => { }); // --------------------------------------------------------------------------- -// buildTextHandler +// Shared handler guard-clause tests (all 5 handler builders) // --------------------------------------------------------------------------- -describe("buildTextHandler", () => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handlerCases: Array<{ + name: string; + build: (p: { account: any; runtime: any; log?: any }) => (ctx: any) => Promise; + validMsg: Record; +}> = [ + { + name: "buildTextHandler", + build: buildTextHandler, + validMsg: { content: "hello", id: "msg-1" }, + }, + { + name: "buildReactionHandler", + build: buildReactionHandler, + validMsg: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, + }, + { + name: "buildAttachmentHandler", + build: buildAttachmentHandler, + validMsg: { content: { url: "https://example.com/file" }, id: "att-1" }, + }, + { + name: "buildInlineAttachmentHandler", + build: buildInlineAttachmentHandler, + validMsg: { + content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, + id: "att-1", + }, + }, + { + name: "buildMultiAttachmentHandler", + build: buildMultiAttachmentHandler, + validMsg: { content: { attachments: [{ url: "https://example.com/file" }] }, id: "multi-1" }, + }, +]; + +describe.each(handlerCases)("$name", ({ build, validMsg }) => { beforeEach(() => { setClientForAccount("default", null); setXmtpRuntime({ @@ -152,10 +188,7 @@ describe("buildTextHandler", () => { it("returns a function", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime } = createMockRuntime(); - - const handler = buildTextHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); + expect(typeof build({ account, runtime })).toBe("function"); }); it("skips denied contacts", async () => { @@ -167,10 +200,10 @@ describe("buildTextHandler", () => { const { runtime, mocks } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; - const handler = buildTextHandler({ account, runtime, log: log as any }); + const handler = build({ account, runtime, log: log as any }); await handler({ isDenied: true, - message: { content: "hello", id: "msg-1" }, + message: validMsg, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => "0xSender", @@ -180,14 +213,14 @@ describe("buildTextHandler", () => { expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); - it("skips messages with non-string content", async () => { + it("skips messages with null content", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const handler = buildTextHandler({ account, runtime }); + const handler = build({ account, runtime }); await handler({ isDenied: false, - message: { content: undefined, id: "msg-1" }, + message: { content: undefined, id: "null-1" }, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => "0xSender", @@ -200,88 +233,10 @@ describe("buildTextHandler", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const handler = buildTextHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: "hello", id: "msg-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildReactionHandler -// --------------------------------------------------------------------------- - -describe("buildReactionHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildReactionHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips reactions with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "r-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips reactions when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); + const handler = build({ account, runtime }); await handler({ isDenied: false, - message: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, + message: validMsg, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => undefined, @@ -291,11 +246,8 @@ describe("buildReactionHandler", () => { }); }); -// --------------------------------------------------------------------------- -// buildAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildAttachmentHandler", () => { +// buildMultiAttachmentHandler has one additional edge case +describe("buildMultiAttachmentHandler (empty array)", () => { beforeEach(() => { setClientForAccount("default", null); setXmtpRuntime({ @@ -305,218 +257,6 @@ describe("buildAttachmentHandler", () => { } as unknown as PluginRuntime); }); - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { content: { url: "https://example.com/file" }, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: { url: "https://example.com/file" }, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildInlineAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildInlineAttachmentHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildInlineAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { - content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, - id: "att-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips inline attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips inline attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { - content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, - id: "att-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildMultiAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildMultiAttachmentHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildMultiAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { - content: { attachments: [{ url: "https://example.com/file" }] }, - id: "multi-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips multi-attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "multi-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - it("skips multi-attachments with empty attachments array", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); @@ -532,25 +272,6 @@ describe("buildMultiAttachmentHandler", () => { expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); - - it("skips multi-attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { - content: { attachments: [{ url: "https://example.com/file" }] }, - id: "multi-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); }); // --------------------------------------------------------------------------- diff --git a/extensions/xmtp/src/lib/ens-resolver.test.ts b/extensions/xmtp/src/lib/ens-resolver.test.ts index 8642cbc24ecd6..6cc17f46279a5 100644 --- a/extensions/xmtp/src/lib/ens-resolver.test.ts +++ b/extensions/xmtp/src/lib/ens-resolver.test.ts @@ -8,96 +8,53 @@ import { } from "./ens-resolver.js"; describe("isEnsName", () => { - it("returns true for simple .eth name", () => { - expect(isEnsName("nick.eth")).toBe(true); - }); - - it("returns true for subdomain .eth name", () => { - expect(isEnsName("pay.nick.eth")).toBe(true); - }); - - it("returns false for bare string", () => { - expect(isEnsName("nick")).toBe(false); - }); - - it("returns false for ethereum address", () => { - expect(isEnsName("0xd8da6bf26964af9d7eed9e03e53415d37aa96045")).toBe(false); - }); - - it("returns false for empty string", () => { - expect(isEnsName("")).toBe(false); + it.each([ + ["simple .eth name", "nick.eth", true], + ["subdomain .eth name", "pay.nick.eth", true], + ["bare string", "nick", false], + ["ethereum address", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", false], + ["empty string", "", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(isEnsName(input)).toBe(expected); }); }); describe("isEthAddress", () => { - it("returns true for valid checksum address", () => { - expect(isEthAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")).toBe(true); - }); - - it("returns true for lowercase address", () => { - expect(isEthAddress("0xd8da6bf26964af9d7eed9e03e53415d37aa96045")).toBe(true); - }); - - it("returns false for short hex", () => { - expect(isEthAddress("0xd8da6b")).toBe(false); - }); - - it("returns false for ENS name", () => { - expect(isEthAddress("vitalik.eth")).toBe(false); + it.each([ + ["valid checksum address", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", true], + ["lowercase address", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", true], + ["short hex", "0xd8da6b", false], + ["ENS name", "vitalik.eth", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(isEthAddress(input)).toBe(expected); }); }); describe("extractEnsNames", () => { - it("extracts .eth names from text", () => { - expect(extractEnsNames("send 1 ETH to nick.eth please")).toEqual(["nick.eth"]); - }); - - it("extracts multiple names", () => { - const result = extractEnsNames("nick.eth and vitalik.eth are friends"); - expect(result).toEqual(["nick.eth", "vitalik.eth"]); - }); - - it("extracts subdomain names", () => { - expect(extractEnsNames("check pay.nick.eth")).toEqual(["pay.nick.eth"]); - }); - - it("deduplicates", () => { - expect(extractEnsNames("nick.eth sent to nick.eth")).toEqual(["nick.eth"]); - }); - - it("returns empty array for no matches", () => { - expect(extractEnsNames("no names here")).toEqual([]); - }); - - it("filters parent when subdomain present", () => { - const result = extractEnsNames("pay.nick.eth and nick.eth"); - expect(result).toEqual(["pay.nick.eth"]); - }); - - it("extracts case-insensitive .eth names", () => { - expect(extractEnsNames("send to NICK.ETH please")).toEqual(["NICK.ETH"]); + it.each([ + ["single name", "send 1 ETH to nick.eth please", ["nick.eth"]], + ["multiple names", "nick.eth and vitalik.eth are friends", ["nick.eth", "vitalik.eth"]], + ["subdomain", "check pay.nick.eth", ["pay.nick.eth"]], + ["deduplicates", "nick.eth sent to nick.eth", ["nick.eth"]], + ["no matches", "no names here", []], + ["filters parent when subdomain present", "pay.nick.eth and nick.eth", ["pay.nick.eth"]], + ["case-insensitive", "send to NICK.ETH please", ["NICK.ETH"]], + ])("%s", (_desc, input, expected) => { + expect(extractEnsNames(input as string)).toEqual(expected); }); }); describe("extractEthAddresses", () => { - it("extracts addresses from text", () => { - const addr = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - expect(extractEthAddresses(`send to ${addr}`)).toEqual([addr]); - }); - - it("extracts multiple addresses", () => { - const a1 = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - const a2 = "0x1234567890abcdef1234567890abcdef12345678"; - expect(extractEthAddresses(`${a1} and ${a2}`)).toEqual([a1, a2]); - }); - - it("deduplicates", () => { - const addr = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - expect(extractEthAddresses(`${addr} ${addr}`)).toEqual([addr]); - }); - - it("returns empty for no matches", () => { - expect(extractEthAddresses("no addresses")).toEqual([]); + const A1 = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; + const A2 = "0x1234567890abcdef1234567890abcdef12345678"; + + it.each([ + ["single address", `send to ${A1}`, [A1]], + ["multiple addresses", `${A1} and ${A2}`, [A1, A2]], + ["deduplicates", `${A1} ${A1}`, [A1]], + ["no matches", "no addresses", []], + ])("%s", (_desc, input, expected) => { + expect(extractEthAddresses(input)).toEqual(expected); }); }); From b840cc8f344d5a139689099ddacc9e2b2705f308 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:51:22 -0800 Subject: [PATCH 03/12] Add more logging --- extensions/xmtp/src/accounts.ts | 4 +++- extensions/xmtp/src/gateway-lifecycle.ts | 2 ++ extensions/xmtp/src/setup.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/extensions/xmtp/src/accounts.ts b/extensions/xmtp/src/accounts.ts index 4df64958edc1c..ebc004672c314 100644 --- a/extensions/xmtp/src/accounts.ts +++ b/extensions/xmtp/src/accounts.ts @@ -199,7 +199,9 @@ export async function autoProvisionAccount( const generated = [needWalletKey && "walletKey", needEncryptionKey && "dbEncryptionKey"] .filter(Boolean) .join(", "); - log?.info(`[${account.accountId}] auto-provisioned XMTP keys: ${generated}`); + const addressSuffix = + publicAddress !== account.publicAddress ? ` (address: ${publicAddress})` : ""; + log?.info(`[${account.accountId}] auto-provisioned XMTP keys: ${generated}${addressSuffix}`); return { ...account, diff --git a/extensions/xmtp/src/gateway-lifecycle.ts b/extensions/xmtp/src/gateway-lifecycle.ts index dfe01ec71c207..7e71ca6f11bda 100644 --- a/extensions/xmtp/src/gateway-lifecycle.ts +++ b/extensions/xmtp/src/gateway-lifecycle.ts @@ -47,6 +47,7 @@ export async function stopAgent(accountId: string, log?: RuntimeLogger): Promise if (agent) { try { await agent.stop(); + log?.info(`[${accountId}] XMTP provider stopped`); } catch (err) { log?.error(`[${accountId}] Error stopping agent: ${String(err)}`); } @@ -129,6 +130,7 @@ export async function startAccount(ctx: { await new Promise((resolve) => { const onAbort = () => { + log?.info(`[${account.accountId}] XMTP provider disconnecting (abort signal)`); void stopAgent(account.accountId, log).finally(resolve); }; if (abortSignal?.aborted) { diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts index b1095c03c3fe1..1ab9c6288215c 100644 --- a/extensions/xmtp/src/setup.ts +++ b/extensions/xmtp/src/setup.ts @@ -23,8 +23,11 @@ export async function handleSetup(params: { env?: "production" | "dev"; ownerAddress?: string; }): Promise<{ publicAddress: string }> { + const log = getXmtpRuntime().logging.getChildLogger(); const env = params.env === "dev" ? "dev" : "production"; + log?.info(`[xmtp] setup started (env: ${env})`); + const { walletKey, dbEncryptionKey, publicAddress } = generateXmtpIdentity(); await runTemporaryXmtpClient({ @@ -41,6 +44,8 @@ export async function handleSetup(params: { publicAddress, ownerAddress: params.ownerAddress, }; + + log?.info(`[xmtp] setup identity generated (address: ${publicAddress})`); return { publicAddress }; } @@ -73,8 +78,11 @@ export async function handleSetupComplete(): Promise<{ saved: true }> { } const runtime = getXmtpRuntime(); + const log = runtime.logging.getChildLogger(); const cfg = runtime.config.loadConfig() as OpenClawConfig; + log?.info("[xmtp] setup complete — writing config"); + const next = updateXmtpSection(cfg, { walletKey: setupResult.walletKey, dbEncryptionKey: setupResult.dbEncryptionKey, @@ -87,11 +95,16 @@ export async function handleSetupComplete(): Promise<{ saved: true }> { await runtime.config.writeConfigFile(next); setupResult = null; + log?.info("[xmtp] setup config saved"); return { saved: true }; } export function handleSetupCancel(): { cancelled: boolean } { + const log = getXmtpRuntime().logging.getChildLogger(); const wasPending = setupResult !== null; setupResult = null; + if (wasPending) { + log?.info("[xmtp] setup cancelled"); + } return { cancelled: wasPending }; } From 13787afbfb867590726fd23dae89e69f9e30dd43 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:35:50 -0800 Subject: [PATCH 04/12] Add config schema --- extensions/xmtp/index.ts | 11 +++++- extensions/xmtp/openclaw.plugin.json | 57 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/extensions/xmtp/index.ts b/extensions/xmtp/index.ts index 716dd50aa7080..c5cf629d9ec68 100644 --- a/extensions/xmtp/index.ts +++ b/extensions/xmtp/index.ts @@ -1,7 +1,8 @@ import type { ServerResponse } from "node:http"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema, readJsonBodyWithLimit } from "openclaw/plugin-sdk"; +import { readJsonBodyWithLimit } from "openclaw/plugin-sdk"; import { xmtpPlugin } from "./src/channel.js"; +import { XMTPConfigSchema } from "./src/config-schema.js"; import { setXmtpRuntime } from "./src/runtime.js"; import { handleSetup, @@ -21,7 +22,13 @@ const plugin = { id: "xmtp", name: "XMTP", description: "XMTP decentralized messaging channel", - configSchema: emptyPluginConfigSchema(), + configSchema: { + safeParse: (value: unknown) => XMTPConfigSchema.safeParse(value), + jsonSchema: XMTPConfigSchema.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }) as Record, + }, register(api: OpenClawPluginApi) { setXmtpRuntime(api.runtime); api.registerChannel({ plugin: xmtpPlugin }); diff --git a/extensions/xmtp/openclaw.plugin.json b/extensions/xmtp/openclaw.plugin.json index 2f546db5a13c5..ad0a49aa6525e 100644 --- a/extensions/xmtp/openclaw.plugin.json +++ b/extensions/xmtp/openclaw.plugin.json @@ -4,6 +4,61 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": {} + "properties": { + "name": { "type": "string" }, + "enabled": { "type": "boolean" }, + "markdown": { + "type": "object", + "additionalProperties": false, + "properties": { + "tables": { "type": "string", "enum": ["off", "bullets", "code"] } + } + }, + "walletKey": { "type": "string" }, + "dbEncryptionKey": { "type": "string" }, + "env": { "type": "string", "enum": ["production", "dev"] }, + "debug": { "type": "boolean" }, + "dmPolicy": { "type": "string", "enum": ["pairing", "allowlist", "open", "disabled"] }, + "allowFrom": { + "type": "array", + "items": { "oneOf": [{ "type": "string" }, { "type": "number" }] } + }, + "groupPolicy": { "type": "string", "enum": ["open", "disabled", "allowlist"] }, + "groups": { "type": "array", "items": { "type": "string" } }, + "textChunkLimit": { "type": "integer", "minimum": 4000 }, + "publicAddress": { "type": "string" }, + "pinataApiKey": { "type": "string" }, + "pinataSecretKey": { "type": "string" }, + "ipfsGatewayUrl": { "type": "string", "format": "uri" }, + "ownerAddress": { "type": "string" }, + "web3BioApiKey": { "type": "string" } + } + }, + "uiHints": { + "walletKey": { + "label": "Wallet Key", + "sensitive": true, + "help": "Wallet private key (hex or env var name)" + }, + "dbEncryptionKey": { + "label": "DB Encryption Key", + "sensitive": true, + "help": "Encryption key for local XMTP storage" + }, + "pinataApiKey": { + "label": "Pinata API Key", + "sensitive": true, + "help": "Pinata API key for IPFS upload" + }, + "pinataSecretKey": { + "label": "Pinata Secret Key", + "sensitive": true, + "help": "Pinata secret key for IPFS upload" + }, + "web3BioApiKey": { + "label": "web3.bio API Key", + "sensitive": true, + "help": "API key for ENS resolution" + } } } From 63276e497eefb41cf5d701a8c76cd6c1e98dbc47 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:50:37 -0800 Subject: [PATCH 05/12] Fix config schema --- extensions/xmtp/src/accounts.ts | 2 +- extensions/xmtp/src/config-schema.ts | 8 +++ extensions/xmtp/src/config-types.ts | 49 ------------------- extensions/xmtp/src/onboarding.ts | 2 +- .../xmtp/src/test-utils/unit-helpers.ts | 2 +- 5 files changed, 11 insertions(+), 52 deletions(-) delete mode 100644 extensions/xmtp/src/config-types.ts diff --git a/extensions/xmtp/src/accounts.ts b/extensions/xmtp/src/accounts.ts index ebc004672c314..cfadf41f19343 100644 --- a/extensions/xmtp/src/accounts.ts +++ b/extensions/xmtp/src/accounts.ts @@ -5,7 +5,7 @@ import { type PluginRuntime, type RuntimeLogger, } from "openclaw/plugin-sdk"; -import type { XMTPConfig } from "./config-types.js"; +import type { XMTPConfig } from "./config-schema.js"; import { generateEncryptionKeyHex, generatePrivateKey, diff --git a/extensions/xmtp/src/config-schema.ts b/extensions/xmtp/src/config-schema.ts index 92159393989b1..4fe448b8930bb 100644 --- a/extensions/xmtp/src/config-schema.ts +++ b/extensions/xmtp/src/config-schema.ts @@ -69,6 +69,14 @@ export const XMTPConfigSchema = z.object({ export type XMTPConfigInput = z.infer; +/** Derived union types (replaces config-types.ts) */ +export type DmPolicy = NonNullable; +export type GroupPolicy = NonNullable; +export type XMTPAccountConfig = XMTPConfigInput; +export type XMTPConfig = XMTPConfigInput & { + accounts?: Record; +}; + /** * JSON Schema for Control UI (converted from Zod) */ diff --git a/extensions/xmtp/src/config-types.ts b/extensions/xmtp/src/config-types.ts deleted file mode 100644 index dd4d775baf614..0000000000000 --- a/extensions/xmtp/src/config-types.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * XMTP channel configuration types. - * Self-contained for the extension to avoid cross-package imports. - */ - -export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; -export type GroupPolicy = "open" | "disabled" | "allowlist"; - -export type XMTPAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** If false, do not start this XMTP account. Default: true. */ - enabled?: boolean; - /** Wallet private key (hex or env var name). Required for agent identity. */ - walletKey?: string; - /** DB encryption key for local XMTP storage. Required. */ - dbEncryptionKey?: string; - /** XMTP environment: production (default) or dev. */ - env?: "production" | "dev"; - /** Enable debug logging for this account. */ - debug?: boolean; - /** Sender access policy (default: pairing). Controls who can message the agent. */ - dmPolicy?: DmPolicy; - /** Allowlist of addresses permitted to message the agent. */ - allowFrom?: Array; - /** Controls how group messages are handled (default: open). */ - groupPolicy?: GroupPolicy; - /** Allowlist of conversation IDs the agent listens in (groupPolicy "allowlist"). Include "*" to allow all. */ - groups?: string[]; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** Ethereum address for display; derived from walletKey if not set. */ - publicAddress?: string; - /** Pinata API key for IPFS upload of media attachments. */ - pinataApiKey?: string; - /** Pinata secret key for IPFS upload of media attachments. */ - pinataSecretKey?: string; - /** Custom IPFS gateway URL (default: https://gateway.pinata.cloud/ipfs/). */ - ipfsGatewayUrl?: string; - /** Ethereum address of the owner. Auto-allowed for DMs; a conversation is created on startup. */ - ownerAddress?: string; - /** web3.bio API key for ENS resolution (optional, improves rate limits). */ - web3BioApiKey?: string; -}; - -export type XMTPConfig = { - /** Per-account XMTP configuration (multi-account). */ - accounts?: Record; -} & XMTPAccountConfig; diff --git a/extensions/xmtp/src/onboarding.ts b/extensions/xmtp/src/onboarding.ts index 9ddc6b363d973..ece52da66b6eb 100644 --- a/extensions/xmtp/src/onboarding.ts +++ b/extensions/xmtp/src/onboarding.ts @@ -3,7 +3,7 @@ import { type ChannelOnboardingDmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk"; -import type { DmPolicy } from "./config-types.js"; +import type { DmPolicy } from "./config-schema.js"; import { getXmtpSection, listXmtpAccountIds, diff --git a/extensions/xmtp/src/test-utils/unit-helpers.ts b/extensions/xmtp/src/test-utils/unit-helpers.ts index 0a5f1e641b70a..3e9aaab6f125e 100644 --- a/extensions/xmtp/src/test-utils/unit-helpers.ts +++ b/extensions/xmtp/src/test-utils/unit-helpers.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import { vi } from "vitest"; import type { ResolvedXmtpAccount, CoreConfig } from "../accounts.js"; -import type { DmPolicy, GroupPolicy, XMTPAccountConfig } from "../config-types.js"; +import type { DmPolicy, GroupPolicy, XMTPAccountConfig } from "../config-schema.js"; import { handleInboundMessage } from "../channel.js"; // --------------------------------------------------------------------------- From 34135077e3be7b49a2d067a7536f1dac0449bbd4 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:15:57 -0800 Subject: [PATCH 06/12] Fix plugin schema --- extensions/xmtp/openclaw.plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/xmtp/openclaw.plugin.json b/extensions/xmtp/openclaw.plugin.json index ad0a49aa6525e..0936097653d1c 100644 --- a/extensions/xmtp/openclaw.plugin.json +++ b/extensions/xmtp/openclaw.plugin.json @@ -29,7 +29,7 @@ "publicAddress": { "type": "string" }, "pinataApiKey": { "type": "string" }, "pinataSecretKey": { "type": "string" }, - "ipfsGatewayUrl": { "type": "string", "format": "uri" }, + "ipfsGatewayUrl": { "type": "string" }, "ownerAddress": { "type": "string" }, "web3BioApiKey": { "type": "string" } } From e0773f1d7eb68af9c0d8279fefa491be2d40fcbe Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:31:16 -0800 Subject: [PATCH 07/12] Fix config --- extensions/xmtp/index.ts | 11 +----- extensions/xmtp/openclaw.plugin.json | 57 +--------------------------- 2 files changed, 3 insertions(+), 65 deletions(-) diff --git a/extensions/xmtp/index.ts b/extensions/xmtp/index.ts index c5cf629d9ec68..716dd50aa7080 100644 --- a/extensions/xmtp/index.ts +++ b/extensions/xmtp/index.ts @@ -1,8 +1,7 @@ import type { ServerResponse } from "node:http"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { readJsonBodyWithLimit } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema, readJsonBodyWithLimit } from "openclaw/plugin-sdk"; import { xmtpPlugin } from "./src/channel.js"; -import { XMTPConfigSchema } from "./src/config-schema.js"; import { setXmtpRuntime } from "./src/runtime.js"; import { handleSetup, @@ -22,13 +21,7 @@ const plugin = { id: "xmtp", name: "XMTP", description: "XMTP decentralized messaging channel", - configSchema: { - safeParse: (value: unknown) => XMTPConfigSchema.safeParse(value), - jsonSchema: XMTPConfigSchema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }) as Record, - }, + configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setXmtpRuntime(api.runtime); api.registerChannel({ plugin: xmtpPlugin }); diff --git a/extensions/xmtp/openclaw.plugin.json b/extensions/xmtp/openclaw.plugin.json index 0936097653d1c..2f546db5a13c5 100644 --- a/extensions/xmtp/openclaw.plugin.json +++ b/extensions/xmtp/openclaw.plugin.json @@ -4,61 +4,6 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": { - "name": { "type": "string" }, - "enabled": { "type": "boolean" }, - "markdown": { - "type": "object", - "additionalProperties": false, - "properties": { - "tables": { "type": "string", "enum": ["off", "bullets", "code"] } - } - }, - "walletKey": { "type": "string" }, - "dbEncryptionKey": { "type": "string" }, - "env": { "type": "string", "enum": ["production", "dev"] }, - "debug": { "type": "boolean" }, - "dmPolicy": { "type": "string", "enum": ["pairing", "allowlist", "open", "disabled"] }, - "allowFrom": { - "type": "array", - "items": { "oneOf": [{ "type": "string" }, { "type": "number" }] } - }, - "groupPolicy": { "type": "string", "enum": ["open", "disabled", "allowlist"] }, - "groups": { "type": "array", "items": { "type": "string" } }, - "textChunkLimit": { "type": "integer", "minimum": 4000 }, - "publicAddress": { "type": "string" }, - "pinataApiKey": { "type": "string" }, - "pinataSecretKey": { "type": "string" }, - "ipfsGatewayUrl": { "type": "string" }, - "ownerAddress": { "type": "string" }, - "web3BioApiKey": { "type": "string" } - } - }, - "uiHints": { - "walletKey": { - "label": "Wallet Key", - "sensitive": true, - "help": "Wallet private key (hex or env var name)" - }, - "dbEncryptionKey": { - "label": "DB Encryption Key", - "sensitive": true, - "help": "Encryption key for local XMTP storage" - }, - "pinataApiKey": { - "label": "Pinata API Key", - "sensitive": true, - "help": "Pinata API key for IPFS upload" - }, - "pinataSecretKey": { - "label": "Pinata Secret Key", - "sensitive": true, - "help": "Pinata secret key for IPFS upload" - }, - "web3BioApiKey": { - "label": "web3.bio API Key", - "sensitive": true, - "help": "API key for ENS resolution" - } + "properties": {} } } From 2a564f9dfd899b8de8a4430f80100443bb130bcd Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:56:45 -0800 Subject: [PATCH 08/12] Fix configuration issue --- extensions/xmtp/src/channel.ts | 4 +- extensions/xmtp/src/lib/xmtp-client.ts | 3 + extensions/xmtp/src/setup.test.ts | 139 +++++++++++++++++++++++++ extensions/xmtp/src/setup.ts | 34 +++++- 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 extensions/xmtp/src/setup.test.ts diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts index ae57bb33fa50a..a17d043434dbf 100644 --- a/extensions/xmtp/src/channel.ts +++ b/extensions/xmtp/src/channel.ts @@ -487,7 +487,9 @@ export const xmtpPlugin: ChannelPlugin = { "ownerAddress", ], }), - isConfigured: (account) => account.configured, + // Always return true for isConfigued so that the auto-provisioning has a chance to run + // Otherwise there is a chicken-and-egg problem that prevents the account from being configured. + isConfigured: (_account) => true, describeAccount: (account) => ({ accountId: account.accountId, name: account.name, diff --git a/extensions/xmtp/src/lib/xmtp-client.ts b/extensions/xmtp/src/lib/xmtp-client.ts index fca8c250d8720..25d3ae910a389 100644 --- a/extensions/xmtp/src/lib/xmtp-client.ts +++ b/extensions/xmtp/src/lib/xmtp-client.ts @@ -6,6 +6,7 @@ */ import { Agent, createSigner, createUser, type HexString } from "@xmtp/agent-sdk"; +import * as fs from "node:fs"; import * as path from "node:path"; import type { ResolvedXmtpAccount } from "../accounts.js"; import { getXmtpRuntime } from "../runtime.js"; @@ -38,6 +39,7 @@ export async function createAgentFromAccount( stateDir: string, ): Promise { const dbPath = path.join(stateDir, "xmtp", account.accountId); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const user = createUser(ensureHexPrefix(account.walletKey)); const signer = createSigner(user); return Agent.create(signer, { @@ -56,6 +58,7 @@ export async function runTemporaryXmtpClient(params: { const accountId = params.accountId ?? "default"; const stateDir = getXmtpRuntime().state.resolveStateDir(); const dbPath = path.join(stateDir, "xmtp", accountId); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const user = createUser(ensureHexPrefix(params.walletKey)); const signer = createSigner(user); const agent = await Agent.create(signer, { diff --git a/extensions/xmtp/src/setup.test.ts b/extensions/xmtp/src/setup.test.ts new file mode 100644 index 0000000000000..77b251cbc4bcc --- /dev/null +++ b/extensions/xmtp/src/setup.test.ts @@ -0,0 +1,139 @@ +/** + * Unit tests for handleSetup — verifies key reuse logic. + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const VALID_WALLET_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const VALID_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const EXISTING_DB_ENC_KEY = "ab".repeat(32); +const GENERATED_DB_ENC_KEY = "cd".repeat(32); +const GENERATED_WALLET_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; +const GENERATED_ADDRESS = "0xGeneratedAddr"; + +// --- Mocks --- + +vi.mock("./runtime.js", () => ({ + getXmtpRuntime: vi.fn(), +})); + +vi.mock("./lib/identity.js", () => ({ + generateXmtpIdentity: vi.fn(() => ({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + publicAddress: GENERATED_ADDRESS, + })), + generateEncryptionKeyHex: vi.fn(() => GENERATED_DB_ENC_KEY), + walletAddressFromPrivateKey: vi.fn(() => VALID_ADDRESS), +})); + +vi.mock("./lib/xmtp-client.js", () => ({ + runTemporaryXmtpClient: vi.fn(async () => {}), +})); + +import { + generateXmtpIdentity, + generateEncryptionKeyHex, + walletAddressFromPrivateKey, +} from "./lib/identity.js"; +import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; +import { getXmtpRuntime } from "./runtime.js"; +import { handleSetup } from "./setup.js"; + +type MockedFn = ReturnType; + +function setupRuntime(xmtpConfig: Record = {}) { + const loadConfig = vi.fn(() => ({ + channels: { xmtp: xmtpConfig }, + })); + const writeConfigFile = vi.fn(async () => {}); + const log = { info: vi.fn(), error: vi.fn() }; + + (getXmtpRuntime as MockedFn).mockReturnValue({ + config: { loadConfig, writeConfigFile }, + logging: { getChildLogger: () => log }, + }); + + return { loadConfig, writeConfigFile, log }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleSetup", () => { + it("reuses both keys when walletKey and dbEncryptionKey exist in config", async () => { + setupRuntime({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: EXISTING_DB_ENC_KEY, + publicAddress: VALID_ADDRESS, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).not.toHaveBeenCalled(); + expect(generateEncryptionKeyHex).not.toHaveBeenCalled(); + expect(result.publicAddress).toBe(VALID_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: EXISTING_DB_ENC_KEY, + }), + ); + }); + + it("generates only dbEncryptionKey when walletKey exists but dbEncryptionKey is missing", async () => { + setupRuntime({ + walletKey: VALID_WALLET_KEY, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).not.toHaveBeenCalled(); + expect(generateEncryptionKeyHex).toHaveBeenCalledTimes(1); + expect(walletAddressFromPrivateKey).toHaveBeenCalledWith(VALID_WALLET_KEY); + expect(result.publicAddress).toBe(VALID_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); + + it("generates full identity when no keys exist in config", async () => { + setupRuntime({}); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).toHaveBeenCalledTimes(1); + expect(result.publicAddress).toBe(GENERATED_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); + + it("generates full identity when only dbEncryptionKey exists (no walletKey)", async () => { + setupRuntime({ + dbEncryptionKey: EXISTING_DB_ENC_KEY, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).toHaveBeenCalledTimes(1); + expect(result.publicAddress).toBe(GENERATED_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); +}); diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts index 1ab9c6288215c..0fe493603909d 100644 --- a/extensions/xmtp/src/setup.ts +++ b/extensions/xmtp/src/setup.ts @@ -6,7 +6,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; import { resolveXmtpAccount, updateXmtpSection, type CoreConfig } from "./accounts.js"; -import { generateXmtpIdentity } from "./lib/identity.js"; +import { + generateEncryptionKeyHex, + generateXmtpIdentity, + walletAddressFromPrivateKey, +} from "./lib/identity.js"; import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; import { getXmtpRuntime } from "./runtime.js"; @@ -23,12 +27,36 @@ export async function handleSetup(params: { env?: "production" | "dev"; ownerAddress?: string; }): Promise<{ publicAddress: string }> { - const log = getXmtpRuntime().logging.getChildLogger(); + const runtime = getXmtpRuntime(); + const log = runtime.logging.getChildLogger(); const env = params.env === "dev" ? "dev" : "production"; log?.info(`[xmtp] setup started (env: ${env})`); - const { walletKey, dbEncryptionKey, publicAddress } = generateXmtpIdentity(); + const cfg = runtime.config.loadConfig() as OpenClawConfig; + const account = resolveXmtpAccount({ + cfg: cfg as CoreConfig, + accountId: params.accountId, + }); + + let walletKey: string; + let dbEncryptionKey: string; + let publicAddress: string; + + if (account.walletKey && account.dbEncryptionKey) { + walletKey = account.walletKey; + dbEncryptionKey = account.dbEncryptionKey; + publicAddress = account.publicAddress; + } else if (account.walletKey) { + walletKey = account.walletKey; + dbEncryptionKey = generateEncryptionKeyHex(); + publicAddress = walletAddressFromPrivateKey(walletKey); + } else { + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; + } await runTemporaryXmtpClient({ walletKey, From 317566ba1bd25e6b086fde623c061ed3a2007165 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:31:37 -0800 Subject: [PATCH 09/12] Fix target handling for conversation IDs --- extensions/xmtp/skills/xmtp-channel.md | 80 -------------------------- extensions/xmtp/src/channel.ts | 5 +- 2 files changed, 4 insertions(+), 81 deletions(-) delete mode 100644 extensions/xmtp/skills/xmtp-channel.md diff --git a/extensions/xmtp/skills/xmtp-channel.md b/extensions/xmtp/skills/xmtp-channel.md deleted file mode 100644 index 0ac9eec062e65..0000000000000 --- a/extensions/xmtp/skills/xmtp-channel.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: xmtp-channel -description: How to use the XMTP channel for decentralized E2E encrypted messaging -read_when: - - Working with XMTP channel - - Sending or receiving messages via XMTP - - Understanding XMTP agent identity and addressing ---- - -# XMTP Channel Guide - -The XMTP channel provides decentralized E2E encrypted messaging via the XMTP protocol. OpenClaw uses the XMTP plugin and `@xmtp/agent-sdk` to communicate with the XMTP network. - -## Architecture Overview - -### SDK-Based Implementation - -- Runs in-process within the gateway -- Uses `Agent.createFromEnv()` with keys from config / `~/.openclaw/.env` -- One identity per account (derived from wallet key) -- Local DB path: `~/.openclaw/xmtp//` (or `XMTP_DB_DIRECTORY`) - -### Wallet-Based Identity - -The agent's public address is the Ethereum address derived from the wallet private key. Anyone can message the agent by that address from any XMTP client (Converse, xmtp.chat, etc.). No invite URL is required for DMs; the agent receives messages automatically once the gateway is running. - -## Slash command - -| Command | Args | Description | -| ---------- | ---- | --------------------------------------------------------------------------------------------------------------- | -| `/address` | None | Print the XMTP public agent address (Ethereum address). Use this to share with others so they can DM the agent. | - -Requires authorization. If XMTP is not configured, the command replies with a short error message. - -## Message Targeting - -- **Direct messages**: Target by the peer's Ethereum address (e.g. `0x1234...`). The agent creates or reuses a DM conversation by address. -- **Group messages**: Target by conversation ID (topic/id from the SDK). The agent must already be in the group to send. -- Outbound actions use `to` as conversation ID or address; the plugin resolves the conversation via `agent.client.conversations.getConversationById(to)` then `conversation.sendText(text)`. - -## Capabilities - -| Feature | Supported | -| ------------------- | ------------ | -| Group conversations | Yes | -| Direct messages | Yes | -| Reactions | No | -| Threads | No | -| Media/attachments | Yes (remote) | -| E2E encryption | Yes (XMTP) | - -## Configuration Reference - -```json -{ - "channels": { - "xmtp": { - "enabled": true, - "walletKey": "0x...", - "dbEncryptionKey": "", - "env": "production", - "dmPolicy": "pairing", - "groupPolicy": "open" - } - } -} -``` - -Key fields: - -- `walletKey`: Wallet private key (hex). Public address is derived from this. -- `dbEncryptionKey`: Encryption key for local XMTP DB. -- `env`: XMTP environment (`production` or `dev`). -- `dmPolicy`: Who can DM the agent (pairing / allowlist / open / disabled). -- `groupPolicy`: Which groups can message the agent (open / disabled / allowlist). - -## Error Handling - -- **Conversation not found**: The agent may not yet have a conversation with that address/ID; for DMs the other party must message the agent first, or use the send action with an existing conversation ID. -- **Agent not available**: Gateway not started or XMTP channel not running; start the gateway with XMTP enabled. diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts index a17d043434dbf..65e22cf1bf80e 100644 --- a/extensions/xmtp/src/channel.ts +++ b/extensions/xmtp/src/channel.ts @@ -516,12 +516,15 @@ export const xmtpPlugin: ChannelPlugin = { messaging: { normalizeTarget: normalizeXmtpMessagingTarget, targetResolver: { - looksLikeId: (raw) => { + looksLikeId: (raw, normalized) => { const t = raw.trim(); if (!t) return false; // Ethereum address: exactly 42 hex chars (0x + 40) if (t.length === 42 && /^0x[0-9a-fA-F]{40}$/.test(t)) return true; if (isEnsName(t)) return true; + // Conversation topic/ID: hex string (uses normalized to handle xmtp: prefix) + const n = (normalized ?? t).trim(); + if (/^[0-9a-fA-F]{16,}$/.test(n)) return true; return false; }, hint: "", From 093414e8e5db745c0d8d930bf2caeb39b12ea3b8 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:32:39 -0800 Subject: [PATCH 10/12] Add tests for extensions --- extensions/xmtp/src/channel.messaging.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/extensions/xmtp/src/channel.messaging.test.ts b/extensions/xmtp/src/channel.messaging.test.ts index 48af4febda71b..2fe3f63b95815 100644 --- a/extensions/xmtp/src/channel.messaging.test.ts +++ b/extensions/xmtp/src/channel.messaging.test.ts @@ -243,6 +243,34 @@ describe("XMTP message flow", () => { expect(looksLikeId(input)).toBe(expected); }); + it.each([ + [ + "32-char conversation ID via normalized", + "xmtp:8f83e95ea30dda840dce97bd9b8b21e4", + "8f83e95ea30dda840dce97bd9b8b21e4", + true, + ], + [ + "64-char conversation topic via normalized", + "xmtp:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + true, + ], + ["short hex (15 chars) rejected", "xmtp:abcdef012345678", "abcdef012345678", false], + [ + "non-hex chars in normalized rejected", + "xmtp:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + false, + ], + ] as const)("conversation ID: %s", (_desc, raw, normalized, expected) => { + expect(looksLikeId(raw, normalized)).toBe(expected); + }); + + it("recognizes bare hex conversation ID without normalized param", () => { + expect(looksLikeId("8f83e95ea30dda840dce97bd9b8b21e4")).toBe(true); + }); + it("hint mentions ENS name", () => { expect(xmtpPlugin.messaging!.targetResolver!.hint).toContain("ENS"); }); From b1974c7ac1b1a78b4b46e0d714ff37c12b93603a Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:36:17 -0800 Subject: [PATCH 11/12] Include messageId context for reactions --- extensions/xmtp/src/channel.ts | 2 +- extensions/xmtp/src/inbound-pipeline.test.ts | 37 +++++++++++++++++++- extensions/xmtp/src/inbound-pipeline.ts | 5 ++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts index 65e22cf1bf80e..a907ca1111961 100644 --- a/extensions/xmtp/src/channel.ts +++ b/extensions/xmtp/src/channel.ts @@ -536,7 +536,7 @@ export const xmtpPlugin: ChannelPlugin = { const hints = [ "- XMTP targets are wallet addresses, ENS names, or conversation topics. Use `to=
` for `action=send`.", "- When ENS names are available (in SenderName, GroupMembers, or [ENS Context] blocks), always refer to users by their ENS name (e.g., nick.eth) rather than raw Ethereum addresses.", - "- Use `action=react` with `to=`, `messageId=`, and `emoji=` to react to messages.", + "- To react to a message, use `action=react` with `emoji=` and `messageId` set to the `[message_id:...]` tag from the message. The `to` parameter is auto-filled from context.", ]; try { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); diff --git a/extensions/xmtp/src/inbound-pipeline.test.ts b/extensions/xmtp/src/inbound-pipeline.test.ts index 498f19fef6a2b..b35124b4b923c 100644 --- a/extensions/xmtp/src/inbound-pipeline.test.ts +++ b/extensions/xmtp/src/inbound-pipeline.test.ts @@ -94,7 +94,7 @@ describe("runInboundPipeline", () => { expect.objectContaining({ channel: "XMTP", from: TEST_SENDER_ADDRESS.slice(0, 12), - body: "test message", + body: "test message [message_id:msg-1]", }), ); }); @@ -190,6 +190,41 @@ describe("runInboundPipeline", () => { }); }); +describe("message ID tagging", () => { + it("tags body with [message_id:...] when messageId is provided", async () => { + const { promise, mocks } = callPipeline({ content: "hello", messageId: "msg-42" }); + await promise; + + expect(mocks.formatAgentEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + body: "hello [message_id:msg-42]", + }), + ); + }); + + it("does not tag body when messageId is undefined", async () => { + const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); + const { runtime, mocks } = createMockRuntime(); + + await runInboundPipeline({ + account, + sender: TEST_SENDER_ADDRESS, + conversationId: CONVERSATION_ID, + content: "hello", + messageId: undefined, + isDirect: true, + runtime, + deliverReply: vi.fn(async () => {}), + }); + + expect(mocks.formatAgentEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + body: "hello", + }), + ); + }); +}); + describe("ENS enrichment", () => { it("uses senderName in envelope from field when provided", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); diff --git a/extensions/xmtp/src/inbound-pipeline.ts b/extensions/xmtp/src/inbound-pipeline.ts index 9b18cea627228..c9400decac7ca 100644 --- a/extensions/xmtp/src/inbound-pipeline.ts +++ b/extensions/xmtp/src/inbound-pipeline.ts @@ -61,6 +61,9 @@ export async function runInboundPipeline(params: { sessionKey: route.sessionKey, }); + // Tag the content with the message ID so the LLM can reference it (e.g. for reactions) + const taggedContent = messageId ? `${content} [message_id:${messageId}]` : content; + const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); const rawBody = runtime.channel.reply.formatAgentEnvelope({ channel: "XMTP", @@ -68,7 +71,7 @@ export async function runInboundPipeline(params: { timestamp: Date.now(), previousTimestamp, envelope: envelopeOptions, - body: content, + body: taggedContent, }); const body = params.ensContext ? `${params.ensContext}\n${rawBody}` : rawBody; From eb041c5c5de9d9b4f479a6b43bbab0f86dcd748e Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:13:46 -0800 Subject: [PATCH 12/12] Add message ID to the correct field --- extensions/xmtp/src/gateway-lifecycle.ts | 3 +++ extensions/xmtp/src/inbound-pipeline.test.ts | 17 +++++++++++------ extensions/xmtp/src/inbound-pipeline.ts | 1 + extensions/xmtp/src/setup.ts | 2 ++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/extensions/xmtp/src/gateway-lifecycle.ts b/extensions/xmtp/src/gateway-lifecycle.ts index 7e71ca6f11bda..048ef211556a1 100644 --- a/extensions/xmtp/src/gateway-lifecycle.ts +++ b/extensions/xmtp/src/gateway-lifecycle.ts @@ -323,6 +323,9 @@ export function buildTextHandler(params: HandlerBaseParams) { }, ensText: (content) => content, dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + log?.info( + `[${account.accountId}] dispatching text event: ${JSON.stringify({ content: content.slice(0, 50), id: messageId })}`, + ); await handleInboundMessage({ account, sender, diff --git a/extensions/xmtp/src/inbound-pipeline.test.ts b/extensions/xmtp/src/inbound-pipeline.test.ts index b35124b4b923c..5b2bb119d570e 100644 --- a/extensions/xmtp/src/inbound-pipeline.test.ts +++ b/extensions/xmtp/src/inbound-pipeline.test.ts @@ -111,6 +111,7 @@ describe("runInboundPipeline", () => { expect.objectContaining({ RawBody: "hello world", CommandBody: "hello world", + BodyForAgent: "hello world [message_id:msg-42]", From: `xmtp:${TEST_SENDER_ADDRESS}`, To: `xmtp:${CONVERSATION_ID}`, ChatType: "direct", @@ -191,18 +192,20 @@ describe("runInboundPipeline", () => { }); describe("message ID tagging", () => { - it("tags body with [message_id:...] when messageId is provided", async () => { + it("sets BodyForAgent with [message_id:...] tag when messageId is provided", async () => { const { promise, mocks } = callPipeline({ content: "hello", messageId: "msg-42" }); await promise; - expect(mocks.formatAgentEnvelope).toHaveBeenCalledWith( + expect(mocks.finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ - body: "hello [message_id:msg-42]", + BodyForAgent: "hello [message_id:msg-42]", + CommandBody: "hello", + RawBody: "hello", }), ); }); - it("does not tag body when messageId is undefined", async () => { + it("does not set BodyForAgent tag when messageId is undefined", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); @@ -217,9 +220,11 @@ describe("message ID tagging", () => { deliverReply: vi.fn(async () => {}), }); - expect(mocks.formatAgentEnvelope).toHaveBeenCalledWith( + expect(mocks.finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ - body: "hello", + BodyForAgent: "hello", + CommandBody: "hello", + RawBody: "hello", }), ); }); diff --git a/extensions/xmtp/src/inbound-pipeline.ts b/extensions/xmtp/src/inbound-pipeline.ts index c9400decac7ca..333c5696f2f1e 100644 --- a/extensions/xmtp/src/inbound-pipeline.ts +++ b/extensions/xmtp/src/inbound-pipeline.ts @@ -82,6 +82,7 @@ export async function runInboundPipeline(params: { Body: body, RawBody: content, CommandBody: content, + BodyForAgent: taggedContent, From: `xmtp:${sender}`, To: `xmtp:${conversationId}`, SessionKey: route.sessionKey, diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts index 0fe493603909d..a200daac6cad8 100644 --- a/extensions/xmtp/src/setup.ts +++ b/extensions/xmtp/src/setup.ts @@ -39,6 +39,8 @@ export async function handleSetup(params: { accountId: params.accountId, }); + log.info(`[xmtp] account resolved: ${account}`); + let walletKey: string; let dbEncryptionKey: string; let publicAddress: string;