From a8fb771e7257377e061a52fa9aadda351e8d725d Mon Sep 17 00:00:00 2001 From: daniella-techie Date: Sun, 27 Sep 2026 11:44:25 +0100 Subject: [PATCH] fix(achievements): key wallets case-insensitively in achievement store (#279) Canonicalize wallet keys to uppercase on every read/write so G... and g... addresses resolve to a single entry, and merge legacy mixed-case entries on read. --- lib/achievement-store.ts | 50 +++++++++++++++++++---- tests/achievement-wallet-case.test.ts | 58 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 tests/achievement-wallet-case.test.ts diff --git a/lib/achievement-store.ts b/lib/achievement-store.ts index 1faf613d..e7c09660 100644 --- a/lib/achievement-store.ts +++ b/lib/achievement-store.ts @@ -51,14 +51,49 @@ const ACHIEVEMENT_NAMES: Record = { phaselq_100: "PHASELQ ×100", }; +/** Stellar G-addresses are case-insensitive on input; store them under one canonical uppercase key. */ +function walletKey(wallet: string): string { + return wallet.trim().toUpperCase(); +} + +function mergeEntries( + a: WalletAchievements, + b: WalletAchievements, +): WalletAchievements { + const byId = new Map(); + for (const ach of [...a.unlocked, ...b.unlocked]) { + const prev = byId.get(ach.id); + if (!prev || ach.unlocked_at < prev.unlocked_at) byId.set(ach.id, ach); + } + const max = (x?: number, y?: number) => + x === undefined ? y : y === undefined ? x : Math.max(x, y); + const latest = (b.last_daily ?? 0) > (a.last_daily ?? 0) ? b : a; + return { + unlocked: [...byId.values()], + mint_count: max(a.mint_count, b.mint_count), + daily_streak: latest.daily_streak, + last_daily: latest.last_daily, + total_upvotes: max(a.total_upvotes, b.total_upvotes), + follower_count: max(a.follower_count, b.follower_count), + narrator_count: max(a.narrator_count, b.narrator_count), + }; +} + async function readStore(): Promise { + let raw: AchievementStore; try { - return JSON.parse( + raw = JSON.parse( await readFile(serverDataJsonPath("achievements"), "utf8"), ) as AchievementStore; } catch { return {}; } + const store: AchievementStore = {}; + for (const [wallet, entry] of Object.entries(raw)) { + const key = walletKey(wallet); + store[key] = store[key] ? mergeEntries(store[key]!, entry) : entry; + } + return store; } async function writeStore(data: AchievementStore): Promise { @@ -71,20 +106,21 @@ function ensureEntry( store: AchievementStore, wallet: string, ): WalletAchievements { - if (!store[wallet]) store[wallet] = { unlocked: [] }; - return store[wallet]!; + const key = walletKey(wallet); + if (!store[key]) store[key] = { unlocked: [] }; + return store[key]!; } export async function getAchievements(wallet: string): Promise { const store = await readStore(); - return store[wallet]?.unlocked ?? []; + return store[walletKey(wallet)]?.unlocked ?? []; } export async function getWalletData( wallet: string, ): Promise { const store = await readStore(); - return store[wallet] ?? { unlocked: [] }; + return store[walletKey(wallet)] ?? { unlocked: [] }; } export async function unlockAchievement( @@ -96,7 +132,7 @@ export async function unlockAchievement( const entry = ensureEntry(store, wallet); if (entry.unlocked.some((a) => a.id === id)) return false; // idempotent entry.unlocked.push({ id, unlocked_at: Date.now(), tx_evidence: evidence }); - store[wallet] = entry; + store[walletKey(wallet)] = entry; await writeStore(store); // Notify (fire-and-forget) void createNotification(wallet, "achievement_unlocked", { @@ -190,7 +226,7 @@ export async function checkAndUnlock( await tryUnlock("phaselq_100"); } - store[wallet] = entry; + store[walletKey(wallet)] = entry; await writeStore(store); return newUnlocks; } diff --git a/tests/achievement-wallet-case.test.ts b/tests/achievement-wallet-case.test.ts new file mode 100644 index 00000000..7f70a49d --- /dev/null +++ b/tests/achievement-wallet-case.test.ts @@ -0,0 +1,58 @@ +/** + * #279: achievement-store must key wallets case-insensitively (G... vs g...) + * Run: npx tsx tests/achievement-wallet-case.test.ts + */ +import assert from "node:assert/strict" +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const WALLET = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + +async function main() { + const dir = await mkdtemp(path.join(os.tmpdir(), "phase-ach-")) + process.env.PHASE_SERVER_DATA_DIR = dir + const file = path.join(dir, "achievements.json") + + const { checkAndUnlock, getAchievements, getWalletData, unlockAchievement } = + await import("@/lib/achievement-store") + + // Writes via lowercase and uppercase land in one entry + await unlockAchievement(WALLET.toLowerCase(), "first_mint") + assert.equal(await unlockAchievement(WALLET, "first_mint"), false) + await checkAndUnlock(` ${WALLET.toLowerCase()} `, { signal_posted: true }) + const stored = JSON.parse(await readFile(file, "utf8")) + assert.deepEqual(Object.keys(stored), [WALLET]) + assert.deepEqual( + (await getAchievements(WALLET.toLowerCase())).map((a) => a.id).sort(), + ["first_mint", "signal_pioneer"], + ) + console.log("✓ G... and g... resolve to one entry") + + // Legacy split entries are merged on read + await writeFile( + file, + JSON.stringify({ + [WALLET]: { unlocked: [{ id: "first_mint", unlocked_at: 200 }], mint_count: 2 }, + [WALLET.toLowerCase()]: { + unlocked: [ + { id: "first_mint", unlocked_at: 100 }, + { id: "collector_5", unlocked_at: 150 }, + ], + mint_count: 5, + }, + }), + ) + const merged = await getWalletData(WALLET) + assert.equal(merged.mint_count, 5) + assert.deepEqual( + merged.unlocked.map((a) => [a.id, a.unlocked_at]).sort(), + [["collector_5", 150], ["first_mint", 100]], + ) + console.log("✓ legacy mixed-case entries merge") +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})