diff --git a/web/.env.example b/web/.env.example index 626173d12..f53feac86 100644 --- a/web/.env.example +++ b/web/.env.example @@ -90,6 +90,16 @@ RP_REGISTRY_SAFE_4337_MODULE_ADDRESS=0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226 RP_REGISTRY_KMS_REGION=eu-west-1 RP_REGISTRY_UPDATE_RP_TYPEHASH=0xe52c0a4424a7d2015a13748c3738793d6b1dd1774e4ee82516251372da305465 RP_REGISTRY_DOMAIN_SEPARATOR=0x150a3a11bbb5ea6c4cfbd7cedc050d67bb55333eb8700bec241368de512607f1 +# Kill switch for POST /api/_pre-register-rp-ids, which defensively claims the +# on-chain rp_id of apps that haven't migrated yet so nobody can squat it. Every +# claim spends L2 gas and the registry's WLD fee, so leave this unset unless a +# run is actually intended. The endpoint dry-runs unless asked not to. +ENABLE_RP_ID_PRE_REGISTRATION= +# Placeholder signer recorded on a defensively claimed rp_id. Must be a valid +# non-zero address that CANNOT sign — nothing should be able to verify against a +# claimed-but-unadopted RP. Replaced with the developer's real signer when the +# app registers for real. +RP_ID_PRE_REGISTRATION_SIGNER= # Verifier Contract VERIFIER_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 diff --git a/web/api/_pre-register-rp-ids/index.ts b/web/api/_pre-register-rp-ids/index.ts new file mode 100644 index 000000000..8fdf2d079 --- /dev/null +++ b/web/api/_pre-register-rp-ids/index.ts @@ -0,0 +1,406 @@ +import { getSdk as getAppInfoSdk } from "@/api/hasura/register-rp/graphql/get-app-info.generated"; +import { getSdk as getFetchRpSdk } from "@/api/helpers/graphql/fetch-rp-registration.generated"; +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { getKMSClient } from "@/api/helpers/kms"; +import { resolveManagerAddress } from "@/api/helpers/rp-manager"; +import { submitRegisterRpTransaction } from "@/api/helpers/rp-transactions"; +import { + addressesEqual, + generateRpIdString, + getRpRegistryConfig, + getStagingRpRegistryConfig, + isZeroAddress, + parseRpId, +} from "@/api/helpers/rp-utils"; +import { releaseClaim, reserveClaim } from "@/api/helpers/rp-claims"; +import { getRpFromContract } from "@/api/helpers/temporal-rpc"; +import { protectInternalEndpoint } from "@/api/helpers/utils"; +import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; +import { logger } from "@/lib/logger"; +import { isAddress } from "ethers"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; + +/** + * Hard ceiling on how many apps one call may claim on-chain. Each claim is a + * UserOp that costs L2 gas and pulls the registry's WLD fee from our Safe, so + * an accidental "pre-register everything" must not be one request away. Drain a + * larger backlog across repeated calls. + */ +const MAX_APPS_PER_CALL = 25; + +const schema = yup + .object({ + app_ids: yup + .array() + .of(yup.string().strict().required()) + .min(1) + .max(MAX_APPS_PER_CALL) + .required(), + /** + * Defaults to true: the only way to spend gas is to ask for it explicitly. + * A dry run reports exactly what a real run would submit. + * + * NOT `.strict()`, deliberately — yup applies defaults during casting, which + * strict mode skips, so `.strict().default(true)` would leave an ABSENT + * dry_run as undefined and therefore falsy. That inverts the safety property. + * Non-boolean values are rejected by an explicit check on the raw body + * instead; see below. + */ + dry_run: yup.boolean().default(true), + }) + .noUnknown(); + +type Outcome = + | "would_claim" + | "claimed" + | "skipped_already_registered_in_portal" + | "skipped_already_claimed_by_us" + | "skipped_claim_in_flight" + | "skipped_taken_by_foreign_manager" + | "skipped_staging" + | "skipped_app_not_found" + | "failed_rpc" + | "failed_submission"; + +/** + * Defensively claim the on-chain rp_id of apps that have not migrated to World + * ID 4.0 yet, so nobody else can. + * + * For every app created before unpredictable rp_ids, `rp_id` is + * `uint64(keccak256(app_id))` over a *public* app_id, and on-chain `register()` + * is permissionless, zero-fee and first-come. Those ids are public and + * predictable forever, so salting new registrations cannot protect the existing + * installed base — the only defense left is to hold the id ourselves until the + * app is ready to use it (H1 #3910854). + * + * A claim registers the rp_id to the Portal's SHARED manager key with a + * placeholder signer. Two consequences worth being explicit about: + * + * - No `rp_registration` row is created. On-chain state is the record of what + * we hold, and `submitManagedRpRegistration` reads it to decide whether to + * adopt. Inventing rows would make the Portal claim apps are registered when + * their owners never asked, and `proof-context` serves off that row. + * - The signer is a placeholder that must never be able to sign. Nothing can + * verify against a claimed-but-unadopted rp_id, which is the intent. + * + * Adoption: when the app later registers in managed mode, + * `submitManagedRpRegistration` sees our own manager on-chain and rotates the + * signer instead of registering. SELF-MANAGED apps cannot adopt yet — the + * developer would need the manager transferred to them + * (`submitTransferManagerTransaction` exists but no flow drives it), so + * `register_rp` fails them loudly with an actionable error rather than leaving + * a row that polls forever. Do not claim ids for apps expected to self-manage + * until that flow lands. + */ +export async function POST(request: NextRequest) { + const { isAuthenticated, errorResponse } = protectInternalEndpoint(request); + if (!isAuthenticated) { + return errorResponse; + } + + // Kill switch. Off means this endpoint cannot spend anything, whatever it is + // called with. + if (process.env.ENABLE_RP_ID_PRE_REGISTRATION !== "true") { + logger.warn("RP id pre-registration is disabled"); + return NextResponse.json( + { error: "RP id pre-registration is disabled." }, + { status: 503 }, + ); + } + + const body = await request.json().catch(() => null); + + // Checked on the RAW body, before yup casts. Opting out of the dry run is the + // only thing that lets this endpoint spend gas, so it has to be an actual JSON + // boolean — yup would happily coerce the string "false", which a hand-built + // curl or wrapper script produces by accident. + const rawDryRun = (body as { dry_run?: unknown } | null)?.dry_run; + if (rawDryRun !== undefined && typeof rawDryRun !== "boolean") { + logger.warn("Rejected a non-boolean dry_run", { type: typeof rawDryRun }); + return NextResponse.json( + { error: "dry_run must be a JSON boolean." }, + { status: 400 }, + ); + } + + const { isValid, parsedParams } = await validateRequestSchema({ + value: body, + schema, + }); + if (!isValid || !parsedParams) { + return NextResponse.json( + { + error: `Invalid request body. Expected { app_ids: string[] (1..${MAX_APPS_PER_CALL}), dry_run?: boolean }.`, + }, + { status: 400 }, + ); + } + + const { app_ids: rawAppIds, dry_run: dryRun } = parsedParams; + + // Dedupe before the loop. submitRegisterRpTransaction returns once the UserOp + // is submitted, not once it is mined, so a repeated app_id would read the + // chain as still uninitialized, submit a second register() for the same rp_id + // with a fresh nonce, and report two claims — double-spending gas and defeating + // the per-call ceiling this endpoint exists to enforce. + const appIds = Array.from(new Set(rawAppIds)); + if (appIds.length !== rawAppIds.length) { + logger.warn("Dropped duplicate app_ids from pre-registration request", { + requested: rawAppIds.length, + unique: appIds.length, + }); + } + + const config = getRpRegistryConfig(); + if (!config) { + logger.error("RP Registry is not configured for pre-registration"); + return NextResponse.json( + { error: "RP Registry is not configured." }, + { status: 500 }, + ); + } + + // Deliberately the shared key, not a dedicated per-RP key: a claim is not a + // registration, and minting a KMS key per unmigrated app would be thousands + // of keys we may never adopt. It is also what makes adoption decidable — + // `submitManagedRpRegistration` compares the on-chain manager against this + // one address. + const managerKmsKeyId = process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + if (!managerKmsKeyId) { + logger.error( + "RP_REGISTRY_MANAGER_KMS_KEY_ID is required for pre-registration", + ); + return NextResponse.json( + { error: "Shared manager key is not configured." }, + { status: 500 }, + ); + } + + const placeholderSigner = process.env.RP_ID_PRE_REGISTRATION_SIGNER; + if ( + !placeholderSigner || + !isAddress(placeholderSigner) || + isZeroAddress(placeholderSigner) + ) { + logger.error( + "RP_ID_PRE_REGISTRATION_SIGNER must be a valid non-zero address", + ); + return NextResponse.json( + { error: "Placeholder signer is not configured." }, + { status: 500 }, + ); + } + + const managerAddress = await resolveManagerAddress( + managerKmsKeyId, + config.kmsRegion, + ); + if (!managerAddress) { + // Without our own manager address we cannot tell "already ours" from + // "someone else's", and claiming blindly could overwrite nothing but would + // report nonsense. Fail the run instead. + logger.error("Could not resolve the shared manager address from KMS"); + return NextResponse.json( + { error: "Could not resolve the manager address." }, + { status: 503 }, + ); + } + + // Only populated on production deployments, matching the managed registration + // flow's own staging mirror condition. + const stagingConfig = + process.env.NEXT_PUBLIC_APP_ENV === "production" + ? getStagingRpRegistryConfig() + : null; + + const client = await getAPIServiceGraphqlClient(); + const results: { + app_id: string; + registry?: "production" | "staging"; + outcome: Outcome; + rp_id?: string; + }[] = []; + + for (const appId of appIds) { + const rpIdString = generateRpIdString(appId); + const rpId = parseRpId(rpIdString); + + const { app } = await getAppInfoSdk(client).GetAppInfo({ app_id: appId }); + const appInfo = app?.[0]; + if (!appInfo) { + results.push({ app_id: appId, outcome: "skipped_app_not_found" }); + continue; + } + if (appInfo.is_staging) { + // Staging apps never migrate to World ID 4.0, so there is nothing to + // protect and their rp_ids are not worth spending gas on. + results.push({ app_id: appId, outcome: "skipped_staging" }); + continue; + } + + // An app that already has a row owns its rp_id through the normal flow; + // claiming on top of it would at best waste a submission and at worst + // interfere with an in-flight registration. + const { rp_registration } = await getFetchRpSdk(client).FetchRpRegistration( + { + app_id: appId, + }, + ); + if (rp_registration?.length) { + results.push({ + app_id: appId, + outcome: "skipped_already_registered_in_portal", + rp_id: rpIdString, + }); + continue; + } + + // Both registries have to be claimed. A managed registration mirrors onto the + // staging registry on production deployments, so leaving that side free lets a + // squatter take it and make the later migration's staging registration fail — + // and this endpoint would still have reported the app as `claimed`. + const registries = [ + { label: "production" as const, config }, + ...(stagingConfig + ? [{ label: "staging" as const, config: stagingConfig }] + : []), + ]; + + for (const registry of registries) { + let onChain; + try { + onChain = await getRpFromContract( + rpId, + registry.config.contractAddress, + ); + } catch (error) { + // Never claim on a failed read: `register()` reverts with IdAlreadyInUse + // if the id is taken, so a blind submission burns gas and, worse, a read + // failure is indistinguishable from "free" here. + logger.warn("Could not read on-chain RP state; skipping", { + error, + app_id: appId, + rpIdString, + registry: registry.label, + }); + results.push({ + app_id: appId, + registry: registry.label, + outcome: "failed_rpc", + rp_id: rpIdString, + }); + continue; + } + + if (onChain.initialized) { + const isOurs = addressesEqual(onChain.manager, managerAddress); + if (!isOurs) { + // Already squatted. Nothing this endpoint can do — the contract has no + // reclaim path — but it is the single most important thing to surface. + logger.error("rp_id is already held by a foreign manager", { + app_id: appId, + rpIdString, + registry: registry.label, + onChainManager: onChain.manager, + onChainSigner: onChain.signer, + }); + } + results.push({ + app_id: appId, + registry: registry.label, + outcome: isOurs + ? "skipped_already_claimed_by_us" + : "skipped_taken_by_foreign_manager", + rp_id: rpIdString, + }); + continue; + } + + if (dryRun) { + results.push({ + app_id: appId, + registry: registry.label, + outcome: "would_claim", + rp_id: rpIdString, + }); + continue; + } + + if (!(await reserveClaim(registry.label, rpIdString))) { + // A claim for this id was submitted recently and has not settled yet. + logger.warn("Skipping an rp_id with a claim still in flight", { + app_id: appId, + rpIdString, + registry: registry.label, + }); + results.push({ + app_id: appId, + registry: registry.label, + outcome: "skipped_claim_in_flight", + rp_id: rpIdString, + }); + continue; + } + + try { + const kmsClient = await getKMSClient(registry.config.kmsRegion); + const operationHash = await submitRegisterRpTransaction( + registry.config, + { + rpId, + managerAddress, + signerAddress: placeholderSigner, + appName: appInfo.app_metadata?.[0]?.name || "", + kmsClient, + }, + ); + logger.info("Claimed rp_id defensively", { + app_id: appId, + rpIdString, + registry: registry.label, + operationHash, + }); + results.push({ + app_id: appId, + registry: registry.label, + outcome: "claimed", + rp_id: rpIdString, + }); + } catch (error) { + // Nothing was submitted, so holding the reservation would report + // `skipped_claim_in_flight` for the whole TTL on an operation that does + // not exist. + await releaseClaim(registry.label, rpIdString); + logger.error("Failed to claim rp_id", { + error, + app_id: appId, + rpIdString, + registry: registry.label, + }); + results.push({ + app_id: appId, + registry: registry.label, + outcome: "failed_submission", + rp_id: rpIdString, + }); + } + } + } + + const counts = results.reduce>((acc, r) => { + const key = r.registry ? `${r.registry}:${r.outcome}` : r.outcome; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + + // Log the breakdown rather than only the total: a run that skipped everything + // for an unexpected reason must not read as a successful sweep. + logger.info("RP id pre-registration run finished", { + dry_run: dryRun, + requested: appIds.length, + counts, + }); + + return NextResponse.json({ dry_run: dryRun, counts, results }); +} diff --git a/web/api/hasura/register-rp/index.ts b/web/api/hasura/register-rp/index.ts index 1fbd9be58..07fa93c4f 100644 --- a/web/api/hasura/register-rp/index.ts +++ b/web/api/hasura/register-rp/index.ts @@ -6,11 +6,16 @@ import { type ManagedRegistrationResult, } from "@/api/helpers/rp-registration-flows"; import { + addressesEqual, generateRpIdString, + getRpRegistryConfig, isZeroAddress, normalizeAddress, + parseRpId, RpRegistrationStatus, } from "@/api/helpers/rp-utils"; +import { resolveManagerAddress } from "@/api/helpers/rp-manager"; +import { getRpFromContract } from "@/api/helpers/temporal-rpc"; import { protectInternalEndpoint } from "@/api/helpers/utils"; import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; import { logger } from "@/lib/logger"; @@ -153,6 +158,142 @@ export const POST = async (req: NextRequest) => { // Self-managed: just create the DB record. No KMS / on-chain work. if (mode === "self_managed") { const rpIdString = generateRpIdString(app_id); + + // A self-managed developer runs `register()` from their own wallet BEFORE + // reaching this mutation — the instructions screen hands them the calldata + // and this is the "Continue" that follows. So an initialized rp_id is the + // HEALTHY state here and must not be treated as a conflict. + // + // The one case that has to fail is an id the Portal claimed defensively via + // _pre-register-rp-ids: the developer's own `register()` reverted against it, + // and handing them the id needs the manager transferred to them, which no + // flow drives yet. + // + // The PLACEHOLDER SIGNER identifies that case, not the manager address. Both + // would work, but resolving our manager means a KMS call, and KMS has no + // business in a self-managed registration — the Portal holds no keys for + // these apps. Depending on it would mean that during a KMS outage this check + // either blocks every legitimate self-managed completion (initialized is the + // normal state here) or silently falls through and lets a pre-claimed id + // through, which rp-status then promotes against the placeholder signer, + // leaving the developer a registration that can never sign. The placeholder + // is a plain env var we control, so the comparison cannot fail open. + // + // Any other signer is the developer's own registration — or a squatter's, + // which for self-managed the Portal cannot tell apart either way, unchanged + // by this PR since it stores no expected roles for self-managed rows. + // + // Two independent tells that Portal holds this id, in cost order: + // + // 1. the PLACEHOLDER SIGNER — a plain env var, no remote call, so it cannot + // fail open or fail closed on someone else's outage. Preferred. + // 2. the SHARED MANAGER address — needs a KMS round trip, so it is only a + // fallback. KMS has no business in a self-managed registration, but a + // silently broken registration is worse than a retryable error. + // + // The fallback exists because defensive claims OUTLIVE the kill switch. Keying + // the guard to ENABLE_RP_ID_PRE_REGISTRATION would leave the exact state this + // comment used to describe as an invariant — sweep has run, flag turned back + // off, placeholder unset — silently unguarded, and a Portal-held placeholder RP + // would be inserted as self_managed. rp-status trusts self-managed rows by + // mode, so it would then be promoted against a signer that can never sign. + // + // With neither tell available there is no in-band signal left, so the guard + // cannot run at all: that is a config error while claims are being made, and a + // no-op in an environment that has never claimed anything. + const primaryConfig = getRpRegistryConfig(); + const placeholderSigner = process.env.RP_ID_PRE_REGISTRATION_SIGNER; + const sharedManagerKeyId = process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + const preRegistrationEnabled = + process.env.ENABLE_RP_ID_PRE_REGISTRATION === "true"; + + const canCheckBySigner = Boolean( + primaryConfig && + placeholderSigner && + isAddress(placeholderSigner) && + !isZeroAddress(placeholderSigner), + ); + const canCheckByManager = Boolean(primaryConfig && sharedManagerKeyId); + + if (!canCheckBySigner && !canCheckByManager && preRegistrationEnabled) { + logger.error( + "Pre-registration is enabled but nothing identifies a Portal pre-claim", + { app_id, hasConfig: Boolean(primaryConfig) }, + ); + return errorHasuraQuery({ + req, + detail: "RP Registry is not configured correctly.", + code: "config_error", + app_id, + }); + } + + if (canCheckBySigner || canCheckByManager) { + let onChain; + try { + onChain = await getRpFromContract( + parseRpId(rpIdString), + primaryConfig!.contractAddress, + ); + } catch (error) { + logger.warn("Could not read on-chain RP state for a self-managed id", { + error, + app_id, + rpIdString, + }); + return errorHasuraQuery({ + req, + detail: + "Could not verify this app's RP ID on-chain. Please try again.", + code: "rpc_error", + app_id, + }); + } + + if (onChain.initialized) { + let heldByPortal = + canCheckBySigner && + addressesEqual(onChain.signer, placeholderSigner!); + + // Only pay for KMS when the cheap tell was unavailable or negative. + if (!heldByPortal && canCheckByManager) { + const ourManagerAddress = await resolveManagerAddress( + sharedManagerKeyId!, + primaryConfig!.kmsRegion, + ); + if (!ourManagerAddress) { + logger.error( + "Cannot tell whether a self-managed rp_id is a Portal pre-claim", + { app_id, rpIdString }, + ); + return errorHasuraQuery({ + req, + detail: + "Could not verify this app's RP ID. Please try again shortly.", + code: "kms_error", + app_id, + }); + } + heldByPortal = addressesEqual(onChain.manager, ourManagerAddress); + } + + if (heldByPortal) { + logger.warn("Self-managed rp_id is held by a Portal pre-claim", { + app_id, + rpIdString, + onChainManager: onChain.manager, + }); + return errorHasuraQuery({ + req, + detail: + "This app's RP ID is held by Portal and cannot be self-managed yet — contact support.", + code: "rp_id_taken", + app_id, + }); + } + } + } + const { insert_rp_registration_one: claimedSlot } = await getClaimRpSdk( client, ).ClaimRpRegistration({ diff --git a/web/api/helpers/rp-claims.ts b/web/api/helpers/rp-claims.ts new file mode 100644 index 000000000..6add1f9e0 --- /dev/null +++ b/web/api/helpers/rp-claims.ts @@ -0,0 +1,107 @@ +import "server-only"; + +/** + * Tracking for defensive rp_id claims that have been submitted but not settled. + * + * `submitRegisterRpTransaction` returns once the UserOp is submitted, not once it + * is mined, so for a window afterwards an on-chain read still shows the rp_id as + * free. Two things must not act on that stale reading: + * + * - `_pre-register-rp-ids` re-running would submit a second `register()`. The + * UserOp nonce carries per-attempt randomness, so both can be accepted and one + * later reverts, burning gas. + * - `submitManagedRpRegistration` would submit a competing `register()`. If the + * pre-claim wins on-chain with the shared manager while the row records a + * dedicated one, every later status check and retry reads the shared claim as + * foreign and that registration never reconciles. + * + * Both live here so the key format and TTL cannot drift between the writer and + * the reader — the failure mode would be a marker nobody sees. + */ + +import { USER_OP_MAX_VALIDITY_MS } from "@/api/helpers/user-operation"; + +/** + * Covers the UserOp validity window plus the same margin the status endpoint uses + * before calling an unsettled op dead. After it, the on-chain read is + * authoritative again. + */ +const CLAIM_IN_FLIGHT_TTL_SECONDS = Math.ceil( + (USER_OP_MAX_VALIDITY_MS + 5 * 60 * 1000) / 1000, +); + +export type ClaimRegistry = "production" | "staging"; + +const claimKey = (registry: ClaimRegistry, rpIdString: string) => + `rp_claim_in_flight:${registry}:${rpIdString}`; + +/** + * Reserves an rp_id for a claim about to be submitted. False means a claim is + * already settling and this one should be skipped. + * + * Fails OPEN when Redis is unavailable or errors. The claim tool is + * operator-driven and dry-runs by default, and registration must not depend on a + * cache being up; the on-chain read still catches anything that has mined. + */ +export async function reserveClaim( + registry: ClaimRegistry, + rpIdString: string, +): Promise { + const redis = global.RedisClient; + if (!redis) { + return true; + } + try { + const reserved = await redis.set( + claimKey(registry, rpIdString), + "1", + "EX", + CLAIM_IN_FLIGHT_TTL_SECONDS, + "NX", + ); + return reserved === "OK"; + } catch { + return true; + } +} + +/** + * Drops a reservation whose submission never happened, so a retry is not blocked + * for the whole TTL waiting on an operation that does not exist. + */ +export async function releaseClaim( + registry: ClaimRegistry, + rpIdString: string, +): Promise { + const redis = global.RedisClient; + if (!redis) { + return; + } + try { + await redis.del(claimKey(registry, rpIdString)); + } catch { + // Best-effort: the TTL clears it either way. + } +} + +/** + * Whether a claim for this rp_id was submitted recently and may still land. + * + * Fails CLOSED — reports "not in flight" — when Redis is unavailable, matching + * `reserveClaim`'s fail-open: neither the claim tool nor registration may be + * blocked by a cache outage. + */ +export async function isClaimInFlight( + registry: ClaimRegistry, + rpIdString: string, +): Promise { + const redis = global.RedisClient; + if (!redis) { + return false; + } + try { + return (await redis.exists(claimKey(registry, rpIdString))) === 1; + } catch { + return false; + } +} diff --git a/web/api/helpers/rp-registration-flows.ts b/web/api/helpers/rp-registration-flows.ts index b5c5e0f6a..c91748e50 100644 --- a/web/api/helpers/rp-registration-flows.ts +++ b/web/api/helpers/rp-registration-flows.ts @@ -24,12 +24,15 @@ import { getSdk as getRevertToggleSdk } from "@/api/hasura/toggle-rp-active/grap import { getSdk as getUpdateToggleSdk } from "@/api/hasura/toggle-rp-active/graphql/update-toggle-result.generated"; import { getKMSClient, scheduleKeyDeletion } from "@/api/helpers/kms"; import { createManagerKey, getEthAddressFromKMS } from "@/api/helpers/kms-eth"; +import { isClaimInFlight } from "@/api/helpers/rp-claims"; +import { resolveManagerAddress } from "@/api/helpers/rp-manager"; import { submitRegisterRpTransaction, submitRotateSignerTransaction, submitToggleRpActiveTransaction, } from "@/api/helpers/rp-transactions"; import { + addressesEqual, generateRpIdString, getRpRegistryConfig, getStagingRpRegistryConfig, @@ -151,9 +154,8 @@ export async function submitManagedRpRegistration({ // dependency to a flow that works fine without it. // // Only the read is inside the try. A wider catch would swallow a failure from - // the slot release below and fall through to KMS and a UserOp for an rp_id we - // have already proven is taken — the exact wedged row this check exists to - // avoid. + // a slot release below and fall through to KMS and a UserOp for an rp_id we + // have already resolved, which is the wedged row this check exists to avoid. let existingOnChainRp: Awaited> | null = null; try { @@ -169,35 +171,186 @@ export async function submitManagedRpRegistration({ }); } - if (existingOnChainRp?.initialized) { - logger.warn("rp_id already registered on-chain by a foreign manager", { - app_id: appId, - rpIdString, - onChainManager: existingOnChainRp.manager, - onChainSigner: existingOnChainRp.signer, - }); - // Release the slot: the app is not registered, and holding the row would - // make every later attempt report `already_registered` instead. A failure - // here leaves the row wedged, which is an ops problem — but the id really is - // taken, so that stays the answer either way, and continuing is not an - // option. + // Releasing the slot must never abort the decision that led here: the row is + // ours to clean up, and a failure leaves it wedged for ops rather than changing + // what the caller should be told. + const releaseSlot = async () => { try { await getDeleteRpSdk(client).DeleteRpRegistration({ rp_id: rpIdString }); } catch (error) { - logger.error("Failed to release the slot for a taken rp_id", { + logger.error("Failed to release the registration slot", { error, app_id: appId, rpIdString, }); } + }; + + // The claim above means the Portal held no row for this app, so an existing + // on-chain entry is either a squatter's or one WE claimed defensively via + // _pre-register-rp-ids. Those look identical except for the manager, which only + // our KMS key can sign for — so the manager is what tells them apart. + // + // Three outcomes, and the difference between them is what the caller can do: + // - manager is ours -> adopt: rotate the placeholder signer to the + // real one instead of registering an id we already hold + // - manager is someone else -> rp_id_taken, terminal, needs support + // - manager unresolvable -> kms_error, RETRYABLE. A KMS outage must not be + // reported as "someone else owns your id, contact support"; that would send + // developers of pre-claimed apps to support over a transient failure. + // Staging is read here too, not later at submission time. The row stores ONE + // manager_kms_key_id, so a pre-claim on EITHER registry has to decide the key + // for both. Deciding staging after a dedicated key was already minted for + // production leaves staging permanently failed: its register() reverts on an + // initialized id, and every later staging status/retry compares against the + // dedicated key and reads the shared pre-claim as foreign. + const stagingConfigForAdoption = + process.env.NEXT_PUBLIC_APP_ENV === "production" + ? getStagingRpRegistryConfig() + : null; + let existingStagingRp: Awaited> | null = + null; + let stagingOwnershipUnknown = false; + if (stagingConfigForAdoption) { + try { + existingStagingRp = await getRpFromContract( + rpId, + stagingConfigForAdoption.contractAddress, + ); + } catch (error) { + // Non-fatal: staging is a best-effort mirror, so blocking a production + // registration on a staging RPC failure would be the wrong trade. But + // "unknown" must not silently become "not ours" — if a pre-claim exists on + // staging and we mint a dedicated key here, staging's register() reverts on + // an initialized id and every later staging status/retry reads the shared + // pre-claim as foreign, so that side never recovers. Force the shared key + // instead, which is always a valid choice for a fresh registration. + stagingOwnershipUnknown = true; + logger.warn("Could not pre-check the staging rp_id; registering", { + error, + app_id: appId, + rpIdString, + }); + } + } + + // Scoped to environments that actually pre-claim: RP_ID_PRE_REGISTRATION_SIGNER + // stays configured once claims exist there (see _pre-register-rp-ids). Elsewhere + // an unreadable or racing staging registry changes nothing, so dedicated keys + // keep their isolation. + const preClaimsPossible = Boolean(process.env.RP_ID_PRE_REGISTRATION_SIGNER); + + let adoptExistingClaim = false; + let adoptStagingClaim = false; + if (existingOnChainRp?.initialized || existingStagingRp?.initialized) { + const sharedManagerKeyId = process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + const ourManagerAddress = sharedManagerKeyId + ? await resolveManagerAddress(sharedManagerKeyId, primaryConfig.kmsRegion) + : null; + + // Two ways ownership can be UNKNOWN rather than foreign, and both must stay + // retryable: the key is configured but KMS cannot resolve it, or the key is not + // configured at all in an environment that does pre-claim. Reporting either as + // rp_id_taken sends a developer whose app we hold to support over a deploy + // problem. + if (!ourManagerAddress && (sharedManagerKeyId || preClaimsPossible)) { + logger.error( + "Cannot tell whether an initialized rp_id is a Portal pre-claim", + { + app_id: appId, + rpIdString, + sharedManagerKeyConfigured: Boolean(sharedManagerKeyId), + }, + ); + await releaseSlot(); + return { + ok: false, + code: sharedManagerKeyId ? "kms_error" : "config_error", + detail: sharedManagerKeyId + ? "Failed to resolve manager key. Please try again." + : "RP Registry is not configured correctly. Please try again later.", + }; + } + + adoptStagingClaim = Boolean( + existingStagingRp?.initialized && + ourManagerAddress && + addressesEqual(existingStagingRp.manager, ourManagerAddress), + ); + + adoptExistingClaim = Boolean( + existingOnChainRp?.initialized && + ourManagerAddress && + addressesEqual(existingOnChainRp.manager, ourManagerAddress), + ); + } + + // A defensive claim may have been submitted moments ago and not mined yet, in + // which case the read above still shows the id as free. Registering into that + // window means two competing register() calls: if the pre-claim wins with the + // shared manager while this row records a dedicated one, every later status + // check and retry reads the shared claim as foreign and the registration never + // reconciles. Ask the developer to retry instead — by then the claim is visible + // and the adoption path takes over. + // Staging is checked as well — the writer reserves both registries. A settling + // staging claim only means staging cannot be decided yet, and staging is a + // best-effort mirror, so it forces the shared key rather than failing the whole + // registration (same treatment as an unreadable staging registry above). + if ( + stagingConfigForAdoption && + !existingStagingRp?.initialized && + (await isClaimInFlight("staging", rpIdString)) + ) { + stagingOwnershipUnknown = true; + logger.warn("A staging defensive claim is still settling", { + app_id: appId, + rpIdString, + }); + } + + if ( + !existingOnChainRp?.initialized && + (await isClaimInFlight("production", rpIdString)) + ) { + logger.warn("Registration raced an in-flight defensive claim", { + app_id: appId, + rpIdString, + }); + await releaseSlot(); return { ok: false, - code: "rp_id_taken", + code: "submission_error", detail: - "This app's RP ID is already registered on-chain by another party. Portal cannot manage it — contact support.", + "This app's RP ID is being prepared. Please try again in a few minutes.", }; } + // Only the PRODUCTION registry is authoritative for whether this app can be + // registered at all; a foreign staging claim just means staging cannot mirror. + if (existingOnChainRp?.initialized) { + if (!adoptExistingClaim) { + logger.warn("rp_id already registered on-chain by a foreign manager", { + app_id: appId, + rpIdString, + onChainManager: existingOnChainRp.manager, + onChainSigner: existingOnChainRp.signer, + }); + await releaseSlot(); + return { + ok: false, + code: "rp_id_taken", + detail: + "This app's RP ID is already registered on-chain by another party. Portal cannot manage it — contact support.", + }; + } + + logger.info("Adopting an rp_id the Portal claimed defensively", { + app_id: appId, + rpIdString, + onChainSigner: existingOnChainRp.signer, + }); + } + // is_unique_manager_key is written together with the manager key at the end // of this flow. If the migration adding it has not been applied yet, fail // here rather than after the on-chain transaction has been submitted. @@ -238,7 +391,15 @@ export async function submitManagedRpRegistration({ let managerAddress: string; let isUniqueManagerKey: boolean; + // Adoption has no choice of key: the manager already recorded on-chain is the + // shared one we claimed with, and only that key can sign the rotation. Minting + // a dedicated key here would produce a manager the contract has never heard + // of, and every update would revert. A staging-only pre-claim forces it too — + // the row records one manager key for both registries. const useSharedManagerKey = + adoptExistingClaim || + adoptStagingClaim || + (stagingOwnershipUnknown && preClaimsPossible) || process.env.ENABLE_SHARED_KEY_RP_REGISTRATION === "true"; if (useSharedManagerKey) { @@ -291,17 +452,29 @@ export async function submitManagedRpRegistration({ let operationHash: string; try { - operationHash = await submitRegisterRpTransaction(primaryConfig, { - rpId, - managerAddress, - signerAddress, - appName, - kmsClient, - }); + operationHash = adoptExistingClaim + ? // The id is already registered to our manager with a placeholder + // signer, so registering again would revert with IdAlreadyInUse. + // Rotating installs the developer's real signer and completes the + // handover in one transaction. + await submitRotateSignerTransaction(primaryConfig, { + rpId, + newSignerAddress: signerAddress, + managerKmsKeyId, + kmsClient, + }) + : await submitRegisterRpTransaction(primaryConfig, { + rpId, + managerAddress, + signerAddress, + appName, + kmsClient, + }); } catch (error) { logger.error("Failed to submit registration transaction", { error, app_id: appId, + adopting: adoptExistingClaim, }); if (isUniqueManagerKey) { await scheduleKeyDeletion(kmsClient, managerKmsKeyId); @@ -323,16 +496,24 @@ export async function submitManagedRpRegistration({ const stagingConfig = getStagingRpRegistryConfig(); if (stagingConfig) { try { - stagingOperationHash = await submitRegisterRpTransaction( - stagingConfig, - { - rpId, - managerAddress, - signerAddress, - appName, - kmsClient, - }, - ); + // adoptStagingClaim was decided before the manager key was chosen — see + // there for why. If the sweep claimed this rp_id on staging, a register() + // here would revert with IdAlreadyInUse and record staging `failed`, + // leaving the developer unable to use staging actions. + stagingOperationHash = adoptStagingClaim + ? await submitRotateSignerTransaction(stagingConfig, { + rpId, + newSignerAddress: signerAddress, + managerKmsKeyId, + kmsClient, + }) + : await submitRegisterRpTransaction(stagingConfig, { + rpId, + managerAddress, + signerAddress, + appName, + kmsClient, + }); stagingStatus = RpRegistrationStatus.Pending; logger.info("Staging registration submitted", { rpIdString, diff --git a/web/api/helpers/rp-utils.ts b/web/api/helpers/rp-utils.ts index 41999c25c..5ca59c472 100644 --- a/web/api/helpers/rp-utils.ts +++ b/web/api/helpers/rp-utils.ts @@ -85,7 +85,12 @@ export function isZeroAddress(address: string): boolean { return normalizeAddress(address).toLowerCase() === ZERO_ADDRESS; } -function addressesEqual(a: string, b: string): boolean { +/** + * Case- and checksum-insensitive address comparison. Exported so ownership + * checks outside this module can't drift into their own `.toLowerCase()` + * comparison that skips normalization. + */ +export function addressesEqual(a: string, b: string): boolean { return ( normalizeAddress(a).toLowerCase() === normalizeAddress(b).toLowerCase() ); diff --git a/web/app/api/%5Fpre-register-rp-ids/route.ts b/web/app/api/%5Fpre-register-rp-ids/route.ts new file mode 100644 index 000000000..67f9f93ae --- /dev/null +++ b/web/app/api/%5Fpre-register-rp-ids/route.ts @@ -0,0 +1 @@ +export { POST } from "@/api/_pre-register-rp-ids"; diff --git a/web/tests/api/hasura/register-rp.test.ts b/web/tests/api/hasura/register-rp.test.ts index 227a6cb26..277b865db 100644 --- a/web/tests/api/hasura/register-rp.test.ts +++ b/web/tests/api/hasura/register-rp.test.ts @@ -14,6 +14,26 @@ jest.mock("@/api/helpers/rp-registration-flows", () => ({ submitManagedRpRegistrationMock(...args), })); +const getRpFromContractMock = jest.fn(); +jest.mock("@/api/helpers/temporal-rpc", () => ({ + getRpFromContract: (...args: unknown[]) => getRpFromContractMock(...args), +})); + +const resolveManagerAddressMock = jest.fn(); +jest.mock("@/api/helpers/rp-manager", () => ({ + resolveManagerAddress: (...args: unknown[]) => + resolveManagerAddressMock(...args), +})); + +const getRpRegistryConfigMock = jest.fn(); +jest.mock("@/api/helpers/rp-utils", () => { + const actual = jest.requireActual("@/api/helpers/rp-utils"); + return { + ...actual, + getRpRegistryConfig: () => getRpRegistryConfigMock(), + }; +}); + jest.mock("@/lib/logger", () => ({ logger: { info: jest.fn(), @@ -62,12 +82,33 @@ const createMockRequest = (input: Record) => }); // #endregion +const portalManagerAddress = "0x2222222222222222222222222222222222222222"; +const placeholderSigner = "0x000000000000000000000000000000000000dEaD"; + beforeEach(() => { jest.clearAllMocks(); process.env.INTERNAL_ENDPOINTS_SECRET = "internal-secret"; + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = + "arn:aws:kms:eu-west-1:000000000000:key/shared-manager"; + process.env.RP_ID_PRE_REGISTRATION_SIGNER = placeholderSigner; + delete process.env.ENABLE_RP_ID_PRE_REGISTRATION; appIsStaging = false; authorizedTeam = [{ id: teamId }]; + getRpRegistryConfigMock.mockReturnValue({ + contractAddress: "0xcontract", + kmsRegion: "eu-west-1", + }); + resolveManagerAddressMock.mockResolvedValue(portalManagerAddress); + // Default: the self-managed developer has already run register() themselves, + // which is the state this mutation is reached in. + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: "0xDeveloperOwnedManager", + signer: "0xDeveloperOwnedSigner", + }); + submitManagedRpRegistrationMock.mockResolvedValue({ ok: true, rpIdString: "rp_abc123", @@ -153,6 +194,136 @@ describe("/api/hasura/register-rp [success]", () => { }); // #endregion +// #region Self-managed vs a defensively claimed rp_id +describe("/api/hasura/register-rp [self-managed on-chain ownership]", () => { + const selfManaged = () => + POST(createMockRequest({ app_id: appId, mode: "self_managed" })); + + it("succeeds when the developer has already registered the id themselves", async () => { + // This mutation is the "Continue" AFTER the instructions screen, so the id + // being initialized on-chain is the healthy state. Treating any initialized + // id as a conflict would break every legitimate self-managed completion. + const res = (await selfManaged())!; + + expect(res.status).toBe(200); + expect((await res.json()).status).toBe("pending"); + }); + + it("refuses when the id carries the Portal's pre-claim placeholder signer", async () => { + // Defensively pre-claimed: the developer's register() reverted against it, + // and handing the id over needs a manager transfer that no flow drives yet. + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: portalManagerAddress, + signer: placeholderSigner, + }); + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("rp_id_taken"); + }); + + it("does not depend on KMS to recognise a pre-claim", async () => { + // The check compares the placeholder signer, an env var we control, rather + // than resolving our manager key. A KMS outage must not decide between + // blocking every legitimate self-managed completion and silently admitting a + // pre-claimed id that rp-status would then promote against a dead signer. + resolveManagerAddressMock.mockResolvedValue(null); + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: portalManagerAddress, + signer: placeholderSigner, + }); + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("rp_id_taken"); + expect(resolveManagerAddressMock).not.toHaveBeenCalled(); + }); + + it("returns a retryable error when the chain cannot be read and pre-claims are possible", async () => { + // Admitting a pre-claimed id here creates a row that rp-status promotes (it + // trusts self-managed rows by mode) against a signer that can never sign. + // A retryable error is recoverable; that registration is not. + getRpFromContractMock.mockRejectedValue(new Error("rpc timeout")); + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("rpc_error"); + }); + + it("skips the check entirely where pre-claims cannot exist", async () => { + // Neither tell available and pre-registration never enabled: there is nothing + // to recognise, so a read failure must not block onboarding. This is every + // environment that has not run the tool. + delete process.env.RP_ID_PRE_REGISTRATION_SIGNER; + delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + delete process.env.ENABLE_RP_ID_PRE_REGISTRATION; + getRpFromContractMock.mockRejectedValue(new Error("rpc timeout")); + + const res = (await selfManaged())!; + + expect(res.status).toBe(200); + expect(getRpFromContractMock).not.toHaveBeenCalled(); + }); + + it("refuses to proceed when pre-registration is on and nothing identifies a claim", async () => { + // Claims are being made and neither tell is available — a deploy fault, not + // something a developer should absorb as a broken registration. + process.env.ENABLE_RP_ID_PRE_REGISTRATION = "true"; + process.env.RP_ID_PRE_REGISTRATION_SIGNER = `0x${"0".repeat(40)}`; + delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("config_error"); + }); + + it("falls back to the shared manager when the placeholder signer is unset", async () => { + // Defensive claims outlive the kill switch, so the guard cannot be keyed to it: + // sweep has run, flag turned back off, placeholder unset. Without the manager + // fallback a Portal-held placeholder RP would be inserted as self_managed and + // rp-status would promote it against a signer that can never sign. + delete process.env.RP_ID_PRE_REGISTRATION_SIGNER; + delete process.env.ENABLE_RP_ID_PRE_REGISTRATION; + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: portalManagerAddress, + signer: "0xSomeOtherSigner", + }); + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("rp_id_taken"); + expect(resolveManagerAddressMock).toHaveBeenCalled(); + }); + + it("returns a retryable error when the manager fallback cannot resolve", async () => { + delete process.env.RP_ID_PRE_REGISTRATION_SIGNER; + resolveManagerAddressMock.mockResolvedValue(null); + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: portalManagerAddress, + signer: "0xSomeOtherSigner", + }); + + const res = (await selfManaged())!; + const body = await res.json(); + + expect(body.code ?? body.extensions?.code).toBe("kms_error"); + }); +}); +// #endregion + // #region Staging app migration (product guard — kept) describe("/api/hasura/register-rp [staging app migration]", () => { it("rejects managed RP registration for staging apps", async () => { diff --git a/web/tests/api/helpers/rp-registration-flows.test.ts b/web/tests/api/helpers/rp-registration-flows.test.ts index 6ac024c31..0cab18d4f 100644 --- a/web/tests/api/helpers/rp-registration-flows.test.ts +++ b/web/tests/api/helpers/rp-registration-flows.test.ts @@ -92,12 +92,14 @@ jest.mock("@/api/helpers/temporal-rpc", () => ({ const submitToggleRpActiveTransactionMock = jest.fn(); const submitRegisterRpTransactionMock = jest.fn(); +const submitRotateSignerTransactionMock = jest.fn(); jest.mock("@/api/helpers/rp-transactions", () => ({ submitToggleRpActiveTransaction: (...args: unknown[]) => submitToggleRpActiveTransactionMock(...args), submitRegisterRpTransaction: (...args: unknown[]) => submitRegisterRpTransactionMock(...args), - submitRotateSignerTransaction: jest.fn(), + submitRotateSignerTransaction: (...args: unknown[]) => + submitRotateSignerTransactionMock(...args), })); jest.mock("@/api/helpers/kms", () => ({ @@ -159,13 +161,15 @@ const sharedManagerKeyArn = "arn:aws:kms:eu-west-1:000000000000:key/shared-manager"; const dedicatedManagerKeyId = "dedicated-kms-key"; -beforeEach(() => { +beforeEach(async () => { jest.clearAllMocks(); + await global.RedisClient?.flushall(); // Non-production by default so the staging mirror is out of scope; the // staging suite opts in explicitly. process.env.NEXT_PUBLIC_APP_ENV = "test"; delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; delete process.env.ENABLE_SHARED_KEY_RP_REGISTRATION; + delete process.env.RP_ID_PRE_REGISTRATION_SIGNER; mockGetRpRegistryConfig.mockReturnValue({ contractAddress: "0xcontract", kmsRegion: "us-east-1", @@ -208,6 +212,7 @@ beforeEach(() => { rp_registration_by_pk: { rp_id: rpId, is_unique_manager_key: false }, }); submitRegisterRpTransactionMock.mockResolvedValue("0xregophash"); + submitRotateSignerTransactionMock.mockResolvedValue("0xrotateophash"); (createManagerKey as jest.Mock).mockResolvedValue({ keyId: dedicatedManagerKeyId, address: managerAddress, @@ -1143,6 +1148,240 @@ describe("submitManagedRpRegistration [rp_id collision guard]", () => { expect(getRpFromContractMock).not.toHaveBeenCalled(); }); + it("adopts an rp_id the Portal claimed defensively by rotating its signer", async () => { + // Pre-registration (_pre-register-rp-ids) claims the id with the SHARED + // manager key and a placeholder signer. Registering again would revert with + // IdAlreadyInUse, so the real registration has to rotate instead — otherwise + // every pre-claimed app is permanently locked out of onboarding. + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: managerAddress, + signer: "0x000000000000000000000000000000000000dead", + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: true, operationHash: "0xrotateophash" }); + expect(submitRotateSignerTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ newSignerAddress: signerAddress }), + ); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + // Adoption must sign with the key already recorded on-chain, so it cannot + // mint a dedicated one even though shared mode is off. + expect(createManagerKey as jest.Mock).not.toHaveBeenCalled(); + }); + + it("returns a retryable kms_error, not rp_id_taken, when our manager cannot be resolved", async () => { + // A KMS outage must not be reported as "someone else owns your id, contact + // support" — that sends developers of pre-claimed apps to support over a + // transient failure they could just retry. + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + (getEthAddressFromKMS as jest.Mock).mockRejectedValue( + new Error("kms unavailable"), + ); + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: managerAddress, + signer: "0x000000000000000000000000000000000000dead", + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: false, code: "kms_error" }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + expect(submitRotateSignerTransactionMock).not.toHaveBeenCalled(); + // The slot is released so a retry is not answered with already_registered. + expect(DeleteRpRegistration).toHaveBeenCalledWith({ + rp_id: expect.stringMatching(/^rp_/), + }); + }); + + it("still refuses a foreign claim when the shared manager key is configured", async () => { + // The manager is what distinguishes our own claim from a squatter's, so the + // adoption path must not widen into "any initialized RP is ours". + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: "0x00000000000000000000000000000000000000ff", + signer: "0x00000000000000000000000000000000000000ee", + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: false, code: "rp_id_taken" }); + expect(submitRotateSignerTransactionMock).not.toHaveBeenCalled(); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("uses the shared key when only STAGING was pre-claimed", async () => { + // The sweep can succeed on staging and not production. The row stores one + // manager key, so minting a dedicated one for production would leave staging + // permanently failed: its register() reverts on an initialized id, and every + // later staging status/retry compares against the dedicated key and reads the + // shared pre-claim as foreign. + process.env.NEXT_PUBLIC_APP_ENV = "production"; + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + mockGetStagingRpRegistryConfig.mockReturnValue({ + contractAddress: "0xstagingcontract", + kmsRegion: "us-east-1", + }); + getRpFromContractMock.mockImplementation( + async (_rpId: bigint, contractAddress: string) => ({ + // Free on production, already ours on staging. + initialized: contractAddress === "0xstagingcontract", + active: contractAddress === "0xstagingcontract", + manager: managerAddress, + signer: "0x000000000000000000000000000000000000dead", + }), + ); + + const res = await register(); + + expect(res).toMatchObject({ ok: true }); + // Shared key, not a dedicated one, even though shared mode is off. + expect(createManagerKey as jest.Mock).not.toHaveBeenCalled(); + // Production registers (it was free); staging rotates (already ours). + expect(submitRegisterRpTransactionMock).toHaveBeenCalledTimes(1); + expect(submitRotateSignerTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ contractAddress: "0xstagingcontract" }), + expect.objectContaining({ newSignerAddress: signerAddress }), + ); + }); + + it("uses the shared key when staging ownership cannot be determined", async () => { + // The staging read is best-effort, so it must not block a production + // registration — but "unknown" must not become "not ours" either. If staging + // is in fact pre-claimed and we mint a dedicated key, staging's register() + // reverts on an initialized id and every later staging status/retry reads the + // shared pre-claim as foreign, so that side never recovers. + process.env.NEXT_PUBLIC_APP_ENV = "production"; + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + // Set only where pre-registration has actually been run, which is what scopes + // this fallback. + process.env.RP_ID_PRE_REGISTRATION_SIGNER = + "0x000000000000000000000000000000000000dEaD"; + mockGetStagingRpRegistryConfig.mockReturnValue({ + contractAddress: "0xstagingcontract", + kmsRegion: "us-east-1", + }); + getRpFromContractMock.mockImplementation( + async (_rpId: bigint, contractAddress: string) => { + if (contractAddress === "0xstagingcontract") { + throw new Error("staging rpc timeout"); + } + return { + initialized: false, + active: false, + manager: `0x${"0".repeat(40)}`, + signer: `0x${"0".repeat(40)}`, + }; + }, + ); + + const res = await register(); + + expect(res).toMatchObject({ ok: true }); + expect(createManagerKey as jest.Mock).not.toHaveBeenCalled(); + }); + + it("refuses to race a defensive claim that has not mined yet", async () => { + // The claim was submitted moments ago, so the on-chain read still shows the id + // as free. Registering here means two competing register() calls; if the + // pre-claim wins with the shared manager while this row records a dedicated + // one, status and retry read the shared claim as foreign forever. + const { reserveClaim } = jest.requireActual("@/api/helpers/rp-claims"); + const { generateRpIdString } = jest.requireActual("@/lib/rp"); + await reserveClaim("production", generateRpIdString(appId)); + + getRpFromContractMock.mockResolvedValue({ + initialized: false, + active: false, + manager: `0x${"0".repeat(40)}`, + signer: `0x${"0".repeat(40)}`, + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: false, code: "submission_error" }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + expect(DeleteRpRegistration).toHaveBeenCalledWith({ + rp_id: expect.stringMatching(/^rp_/), + }); + }); + + it("returns a retryable config error when the shared key is missing but pre-claims exist", async () => { + // A missing RP_REGISTRY_MANAGER_KMS_KEY_ID is a deploy problem, not proof that + // someone else owns the id. Reporting rp_id_taken sends a developer whose app + // we are holding to support over a config outage. + process.env.RP_ID_PRE_REGISTRATION_SIGNER = + "0x000000000000000000000000000000000000dEaD"; + delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: managerAddress, + signer: "0x000000000000000000000000000000000000dead", + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: false, code: "config_error" }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("still reports rp_id_taken for a foreign id where pre-claims are impossible", async () => { + // The retryable path above must not swallow the genuine squatter case in an + // environment that has never pre-claimed anything. + delete process.env.RP_ID_PRE_REGISTRATION_SIGNER; + delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: "0x00000000000000000000000000000000000000ff", + signer: "0x00000000000000000000000000000000000000ee", + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: false, code: "rp_id_taken" }); + }); + + it("forces the shared key when a STAGING claim is still settling", async () => { + // The writer reserves both registries; a reader that only checks production + // lets a dedicated key be minted while the staging pre-claim lands, which + // leaves staging unrecoverable. Staging is best-effort, so this forces the + // shared key rather than failing the registration. + process.env.NEXT_PUBLIC_APP_ENV = "production"; + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = sharedManagerKeyArn; + process.env.RP_ID_PRE_REGISTRATION_SIGNER = + "0x000000000000000000000000000000000000dEaD"; + mockGetStagingRpRegistryConfig.mockReturnValue({ + contractAddress: "0xstagingcontract", + kmsRegion: "us-east-1", + }); + const { reserveClaim } = jest.requireActual("@/api/helpers/rp-claims"); + const { generateRpIdString } = jest.requireActual("@/lib/rp"); + await reserveClaim("staging", generateRpIdString(appId)); + + // Both registries read as free — the staging claim has not mined. + getRpFromContractMock.mockResolvedValue({ + initialized: false, + active: false, + manager: `0x${"0".repeat(40)}`, + signer: `0x${"0".repeat(40)}`, + }); + + const res = await register(); + + expect(res).toMatchObject({ ok: true }); + expect(createManagerKey as jest.Mock).not.toHaveBeenCalled(); + }); + it("still reports rp_id_taken when releasing the slot fails", async () => { // The read's non-fatal catch must not extend over the slot release: swallowing // that failure would fall through to KMS and a UserOp for an rp_id already diff --git a/web/tests/api/pre-register-rp-ids.test.ts b/web/tests/api/pre-register-rp-ids.test.ts new file mode 100644 index 000000000..331f1406d --- /dev/null +++ b/web/tests/api/pre-register-rp-ids.test.ts @@ -0,0 +1,391 @@ +import { POST } from "@/api/_pre-register-rp-ids"; +import { NextRequest } from "next/server"; + +// #region Mocks +const GetAppInfo = jest.fn(); +jest.mock("@/api/hasura/register-rp/graphql/get-app-info.generated", () => ({ + getSdk: () => ({ GetAppInfo }), +})); + +const FetchRpRegistration = jest.fn(); +jest.mock("@/api/helpers/graphql/fetch-rp-registration.generated", () => ({ + getSdk: () => ({ FetchRpRegistration }), +})); + +jest.mock("@/api/helpers/graphql", () => ({ + getAPIServiceGraphqlClient: jest.fn().mockResolvedValue({}), +})); + +jest.mock("@/api/helpers/kms", () => ({ + getKMSClient: jest.fn().mockResolvedValue({}), +})); + +const resolveManagerAddressMock = jest.fn(); +jest.mock("@/api/helpers/rp-manager", () => ({ + resolveManagerAddress: (...args: unknown[]) => + resolveManagerAddressMock(...args), +})); + +const submitRegisterRpTransactionMock = jest.fn(); +jest.mock("@/api/helpers/rp-transactions", () => ({ + submitRegisterRpTransaction: (...args: unknown[]) => + submitRegisterRpTransactionMock(...args), +})); + +const getRpFromContractMock = jest.fn(); +jest.mock("@/api/helpers/temporal-rpc", () => ({ + getRpFromContract: (...args: unknown[]) => getRpFromContractMock(...args), +})); + +const getRpRegistryConfigMock = jest.fn(); +const getStagingRpRegistryConfigMock = jest.fn(); +jest.mock("@/api/helpers/rp-utils", () => { + const actual = jest.requireActual("@/api/helpers/rp-utils"); + return { + ...actual, + getRpRegistryConfig: () => getRpRegistryConfigMock(), + getStagingRpRegistryConfig: () => getStagingRpRegistryConfigMock(), + }; +}); + +jest.mock("@/lib/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); +// #endregion + +// #region Test Data +const appId = "app_00000000000000000000000000000001"; +const managerAddress = "0x2222222222222222222222222222222222222222"; +const placeholderSigner = "0x000000000000000000000000000000000000dEaD"; + +const createRequest = (body: unknown) => + new NextRequest("https://cdn.test.com/api/_pre-register-rp-ids", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + authorization: `Bearer ${process.env.INTERNAL_ENDPOINTS_SECRET}`, + }), + body: JSON.stringify(body), + }); + +// The handler's return type is nullable because protectInternalEndpoint's +// errorResponse is; every call here is authenticated, so narrow once. +const post = async (body: unknown) => { + const res = await POST(createRequest(body)); + expect(res).not.toBeNull(); + return res!; +}; +// #endregion + +beforeEach(async () => { + jest.clearAllMocks(); + // The in-flight claim markers are real Redis keys; without this the second test + // to touch an rp_id would be skipped as still settling. + await global.RedisClient?.flushall(); + process.env.INTERNAL_ENDPOINTS_SECRET = "test_secret"; + process.env.ENABLE_RP_ID_PRE_REGISTRATION = "true"; + process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID = + "arn:aws:kms:eu-west-1:000000000000:key/shared-manager"; + process.env.RP_ID_PRE_REGISTRATION_SIGNER = placeholderSigner; + + getRpRegistryConfigMock.mockReturnValue({ + contractAddress: "0xcontract", + kmsRegion: "eu-west-1", + }); + delete process.env.NEXT_PUBLIC_APP_ENV; + getStagingRpRegistryConfigMock.mockReturnValue(null); + resolveManagerAddressMock.mockResolvedValue(managerAddress); + GetAppInfo.mockResolvedValue({ + app: [{ id: appId, is_staging: false, app_metadata: [{ name: "Test" }] }], + }); + FetchRpRegistration.mockResolvedValue({ rp_registration: [] }); + getRpFromContractMock.mockResolvedValue({ + initialized: false, + active: false, + manager: `0x${"0".repeat(40)}`, + signer: `0x${"0".repeat(40)}`, + }); + submitRegisterRpTransactionMock.mockResolvedValue("0xclaimop"); +}); + +// #region Kill switch and configuration guards +describe("/api/_pre-register-rp-ids [guards]", () => { + it("refuses to run when the kill switch is off", async () => { + delete process.env.ENABLE_RP_ID_PRE_REGISTRATION; + + const res = await post({ app_ids: [appId] }); + + expect(res.status).toBe(503); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("refuses to run without a shared manager key", async () => { + delete process.env.RP_REGISTRY_MANAGER_KMS_KEY_ID; + + const res = await post({ app_ids: [appId] }); + + expect(res.status).toBe(500); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("refuses a zero-address placeholder signer", async () => { + // A zero signer would be accepted by the contract and leave an RP nobody + // can ever sign for. + process.env.RP_ID_PRE_REGISTRATION_SIGNER = `0x${"0".repeat(40)}`; + + const res = await post({ app_ids: [appId] }); + + expect(res.status).toBe(500); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("refuses to claim when our own manager address cannot be resolved", async () => { + // Without it, "already ours" is indistinguishable from "someone else's". + resolveManagerAddressMock.mockResolvedValue(null); + + const res = await post({ app_ids: [appId] }); + + expect(res.status).toBe(503); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it('rejects the string "false" for dry_run rather than spending gas on it', async () => { + // yup would otherwise cast it to boolean false. Opting out of the dry run has + // to be an explicit JSON boolean — that is the whole safety property. + const res = await post({ app_ids: [appId], dry_run: "false" }); + + expect(res.status).toBe(400); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("still dry-runs when dry_run is omitted entirely", async () => { + // Guards against fixing the cast by making the field strict, which would skip + // yup's default and leave an absent dry_run undefined — falsy, so it would + // spend gas. Exactly backwards. + const res = await post({ app_ids: [appId] }); + const body = await res.json(); + + expect(body.dry_run).toBe(true); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("rejects a batch larger than the per-call ceiling", async () => { + const tooMany = Array.from( + { length: 26 }, + (_, i) => `app_${String(i).padStart(32, "0")}`, + ); + + const res = await post({ app_ids: tooMany }); + + expect(res.status).toBe(400); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); +}); +// #endregion + +// #region Dry run is the default +describe("/api/_pre-register-rp-ids [dry run]", () => { + it("reports what it would claim without submitting anything", async () => { + const res = await post({ app_ids: [appId] }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.dry_run).toBe(true); + expect(body.counts).toEqual({ "production:would_claim": 1 }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("only submits when dry_run is explicitly false", async () => { + const res = await post({ app_ids: [appId], dry_run: false }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.counts).toEqual({ "production:claimed": 1 }); + expect(submitRegisterRpTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + managerAddress, + signerAddress: placeholderSigner, + }), + ); + }); +}); +// #endregion + +// #region Per-app skip decisions +describe("/api/_pre-register-rp-ids [skips]", () => { + const run = () => post({ app_ids: [appId], dry_run: false }); + + it("skips an app that already has a Portal registration", async () => { + FetchRpRegistration.mockResolvedValue({ + rp_registration: [{ rp_id: "rp_0123456789abcdef" }], + }); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ skipped_already_registered_in_portal: 1 }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("skips staging apps, which never migrate", async () => { + GetAppInfo.mockResolvedValue({ + app: [{ id: appId, is_staging: true, app_metadata: [{ name: "Test" }] }], + }); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ skipped_staging: 1 }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("skips an id we already claimed, so repeated runs are idempotent", async () => { + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: managerAddress, + signer: placeholderSigner, + }); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ + "production:skipped_already_claimed_by_us": 1, + }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("reports an id already squatted by a foreign manager", async () => { + getRpFromContractMock.mockResolvedValue({ + initialized: true, + active: true, + manager: "0x00000000000000000000000000000000000000ff", + signer: "0x00000000000000000000000000000000000000ee", + }); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ + "production:skipped_taken_by_foreign_manager": 1, + }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("does not claim on a failed on-chain read", async () => { + // register() reverts with IdAlreadyInUse if the id is taken, so a blind + // submission after a failed read just burns gas. + getRpFromContractMock.mockRejectedValue(new Error("rpc timeout")); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ "production:failed_rpc": 1 }); + expect(submitRegisterRpTransactionMock).not.toHaveBeenCalled(); + }); + + it("claims a repeated app_id only once", async () => { + // submitRegisterRpTransaction returns on submission, not on mining, so a + // duplicate would read the chain as still free and submit a second + // register() for the same rp_id — double-spending gas. + const res = await post({ app_ids: [appId, appId], dry_run: false }); + + const body = await res.json(); + expect(body.counts).toEqual({ "production:claimed": 1 }); + expect(submitRegisterRpTransactionMock).toHaveBeenCalledTimes(1); + }); + + it("claims the staging registry too when it is configured", async () => { + // A managed registration mirrors onto staging on production deployments, so + // leaving that side free lets a squatter take it and make the later + // migration's staging registration fail — while this endpoint still reported + // the app as claimed. + process.env.NEXT_PUBLIC_APP_ENV = "production"; + getStagingRpRegistryConfigMock.mockReturnValue({ + contractAddress: "0xstagingcontract", + kmsRegion: "eu-west-1", + }); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ + "production:claimed": 1, + "staging:claimed": 1, + }); + expect(submitRegisterRpTransactionMock).toHaveBeenCalledTimes(2); + const contracts = submitRegisterRpTransactionMock.mock.calls.map( + (call) => call[0].contractAddress, + ); + expect(contracts).toEqual(["0xcontract", "0xstagingcontract"]); + }); + + it("reports the registries separately when only one side is free", async () => { + // Half-claimed must be visible in the counts rather than collapsing into a + // single "claimed". + process.env.NEXT_PUBLIC_APP_ENV = "production"; + getStagingRpRegistryConfigMock.mockReturnValue({ + contractAddress: "0xstagingcontract", + kmsRegion: "eu-west-1", + }); + getRpFromContractMock.mockImplementation( + async (_rpId: bigint, contractAddress: string) => ({ + initialized: contractAddress === "0xstagingcontract", + active: contractAddress === "0xstagingcontract", + manager: "0x00000000000000000000000000000000000000ff", + signer: "0x00000000000000000000000000000000000000ee", + }), + ); + + const body = await (await run()).json(); + + expect(body.counts).toEqual({ + "production:claimed": 1, + "staging:skipped_taken_by_foreign_manager": 1, + }); + }); + + it("does not resubmit a claim that is still settling", async () => { + // submitRegisterRpTransaction returns on submission, not on mining, so a + // repeat run sees the id as still uninitialized. The UserOp nonce carries + // per-attempt randomness, so both can be accepted and one later reverts, + // burning gas. Dedupe within one payload does not cover this. + const first = await (await run()).json(); + expect(first.counts).toEqual({ "production:claimed": 1 }); + + const second = await (await run()).json(); + + expect(second.counts).toEqual({ "production:skipped_claim_in_flight": 1 }); + expect(submitRegisterRpTransactionMock).toHaveBeenCalledTimes(1); + }); + + it("releases the reservation when the submission fails", async () => { + // Nothing was submitted, so holding the reservation would report + // skipped_claim_in_flight for the whole TTL on an operation that never + // existed, leaving the id unclaimed by the tool. + submitRegisterRpTransactionMock.mockRejectedValueOnce( + new Error("bundler down"), + ); + const first = await (await run()).json(); + expect(first.counts).toEqual({ "production:failed_submission": 1 }); + + const second = await (await run()).json(); + + expect(second.counts).toEqual({ "production:claimed": 1 }); + }); + + it("records a submission failure without aborting the rest of the batch", async () => { + const secondAppId = "app_00000000000000000000000000000002"; + submitRegisterRpTransactionMock + .mockRejectedValueOnce(new Error("bundler down")) + .mockResolvedValueOnce("0xclaimop"); + + const res = await post({ + app_ids: [appId, secondAppId], + dry_run: false, + }); + const body = await res.json(); + + expect(body.counts).toEqual({ + "production:failed_submission": 1, + "production:claimed": 1, + }); + }); +}); +// #endregion