Skip to content
This repository was archived by the owner on Feb 27, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 0 additions & 80 deletions extensions/xmtp/skills/xmtp-channel.md

This file was deleted.

33 changes: 19 additions & 14 deletions extensions/xmtp/src/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -373,17 +373,13 @@ function makeAccount(overrides?: Partial<ResolvedXmtpAccount>): ResolvedXmtpAcco
};
}

function makeMockRuntime(): { runtime: PluginRuntime; writeConfigFile: ReturnType<typeof vi.fn> } {
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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down
21 changes: 15 additions & 6 deletions extensions/xmtp/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ 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,
generateXmtpIdentity,
walletAddressFromPrivateKey,
} from "./lib/identity.js";

Expand Down Expand Up @@ -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;
}
Expand All @@ -192,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,
Expand Down
9 changes: 2 additions & 7 deletions extensions/xmtp/src/actions.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 });
}
Expand Down
102 changes: 45 additions & 57 deletions extensions/xmtp/src/channel.messaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});

expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true);
});

it("groupPolicy 'disabled' blocks all groups", () => {
const account = createTestAccount({
address: TEST_OWNER_ADDRESS,
groupPolicy: "disabled",
groupPolicy: groupPolicy as any,
groups: groups as any,
});

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 () => {
Expand Down Expand Up @@ -263,24 +230,45 @@ 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.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("rejects empty strings", () => {
expect(looksLikeId("")).toBe(false);
expect(looksLikeId(" ")).toBe(false);
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("rejects non-ENS non-address strings", () => {
expect(looksLikeId("hello")).toBe(false);
expect(looksLikeId("not-an-address")).toBe(false);
it("recognizes bare hex conversation ID without normalized param", () => {
expect(looksLikeId("8f83e95ea30dda840dce97bd9b8b21e4")).toBe(true);
});

it("hint mentions ENS name", () => {
Expand Down
Loading
Loading