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
8 changes: 8 additions & 0 deletions context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ALBEDO_ID, FREIGHTER_ID, LOBSTR_ID, RABET_ID, XBULL_ID } from "@creit.tech/stellar-wallets-kit";
import { getKit } from "@/constants/wallet-kits.constant";
import { clearIntentsForWallet } from "@/lib/transaction/intent";

export type WalletErrorKind = "unauthenticated" | "forbidden" | "user_rejected" | "wallet_locked" | "identity_changed" | "validation" | "network" | "conflict" | "unknown";
export type WalletOperationKind = "connect" | "disconnect" | "reconcile";
Expand Down Expand Up @@ -94,8 +95,12 @@ export function WalletProvider({ children }: { children: ReactNode }) {
const identityRef = useRef({ address: null as string | null, generation: 0 });

const updateIdentity = useCallback((nextAddress: string | null, nextName: string | null) => {
const previousAddress = identityRef.current.address;
const nextGeneration = identityRef.current.generation + 1;
identityRef.current = { address: nextAddress, generation: nextGeneration };
// Leaving an identity behind (disconnect or account switch) invalidates any
// signed intents persisted for that address.
if (previousAddress && previousAddress !== nextAddress) clearIntentsForWallet(previousAddress);
setAddress(nextAddress);
setName(nextName);
setConnected(Boolean(nextAddress && nextName));
Expand Down Expand Up @@ -161,7 +166,9 @@ export function WalletProvider({ children }: { children: ReactNode }) {
try {
await getKit().disconnect();
if (!isOperationCurrent(operation)) return conflictFailure(operation.id);
const disconnectedAddress = identityRef.current.address;
updateIdentity(null, null);
if (disconnectedAddress) clearIntentsForWallet(disconnectedAddress);
return { success: true, operationId: operation.id };
} catch (error: unknown) {
if (!isOperationCurrent(operation)) return conflictFailure(operation.id);
Expand Down Expand Up @@ -195,6 +202,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
const result = await kit.getAddress();
if (!mounted || !isOperationCurrent(operation)) return;
if (result.address !== persisted.address || !isValidStellarAddress(result.address)) {
clearIntentsForWallet(persisted.address);
localStorage.removeItem(WALLET_STORAGE_KEY);
setOperationError({ success: false, error: "The active wallet account changed. Connect it again to continue.", errorKind: "identity_changed", operationId: operation.id });
return;
Expand Down
39 changes: 39 additions & 0 deletions context/__tests__/WalletContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { WalletProvider, useWalletContext, walletStateValidation } from "@/context/WalletContext";
import { upsertIntent, listIntents } from "@/lib/transaction/intent";

jest.mock("@creit.tech/stellar-wallets-kit", () => ({
ALBEDO_ID: "albedo",
Expand Down Expand Up @@ -80,6 +81,44 @@ describe("WalletProvider operation coordinator", () => {
expect(hook.result.current.identityGeneration).toBeGreaterThan(connectedGeneration);
});

it("purges persisted intents for the wallet on disconnect", async () => {
mockGetAddress.mockResolvedValue({ address: ADDRESS_A });
mockDisconnect.mockResolvedValue(undefined);
const hook = await renderWallet();
await act(async () => { await hook.result.current.connectWallet("freighter"); });
upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "signed-envelope" });
expect(listIntents()).toHaveLength(1);

await act(async () => { await hook.result.current.disconnectWallet(); });

expect(listIntents()).toHaveLength(0);
expect(localStorage.getItem("predictify:intents:v1") ?? "").not.toContain(ADDRESS_A);
});

it("purges the previous wallet intents when the active identity changes", async () => {
mockGetAddress.mockResolvedValueOnce({ address: ADDRESS_A });
const hook = await renderWallet();
await act(async () => { await hook.result.current.connectWallet("freighter"); });
upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "s" });

mockGetAddress.mockResolvedValueOnce({ address: ADDRESS_B });
await act(async () => { await hook.result.current.connectWallet("lobstr"); });

expect(hook.result.current.address).toBe(ADDRESS_B);
expect(listIntents().some((i) => i.walletAddress === ADDRESS_A)).toBe(false);
});

it("purges intents for a persisted wallet that fails reconciliation", async () => {
localStorage.setItem("predictify_wallet_state", JSON.stringify({ address: ADDRESS_A, name: "Freighter", connected: true }));
upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "s" });
mockGetAddress.mockResolvedValue({ address: ADDRESS_B });

const hook = await renderWallet();

expect(hook.result.current.operationError?.errorKind).toBe("identity_changed");
expect(listIntents().some((i) => i.walletAddress === ADDRESS_A)).toBe(false);
});

it("classifies a rejected provider request without logging the raw error", async () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
mockGetAddress.mockRejectedValue(new Error(`User rejected ${ADDRESS_A}`));
Expand Down
106 changes: 105 additions & 1 deletion lib/transaction/__tests__/intent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
import { computeXdrHash, upsertIntent, getIntent, removeIntent, listIntents, clearAllIntents } from '../intent';
import {
computeXdrHash,
upsertIntent,
getIntent,
removeIntent,
listIntents,
clearAllIntents,
clearIntentsForWallet,
} from '../intent';

const STORAGE_KEY = 'predictify:intents:v1';
const TTL_MS = 24 * 60 * 60 * 1000;

function readRaw(): Record<string, unknown> {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, unknown>;
}

function buildRecord(overrides: Record<string, unknown> = {}) {
const time = Date.now();
return {
key: 'k',
walletAddress: 'GABC',
xdrHash: 'hash',
status: 'built',
createdAt: time,
updatedAt: time,
...overrides,
};
}

describe('intent store', () => {
beforeEach(() => {
Expand Down Expand Up @@ -28,4 +56,80 @@ describe('intent store', () => {
removeIntent(key);
expect(getIntent(key)).toBeUndefined();
});

it('discards records with a missing or wrongly typed status', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'missing-status': buildRecord({ key: 'missing-status', status: undefined }),
'typed-status': buildRecord({ key: 'typed-status', status: 42 }),
'unknown-status': buildRecord({ key: 'unknown-status', status: 'processing' }),
valid: buildRecord({ key: 'valid' }),
}));

expect(listIntents().map((i) => i.key)).toEqual(['valid']);
expect(getIntent('missing-status')).toBeUndefined();

const raw = readRaw();
expect(raw['missing-status']).toBeUndefined();
expect(raw['typed-status']).toBeUndefined();
expect(raw['unknown-status']).toBeUndefined();
expect(raw.valid).toBeDefined();
});

it('discards records with a missing or wrongly typed walletAddress', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'missing-wallet': buildRecord({ key: 'missing-wallet', walletAddress: undefined }),
'typed-wallet': buildRecord({ key: 'typed-wallet', walletAddress: 7 }),
valid: buildRecord({ key: 'valid' }),
}));

expect(listIntents().map((i) => i.key)).toEqual(['valid']);
const raw = readRaw();
expect(raw['missing-wallet']).toBeUndefined();
expect(raw['typed-wallet']).toBeUndefined();
});

