Skip to content
Merged
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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unicitylabs/openclaw-unicity",
"version": "0.5.5",
"version": "0.5.6",
"description": "Unicity wallet identity and encrypted DMs for OpenClaw agents — powered by Sphere SDK",
"type": "module",
"main": "src/index.ts",
Expand Down Expand Up @@ -44,7 +44,7 @@
"dependencies": {
"@clack/prompts": "^0.10.0",
"@sinclair/typebox": "^0.34.48",
"@unicitylabs/sphere-sdk": "^0.6.7"
"@unicitylabs/sphere-sdk": "0.6.8-dev.2"
},
"peerDependencies": {
"openclaw": "*"
Expand Down
38 changes: 32 additions & 6 deletions src/sphere.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export { DATA_DIR, MNEMONIC_PATH, walletExists };
/** Default testnet API key (from Sphere app) */
const DEFAULT_API_KEY = "sk_06365a9c44654841a366068bcfc68986";

/** How far back to fetch DMs on first connect (seconds). */
export const DM_LOOKBACK_SECONDS = 86_400;

let sphereInstance: Sphere | null = null;
let initPromise: Promise<InitSphereResult> | null = null;

Expand Down Expand Up @@ -116,6 +119,7 @@ async function doInitSphere(
...(existingMnemonic ? { mnemonic: existingMnemonic } : { autoGenerate: true }),
...(cfg.nametag ? { nametag: cfg.nametag } : {}),
...(groupChat ? { groupChat: groupChatRelays ? { relays: groupChatRelays } : true } : {}),
dmSince: Math.floor(Date.now() / 1000) - DM_LOOKBACK_SECONDS,
});
Comment on lines 119 to 123

sphereInstance = result.sphere;
Expand All @@ -133,8 +137,10 @@ async function doInitSphere(
}

