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
101 changes: 101 additions & 0 deletions app/lib/active-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const state = vi.hoisted(() => ({
wallets: [] as Array<{ id?: string; name: string; accounts: Array<{ chainId: string; address: string }> }>,
settings: new Map<string, string>(),
}));

vi.mock("../../lib/ows/wallet", () => ({
listAgentWallets: vi.fn(() => state.wallets),
getBaseAddress: vi.fn((wallet: { accounts: Array<{ chainId: string; address: string }> }) =>
wallet.accounts.find((account) => account.chainId.startsWith("eip155:"))?.address,
),
}));

vi.mock("../db", () => ({
db: {
setting: {
findUnique: vi.fn(async ({ where }: { where: { key: string } }) => {
const value = state.settings.get(where.key);
return value ? { key: where.key, value } : null;
}),
upsert: vi.fn(async ({ where, create, update }: { where: { key: string }; create: { value: string }; update: { value: string } }) => {
state.settings.set(where.key, update.value || create.value);
return { key: where.key, value: state.settings.get(where.key) };
}),
},
},
}));

import { nextPlotlinkWalletName, resolveActiveWallet, selectActiveWallet } from "./active-wallet";

function wallet(id: string, name: string, address: string) {
return {
id,
name,
accounts: [{ chainId: "eip155:8453", address }],
};
}

describe("active OWS wallet selection", () => {
beforeEach(() => {
state.wallets = [];
state.settings.clear();
vi.clearAllMocks();
});

it("auto-selects and persists the only recognized PlotLink wallet", async () => {
state.wallets = [
wallet("w1", "plotlink-writer", "0x1111111111111111111111111111111111111111"),
];

const resolved = await resolveActiveWallet();

expect(resolved.selectionRequired).toBe(false);
expect(resolved.activeWallet?.name).toBe("plotlink-writer");
expect(resolved.activeWallet?.address).toBe("0x1111111111111111111111111111111111111111");
expect(resolved.wallets).toEqual([
expect.objectContaining({ name: "plotlink-writer", active: true }),
]);
expect([...state.settings.values()][0]).toContain("plotlink-writer");
});

it("requires selection when multiple recognized wallets exist and no active wallet is stored", async () => {
state.wallets = [
wallet("w1", "plotlink-writer", "0x1111111111111111111111111111111111111111"),
wallet("w2", "plotlink-writer-2", "0x2222222222222222222222222222222222222222"),
];

const resolved = await resolveActiveWallet();

expect(resolved.activeWallet).toBeNull();
expect(resolved.selectionRequired).toBe(true);
expect(resolved.error).toMatch(/Multiple OWS wallets/);
expect(resolved.wallets).toHaveLength(2);
expect(resolved.wallets.every((choice) => choice.active === false)).toBe(true);
});

it("switches and resolves the selected wallet by id", async () => {
state.wallets = [
wallet("w1", "plotlink-writer", "0x1111111111111111111111111111111111111111"),
wallet("w2", "plotlink-writer-2", "0x2222222222222222222222222222222222222222"),
];

const selected = await selectActiveWallet({ walletId: "w2" });
const resolved = await resolveActiveWallet();

expect(selected.activeWallet?.name).toBe("plotlink-writer-2");
expect(resolved.activeWallet?.name).toBe("plotlink-writer-2");
expect(resolved.activeWallet?.address).toBe("0x2222222222222222222222222222222222222222");
expect(resolved.wallets.find((choice) => choice.name === "plotlink-writer-2")?.active).toBe(true);
expect(resolved.wallets.find((choice) => choice.name === "plotlink-writer")?.active).toBe(false);
});

it("generates the next PlotLink writer wallet name without reusing an existing name", () => {
expect(nextPlotlinkWalletName([] as never)).toBe("plotlink-writer");
expect(nextPlotlinkWalletName([
wallet("w1", "plotlink-writer", "0x1111111111111111111111111111111111111111"),
wallet("w2", "plotlink-writer-2", "0x2222222222222222222222222222222222222222"),
] as never)).toBe("plotlink-writer-3");
});
});
260 changes: 260 additions & 0 deletions app/lib/active-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import type { WalletInfo } from "../../lib/ows/types";
import { getBaseAddress, listAgentWallets } from "../../lib/ows/wallet";
import { db } from "../db";

const ACTIVE_WALLET_SETTING_KEY = "activeOwsWallet.v1";
const PLOTLINK_WALLET_PREFIX = "plotlink-writer";

export interface StoredWalletSelection {
walletId?: string;
name?: string;
address?: string;
source: "ows";
label?: string;
}

