-
Notifications
You must be signed in to change notification settings - Fork 105
Smart Backend Wallets #709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
68c21b6
smart backend wallet creation + initial flow
d4mr 05491fe
Merge branch 'main' into pb/sbw
d4mr 082b54a
refactor wallet detail decryption
d4mr 1b87760
change names
d4mr fc12095
Merge branch 'main' into pb/sbw
d4mr aaa5691
Refactor import statement for TransactionReceipt in sdk.ts
d4mr dbc567f
Refactor biome.json to ignore the "sdk" directory
d4mr 941e55d
Fix smart backend wallet functionality for both v4 and v5 SDK
d4mr 51bd785
use v6 factory default
d4mr 17245a1
consistent naming
d4mr 7bb1b80
remove redundant comment
d4mr a6ece2b
entrypoint updates
d4mr 8984844
chainId in sign + thirdweb client in e2e tests
d4mr f4ec6f9
worker refactors
d4mr 550dc14
more tests
d4mr 3256832
consistent naming
d4mr 38b46c6
Merge branch 'main' into pb/sbw
d4mr 3a47e77
conditionally require chainId for signing smart account messsages
d4mr a3957ba
naming consistencies
d4mr ef64f4c
reject transaction if unsupported chain
d4mr b5503b9
fix userop tests
d4mr b64dff4
fix: only transform QueuedTransaction if sbw
d4mr c1986ef
Refactor SWRCache to log errors during cache revalidation
d4mr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }); | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
||
| 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, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.