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
41 changes: 41 additions & 0 deletions docs/optimistic-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Optimistic Updates

GuildPass uses TanStack Query for server state and should prefer optimistic
updates only when the user action is reversible and the app can restore the
previous cache value on failure.

## When to use optimism

Use an optimistic cache update for low-risk user preferences, drafts, and other
queueable mutations where the submitted payload is the expected final state.

Do not optimistically grant credentials, access decisions, membership status, or
role ownership. Those values are security-sensitive and must be shown only after
the backend, local credential verifier, or sync engine produces an authoritative
result.

## Pattern

1. Give every affected view a stable query key in `src/lib/queryKeys.ts`.
2. In `onMutate`, cancel the exact affected queries, snapshot the current data,
and write the optimistic value with `applyOptimisticCacheUpdates`.
3. In `onError`, call `rollbackOptimisticCacheUpdates` with the context returned
from `onMutate`.
4. In `onSuccess`, write the authoritative response into the exact cache entry.
Avoid broad invalidation when the response already contains the final state.
5. Use targeted invalidation only when the mutation changes data that must be
recomputed by active queries, such as an issuer-key refresh invalidating
active attestation checks for one guild.

## Current behavior

Preference updates write a pending cache entry immediately and roll back to the
previous preferences when the mutation fails.

Access checks are confirmed-only. Successful checks are written to
`queryKeys.accessCheck.byParams(walletAddress, guildId, resourceId)` so any
screen reading the same result can reuse it without an extra request.

Attestation fetches write exact verification, existence, and per-guild aggregate
queries from the verified result. Invalid verified results remove stale aggregate
entries for the affected role.
28 changes: 27 additions & 1 deletion src/features/access/attestationIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
* Augments access check results with cryptographic proof validation
*/

import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { guildPassClient } from "../../lib/guildpassClient";
import { queryKeys } from "../../lib/queryKeys";
import type { AccessCheckParams, AccessCheckResult } from "./useAccessCheck";
import type { AttestationService } from "../attestation/attestationService";
import { writeVerifiedAttestationToCache } from "../attestation/attestationQueryCache";

/**
* Enhanced access check result with attestation verification
Expand All @@ -27,6 +29,8 @@ export interface AttestationAugmentedAccessCheck extends AccessCheckResult {
* @returns Mutation function for access checking
*/
export function useAccessCheckWithAttestations(attestationService: AttestationService | null) {
const queryClient = useQueryClient();

return useMutation<AttestationAugmentedAccessCheck, Error, AccessCheckParams>({
mutationKey: ["access-check-with-attestations"],
mutationFn: async (params: AccessCheckParams) => {
Expand Down Expand Up @@ -59,6 +63,12 @@ export function useAccessCheckWithAttestations(attestationService: AttestationSe
};
},
networkMode: "offlineFirst",
onSuccess: (result, params) => {
queryClient.setQueryData(
queryKeys.accessCheck.byParams(params.walletAddress, params.guildId, params.resourceId),
result,
);
},
});
}