// Register nametag if configured and wallet doesn't have one yet
const walletNametag = result.sphere.identity?.nametag;
if (cfg.nametag && !walletNametag) {
// Normalize: strip leading '@' for consistent comparison
const walletNametag = result.sphere.identity?.nametag?.replace(/^@/, "");
const cfgNametag = cfg.nametag?.replace(/^@/, "");
if (cfgNametag && !walletNametag) {
try {
await result.sphere.registerNametag(cfg.nametag);
const log = logger ?? console;
Expand All @@ -148,11 +154,31 @@ async function doInitSphere(
console.warn(msg);
}
}
} else if (cfg.nametag && walletNametag && cfg.nametag !== walletNametag) {
} else if (cfgNametag && walletNametag && cfgNametag !== walletNametag) {
// Nametag changed — check if another address in this wallet already owns it,
// otherwise derive a new HD address and mint the nametag there.
const log = logger ?? console;
log.warn(
`[unicity] Config nametag '${cfg.nametag}' differs from wallet nametag '${walletNametag}'. Wallet nametag is used. To change nametag, create a new wallet.`,
);
try {
const activeAddresses = result.sphere.getActiveAddresses() as
{ index: number; nametag?: string }[];
const existing = activeAddresses.find(
(a) => a.nametag?.replace(/^@/, "") === cfgNametag,
);
if (existing) {
log.info(`[unicity] Switching to existing address ${existing.index} for nametag '${cfg.nametag}'...`);
await result.sphere.switchToAddress(existing.index);
log.info(`[unicity] Switched to address ${existing.index} with nametag '${cfg.nametag}'.`);
} else {
const nextIndex = activeAddresses.length > 0
? Math.max(...activeAddresses.map((a) => a.index)) + 1
: 1;
log.info(`[unicity] Minting nametag '${cfg.nametag}' on new address ${nextIndex}...`);
await result.sphere.switchToAddress(nextIndex, { nametag: cfg.nametag });
log.info(`[unicity] Switched to address ${nextIndex} with nametag '${cfg.nametag}'.`);
}
} catch (err) {
log.warn(`[unicity] Failed to switch address for nametag '${cfg.nametag}': ${err}`);
}
}

// Send greeting DM to owner on first wallet creation
Expand Down
61 changes: 55 additions & 6 deletions test/sphere.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockSphereInit = vi.fn();
const mockCreateNodeProviders = vi.fn();
const mockRegisterNametag = vi.fn();
const mockSwitchToAddress = vi.fn();
const mockGetActiveAddresses = vi.fn();
const mockDestroy = vi.fn();
const mockMkdirSync = vi.fn();
const mockWriteFileSync = vi.fn();
Expand All @@ -28,7 +30,7 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

// Dynamic import so mocks are in place
const { initSphere, getSphere, getSphereOrNull, destroySphere, waitForSphere, MNEMONIC_PATH } =
const { initSphere, getSphere, getSphereOrNull, destroySphere, waitForSphere, MNEMONIC_PATH, DM_LOOKBACK_SECONDS } =
await import("../src/sphere.js");

describe("sphere", () => {
Expand All @@ -39,6 +41,8 @@ describe("sphere", () => {
l1Address: "alpha1agent",
},
registerNametag: mockRegisterNametag,
switchToAddress: mockSwitchToAddress,
getActiveAddresses: mockGetActiveAddresses,
destroy: mockDestroy,
};

Expand All @@ -50,6 +54,8 @@ describe("sphere", () => {
l1Address: "alpha1agent",
},
registerNametag: mockRegisterNametag,
switchToAddress: mockSwitchToAddress,
getActiveAddresses: mockGetActiveAddresses,
destroy: mockDestroy,
};

Expand Down Expand Up @@ -100,6 +106,26 @@ describe("sphere", () => {
expect(getSphereOrNull()).toBe(fakeSphere);
});

it("passes dmSince (seconds) to Sphere.init with 24h lookback", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
const nowSec = Math.floor(Date.now() / 1000);

mockSphereInit.mockResolvedValue({
sphere: fakeSphere,
created: false,
});

await initSphere({ network: "testnet" });

expect(mockSphereInit).toHaveBeenCalledWith(
expect.objectContaining({
dmSince: nowSec - DM_LOOKBACK_SECONDS,
}),
);
vi.useRealTimers();
});

it("saves mnemonic to file with 0o600 permissions on creation", async () => {
mockSphereInit.mockResolvedValue({
sphere: fakeSphere,
Expand Down Expand Up @@ -174,19 +200,42 @@ describe("sphere", () => {
expect(mockRegisterNametag).toHaveBeenCalledWith("mybot");
});

it("warns when config nametag differs from wallet nametag (no re-registration)", async () => {
it("switches to existing address when config nametag already exists in wallet", async () => {
mockSphereInit.mockResolvedValue({
sphere: fakeSphere, // has nametag: "@agent"
sphere: fakeSphere, // has nametag: "@agent" at index 0
created: false,
});
mockGetActiveAddresses.mockReturnValue([
{ index: 0, nametag: "@agent" },
{ index: 1, nametag: "mybot" },
]);
mockSwitchToAddress.mockResolvedValue(undefined);
const logger = { info: vi.fn(), warn: vi.fn() };

await initSphere({ network: "testnet", nametag: "mybot" }, logger);

// Should NOT attempt registration — SDK doesn't allow changing nametags
expect(mockRegisterNametag).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Config nametag 'mybot' differs from wallet nametag '@agent'"),
expect(mockSwitchToAddress).toHaveBeenCalledWith(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Switched to address 1 with nametag 'mybot'"),
);
});

it("mints nametag on new address when config nametag is unknown to wallet", async () => {
mockSphereInit.mockResolvedValue({
sphere: fakeSphere, // has nametag: "@agent" at index 0
created: false,
});
mockGetActiveAddresses.mockReturnValue([{ index: 0, nametag: "@agent" }]);
mockSwitchToAddress.mockResolvedValue(undefined);
const logger = { info: vi.fn(), warn: vi.fn() };

await initSphere({ network: "testnet", nametag: "newbot" }, logger);

expect(mockRegisterNametag).not.toHaveBeenCalled();
expect(mockSwitchToAddress).toHaveBeenCalledWith(1, { nametag: "newbot" });
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Switched to address 1 with nametag 'newbot'"),
);
});

Expand Down
Loading