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 apps/web/lib/__tests__/huntStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import {
saveCluesLocallyBatch,
setLocalFeaturedHunt,
startHuntProgress,
advanceHuntProgress,
clearHuntProgress,
gcHunt,
migrateGuestProgressToWallet,
getWalletProgressKey,
MAX_CLUES_PER_HUNT,
takeHuntStoreSnapshot,
updateClueAnswer,
updateHuntEndTime,
Expand Down Expand Up @@ -1295,6 +1301,24 @@ describe("huntStore", () => {
clearHuntProgress(9777);
expect(localStorage.getItem("hunty_hunt_progress_9777")).toBeNull();
});

it("migrates guest progress into a wallet-scoped key and stays idempotent", () => {
const guestProgress = startHuntProgress(8765);
const walletAddress = "GABC1234567890";

const migrated = migrateGuestProgressToWallet(8765, walletAddress);

expect(migrated).toEqual({
...guestProgress,
currentClueIndex: guestProgress.currentClueIndex,
});
expect(localStorage.getItem(getWalletProgressKey(8765, walletAddress))).toBeTruthy();
expect(localStorage.getItem("hunty_hunt_progress_8765")).toBeNull();

const duplicate = migrateGuestProgressToWallet(8765, walletAddress);
expect(duplicate).toEqual(migrated);
expect(localStorage.getItem(getWalletProgressKey(8765, walletAddress))).toBeTruthy();
});
});

describe("gcHunt", () => {
Expand Down
71 changes: 71 additions & 0 deletions apps/web/lib/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import {
useRef,
} from "react";

import { useIsMounted } from "@/hooks/useIsMounted"
import { migrateGuestProgressToWallet } from "@/lib/huntStore"
import {
clearStoredWalletSession,
connectWalletProvider,
getStoredWalletSession,
setStoredWalletSession,
type WalletProvider,
} from "@/lib/walletAdapter"
import { useWalletStore } from "@/lib/wallets/walletStore"
import { truncateAddress } from "@/lib/walletAddress"
import { truncateAddress } from "@/lib/walletAddress";
import { useWalletMachine } from "@/lib/wallet/walletMachine";
import { useWalletStore } from "@/lib/wallets/walletStore";
Expand Down Expand Up @@ -82,6 +93,66 @@ export function WalletProvider({ children }: { children: ReactNode }) {
useLegacyWalletStore.getState().clearWallet();
usePlayerStore.getState().clearProgress();
}
}, [mounted])

/**
* Trigger wallet popup to request wallet access.
* requestAccess() prompts if not yet on the allow list,
* or returns immediately if the user already approved this app.
*/
const connect = useCallback(async (provider: WalletProvider = "freighter"): Promise<{ error?: string }> => {
try {
if (provider === "freighter") {
const connResult = await isConnected()
if (!connResult.isConnected) {
return {
error:
"Freighter extension not found. Please install it from freighter.app",
}
}

// requestAccess() returns { address: string, error?: string }
// error is a plain string per the Freighter API docs
const accessResult = await requestAccess()

if (accessResult.error) {
return { error: String(accessResult.error) }
}

const address = accessResult.address
if (!address) {
return { error: "No public key returned. Please try again." }
}

setStoredWalletSession("freighter", address)
localStorage.setItem(STORAGE_KEY, address)
migrateGuestProgressToWallet(address)
setPublicKey(address)
setWalletProvider("freighter")
setConnected(true)
storeSetConnected(address, "freighter")
return {}
}

const address = await connectWalletProvider(provider)
setStoredWalletSession(provider, address)
localStorage.setItem(STORAGE_KEY, address)
migrateGuestProgressToWallet(address)
setPublicKey(address)
setWalletProvider(provider)
setConnected(true)
storeSetConnected(address, provider)
return {}
} catch (err) {
return {
error:
err instanceof Error
? err.message
: "Unexpected error during connection.",
}
}
}, [storeSetConnected])

}, [status, publicKey, provider, error, storeSync]);

// ── Connect wrapper (matches existing interface) ───────────────────
Expand Down
209 changes: 195 additions & 14 deletions apps/web/lib/huntStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,25 +210,198 @@ function getProgressKey(huntId: number): string {
return `${HUNT_PROGRESS_KEY_PREFIX}${huntId}`;
}

