Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/utils/key-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";

import { keyValueKeys, SqliteKeyValueStore } from "./key-value";

describe("SqliteKeyValueStore", () => {
test("stores and loads typed values", async () => {
const database = new Database(":memory:");
const store = new SqliteKeyValueStore(database);
const key = keyValueKeys.npcSyncSince("https://npubx.cash", "pubkey-1");

await store.set(key, 1234);

await expect(store.get(key)).resolves.toBe(1234);
});

test("returns null for missing keys", async () => {
const database = new Database(":memory:");
const store = new SqliteKeyValueStore(database);
const key = keyValueKeys.npcSyncSince("https://npubx.cash", "pubkey-2");

await expect(store.get(key)).resolves.toBeNull();
});
});
93 changes: 93 additions & 0 deletions src/utils/key-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Database } from "bun:sqlite";

export interface KeyValueKey<T> {
key: string;
parse(value: string): T;
serialize(value: T): string;
}

export interface KeyValueEntry<T> {
get(): Promise<T | null>;
set(value: T): Promise<void>;
}

export interface RequiredKeyValueEntry<T> {
get(): Promise<T>;
set(value: T): Promise<void>;
}

function createIntegerKey(key: string): KeyValueKey<number> {
return {
key,
parse(value: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error(`Invalid integer value for key '${key}'`);
}
return parsed;
},
serialize(value: number): string {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`Invalid integer value for key '${key}'`);
}
return value.toString();
},
};
}

export const keyValueKeys = {
npcSyncSince(baseUrl: string, pubkey: string): KeyValueKey<number> {
return createIntegerKey(`npc.syncSince:${baseUrl}:${pubkey}`);
},
} as const;

export function withDefault<T>(
entry: KeyValueEntry<T>,
fallbackValue: T,
): RequiredKeyValueEntry<T> {
return {
get: async () => (await entry.get()) ?? fallbackValue,
set: entry.set,
};
}

export class SqliteKeyValueStore {
constructor(private readonly database: Database) {
this.database.run(`
CREATE TABLE IF NOT EXISTS cocod_key_value (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
}

async get<T>(key: KeyValueKey<T>): Promise<T | null> {
const row = this.database
.query("SELECT value FROM cocod_key_value WHERE key = ?1")
.get(key.key) as { value: string } | null;

if (!row) {
return null;
}

return key.parse(row.value);
}

async set<T>(key: KeyValueKey<T>, value: T): Promise<void> {
this.database.run(
`
INSERT INTO cocod_key_value (key, value)
VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`,
[key.key, key.serialize(value)],
);
}

entry<T>(key: KeyValueKey<T>): KeyValueEntry<T> {
return {
get: () => this.get(key),
set: (value: T) => this.set(key, value),
};
}
}
17 changes: 13 additions & 4 deletions src/utils/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { Database } from "bun:sqlite";
import { mnemonicToSeedSync } from "@scure/bip39";
import { NPCPlugin } from "coco-cashu-plugin-npc";
import { privateKeyFromSeedWords } from "nostr-tools/nip06";
import { finalizeEvent, type EventTemplate } from "nostr-tools";
import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools";
import { decryptMnemonic } from "./crypto.js";
import { SALT_FILE, DB_FILE } from "./config.js";
import { keyValueKeys, SqliteKeyValueStore, withDefault } from "./key-value.js";
import type { WalletConfig } from "./config.js";

export async function initializeWallet(
Expand All @@ -27,15 +28,23 @@ export async function initializeWallet(
}

const seed = mnemonicToSeedSync(mnemonic);

const repo = new SqliteRepositories({ database: new Database(DB_FILE) });
const database = new Database(DB_FILE);
const repo = new SqliteRepositories({ database });
const keyValueStore = new SqliteKeyValueStore(database);
const walletLogger = logger?.child?.({ component: "coco" }) ?? logger;
const cocoLogger = walletLogger ?? new ConsoleLogger("Coco", { level: "info" });
const npcBaseUrl = "https://npubx.cash";
const sk = privateKeyFromSeedWords(mnemonic);
const pubkey = getPublicKey(sk);
const signer = async (t: EventTemplate) => finalizeEvent(t, sk);
const npcPlugin = new NPCPlugin("https://npubx.cash", signer, {
const sinceStore = withDefault(
keyValueStore.entry(keyValueKeys.npcSyncSince(npcBaseUrl, pubkey)),
0,
);
const npcPlugin = new NPCPlugin(npcBaseUrl, signer, {
useWebsocket: true,
logger: cocoLogger,
sinceStore,
});
const coco = await initializeCoco({
repo,
Expand Down
Loading