Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
82 changes: 82 additions & 0 deletions src/lib/cache/swr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
type CacheEntry<T> = {
data: T;
validUntil: number;
};

type SWROptions = {
ttlMs?: number; // How long until data is considered stale
maxEntries?: number; // Max number of entries to keep in cache
};

export class SWRCache<K, V> {
private cache = new Map<K, CacheEntry<V>>();
private inFlight = new Map<K, Promise<V>>();
private readonly options: Required<SWROptions>;

constructor(options: SWROptions = {}) {
this.options = {
ttlMs: options.ttlMs ?? 5 * 60 * 1000, // 5 minutes default
maxEntries: options.maxEntries ?? 1000,
};
}

async get(key: K, fetchFn: () => Promise<V>): Promise<V> {
const entry = this.cache.get(key);
const now = Date.now();

// If no cached data, handle fetch with deduplication
if (!entry) {
return this.dedupedFetch(key, fetchFn);
}

// Check if stale
const isStale = now > entry.validUntil;

// If stale, trigger background revalidation and return stale data
if (isStale) {
this.dedupedFetch(key, fetchFn).catch(() => {
// Silence background revalidation errors
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this catch makes it if any error occurs while fetching, the old data continues to be used.

Let's at least log a warning here. If this errors silently we won't know if the cache is stuck in a stale state.

}

return entry.data;
}

private async dedupedFetch(key: K, fetchFn: () => Promise<V>): Promise<V> {
// Check for in-flight request
const inFlight = this.inFlight.get(key);
if (inFlight) {
return inFlight;
}

// Create new request
const fetchPromise = (async () => {
try {
const data = await fetchFn();
this.set(key, data);
return data;
} finally {
this.inFlight.delete(key);
}
})();

this.inFlight.set(key, fetchPromise);
return fetchPromise;
}

private set(key: K, data: V): void {
if (this.cache.size >= this.options.maxEntries) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}

this.cache.set(key, {
data,
validUntil: Date.now() + this.options.ttlMs,
});
}
}

export function createSWRCache<K, V>(options?: SWROptions) {
return new SWRCache<K, V>(options);
}
60 changes: 60 additions & 0 deletions src/lib/chain/chain-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createSWRCache } from "../cache/swr";

const Services = [
"contracts",
"connect-sdk",
"engine",
"account-abstraction",
"pay",
"rpc-edge",
"chainsaw",
"insight",
] as const;

export type Service = (typeof Services)[number];

export type ChainCapabilities = Array<{
service: Service;
enabled: boolean;
}>;

// Create cache with 2048 entries and 5 minute TTL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment is wrong

const chainCapabilitiesCache = createSWRCache<number, ChainCapabilities>({
maxEntries: 2048,
ttlMs: 1000 * 60 * 30, // 30 minutes
});

/**
* Get the capabilities of a chain (cached with stale-while-revalidate)
*/
export async function getChainCapabilities(
chainId: number,
): Promise<ChainCapabilities> {
return chainCapabilitiesCache.get(chainId, async () => {
const response = await fetch(
`https://api.thirdweb.com/v1/chains/${chainId}/services`,
);

const data = await response.json();

if (data.error) {
throw new Error(data.error);
}

return data.data.services as ChainCapabilities;
});
}

/**
* Check if a chain supports a given service
*/
export async function doesChainSupportService(
chainId: number,
service: Service,
): Promise<boolean> {
const chainCapabilities = await getChainCapabilities(chainId);

return chainCapabilities.some(
(capability) => capability.service === service && capability.enabled,
);
}
29 changes: 24 additions & 5 deletions src/server/routes/backend-wallet/signMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import type { FastifyInstance } from "fastify";
import { StatusCodes } from "http-status-codes";
import { isHex, type Hex } from "thirdweb";
import { arbitrumSepolia } from "thirdweb/chains";
import { getAccount } from "../../../utils/account";
import { getChecksumAddress } from "../../../utils/primitiveTypes";
import {
getWalletDetails,
isSmartBackendWallet,
} from "../../../db/wallets/getWalletDetails";
import { walletDetailsToAccount } from "../../../utils/account";
import { getChain } from "../../../utils/chain";
import { createCustomError } from "../../middleware/error";
import { standardResponseSchema } from "../../schemas/sharedApiSchemas";
import { walletHeaderSchema } from "../../schemas/wallet";
Expand Down Expand Up @@ -51,10 +55,25 @@ export async function signMessageRoute(fastify: FastifyInstance) {
);
}