it('prunes expired intents on the first read and persists the pruning', () => {
const stale = Date.now() - TTL_MS - 1;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
fresh: buildRecord({ key: 'fresh' }),
stale: buildRecord({ key: 'stale', updatedAt: stale }),
}));

expect(listIntents().map((i) => i.key)).toEqual(['fresh']);
expect(readRaw().stale).toBeUndefined();
expect(getIntent('stale')).toBeUndefined();
});

it('drops signedXdr as soon as a submissionHash exists', () => {
const key = 'submit:1';
upsertIntent({ key, walletAddress: 'GABC', xdrHash: 'h', status: 'signed', signedXdr: 'signed-envelope' });
expect(getIntent(key)?.signedXdr).toBe('signed-envelope');

upsertIntent({ key, status: 'submitted', submissionHash: 'tx-hash' });
const submitted = getIntent(key);
expect(submitted?.submissionHash).toBe('tx-hash');
expect(submitted?.signedXdr).toBeUndefined();
});

it('strips a persisted signedXdr when a submissionHash is already present', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
legacy: buildRecord({ key: 'legacy', signedXdr: 'leftover-envelope', submissionHash: 'tx-hash' }),
}));

const record = getIntent('legacy');
expect(record?.signedXdr).toBeUndefined();
expect((readRaw().legacy as Record<string, unknown>).signedXdr).toBeUndefined();
});

it('clears only the intents belonging to the given wallet', () => {
upsertIntent({ key: 'a1', walletAddress: 'GAAA', xdrHash: 'h1', status: 'built' });
upsertIntent({ key: 'a2', walletAddress: 'GAAA', xdrHash: 'h2', status: 'signed', signedXdr: 's' });
upsertIntent({ key: 'b1', walletAddress: 'GBBB', xdrHash: 'h3', status: 'built' });

clearIntentsForWallet('GAAA');

expect(getIntent('a1')).toBeUndefined();
expect(getIntent('a2')).toBeUndefined();
expect(getIntent('b1')).toBeDefined();
});
});
98 changes: 83 additions & 15 deletions lib/transaction/intent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from 'zod';

