Skip to content

feat: unite connection requests #1923

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion scripts/unit-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ echo "Anvil Launched..."

# Run the tests and store the result
echo "Running Tests..."
NODE_OPTIONS='--no-experimental-fetch' vitest
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't recall exactly why we adopted this, but this conflicts with amockFetch injection; this could have changed since node v18

vitest
TEST_RESULT=$?

# kill anvil
Expand Down
77 changes: 74 additions & 3 deletions src/core/state/requests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,92 @@ test('should be able to add request', async () => {
usePendingRequestStore.getState();
expect(pendingRequests).toStrictEqual([]);

addPendingRequest({ id: 1, method: 'eth_requestAccounts', params: [] });
const result1 = addPendingRequest({
id: 1,
method: 'eth_requestAccounts',
params: [],
});
expect(result1).toBe(true);
expect(usePendingRequestStore.getState().pendingRequests).toStrictEqual([
{ id: 1, method: 'eth_requestAccounts', params: [] },
]);
addPendingRequest({ id: 2, method: 'eth_accounts', params: [] });
const result2 = addPendingRequest({
id: 2,
method: 'eth_accounts',
params: [],
});
expect(result2).toBe(true);

expect(usePendingRequestStore.getState().pendingRequests).toStrictEqual([
{ id: 1, method: 'eth_requestAccounts', params: [] },
{ id: 2, method: 'eth_accounts', params: [] },
]);
});

test('should prevent duplicate eth_requestAccounts requests from same host', async () => {
const { addPendingRequest } = usePendingRequestStore.getState();

// Clear any existing requests
usePendingRequestStore.setState({ pendingRequests: [] });

const request1 = {
id: 3,
method: 'eth_requestAccounts',
params: [],
meta: {
sender: { url: 'https://example.com' },
topic: 'test_topic',
},
};

const request2 = {
id: 4,
method: 'eth_requestAccounts',
params: [],
meta: {
sender: { url: 'https://example.com' },
topic: 'test_topic',
},
};

const result1 = addPendingRequest(request1);
expect(result1).toBe(true);
expect(usePendingRequestStore.getState().pendingRequests).toHaveLength(1);

const result2 = addPendingRequest(request2);
expect(result2).toBe(false); // Should be rejected as duplicate
expect(usePendingRequestStore.getState().pendingRequests).toHaveLength(1); // Still only one request
});