Expand All @@ -70,7 +80,10 @@ export function useAccessCheckWithAttestations(attestationService: AttestationSe
* @returns Mutation function
*/
export function useCacheAccessAttestationsMutation(attestationService: AttestationService | null) {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ["cache-access-attestations"],
mutationFn: async (params: {
walletAddress: string;
guildId: string;
Expand Down Expand Up @@ -101,5 +114,18 @@ export function useCacheAccessAttestationsMutation(attestationService: Attestati

return results;
},
onSuccess: (results, params) => {
for (const result of results) {
writeVerifiedAttestationToCache(
queryClient,
{
walletAddress: params.walletAddress,
guildId: params.guildId,
roleId: "access-" + result.resourceId,
},
result,
);
}
},
});
}
28 changes: 13 additions & 15 deletions src/features/access/useAccessCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ import { guildPassClient } from "../../lib/guildpassClient";
import { useMultiChainRoleEligibility } from "./useMultiChainRoleEligibility";
import type { PerChainRoleEligibilityResolution } from "./roleEligibilityResolver";
import { useNetworkStatus } from "../offline/useNetworkStatus";
import {
resolveAccessDecision,
type AccessDecisionConfidence,
} from "./accessDecisionPipeline";
import { resolveAccessDecision, type AccessDecisionConfidence } from "./accessDecisionPipeline";
import {
getCachedOfflineVerificationInputs,
verifyOfflineCredentialAccess,
} from "./offlineCredentialVerifier";
import { queryKeys } from "../../lib/queryKeys";

type AccessCheckMutateOptions = MutateOptions<AccessCheckResult, Error, AccessCheckParams, unknown>;

Expand Down Expand Up @@ -103,7 +101,7 @@ export const useAccessCheck = () => {
);

const mutation = useMutation<AccessCheckResult, Error, AccessCheckParams>({
mutationKey: ["access-check"],
mutationKey: queryKeys.accessCheck.all,
mutationFn: async (params) => {
const decision = await resolveAccessDecision({
walletAddress: params.walletAddress,
Expand Down Expand Up @@ -151,22 +149,22 @@ export const useAccessCheck = () => {
discrepancy: decision.discrepancy,
};
},
onSuccess: (result) => {
onSuccess: (result, variables) => {
queryClient.setQueryData(
queryKeys.accessCheck.byParams(
variables.walletAddress,
variables.guildId,
variables.resourceId,
),
result,
);
dispatch({ type: "SUBMIT_SUCCESS", result });
},
onError: (error: Error) => {
dispatch({ type: "SUBMIT_ERROR", error: error.message });
},
});
const {
data,
error,
isPending,
isError,
mutate,
mutateAsync,
reset: resetMutation,
} = mutation;
const { data, error, isPending, isError, mutate, mutateAsync, reset: resetMutation } = mutation;

const startScan = useCallback(() => {
dispatch({ type: "START_SCAN" });
Expand Down
106 changes: 106 additions & 0 deletions src/features/attestation/attestationQueryCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { GuildIssuerKey, RoleAttestation } from "./types";

export interface AttestationVerificationCacheResult {
valid: boolean;
attestation?: RoleAttestation;
error?: string;
validityStatus?: string;
}

export interface AttestationCacheParams {
walletAddress: string;
guildId: string;
roleId: string;
}

export const attestationQueryKeys = {
verification: (walletAddress: string | null, guildId: string | null, roleId: string | null) =>
["attestation", walletAddress, guildId, roleId] as const,
localVerification: (
walletAddress: string | null,
guildId: string | null,
roleId: string | null,
) => ["local-attestation-verification", walletAddress, guildId, roleId] as const,
cachedExists: (walletAddress: string | null, guildId: string | null, roleId: string | null) =>
["cached-attestation-exists", walletAddress, guildId, roleId] as const,
cachedForGuild: (walletAddress: string | null, guildId: string | null) =>
["cached-attestations-guild", walletAddress, guildId] as const,
issuerKey: (guildId: string) => ["attestation-issuer-key", guildId] as const,
issuerKeyRefresh: ["attestation-issuer-key-refresh"] as const,
};

function upsertAttestation(
current: RoleAttestation[] | undefined,
next: RoleAttestation,
): RoleAttestation[] {
const existing = current ?? [];
return [...existing.filter((attestation) => attestation.roleId !== next.roleId), next];
}

function removeAttestation(
current: RoleAttestation[] | undefined,
roleId: string,
): RoleAttestation[] {
return (current ?? []).filter((attestation) => attestation.roleId !== roleId);
}

export function writeVerifiedAttestationToCache(
queryClient: QueryClient,
params: AttestationCacheParams,
result: AttestationVerificationCacheResult,
): void {
queryClient.setQueryData(
attestationQueryKeys.verification(params.walletAddress, params.guildId, params.roleId),
result,
);
queryClient.setQueryData(
attestationQueryKeys.cachedExists(params.walletAddress, params.guildId, params.roleId),
result.valid && !!result.attestation,
);
queryClient.setQueryData<RoleAttestation[]>(
attestationQueryKeys.cachedForGuild(params.walletAddress, params.guildId),
(current) =>
result.valid && result.attestation
? upsertAttestation(current, result.attestation)
: removeAttestation(current, params.roleId),
);
}

export function writeIssuerKeyToCache(
queryClient: QueryClient,
guildId: string,
issuerAddress: `0x${string}`,
now: number = Date.now(),
): GuildIssuerKey {
const issuerKey: GuildIssuerKey = {
guildId,
issuerAddress,
registeredAt: Math.floor(now / 1000),
cachedAt: now,
};

queryClient.setQueryData(attestationQueryKeys.issuerKey(guildId), issuerKey);
return issuerKey;
}

function isAttestationQueryForGuild(queryKey: QueryKey, guildId: string): boolean {
const root = queryKey[0];
return (
(root === "attestation" ||
root === "local-attestation-verification" ||
root === "cached-attestation-exists" ||
root === "cached-attestations-guild") &&
queryKey.includes(guildId)
);
}

export async function invalidateAttestationQueriesForGuild(
queryClient: QueryClient,
guildId: string,
): Promise<void> {
await queryClient.invalidateQueries({
predicate: (query) => isAttestationQueryForGuild(query.queryKey, guildId),
refetchType: "active",
});
}
32 changes: 25 additions & 7 deletions src/features/attestation/useAttestations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
* Integrates attestation logic with React Query for data fetching and caching
*/

import { useQuery, useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import type { RoleAttestation, AttestationValidationResult } from "./types";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { RoleAttestation } from "./types";
import type { AttestationService } from "./attestationService";
import { validateAttestation, getAttestationValidityStatus } from "./verifySignature";
import { getCachedIssuerKey } from "./issuerKeyRegistry";
import {
attestationQueryKeys,
invalidateAttestationQueriesForGuild,
writeIssuerKeyToCache,
writeVerifiedAttestationToCache,
} from "./attestationQueryCache";

/**
* Hook to fetch and verify an attestation
Expand All @@ -27,7 +32,7 @@ export function useAttestationVerification(
roleId: string | null,
) {
return useQuery({
queryKey: ["attestation", walletAddress, guildId, roleId],
queryKey: attestationQueryKeys.verification(walletAddress, guildId, roleId),
queryFn: async () => {
if (!service || !walletAddress || !guildId || !roleId) {
throw new Error("Missing required parameters");
Expand Down Expand Up @@ -58,7 +63,7 @@ export function useLocalAttestationVerification(
roleId: string | null,
) {
return useQuery({
queryKey: ["local-attestation-verification", walletAddress, guildId, roleId],
queryKey: attestationQueryKeys.localVerification(walletAddress, guildId, roleId),
queryFn: async () => {
if (!service || !walletAddress || !guildId || !roleId) {
throw new Error("Missing required parameters");
Expand Down Expand Up @@ -88,7 +93,7 @@ export function useCachedAttestationExists(
roleId: string | null,
) {
return useQuery({
queryKey: ["cached-attestation-exists", walletAddress, guildId, roleId],
queryKey: attestationQueryKeys.cachedExists(walletAddress, guildId, roleId),
queryFn: async () => {
if (!service || !walletAddress || !guildId || !roleId) {
return false;
Expand Down Expand Up @@ -116,7 +121,7 @@ export function useCachedAttestationsForGuild(
guildId: string | null,
) {
return useQuery({
queryKey: ["cached-attestations-guild", walletAddress, guildId],
queryKey: attestationQueryKeys.cachedForGuild(walletAddress, guildId),
queryFn: async () => {
if (!service || !walletAddress || !guildId) {
return [];
Expand All @@ -138,14 +143,21 @@ export function useCachedAttestationsForGuild(
* @returns Mutation result
*/
export function useRefreshIssuerKey(service: AttestationService | null) {
const queryClient = useQueryClient();

return useMutation({
mutationKey: attestationQueryKeys.issuerKeyRefresh,
mutationFn: async (guildId: string) => {
if (!service) {
throw new Error("Service not initialized");
}

return service.refreshIssuerKey(guildId);
},
onSuccess: (issuerAddress, guildId) => {
writeIssuerKeyToCache(queryClient, guildId, issuerAddress);
void invalidateAttestationQueriesForGuild(queryClient, guildId);
},
});
}

Expand Down Expand Up @@ -206,13 +218,19 @@ export function useAttestationValidityStatus(attestation: RoleAttestation | null
* @returns Mutation result
*/
export function useFetchAttestationMutation(service: AttestationService | null) {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ["fetch-attestation"],
mutationFn: async (params: { walletAddress: string; guildId: string; roleId: string }) => {
if (!service) {
throw new Error("Service not initialized");
}

return service.fetchAndVerifyAttestation(params.walletAddress, params.guildId, params.roleId);
},
onSuccess: (result, params) => {
writeVerifiedAttestationToCache(queryClient, params, result);
},
});
}
Loading