function readProgressEntry(huntId: number): HuntProgressSnapshot | null {
export function getWalletProgressKey(
huntId: number,
walletAddress: string,
): string {
if (!walletAddress?.trim()) {
return getProgressKey(huntId);
}

const scope = walletAddress.trim().toLowerCase();
return `hunty_hunt_progress_wallet_${scope}_${huntId}`;
}

function getWalletAddressFromStorage(): string | null {
if (typeof window === "undefined") return null;

const directKeys = [
"freighter_public_key",
"wallet_public_key",
"hunty_wallet_public_key",
];

for (const key of directKeys) {
const value = localStorage.getItem(key)?.trim();
if (value) return value;
}

const walletStoreRaw = localStorage.getItem("hunty_wallet_store");
if (!walletStoreRaw) return null;

try {
const parsed = JSON.parse(walletStoreRaw) as {
state?: { publicKey?: string };
publicKey?: string;
};
const value = parsed?.state?.publicKey ?? parsed?.publicKey;
if (typeof value === "string" && value.trim()) return value.trim();
} catch {
// ignore malformed wallet-store payloads and fall back to legacy keys
}

return null;
}

function resolveProgressStorageKey(huntId: number, walletAddress?: string | null) {
const activeWalletAddress = walletAddress?.trim() || getWalletAddressFromStorage();
return activeWalletAddress
? getWalletProgressKey(huntId, activeWalletAddress)
: getProgressKey(huntId);
}

function readProgressEntry(
huntId: number,
walletAddress?: string | null,
): HuntProgressSnapshot | null {
if (typeof window === "undefined") return null;

const activeWalletAddress = walletAddress?.trim() || getWalletAddressFromStorage();
const walletKey = activeWalletAddress
? getWalletProgressKey(huntId, activeWalletAddress)
: null;

try {
const raw = localStorage.getItem(getProgressKey(huntId));
return raw ? (JSON.parse(raw) as HuntProgressSnapshot) : null;
if (walletKey) {
const walletRaw = localStorage.getItem(walletKey);
if (walletRaw) {
return JSON.parse(walletRaw) as HuntProgressSnapshot;
}
}

const legacyRaw = localStorage.getItem(getProgressKey(huntId));
return legacyRaw ? (JSON.parse(legacyRaw) as HuntProgressSnapshot) : null;
} catch {
return null;
}
}

function writeProgressEntry(progress: HuntProgressSnapshot): void {
function writeProgressEntry(
progress: HuntProgressSnapshot,
walletAddress?: string | null,
): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(
resolveProgressStorageKey(progress.huntId, walletAddress),
JSON.stringify(progress),
);
localStorage.setItem(getProgressKey(progress.huntId), JSON.stringify(progress));
} catch {
// ignore
}
}

function mergeProgressSnapshots(
current: HuntProgressSnapshot,
incoming: HuntProgressSnapshot,
): HuntProgressSnapshot {
const currentClueIndex = Math.max(
current.currentClueIndex,
incoming.currentClueIndex,
);
const startedAt = Math.min(current.startedAt || incoming.startedAt, incoming.startedAt || current.startedAt || Date.now());
const completed = current.completed || incoming.completed;
const completedAt =
current.completedAt && incoming.completedAt
? new Date(
Math.max(new Date(current.completedAt).getTime(), new Date(incoming.completedAt).getTime()),
).getTime()
: current.completedAt ?? incoming.completedAt;

return {
...current,
...incoming,
huntId: current.huntId || incoming.huntId,
currentClueIndex,
startedAt,
completed,
completedAt,
};
}

export function migrateGuestProgressToWallet(
walletAddressOrHuntId: string | number,
walletAddressOrHuntIdValue?: string | number | null,
): HuntProgressSnapshot | null {
if (typeof window === "undefined") return null;

const isWalletArgumentFirst = typeof walletAddressOrHuntId === "string";
const walletAddress = (isWalletArgumentFirst
? walletAddressOrHuntId
: String(walletAddressOrHuntIdValue ?? ""))
.trim();
const huntId = isWalletArgumentFirst
? Number(walletAddressOrHuntIdValue ?? NaN)
: Number(walletAddressOrHuntId);

if (!walletAddress) {
return null;
}

const hasSpecificHunt = Number.isFinite(huntId);
const guestKeys = hasSpecificHunt
? [getProgressKey(huntId)]
: Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index))
.filter(
(key): key is string =>
typeof key === "string" && key.startsWith(HUNT_PROGRESS_KEY_PREFIX),
)
.filter((key) => !key.includes("wallet_"));

