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
25 changes: 25 additions & 0 deletions @shared/api/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,28 @@ export const setAllowedStatus = async (): Promise<{

return { isAllowed, error: response?.apiError };
};

export const disconnect = async (): Promise<{
error?: FreighterApiError;
}> => {
let response;

try {
response = await sendMessageToContentScript({
type: EXTERNAL_SERVICE_TYPES.DISCONNECT,
});
} catch (e) {
console.error(e);
}

if (response?.apiError) {
return { error: response.apiError };
}

// the content script returns only `error` when it can't reach the background
if (!response || response.error) {
return { error: FreighterApiInternalError };
}

return {};
};
27 changes: 27 additions & 0 deletions @shared/api/helpers/__tests__/extensionMessaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EXTERNAL_SERVICE_TYPES } from "../../../constants/services";
import {
FreighterApiInternalError,
sendMessageToContentScript,
} from "../extensionMessaging";

describe("sendMessageToContentScript", () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("resolves DISCONNECT with an error when the extension never responds", async () => {
const response = sendMessageToContentScript({
type: EXTERNAL_SERVICE_TYPES.DISCONNECT,
});

jest.advanceTimersByTime(2000);

await expect(response).resolves.toEqual({
apiError: FreighterApiInternalError,
});
});
});
16 changes: 16 additions & 0 deletions @shared/api/helpers/extensionMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export const sendMessageToContentScript = (msg: Msg): Promise<Response> => {
}, 2000);
}

/*
Versions of Freighter that predate DISCONNECT drop the message without
responding, so resolve with an error rather than hanging forever.
*/
if (msg.type === EXTERNAL_SERVICE_TYPES.DISCONNECT) {
requestTimeout = setTimeout(() => {
resolve({ apiError: FreighterApiInternalError } as Response);
Comment thread
JakeUrban marked this conversation as resolved.
window.removeEventListener("message", messageListener);
}, 2000);
}

const messageListener = (event: { source: any; data: Response }) => {
// We only accept messages from ourselves
if (event.source !== window) return;
Expand Down Expand Up @@ -97,6 +108,11 @@ export const FreighterApiInternalError = {
"The wallet encountered an internal error. Please try again or contact the wallet if the problem persists.",
};

export const FreighterApiLockedError = {
code: -1,
message: "The wallet is locked. Unlock Freighter and try again.",
};

export const FreighterApiDeclinedError = {
code: -4,
message: "The user rejected this request.",
Expand Down
1 change: 1 addition & 0 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export enum EXTERNAL_SERVICE_TYPES {
REQUEST_ALLOWED_STATUS = "REQUEST_ALLOWED_STATUS",
SET_ALLOWED_STATUS = "SET_ALLOWED_STATUS",
REQUEST_USER_INFO = "REQUEST_USER_INFO",
DISCONNECT = "DISCONNECT",
}

export const EXTERNAL_MSG_REQUEST = "FREIGHTER_EXTERNAL_MSG_REQUEST";
Expand Down
28 changes: 28 additions & 0 deletions @stellar/freighter-api/src/__tests__/disconnect.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as extensionMessaging from "@shared/api/helpers/extensionMessaging";
import { disconnect } from "../disconnect";

describe("disconnect", () => {
it("returns an empty object on success", async () => {
extensionMessaging.sendMessageToContentScript = jest
.fn()
.mockReturnValue({});
const res = await disconnect();
expect(res).toEqual({});
});
it("returns an error", async () => {
extensionMessaging.sendMessageToContentScript = jest
.fn()
.mockReturnValue({ apiError: "error" });
const res = await disconnect();
expect(res).toEqual({ error: "error" });
});
it("returns an internal error when the extension can't be reached", async () => {
extensionMessaging.sendMessageToContentScript = jest
.fn()
.mockReturnValue({ error: "Unable to send message to extension" });
const res = await disconnect();
expect(res).toEqual({
error: extensionMessaging.FreighterApiInternalError,
});
});
});
12 changes: 12 additions & 0 deletions @stellar/freighter-api/src/disconnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { disconnect as disconnectSite } from "@shared/api/external";
import { FreighterApiError } from "@shared/api/types";
import { FreighterApiNodeError } from "@shared/api/helpers/extensionMessaging";
import { isBrowser } from ".";

export const disconnect = async (): Promise<{ error?: FreighterApiError }> => {
if (isBrowser) {
return disconnectSite();
}

return { error: FreighterApiNodeError };
};
3 changes: 3 additions & 0 deletions @stellar/freighter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getNetworkDetails } from "./getNetworkDetails";
import { isAllowed } from "./isAllowed";
import { setAllowed } from "./setAllowed";
import { requestAccess } from "./requestAccess";
import { disconnect } from "./disconnect";
import { WatchWalletChanges } from "./watchWalletChanges";

export const isBrowser = typeof window !== "undefined";
Expand All @@ -25,6 +26,7 @@ export {
isAllowed,
setAllowed,
requestAccess,
disconnect,
WatchWalletChanges,
};
export default {
Expand All @@ -39,5 +41,6 @@ export default {
isAllowed,
setAllowed,
requestAccess,
disconnect,
WatchWalletChanges,
};
111 changes: 109 additions & 2 deletions extension/src/background/helpers/__tests__/account.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { getIsRpcHealthy } from "../account";
import { NETWORK_ID } from "constants/localStorageTypes";
import {
getIsRpcHealthy,
removeAllowListDomain,
setAllowListDomain,
} from "../account";
import { ALLOWLIST_ID, NETWORK_ID } from "constants/localStorageTypes";
import { DEFAULT_NETWORKS } from "@shared/constants/stellar";
import type { DataStorageAccess } from "background/helpers/dataStorageAccess";
import { callBackendV2 } from "background/helpers/callBackendV2";
Expand Down Expand Up @@ -113,3 +117,106 @@ describe("getIsRpcHealthy", () => {
);
});
});