export interface WalletChoice {
walletId?: string;
name: string;
address?: string;
normalizedAddress?: string;
source: "ows";
label: string;
recognized: boolean;
active: boolean;
}

export interface ActiveWallet {
wallet: WalletInfo;
walletId?: string;
name: string;
address: string;
normalizedAddress: string;
source: "ows";
label: string;
}

export interface PublicActiveWallet {
walletId?: string;
name: string;
address: string;
normalizedAddress: string;
source: "ows";
label: string;
}

export interface ActiveWalletResolution {
activeWallet: ActiveWallet | null;
wallets: WalletChoice[];
selectionRequired: boolean;
error?: string;
}

function normalizeAddress(address: string | undefined): string | undefined {
const trimmed = address?.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}

function getWalletId(wallet: WalletInfo): string | undefined {
const maybeId = (wallet as WalletInfo & { id?: unknown }).id;
return typeof maybeId === "string" && maybeId.trim() ? maybeId : undefined;
}

function toWalletChoice(wallet: WalletInfo, activeSelection?: StoredWalletSelection): WalletChoice {
const address = getBaseAddress(wallet);
const normalizedAddress = normalizeAddress(address);
const walletId = getWalletId(wallet);
const recognized = wallet.name.startsWith(PLOTLINK_WALLET_PREFIX);
return {
walletId,
name: wallet.name,
address: normalizedAddress,
normalizedAddress,
source: "ows",
label: recognized ? "PlotLink writer wallet" : "OWS wallet",
recognized,
active: matchesSelection(wallet, address, activeSelection),
};
}

function matchesSelection(wallet: WalletInfo, address: string | undefined, selection: StoredWalletSelection | null | undefined): boolean {
if (!selection) return false;
const walletId = getWalletId(wallet);
const normalizedAddress = normalizeAddress(address);
const selectedAddress = normalizeAddress(selection.address);
if (selection.walletId && walletId && selection.walletId === walletId) return true;
if (selectedAddress && normalizedAddress && selectedAddress === normalizedAddress) return true;
if (selection.name && selection.name === wallet.name) return true;
return false;
}

function storedSelectionFor(wallet: WalletInfo): StoredWalletSelection {
const address = normalizeAddress(getBaseAddress(wallet));
return {
walletId: getWalletId(wallet),
name: wallet.name,
address,
source: "ows",
label: wallet.name.startsWith(PLOTLINK_WALLET_PREFIX) ? "PlotLink writer wallet" : "OWS wallet",
};
}

async function readStoredSelection(): Promise<StoredWalletSelection | null> {
try {
const row = await db.setting.findUnique({ where: { key: ACTIVE_WALLET_SETTING_KEY } });
if (!row?.value) return null;
const parsed = JSON.parse(row.value) as Partial<StoredWalletSelection>;
if (parsed.source !== "ows") return null;
return {
walletId: typeof parsed.walletId === "string" ? parsed.walletId : undefined,
name: typeof parsed.name === "string" ? parsed.name : undefined,
address: normalizeAddress(parsed.address),
source: "ows",
label: typeof parsed.label === "string" ? parsed.label : undefined,
};
} catch {
return null;
}
}

async function writeStoredSelection(selection: StoredWalletSelection): Promise<void> {
try {
await db.setting.upsert({
where: { key: ACTIVE_WALLET_SETTING_KEY },
create: { key: ACTIVE_WALLET_SETTING_KEY, value: JSON.stringify(selection) },
update: { value: JSON.stringify(selection) },
});
} catch {
// The app can still operate in legacy single-wallet mode if persistence is
// temporarily unavailable; signing never depends on this write succeeding.
}
}

function findSelectedWallet(wallets: WalletInfo[], selection: StoredWalletSelection | null): WalletInfo | null {
if (!selection) return null;
return wallets.find((wallet) => matchesSelection(wallet, getBaseAddress(wallet), selection)) ?? null;
}

function toActiveWallet(wallet: WalletInfo): ActiveWallet | null {
const address = normalizeAddress(getBaseAddress(wallet));
if (!address) return null;
return {
wallet,
walletId: getWalletId(wallet),
name: wallet.name,
address,
normalizedAddress: address,
source: "ows",
label: wallet.name.startsWith(PLOTLINK_WALLET_PREFIX) ? "PlotLink writer wallet" : "OWS wallet",
};
}

export async function listWalletChoices(): Promise<WalletChoice[]> {
const wallets = listAgentWallets();
const selection = await readStoredSelection();
return wallets.map((wallet) => toWalletChoice(wallet, selection));
}