const account = await getAccount({
chainId: chainId ?? arbitrumSepolia.id,
from: getChecksumAddress(walletAddress),
const walletDetails = await getWalletDetails({
address: walletAddress,
});

if (isSmartBackendWallet(walletDetails) && !chainId) {
throw createCustomError(
"Chain ID is required for signing messages with smart wallets.",
StatusCodes.BAD_REQUEST,
"CHAIN_ID_REQUIRED",
);
}

const chain = chainId ? await getChain(chainId) : arbitrumSepolia;

const { account } = await walletDetailsToAccount({
walletDetails,
chain,
});

const messageToSign = isBytes ? { raw: message as Hex } : message;
const signedMessage = await account.signMessage({
message: messageToSign,
Expand Down
4 changes: 2 additions & 2 deletions src/server/utils/wallets/createLocalWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface CreateLocalWallet {
* Create a local wallet with a random private key
* Does not store the wallet in the database
*/
export const createRandomLocalWallet = async () => {
export const generateLocalWallet = async () => {
const pk = generatePrivateKey();
const account = privateKeyToAccount({
client: thirdwebClient,
Expand Down Expand Up @@ -47,7 +47,7 @@ export const createRandomLocalWallet = async () => {
export const createLocalWalletDetails = async ({
label,
}: CreateLocalWallet): Promise<string> => {
const { account, encryptedJson } = await createRandomLocalWallet();
const { account, encryptedJson } = await generateLocalWallet();

await createWalletDetails({
type: "local",
Expand Down
4 changes: 2 additions & 2 deletions src/server/utils/wallets/createSmartWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
createGcpKmsKey,
type CreateGcpKmsWalletParams,
} from "./createGcpKmsWallet";
import { createRandomLocalWallet } from "./createLocalWallet";
import { generateLocalWallet } from "./createLocalWallet";
import { getAwsKmsAccount } from "./getAwsKmsAccount";
import { getGcpKmsAccount } from "./getGcpKmsAccount";

Expand Down Expand Up @@ -151,7 +151,7 @@ export const createSmartLocalWalletDetails = async ({
accountFactoryAddress,
entrypointAddress,
}: CreateSmartLocalWalletParams) => {
const { account, encryptedJson } = await createRandomLocalWallet();
const { account, encryptedJson } = await generateLocalWallet();

const wallet = await getConnectedSmartWallet({
adminAccount: account,
Expand Down
162 changes: 162 additions & 0 deletions src/tests/swr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createSWRCache, type SWRCache } from "../lib/cache/swr";

describe("SWRCache", () => {
let cache: SWRCache<string, number>;
let fetchCount: number;
let now: number;

// Use vi.setSystemTime() instead of real timeouts
beforeEach(() => {
now = Date.now();
vi.setSystemTime(now);
cache = createSWRCache<string, number>({ ttlMs: 100 }); // 100ms TTL for tests
fetchCount = 0;
});

const createFetcher = () => {
return async () => {
fetchCount++;
return fetchCount;
};
};

test("should fetch and cache new values", async () => {
const fetcher = createFetcher();
const value = await cache.get("key1", fetcher);

expect(value).toBe(1);
expect(fetchCount).toBe(1);

// Second get should use cache
const cachedValue = await cache.get("key1", fetcher);
expect(cachedValue).toBe(1);
expect(fetchCount).toBe(1);
});

test("should handle multiple concurrent requests", async () => {
const fetcher = createFetcher();
const results = await Promise.all([
cache.get("key1", fetcher),
cache.get("key1", fetcher),
cache.get("key1", fetcher),
]);

expect(results).toEqual([1, 1, 1]);
expect(fetchCount).toBe(1);
});

test("should handle multiple concurrent failed requests", async () => {
let attempts = 0;
const errorFetcher = async () => {
attempts++;
throw new Error(`Attempt ${attempts} failed`);
};

const promises = Promise.all([
expect(cache.get("key1", errorFetcher)).rejects.toThrow(
"Attempt 1 failed",
),
expect(cache.get("key1", errorFetcher)).rejects.toThrow(
"Attempt 1 failed",
),
expect(cache.get("key1", errorFetcher)).rejects.toThrow(
"Attempt 1 failed",
),
]);

await promises;
expect(attempts).toBe(1);
});

test("should revalidate stale data in background", async () => {
const fetcher = createFetcher();

// Initial fetch
const initial = await cache.get("key1", fetcher);
expect(initial).toBe(1);
expect(fetchCount).toBe(1);

// Move time forward past TTL
vi.setSystemTime(now + 200);

// Should get stale data immediately while revalidating
const stalePromise = cache.get("key1", fetcher);

// Value should be returned immediately (stale)
const stale = await stalePromise;
expect(stale).toBe(1);

// Let revalidation complete
await new Promise((resolve) => setTimeout(resolve, 0));

// Should have fresh data now
const fresh = await cache.get("key1", fetcher);
expect(fresh).toBe(2);
expect(fetchCount).toBe(2);
});

test("should respect max entries", async () => {
const cache = createSWRCache<string, number>({ maxEntries: 2 });
const fetcher = createFetcher();

await cache.get("key1", fetcher);
await cache.get("key2", fetcher);
await cache.get("key3", fetcher);

// Try to get first key (should trigger new fetch)
fetchCount = 0;
await cache.get("key1", fetcher);
expect(fetchCount).toBe(1);
});

test("should handle errors during background revalidation", async () => {
const fetcher = createFetcher();
const errorFetcher = async () => {
throw new Error("Revalidation failed");
};

// Initial successful fetch
const initial = await cache.get("key1", fetcher);
expect(initial).toBe(1);

// Move time forward past TTL
vi.setSystemTime(now + 200);

// Should return stale data even if revalidation fails
const stale = await cache.get("key1", errorFetcher);
expect(stale).toBe(1);

// Let background revalidation attempt complete
await new Promise((resolve) => setTimeout(resolve, 0));

// Should still have stale data after failed revalidation
const stillStale = await cache.get("key1", errorFetcher);
expect(stillStale).toBe(1);
});

test("should handle rapid successive requests on stale data", async () => {
const fetcher = createFetcher();

// Initial fetch
await cache.get("key1", fetcher);

// Move time forward past TTL
vi.setSystemTime(now + 200);

// Create 10 rapid requests
const promises = Array.from({ length: 10 }, () =>
cache.get("key1", fetcher),
);
const results = await Promise.all(promises);

// All should have same value
expect(results.every((r) => r === 1)).toBe(true);
// Should only trigger one background revalidation
expect(fetchCount).toBe(2);
});

afterEach(() => {
vi.useRealTimers();
});
});
Loading
Loading