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
8 changes: 7 additions & 1 deletion apps/extension/src/background/runtime/runtime-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
type NftOwnerInput,
type NftOwnershipInput,
type NftTokenUriInput,
rememberDeployedAccounts,
reviewArgentRecovery,
reviewBraavosRecovery,
type SigningRestrictionInput,
Expand Down Expand Up @@ -208,6 +209,11 @@ export type RuntimeRpc = {
readonly submitBraavosRecovery?: (
input: BraavosRecoveryInput,
) => ReturnType<typeof submitBraavosRecovery>;
/**
* Whether the account contract exists. The production wiring reuses a
* "deployed" answer per address and endpoint list for a few minutes (never
* for a local node); "not deployed" and failures always read the chain.
*/
readonly getDeploymentStatus: (
input: DeploymentStatusInput,
) => ReturnType<typeof getDeploymentStatus>;
Expand Down Expand Up @@ -349,7 +355,7 @@ export const createDefaultRuntimeRpc = ({ readPreferences }: RuntimeRpcOptions):
getArgentRecoveryState,
getBraavosRecoveryState,
getBraavosSigningRestriction,
getDeploymentStatus,
getDeploymentStatus: rememberDeployedAccounts(getDeploymentStatus),
reviewArgentRecovery,
reviewBraavosRecovery,
submitArgentRecovery,
Expand Down
13 changes: 13 additions & 0 deletions packages/foundation/core/src/service-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export const normalizeHostname = (hostname: string): string => {
return lower.endsWith(".") ? lower.slice(0, -1) : lower;
};

/**
* Whether an endpoint points at this machine — a local node such as
* starknet-devnet, whose chain can be reset under the same URL. Pure; a string
* the URL parser rejects is not loopback.
*/
export const isLoopbackServiceUrl = (value: string): boolean => {
try {
return LOOPBACK_HOSTS.has(normalizeHostname(new URL(value).hostname));
} catch {
return false;
}
};

// The longest endpoint kept, measured on its canonical spelling.
const MAX_SERVICE_URL_LENGTH = 2048;

Expand Down
10 changes: 10 additions & 0 deletions packages/foundation/core/tests/service-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { describe, expect, test } from "bun:test";
import {
isBoundedServiceUrlList,
isLoopbackServiceUrl,
isServiceUrl,
isWebSocketServiceUrl,
normalizeHostname,
Expand Down Expand Up @@ -46,6 +47,15 @@ describe("service URLs", () => {
expect(normalizeHostname("127.0.0.1")).toBe("127.0.0.1");
});

test("recognizes an endpoint on this machine, on either scheme", () => {
expect(isLoopbackServiceUrl("http://127.0.0.1:5050/rpc")).toBe(true);
expect(isLoopbackServiceUrl("http://localhost:5050/rpc")).toBe(true);
expect(isLoopbackServiceUrl("https://LOCALHOST./rpc")).toBe(true);
expect(isLoopbackServiceUrl("https://rpc.example/localhost")).toBe(false);
expect(isLoopbackServiceUrl("https://127.0.0.1.example")).toBe(false);
expect(isLoopbackServiceUrl("not a url")).toBe(false);
});

test("bounds the number of entries in a dapp endpoint list (Sherlock audit 153)", () => {
const urls = (count: number) =>
Array.from({ length: count }, (_, index) => `https://rpc${index}.example`);
Expand Down
4 changes: 4 additions & 0 deletions packages/platform/starknet/src/rpc/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export {
getDeploymentStatus,
submitAccountDeployment,
} from "../transactions/deployment";
/**
* Re-exported deployment-status memo for the background RPC wiring.
*/
export { rememberDeployedAccounts } from "../transactions/deployment-status-cache";
/**
* Re-exported amount helpers and balance reads so callers keep one RPC import path.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Deployment-status reads that briefly remember a "deployed" answer.
*
* Every deployment-status read is one `starknet_getClassHashAt`, and the
* background repeats it for the same address on each Home refresh, fee review,
* approval and recovery rescan. A Starknet contract cannot be removed from its
* address, so "deployed" is reused for a short window once a node has said it;
* "not deployed" can change with the account's next transaction, so it is never
* remembered and a Retry still reads the chain again (Sherlock 218).
*
* The window, and skipping local nodes, exist for the cases where "deployed"
* can stop being true: a devnet reset under the same URL, or a reorg of the
* latest block. A local node is never remembered; anything else is read again
* once its answer is older than `freshForMs`.
*
* Only that existence answer is kept. The class hash an account runs can change
* through an upgrade, and the signing-path compatibility checks read it live at
* a pinned block (`account-compatibility`) without going through this cache.
*/
import { isLoopbackServiceUrl } from "@bramble/core/service-url";
import { canonicalFelt, isNonZeroStarkFieldElement } from "@bramble/wallet-model/networks";
import type { DeploymentStatusInput } from "./deployment";

/** The deployment-status read this cache wraps. */
type DeploymentStatusRead = (input: DeploymentStatusInput) => Promise<boolean>;

/** Tuning and clock seams for {@link rememberDeployedAccounts}. */
export type DeployedAccountMemoryOptions = {
/** Most addresses held at once; the oldest is dropped first. */
readonly capacity?: number;
/** How long a "deployed" answer is reused before the chain is read again. */
readonly freshForMs?: number;
/** Monotonic milliseconds; injected so tests control the freshness window's clock. */
readonly now?: () => number;
};

// Far above the accounts one wallet holds, so in practice nothing is evicted;
// the bound only stops an endpoint that answers "deployed" to every recovery
// probe from growing the map without limit.
const DEPLOYED_ACCOUNT_CAPACITY = 512;

// Longer than the side panel's 60-second refresh, so a Home poll usually reuses
// the last answer, and short enough that a reset remote devnet or a reorg is
// seen again within minutes rather than for the worker's lifetime.
const DEPLOYED_ANSWER_FRESH_MS = 5 * 60_000;

// The same address is a different account on each network, and an answer one
// endpoint list gave is not reused once the user configures another, so the key
// is the canonical address together with the exact endpoint list it was read from.
// Only an address the reader itself accepts gets a key, so a spelling it would
// refuse (decimal, uppercase, zero) can never be answered from memory instead;
// and a list that reaches a local node gets none, since its chain can be reset.
const deployedKey = ({ accountAddress, rpcUrls }: DeploymentStatusInput): string | null =>
isNonZeroStarkFieldElement(accountAddress) && !rpcUrls.some(isLoopbackServiceUrl)
? JSON.stringify([canonicalFelt(accountAddress), ...rpcUrls])
: null;

/**
* Wraps a deployment-status read so an address a node has reported deployed is
* answered from memory for `freshForMs` afterwards, per endpoint list.
* Undeployed answers and failures always reach `read` and are never stored; an
* address `read` would refuse, and any endpoint list that includes a loopback
* node, always pass through. When reads of one address overlap, only the one
* started last decides what is remembered. The map lives as long as the
* returned function (one background worker), is never persisted, and holds at
* most `capacity` addresses, dropping the oldest first.
*/
export const rememberDeployedAccounts = (
read: DeploymentStatusRead,
{
capacity = DEPLOYED_ACCOUNT_CAPACITY,
freshForMs = DEPLOYED_ANSWER_FRESH_MS,
now = () => performance.now(),
}: DeployedAccountMemoryOptions = {},
): DeploymentStatusRead => {
// Key -> when the node said "deployed". Insertion order is age, so the first
// entry is the one to let go.
const deployedAt = new Map<string, number>();
const isFresh = (key: string): boolean => {
const at = deployedAt.get(key);
return at !== undefined && now() - at < freshForMs;
};
const remember = (key: string): void => {
deployedAt.delete(key);
const oldest = deployedAt.size >= capacity ? deployedAt.keys().next().value : undefined;
if (oldest !== undefined) {
deployedAt.delete(oldest);
}
deployedAt.set(key, now());
};
// Key -> the newest read started for it. Only that read may store or clear
// the answer, so an older "deployed" resolving after a newer "not deployed"
// (a reorg one remote node has seen) cannot overwrite it. An entry is removed
// when its read settles, so the map only ever holds reads in flight.
let lastTicket = 0;
const newestRead = new Map<string, number>();
const settle = (key: string, ticket: number, isDeployed: boolean | null): void => {
if (newestRead.get(key) !== ticket) {
return;
}
newestRead.delete(key);
if (isDeployed === true) {
remember(key);
} else if (isDeployed === false) {
deployedAt.delete(key);
}
};
const readAndSettle = async (key: string, input: DeploymentStatusInput): Promise<boolean> => {
lastTicket += 1;
const ticket = lastTicket;
newestRead.set(key, ticket);
let isDeployed: boolean | null = null;
try {
isDeployed = await read(input);
return isDeployed;
} finally {
settle(key, ticket, isDeployed);
}
};
return async (input) => {
const key = deployedKey(input);
if (key === null) {
return read(input);
}
return isFresh(key) ? true : readAndSettle(key, input);
};
};
Loading
Loading