test('should be able to remove request', async () => {
const { removePendingRequest } = usePendingRequestStore.getState();
// Clear any existing requests and set up the test state
usePendingRequestStore.setState({ pendingRequests: [] });

const { addPendingRequest, removePendingRequest } =
usePendingRequestStore.getState();

// Add the test requests
addPendingRequest({
id: 1,
method: 'eth_requestAccounts',
params: [],
});
addPendingRequest({
id: 2,
method: 'eth_accounts',
params: [],
});

// Verify initial state
expect(usePendingRequestStore.getState().pendingRequests).toStrictEqual([
{ id: 1, method: 'eth_requestAccounts', params: [] },
{ id: 2, method: 'eth_accounts', params: [] },
]);

// Remove request with id 1
removePendingRequest(1);

// Verify only request with id 2 remains
expect(usePendingRequestStore.getState().pendingRequests).toStrictEqual([
{ id: 2, method: 'eth_accounts', params: [] },
]);
Expand Down
66 changes: 64 additions & 2 deletions src/core/state/requests/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,88 @@
import { createRainbowStore } from '~/core/state/internal/createRainbowStore';

import { ProviderRequestPayload } from '../../transports/providerRequestTransport';
import type { ProviderRequestPayload } from '../../transports/providerRequestTransport';

import { getSenderHost, getTabIdString } from './utils';

type PromiseResolver = {
resolve: (value: object) => void;
reject: (error: Error) => void;
};

export interface PendingRequestsStore {
pendingRequests: ProviderRequestPayload[];
addPendingRequest: (request: ProviderRequestPayload) => void;
connectionPromiseResolvers: Map<string, PromiseResolver[]>;
addPendingRequest: (request: ProviderRequestPayload) => boolean;
removePendingRequest: (id: number) => void;
addConnectionResolver: (host: string, resolver: PromiseResolver) => void;
resolveConnectionRequests: (
host: string,
result: unknown,
isError?: boolean,
) => void;
}

export const usePendingRequestStore = createRainbowStore<PendingRequestsStore>(
(set, get) => ({
pendingRequests: [],
connectionPromiseResolvers: new Map(),
addPendingRequest: (newRequest) => {
// Check for duplicate eth_requestAccounts requests from the same host
if (newRequest.method === 'eth_requestAccounts') {
const tabId = getTabIdString(newRequest);
const senderHost = getSenderHost(newRequest);
if (senderHost && tabId) {
// Check if there's already a pending connection request for this host
const existingConnectionRequest = get().pendingRequests.find(
(request) => {
if (request.method !== 'eth_requestAccounts') return false;
const existingHost = getSenderHost(request);
const existingTabId = getTabIdString(request);
return existingHost === senderHost && existingTabId === tabId;
},
);

if (existingConnectionRequest) {
// Don't add duplicate - return false to indicate this is a duplicate
return false;
}
}
}

const pendingRequests = get().pendingRequests;
set({ pendingRequests: pendingRequests.concat([newRequest]) });
return true;
},
removePendingRequest: (id) => {
const pendingRequests = get().pendingRequests;
set({
pendingRequests: pendingRequests.filter((request) => request.id !== id),
});
},
addConnectionResolver: (host, resolver) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add some notes about the resolver paradigm used here? Trying to wrap my head around the goal.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added some in code comments :) Basically it's needed to allow multiple connect requests to resolve when one dialog gets accepted/declined

const resolvers = get().connectionPromiseResolvers;
const existingResolvers = resolvers.get(host) || [];
resolvers.set(host, [...existingResolvers, resolver]);
set({ connectionPromiseResolvers: new Map(resolvers) });
},
resolveConnectionRequests: (host, result, isError = false) => {
const resolvers = get().connectionPromiseResolvers;
const hostResolvers = resolvers.get(host);

if (hostResolvers) {
hostResolvers.forEach((resolver) => {
if (isError) {
resolver.reject(result as Error);
} else {
resolver.resolve(result as object);
}
});

// Clean up resolved promises
resolvers.delete(host);
set({ connectionPromiseResolvers: new Map(resolvers) });
}
},
}),
{
storageKey: 'pendingRequestStore',
Expand Down
23 changes: 23 additions & 0 deletions src/core/state/requests/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ProviderRequestPayload } from '~/core/transports/providerRequestTransport';

export const getSenderHost = (
request: ProviderRequestPayload,
): string | null => {
const senderUrl = request.meta?.sender?.url;
if (!senderUrl) return null;
try {
return new URL(senderUrl).hostname;
} catch {
return null;
}
};

export const getTabIdString = (
request: ProviderRequestPayload,
): string | null => {
const tabId = request.meta?.sender?.tab?.id;
if (typeof tabId === 'number' || typeof tabId === 'string') {
return tabId.toString();
}
return null;
};
103 changes: 74 additions & 29 deletions src/entries/background/handlers/handleProviderRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
usePendingRequestStore,
} from '~/core/state';
import { useNetworkStore } from '~/core/state/networks/networks';
import { getSenderHost, getTabIdString } from '~/core/state/requests/utils';
import { SessionStorage } from '~/core/storage';
import { providerRequestTransport } from '~/core/transports';
import { ProviderRequestPayload } from '~/core/transports/providerRequestTransport';
Expand Down Expand Up @@ -66,62 +67,106 @@ const focusOnWindow = (windowId: number) => {
const openWindowForTabId = async (tabId: string) => {
const { notificationWindows } = useNotificationWindowStore.getState();
const notificationWindow = notificationWindows[tabId];
if (notificationWindow) {
chrome.windows.get(
notificationWindow.id as number,
async (existingWindow) => {
if (chrome.runtime.lastError) {
createNewWindow(tabId);
if (notificationWindow?.id) {
chrome.windows.get(notificationWindow.id, async (existingWindow) => {
if (chrome.runtime.lastError) {
createNewWindow(tabId);
} else {
if (existingWindow.id) {
focusOnWindow(existingWindow.id);
} else {
if (existingWindow) {
focusOnWindow(existingWindow.id as number);
} else {
createNewWindow(tabId);
}
createNewWindow(tabId);
}
},
);
}
});
} else {
createNewWindow(tabId);
}
};

const handleConnectionResolver = (
host: string,
addConnectionResolver: (
host: string,
resolver: {
resolve: (value: object) => void;
reject: (error: Error) => void;
},
) => void,
): Promise<object> => {
return new Promise((resolve, reject) => {
addConnectionResolver(host, { resolve, reject });
});
};

const resolveConnectionRequestsIfNeeded = (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there any duplicative implementation between this concept and handleTabAndWindowUpdates? We attach to the window/tab listeners there for things like expiry

Copy link
Contributor Author

Choose a reason for hiding this comment

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

helped me to prevent some memory leaks, thanks
but in general I think it's not the same concept

tabId -> popups/requests
vs
popup/request -> inpage request resolvers

request: ProviderRequestPayload,
payload: unknown,
) => {
if (request.method !== 'eth_requestAccounts') return;
const host = getSenderHost(request);
if (!host) return;
const { resolveConnectionRequests } = usePendingRequestStore.getState();
if (payload) {
resolveConnectionRequests(host, payload);
} else {
resolveConnectionRequests(
host,
new UserRejectedRequestError(Error('User rejected the request.')),
true,
);
}
};

/**
* Uses extensionMessenger to send messages to popup for the user to approve or reject
* @param {PendingRequest} request
* @returns {boolean}
* @returns {object}
*/
const messengerProviderRequest = async (
messenger: Messenger,
request: ProviderRequestPayload,
) => {
const { addPendingRequest } = usePendingRequestStore.getState();
// Add pending request to global background state.
addPendingRequest(request);
const { addPendingRequest, addConnectionResolver } =
usePendingRequestStore.getState();

// Check if this is a duplicate connection request
const isRequestAdded = addPendingRequest(request);

let ready = isInitialized();
while (!ready) {
if (!isRequestAdded) {
// This is a duplicate connection request - add resolver and focus existing window
const host = getSenderHost(request);
const tabId = getTabIdString(request);
if (host && tabId) {
openWindowForTabId(tabId);
return handleConnectionResolver(host, addConnectionResolver);
}
}

// Wait for initialization
while (!isInitialized()) {
// eslint-disable-next-line no-promise-executor-return, no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 100));
ready = isInitialized();
}
const _hasVault = ready && (await hasVault());
const _hasVault = await hasVault();
const passwordSet = _hasVault && (await isPasswordSet());

if (_hasVault && passwordSet) {
openWindowForTabId(Number(request.meta?.sender.tab?.id).toString());
const tabId = getTabIdString(request);
if (_hasVault && passwordSet && tabId) {
openWindowForTabId(tabId);
} else {
goToNewTab({
url: WELCOME_URL,
});
goToNewTab({ url: WELCOME_URL });
}

// Wait for response from the popup.
const payload: unknown | null = await new Promise((resolve) =>
// eslint-disable-next-line no-promise-executor-return
messenger.reply(`message:${request.id}`, async (payload) =>
resolve(payload),
),
messenger.reply(`message:${request.id}`, async (payload) => {
resolve(payload);
resolveConnectionRequestsIfNeeded(request, payload);
}),
);

if (!payload) {
throw new UserRejectedRequestError(Error('User rejected the request.'));
}
Expand Down
Loading