export async function resolveActiveWallet(): Promise<ActiveWalletResolution> {
const wallets = listAgentWallets();
const selection = await readStoredSelection();
const storedWallet = findSelectedWallet(wallets, selection);
const activeFromStored = storedWallet ? toActiveWallet(storedWallet) : null;
if (activeFromStored) {
return {
activeWallet: activeFromStored,
wallets: wallets.map((wallet) => toWalletChoice(wallet, storedSelectionFor(storedWallet))),
selectionRequired: false,
};
}

const evmWallets = wallets.filter((wallet) => Boolean(getBaseAddress(wallet)));
const recognizedWallets = evmWallets.filter((wallet) => wallet.name.startsWith(PLOTLINK_WALLET_PREFIX));
const autoSelected = recognizedWallets.length === 1
? recognizedWallets[0]
: recognizedWallets.length === 0 && evmWallets.length === 1
? evmWallets[0]
: null;

if (autoSelected) {
const stored = storedSelectionFor(autoSelected);
await writeStoredSelection(stored);
return {
activeWallet: toActiveWallet(autoSelected),
wallets: wallets.map((wallet) => toWalletChoice(wallet, stored)),
selectionRequired: false,
};
}

const choices = wallets.map((wallet) => toWalletChoice(wallet, null));
const hasSelectableWallets = evmWallets.length > 0;
return {
activeWallet: null,
wallets: choices,
selectionRequired: hasSelectableWallets,
error: hasSelectableWallets
? "Multiple OWS wallets found. Select an active wallet before publishing or signing."
: "No OWS wallet found",
};
}

export async function selectActiveWallet(input: { walletId?: string; name?: string; address?: string }): Promise<ActiveWalletResolution> {
const wallets = listAgentWallets();
const normalizedInputAddress = normalizeAddress(input.address);
const selected = wallets.find((wallet) => {
const walletId = getWalletId(wallet);
const address = normalizeAddress(getBaseAddress(wallet));
if (input.walletId && walletId && walletId === input.walletId) return true;
if (normalizedInputAddress && address && address === normalizedInputAddress) return true;
if (input.name && wallet.name === input.name) return true;
return false;
});

if (!selected) {
return {
activeWallet: null,
wallets: wallets.map((wallet) => toWalletChoice(wallet, null)),
selectionRequired: true,
error: "Selected OWS wallet was not found",
};
}

const activeWallet = toActiveWallet(selected);
if (!activeWallet) {
return {
activeWallet: null,
wallets: wallets.map((wallet) => toWalletChoice(wallet, null)),
selectionRequired: true,
error: "Selected OWS wallet has no EVM address",
};
}

const stored = storedSelectionFor(selected);
await writeStoredSelection(stored);
return {
activeWallet,
wallets: wallets.map((wallet) => toWalletChoice(wallet, stored)),
selectionRequired: false,
};
}

export function nextPlotlinkWalletName(wallets: WalletInfo[]): string {
const names = new Set(wallets.map((wallet) => wallet.name));
if (!names.has(PLOTLINK_WALLET_PREFIX)) return PLOTLINK_WALLET_PREFIX;
for (let index = 2; index < 1000; index += 1) {
const name = `${PLOTLINK_WALLET_PREFIX}-${index}`;
if (!names.has(name)) return name;
}
return `${PLOTLINK_WALLET_PREFIX}-${Date.now()}`;
}

export function toPublicActiveWallet(wallet: ActiveWallet): PublicActiveWallet {
return {
walletId: wallet.walletId,
name: wallet.name,
address: wallet.address,
normalizedAddress: wallet.normalizedAddress,
source: wallet.source,
label: wallet.label,
};
}
10 changes: 6 additions & 4 deletions app/routes/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { base } from "viem/chains";
import fs from "fs";
import path from "path";
import { getEthBalance } from "../lib/publish";
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
import { resolveActiveWallet } from "../lib/active-wallet";
import { mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
import { STORIES_DIR, readPublishStatus } from "./stories";

Expand Down Expand Up @@ -82,10 +82,10 @@ dashboard.get("/", async (c) => {
// Get wallet info
let walletInfo = null;
try {
const wallets = listAgentWallets();
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
const resolvedWallet = await resolveActiveWallet();
const wallet = resolvedWallet.activeWallet;
if (wallet) {
const address = getBaseAddress(wallet);
const address = wallet.address;
if (address) {
const ethBalance = await getEthBalance(address);

Expand All @@ -107,6 +107,8 @@ dashboard.get("/", async (c) => {
} catch { /* best effort */ }

walletInfo = {
walletId: wallet.walletId,
name: wallet.name,
address,
ethBalance: ethBalance.toString(),
ethFormatted: (Number(ethBalance) / 1e18).toFixed(6),
Expand Down
Loading
Loading