let lastMigrated: HuntProgressSnapshot | null = null;

for (const guestKey of guestKeys) {
const guestHuntId = Number(guestKey.replace(HUNT_PROGRESS_KEY_PREFIX, ""));
if (!Number.isFinite(guestHuntId)) continue;

const guestProgress = (() => {
try {
const raw = localStorage.getItem(guestKey);
return raw ? (JSON.parse(raw) as HuntProgressSnapshot) : null;
} catch {
return null;
}
})();

if (!guestProgress) continue;

const walletKey = getWalletProgressKey(guestHuntId, walletAddress);
const storedWalletProgress = (() => {
try {
const raw = localStorage.getItem(walletKey);
return raw ? (JSON.parse(raw) as HuntProgressSnapshot) : null;
} catch {
return null;
}
})();

const mergedProgress = storedWalletProgress
? mergeProgressSnapshots(storedWalletProgress, guestProgress)
: guestProgress;

localStorage.setItem(walletKey, JSON.stringify(mergedProgress));
localStorage.removeItem(guestKey);
lastMigrated = mergedProgress;

if (hasSpecificHunt) {
return mergedProgress;
}
}

return lastMigrated;
}

function measureStorageEntrySize(key: string, value: string): number {
return new TextEncoder().encode(`${key}:${value}`).length;
}
Expand Down Expand Up @@ -735,8 +908,11 @@ export function updateClueAnswer(huntId: number, clueId: number, answer: string)
}

/** Reads the current per-hunt progress snapshot. */
export function getHuntProgress(huntId: number): HuntProgressSnapshot {
const existing = readProgressEntry(huntId);
export function getHuntProgress(
huntId: number,
walletAddress?: string | null,
): HuntProgressSnapshot {
const existing = readProgressEntry(huntId, walletAddress);
if (existing) {
return existing;
}
Expand All @@ -747,43 +923,48 @@ export function getHuntProgress(huntId: number): HuntProgressSnapshot {
startedAt: Date.now(),
completed: false,
};
writeProgressEntry(initial);
writeProgressEntry(initial, walletAddress);
return initial;
}

/** Records that a hunt has started for the current browser session. */
export function startHuntProgress(huntId: number): HuntProgressSnapshot {
const current = getHuntProgress(huntId);
export function startHuntProgress(
huntId: number,
walletAddress?: string | null,
): HuntProgressSnapshot {
const current = getHuntProgress(huntId, walletAddress);
const next: HuntProgressSnapshot = {
...current,
startedAt: current.startedAt || Date.now(),
};
writeProgressEntry(next);
writeProgressEntry(next, walletAddress);
return next;
}

/** Advances the tracked progress to the next clue index. */
export function advanceHuntProgress(
huntId: number,
nextClueIndex: number,
totalClues: number,
walletAddress?: string | null,
totalClues: number
): HuntProgressSnapshot {
const current = getHuntProgress(huntId);
const current = getHuntProgress(huntId, walletAddress);
const completed = nextClueIndex >= totalClues;
const next: HuntProgressSnapshot = {
...current,
currentClueIndex: Math.max(current.currentClueIndex, nextClueIndex),
completed,
completedAt: completed ? Date.now() : current.completedAt,
};
writeProgressEntry(next);
writeProgressEntry(next, walletAddress);
return next;
}

/** Clears the tracked hunt progress for the current browser session. */
export function clearHuntProgress(huntId: number): void {
export function clearHuntProgress(huntId: number, walletAddress?: string | null): void {
if (typeof window === "undefined") return;
localStorage.removeItem(getProgressKey(huntId));
localStorage.removeItem(resolveProgressStorageKey(huntId, walletAddress));
}

/**
Expand Down
Loading