export type IntentStatus =
| 'built'
| 'signed'
Expand All @@ -22,15 +24,68 @@ export interface IntentRecord {
const STORAGE_KEY = 'predictify:intents:v1';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

const INTENT_STATUSES = ['built', 'signed', 'submitted', 'confirming', 'success', 'failed'] as const;

const intentRecordSchema = z.object({
key: z.string(),
walletAddress: z.string(),
xdrHash: z.string(),
status: z.enum(INTENT_STATUSES),
builtXdr: z.string().optional(),
signedXdr: z.string().optional(),
submissionHash: z.string().optional(),
error: z.string().optional(),
createdAt: z.number().finite(),
updatedAt: z.number().finite(),
});

function now() {
return Date.now();
}

interface SanitizedStore {
map: Record<string, IntentRecord>;
changed: boolean;
}

// Validate every persisted record and drop malformed or expired entries so a
// tampered localStorage payload can never reach consumers such as useTransaction.
function sanitizeStore(value: unknown): SanitizedStore {
const map: Record<string, IntentRecord> = {};
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { map, changed: true };
}
let changed = false;
const current = now();
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const parsed = intentRecordSchema.safeParse(entry);
if (!parsed.success) {
changed = true;
continue;
}
const record = parsed.data;
if (current - record.updatedAt > DEFAULT_TTL_MS) {
changed = true;
continue;
}
// A signed envelope must not outlive the submission it belongs to.
if (record.submissionHash && record.signedXdr !== undefined) {
delete record.signedXdr;
changed = true;
}
map[key] = record;
}
return { map, changed };
}

function safeGetStorage(): Record<string, IntentRecord> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, IntentRecord>;
const parsed = JSON.parse(raw);
const { map, changed } = sanitizeStore(parsed);
if (changed) safeSetStorage(map);
return map;
} catch (err) {
console.debug('intent: failed to read storage', err);
return {};
Expand Down Expand Up @@ -64,40 +119,40 @@ export async function computeXdrHash(xdr: string): Promise<string> {

export function getIntent(key: string): IntentRecord | undefined {
const map = safeGetStorage();
const item = map[key];
if (!item) return undefined;
// expire stale entries
if (now() - item.updatedAt > DEFAULT_TTL_MS) {
removeIntent(key);
return undefined;
}
return item;
return map[key];
}

export function listIntents(): IntentRecord[] {
const map = safeGetStorage();
return Object.values(map).filter((i) => now() - i.updatedAt <= DEFAULT_TTL_MS);
return Object.values(map);
}

export function upsertIntent(partial: Partial<IntentRecord> & { key: string; walletAddress?: string; xdrHash?: string; }) {
const map = safeGetStorage();
const existing = map[partial.key];
const time = now();
const submissionHash = partial.submissionHash ?? existing?.submissionHash;
const merged: IntentRecord = {
key: partial.key,
walletAddress: partial.walletAddress ?? existing?.walletAddress ?? '',
xdrHash: partial.xdrHash ?? existing?.xdrHash ?? '',
status: (partial as any).status ?? existing?.status ?? 'built',
status: partial.status ?? existing?.status ?? 'built',
builtXdr: partial.builtXdr ?? existing?.builtXdr,
signedXdr: partial.signedXdr ?? existing?.signedXdr,
submissionHash: partial.submissionHash ?? existing?.submissionHash,
// Once a submission hash exists the signed envelope is no longer required.
signedXdr: submissionHash ? undefined : (partial.signedXdr ?? existing?.signedXdr),
submissionHash,
error: partial.error ?? existing?.error,
createdAt: existing?.createdAt ?? time,
updatedAt: time,
};
map[partial.key] = merged;
const parsed = intentRecordSchema.safeParse(merged);
if (!parsed.success) {
console.debug('intent: refused to persist an invalid record', parsed.error.issues);
return existing ?? merged;
}
map[partial.key] = parsed.data;
safeSetStorage(map);
return merged;
return parsed.data;
}

export function removeIntent(key: string) {
Expand All @@ -108,6 +163,19 @@ export function removeIntent(key: string) {
}
}

export function clearIntentsForWallet(walletAddress: string) {
if (!walletAddress) return;
const map = safeGetStorage();
let changed = false;
for (const key of Object.keys(map)) {
if (map[key].walletAddress === walletAddress) {
delete map[key];
changed = true;
}
}
if (changed) safeSetStorage(map);
}

export function clearAllIntents() {
try {
localStorage.removeItem(STORAGE_KEY);
Expand Down