describe("allowlist updates", () => {
const PUBLIC_KEY = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF";
const networkDetails = { ...DEFAULT_NETWORKS[1], networkName: "Testnet" };

// Yields between each read and write, so concurrent updates interleave
// unless they're serialized.
const makeSlowStore = (allowList: Record<string, unknown>) => {
const data: Record<string, unknown> = { [ALLOWLIST_ID]: allowList };
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

return {
data,
store: {
getItem: async (key: string) => {
await tick();
return data[key];
},
setItem: async (key: string, value: unknown) => {
await tick();
data[key] = value;
},
} as unknown as DataStorageAccess,
};
};

it("applies concurrent removals without losing either", async () => {
const { data, store } = makeSlowStore({
Testnet: { [PUBLIC_KEY]: ["a.com", "b.com", "c.com"] },
});

await Promise.all([
removeAllowListDomain({
publicKey: PUBLIC_KEY,
networkName: "Testnet",
domain: "a.com",
localStore: store,
}),
removeAllowListDomain({
publicKey: PUBLIC_KEY,
networkName: "Testnet",
domain: "b.com",
localStore: store,
}),
]);

expect(data[ALLOWLIST_ID]).toEqual({
Testnet: { [PUBLIC_KEY]: ["c.com"] },
});
});

it("applies a concurrent removal and grant without losing either", async () => {
const { data, store } = makeSlowStore({
Testnet: { [PUBLIC_KEY]: ["a.com"] },
});

await Promise.all([
removeAllowListDomain({
publicKey: PUBLIC_KEY,
networkName: "Testnet",
domain: "a.com",
localStore: store,
}),
setAllowListDomain({
publicKey: PUBLIC_KEY,
networkDetails,
domain: "b.com",
localStore: store,
}),
]);

expect(data[ALLOWLIST_ID]).toEqual({
Testnet: { [PUBLIC_KEY]: ["b.com"] },
});
});

it("runs the next update after a failed one", async () => {
const { data, store } = makeSlowStore({
Testnet: { [PUBLIC_KEY]: ["a.com"] },
});
const failingStore = {
...store,
getItem: () => Promise.reject(new Error("storage failure")),
} as unknown as DataStorageAccess;

const failed = removeAllowListDomain({
publicKey: PUBLIC_KEY,
networkName: "Testnet",
domain: "a.com",
localStore: failingStore,
});
const succeeded = removeAllowListDomain({
publicKey: PUBLIC_KEY,
networkName: "Testnet",
domain: "a.com",
localStore: store,
});

await expect(failed).rejects.toThrow("storage failure");
await succeeded;
expect(data[ALLOWLIST_ID]).toEqual({ Testnet: { [PUBLIC_KEY]: [] } });
});
});
91 changes: 53 additions & 38 deletions extension/src/background/helpers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,18 @@ export const getAllowListSegment = async ({
return allowListByKey;
};

export const setAllowListDomain = async ({
// Allowlist updates read the whole allowlist and write it back. Run them one
// at a time so that concurrent updates (for example, two dApps calling
// disconnect) don't overwrite each other.
let allowListQueue: Promise<unknown> = Promise.resolve();

const withAllowListLock = <T>(fn: () => Promise<T>): Promise<T> => {
const result = allowListQueue.then(fn);
allowListQueue = result.catch(() => undefined);
return result;
};

export const setAllowListDomain = ({
publicKey,
networkDetails,
domain,
Expand All @@ -131,28 +142,30 @@ export const setAllowListDomain = async ({
networkDetails: NetworkDetails;
domain: string;
localStore: DataStorageAccess;
}) => {
const allowList = (await localStore.getItem(ALLOWLIST_ID)) || {};
const allowListByNetwork = { ...allowList[networkDetails.networkName] };
const allowListPublicKeyArray: string[] = allowListByNetwork[publicKey] || [];
}) =>
withAllowListLock(async () => {
const allowList = (await localStore.getItem(ALLOWLIST_ID)) || {};
const allowListByNetwork = { ...allowList[networkDetails.networkName] };
const allowListPublicKeyArray: string[] =
allowListByNetwork[publicKey] || [];

if (allowListPublicKeyArray.includes(domain)) {
return allowListPublicKeyArray;
}

if (allowListPublicKeyArray.includes(domain)) {
return allowListPublicKeyArray;
}
allowListPublicKeyArray.push(domain);
await localStore.setItem(ALLOWLIST_ID, {
...allowList,
[networkDetails.networkName]: {
...allowListByNetwork,
[publicKey]: allowListPublicKeyArray,
},
});

allowListPublicKeyArray.push(domain);
await localStore.setItem(ALLOWLIST_ID, {
...allowList,
[networkDetails.networkName]: {
...allowListByNetwork,
[publicKey]: allowListPublicKeyArray,
},
return allowListPublicKeyArray;
});

return allowListPublicKeyArray;
};

export const removeAllowListDomain = async ({
export const removeAllowListDomain = ({
publicKey,
networkName,
domain,
Expand All @@ -162,30 +175,32 @@ export const removeAllowListDomain = async ({
networkName: string;
domain: string;
localStore: DataStorageAccess;
}) => {
const allowList = (await localStore.getItem(ALLOWLIST_ID)) || {};
const allowListByNetwork = { ...allowList[networkName] };
const allowListPublicKeyArray: string[] = allowListByNetwork[publicKey] || [];
}) =>
withAllowListLock(async () => {
const allowList = (await localStore.getItem(ALLOWLIST_ID)) || {};
const allowListByNetwork = { ...allowList[networkName] };
const allowListPublicKeyArray: string[] =
allowListByNetwork[publicKey] || [];

if (!allowListPublicKeyArray.includes(domain)) {
return allowListPublicKeyArray;
}

if (!allowListPublicKeyArray.includes(domain)) {
return allowListPublicKeyArray;
}
const editedAllowList = allowListPublicKeyArray.filter(
(item) => item !== domain,
);

const editedAllowList = allowListPublicKeyArray.filter(
(item) => item !== domain,
);
await localStore.setItem(ALLOWLIST_ID, {
...allowList,
[networkName]: {
...allowListByNetwork,
[publicKey]: editedAllowList,
},
});

await localStore.setItem(ALLOWLIST_ID, {
...allowList,
[networkName]: {
...allowListByNetwork,
[publicKey]: editedAllowList,
},
return allowListPublicKeyArray;
});

return allowListPublicKeyArray;
};

export const getIsMemoValidationEnabled = async ({
localStore,
}: {
Expand Down
Loading
Loading