diff --git a/docs/management/api_direct.mdx b/docs/management/api_direct.mdx
index 4a076854..ea714d10 100644
--- a/docs/management/api_direct.mdx
+++ b/docs/management/api_direct.mdx
@@ -380,6 +380,14 @@ No API key required. `primaryType` must be `CreateWallet`.
Pass `derivation_path` verbatim into `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)` on the AccountConfig contract — until that lands, the PKP exists in MPC but is not registered to any account.
+
+**Prefer [`POST /prepare_wallet`](#post-prepare-wallet) unless you specifically need the ownership signature.** It returns the same `{ wallet_address, derivation_path }` with no signature and no API key, and exists purely to streamline the ChainSecured wallet-creation ceremony:
+
+- **One wallet prompt instead of two or three.** The full signature flow is: sign the `CreateWallet` typed data → call this endpoint → sign (and wait on) the `registerWalletDerivation` transaction — plus an `AddUsageApiKey` signature if you're attaching a usage key in the same flow. With `prepare_wallet`, the user signs exactly once: the on-chain registration transaction.
+- **Faster.** No round-trip to the user's wallet before the mint, and no waiting between sequential prompts — you fetch the address, then go straight to the one transaction that matters.
+- **Same security.** The on-chain `registerWalletDerivation` (inside your owner-signed transaction) is the real authorization boundary; the `CreateWallet` signature authorizes nothing durable, so dropping it gives up nothing.
+
+
```bash
curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet_with_signature" \
-H "Content-Type: application/json" \
@@ -404,6 +412,38 @@ curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet_with
}'
```
+#### `POST /prepare_wallet`
+
+The no-signature equivalent of `create_wallet_with_signature`. Returns a freshly derived `{ wallet_address, derivation_path }` so you can register the PKP on-chain yourself — folding the whole ChainSecured owner ceremony into a **single** signed transaction (the bind UserOp) instead of one wallet prompt per mint.
+
+The wallet-ownership signature on `create_wallet_with_signature` authorizes nothing durable: the real authorization boundary is your own on-chain `registerWalletDerivation`, signed inside your owner transaction. So this endpoint safely omits it.
+
+**No API key. No request body.** The server generates the derivation path itself (256-bit CSPRNG entropy); you cannot supply your own.
+
+**Response:**
+
+```json
+{
+ "wallet_address": "0x...",
+ "derivation_path": "0x..."
+}
+```
+
+Pass `derivation_path` verbatim into `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)`. Until that on-chain call lands, the PKP exists in MPC but is registered to no account — an un-registered response is equivalent to a discarded keypair (compute-only cost, exactly like a replay of the signature endpoints).
+
+```bash
+curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/prepare_wallet"
+```
+
+
+**`prepare_wallet` is not idempotent.** Every call returns a **brand-new** wallet with a fresh random derivation path:
+
+- **Retrying returns a different address.** If a call succeeds but your network drops the response, calling again does **not** recover the same wallet — it mints another one. Treat each response as throwaway until you've registered it on-chain, and register exactly once.
+- **Concurrent callers each get their own wallet.** There is no server-side dedup. If two systems both "ensure the account has a wallet," they will each get a different address and each fire a different bind UserOp, leaving the account with **two** wallets rather than one shared wallet. To converge on a single wallet, coordinate client-side (check the existing PKP registry via `list_wallets` first, or designate a single writer).
+
+There is no meaningful *collision* risk between concurrent callers: each gets an independent 256-bit random path, so deriving the same address is cryptographically negligible. If the same address somehow were registered twice **within one account**, the second call reverts (`"PKP already registered"`) — note that guard is per-account, so it does not prevent a *different* account from registering an address it observed elsewhere.
+
+
#### `POST /convert_to_chain_secured_account`
Flips a managed (API-mode) account to ChainSecured (unmanaged) in a single on-chain transaction. The account's `apiKeyHash` is preserved — groups, PKPs, action metadata, and usage API keys (everything keyed by `apiKeyHash` on-chain) stay attached. Only the admin wallet and the `managed` flag change on-chain. **Billing should be re-verified after conversion:** Stripe credits are associated with the Stripe customer resolved from the current admin wallet address, so a credit balance is not guaranteed to carry over automatically when the admin wallet changes. There is no reverse path.
diff --git a/e2e/fixtures/api-client.ts b/e2e/fixtures/api-client.ts
index 2f8ed085..e41b0add 100644
--- a/e2e/fixtures/api-client.ts
+++ b/e2e/fixtures/api-client.ts
@@ -29,6 +29,11 @@ export interface AddUsageKeyResponse {
usage_api_key: string;
}
+export interface PrepareWalletResponse {
+ wallet_address: string;
+ derivation_path: string;
+}
+
export interface LitActionResponse {
has_error: boolean;
response: unknown;
@@ -114,6 +119,14 @@ export class LitApiClient {
return this.request('POST', '/new_account', { body: input });
}
+ /**
+ * Fetch a fresh derived wallet address + derivation path. No auth, no body.
+ * NOT idempotent — every call returns a brand-new wallet.
+ */
+ prepareWallet(): Promise {
+ return this.request('POST', '/prepare_wallet');
+ }
+
addUsageApiKey(
apiKey: string,
input: {
diff --git a/e2e/tests/api/prepare-wallet.spec.ts b/e2e/tests/api/prepare-wallet.spec.ts
new file mode 100644
index 00000000..a91d80ac
--- /dev/null
+++ b/e2e/tests/api/prepare-wallet.spec.ts
@@ -0,0 +1,34 @@
+/**
+ * `POST /core/v1/prepare_wallet` — the unsigned derived-wallet-address endpoint.
+ *
+ * This is the no-signature equivalent of `create_wallet_with_signature`: it lets
+ * a ChainSecured client obtain a PKP address before registering it on-chain, so
+ * the owner ceremony collapses into a single signed bind UserOp (flows#532).
+ *
+ * Covers:
+ * - No auth / no body required; returns a well-formed address + derivation path.
+ * - NOT idempotent: two calls return two different wallets (no server-side dedup).
+ */
+
+import { test, expect } from '../../fixtures/test';
+
+const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
+// 0x-prefixed lowercase hex uint256; server formats via `format!("0x{:x}", ..)`
+// so leading zeros are trimmed — accept 1..=64 hex digits.
+const DERIVATION_PATH_RE = /^0x[0-9a-f]{1,64}$/;
+
+test.describe('prepare_wallet (unsigned derived address)', () => {
+ test('returns a well-formed address and derivation path with no auth', async ({ apiClient }) => {
+ const res = await apiClient.prepareWallet();
+ expect(res.wallet_address).toMatch(ADDRESS_RE);
+ expect(res.derivation_path).toMatch(DERIVATION_PATH_RE);
+ expect(res.derivation_path).not.toBe('0x0');
+ });
+
+ test('is NOT idempotent — each call mints a distinct wallet', async ({ apiClient }) => {
+ const a = await apiClient.prepareWallet();
+ const b = await apiClient.prepareWallet();
+ expect(a.wallet_address.toLowerCase()).not.toBe(b.wallet_address.toLowerCase());
+ expect(a.derivation_path).not.toBe(b.derivation_path);
+ });
+});
diff --git a/k6/litApiServer.ts b/k6/litApiServer.ts
index 6ec00a68..52cdccc8 100644
--- a/k6/litApiServer.ts
+++ b/k6/litApiServer.ts
@@ -91,6 +91,17 @@ export interface CreateWalletWithSignatureRequest {
signature: string;
}
+/**
+ * Returned by `POST /prepare_wallet`. Same shape as `CreateWalletWithSignatureResponse` but obtained with no owner signature and no API key. The client MUST follow up with an on-chain `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)` call — until that lands the PKP exists in MPC but is registered to no account, which makes an un-registered response equivalent to a discarded keypair.
+
+NOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation path). Retrying does not return the previous address; concurrent callers each get a different wallet with no server-side dedup. See `docs/management/api_direct.mdx` for the full concurrency semantics.
+ */
+export interface PrepareWalletResponse {
+ wallet_address: string;
+ /** 0x-prefixed lowercase hex (uint256). Pass through verbatim to `registerWalletDerivation`'s `derivationPath` arg. */
+ derivation_path: string;
+}
+
/**
* Request for delete_wallet (AccountConfig.removeWalletDerivation). Master (account) API key via header — usage API keys are rejected on-chain (`NotMasterAccount`).
@@ -692,6 +703,8 @@ export type CreateWalletWithSignatureDefault =
| CreateWalletWithSignatureResponse
| ErrMessage;
+export type PrepareWalletDefault = PrepareWalletResponse | ErrMessage;
+
export type DeleteWalletHeaders = {
/**
* Account or usage API key. Alternatively use Authorization: Bearer .
@@ -1316,6 +1329,45 @@ Deprecated: minting is a metered write, so it should not live on a GET — link
};
}
+ /**
+ * Return a fresh derived wallet address + derivation path — no signature, no API key.
+
+The no-signature equivalent of `create_wallet_with_signature`: it collapses the ChainSecured owner ceremony into a single signed bind UserOp. Fetch the address here, then register it on-chain yourself with `registerWalletDerivation`.
+
+Unauthenticated, so it carries the same `CpuAvailable` load-shedding guard as `lit_action`: each request drives a dstack KDF call, and unlike the `_with_signature` siblings there is no EIP-712 verification in front of it, so the guard bounds how hard an anonymous caller can hammer the KDF path when the box is already saturated.
+
+NOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation path). Retrying returns a different address, and concurrent callers each get a separate wallet with no server-side dedup. See `docs/management/api_direct.mdx`.
+ */
+ prepareWallet(requestParameters?: Params): {
+ response: Response;
+ data: PrepareWalletDefault;
+ operationId: string;
+ } {
+ const k6url = new URL(this.cleanBaseUrl + `/prepare_wallet`);
+ const mergedRequestParameters = this._mergeRequestParameters(
+ requestParameters || {},
+ this.commonRequestParameters,
+ );
+ const response = http.request(
+ "POST",
+ k6url.toString(),
+ undefined,
+ mergedRequestParameters,
+ );
+ let data;
+
+ try {
+ data = response.json();
+ } catch {
+ data = response.body;
+ }
+ return {
+ response,
+ data,
+ operationId: "prepare_wallet",
+ };
+ }
+
/**
* Permanently delete a wallet (PKP). HARD DELETE: wipes the on-chain derivation path so the key can never be re-derived and anything secured by it becomes unrecoverable. Requires the master (account) API key — usage API keys are rejected on-chain (`NotMasterAccount`).
*/
diff --git a/lit-api-server/src/core/account_management.rs b/lit-api-server/src/core/account_management.rs
index ce5d5916..94015ed6 100644
--- a/lit-api-server/src/core/account_management.rs
+++ b/lit-api-server/src/core/account_management.rs
@@ -16,7 +16,7 @@ use crate::core::v1::models::response::{
AccountOpResponse, AddGroupResponse, AddUsageApiKeyResponse,
AddUsageApiKeyWithSignatureResponse, ApiKeyItem, ChainConfigKeysResponse, CreateWalletResponse,
CreateWalletWithSignatureResponse, ListMetadataItem, NewAccountResponse,
- NodeChainConfigResponse, WalletItem,
+ NodeChainConfigResponse, PrepareWalletResponse, WalletItem,
};
use crate::dstack::v1::get_client_key;
use crate::stripe::StripeState;
@@ -255,6 +255,44 @@ pub async fn create_wallet_with_signature(
})
}
+/// Return a fresh derived wallet address + derivation path with **no owner
+/// signature and no API key** — the no-signature equivalent of
+/// `create_wallet_with_signature`. Lets a ChainSecured client obtain a PKP
+/// address before registering it, so the whole owner ceremony collapses into a
+/// single signed bind UserOp instead of one WebAuthn prompt per mint.
+///
+/// The EIP-712 signature on the sibling endpoint authorizes nothing durable: it
+/// gates only the mint API's shape, and a caller can self-sign it trivially, so it
+/// is not an access-control or rate-limit boundary. What actually attaches a PKP to
+/// an account is the client's own on-chain `registerWalletDerivation` (see
+/// `core::eip712`). Dropping the signature only removes UX friction, not a security
+/// check — an un-registered response is equivalent to a discarded keypair
+/// (compute-only cost).
+///
+/// This does NOT make the derivation path a secret: paths are public (emitted by
+/// `registerWalletDerivation` and readable via `getWalletDerivation`), and the
+/// contract's per-account uniqueness does not stop a different account from
+/// registering an observed path. That cross-account hijack is a pre-existing issue
+/// independent of this endpoint (paths/addresses are already public today) and is
+/// tracked in chipotle#575 — this endpoint neither introduces nor widens it.
+///
+/// NOT IDEMPOTENT: every call generates a fresh random derivation path and thus a
+/// brand-new wallet. Retrying returns a *different* address; concurrent callers
+/// each get their own wallet with no server-side dedup. Callers that must
+/// converge on one wallet coordinate client-side. See
+/// `docs/management/api_direct.mdx`.
+pub async fn prepare_wallet() -> Result {
+ let (_public_key, wallet_address, _secret, derivation_u256) = create_new_wallet().await?;
+ tracing::info!(
+ "prepare_wallet: generated unregistered PKP {:?}",
+ wallet_address
+ );
+ Ok(PrepareWalletResponse {
+ wallet_address: bytes_to_0x_hex(wallet_address.as_slice()),
+ derivation_path: format!("0x{:x}", derivation_u256),
+ })
+}
+
/// ChainSecured usage API key: server only mints the wallet (PKP) gated by an
/// EIP-712 wallet signature (`primaryType: "AddUsageApiKey"`); the client
/// follows up with on-chain `registerWalletDerivation` and `setUsageApiKey`
diff --git a/lit-api-server/src/core/v1/endpoints/account_management.rs b/lit-api-server/src/core/v1/endpoints/account_management.rs
index 8b609c38..7dd6c092 100644
--- a/lit-api-server/src/core/v1/endpoints/account_management.rs
+++ b/lit-api-server/src/core/v1/endpoints/account_management.rs
@@ -4,6 +4,7 @@ use crate::accounts::signer_pool::SignerPool;
use crate::core::account_management;
use crate::core::v1::guards::apikey::ApiKey;
use crate::core::v1::guards::billing::BilledManagementApiKey;
+use crate::core::v1::guards::cpu_overload::CpuAvailable;
use crate::core::v1::helpers::api_status::{ApiResult, ErrMessage};
use crate::core::v1::helpers::open_api_response::OpenApiResponse;
use crate::core::v1::models::request::{
@@ -18,7 +19,7 @@ use crate::core::v1::models::response::{
AccountOpResponse, AddGroupResponse, AddUsageApiKeyResponse,
AddUsageApiKeyWithSignatureResponse, ApiKeyItem, ChainConfigKeysResponse, CreateWalletResponse,
CreateWalletWithSignatureResponse, ListMetadataItem, NewAccountResponse,
- NodeChainConfigResponse, WalletItem,
+ NodeChainConfigResponse, PrepareWalletResponse, WalletItem,
};
use crate::stripe::StripeState;
use rocket::State;
@@ -250,6 +251,31 @@ pub(super) async fn create_wallet_with_signature(
}
}
+/// Return a fresh derived wallet address + derivation path — no signature, no API key.
+///
+/// The no-signature equivalent of `create_wallet_with_signature`: it collapses the
+/// ChainSecured owner ceremony into a single signed bind UserOp. Fetch the address
+/// here, then register it on-chain yourself with `registerWalletDerivation`.
+///
+/// Unauthenticated, so it carries the same `CpuAvailable` load-shedding guard as
+/// `lit_action`: each request drives a dstack KDF call, and unlike the
+/// `_with_signature` siblings there is no EIP-712 verification in front of it, so
+/// the guard bounds how hard an anonymous caller can hammer the KDF path when the
+/// box is already saturated.
+///
+/// NOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation
+/// path). Retrying returns a different address, and concurrent callers each get a
+/// separate wallet with no server-side dedup. See `docs/management/api_direct.mdx`.
+#[openapi(tag = "Account Management")]
+#[post("/prepare_wallet")]
+pub(super) async fn prepare_wallet(
+ _cpu: CpuAvailable,
+) -> OpenApiResponse {
+ OpenApiResponse {
+ response: ApiResult(account_management::prepare_wallet().await).into(),
+ }
+}
+
#[openapi(tag = "Account Management")]
#[post("/add_group", format = "json", data = "")]
pub(super) async fn add_group(
diff --git a/lit-api-server/src/core/v1/endpoints/mod.rs b/lit-api-server/src/core/v1/endpoints/mod.rs
index d89dca69..ab72c5f3 100644
--- a/lit-api-server/src/core/v1/endpoints/mod.rs
+++ b/lit-api-server/src/core/v1/endpoints/mod.rs
@@ -24,6 +24,7 @@ pub fn routes_with_spec() -> (Vec, OpenApi) {
create_wallet,
create_wallet_post,
create_wallet_with_signature,
+ prepare_wallet,
delete_wallet,
lit_action,
lit_binary_action,
diff --git a/lit-api-server/src/core/v1/models/response.rs b/lit-api-server/src/core/v1/models/response.rs
index 20575711..f2a58856 100644
--- a/lit-api-server/src/core/v1/models/response.rs
+++ b/lit-api-server/src/core/v1/models/response.rs
@@ -29,6 +29,25 @@ pub struct CreateWalletWithSignatureResponse {
pub derivation_path: String,
}
+/// Returned by `POST /prepare_wallet`. Same shape as
+/// `CreateWalletWithSignatureResponse` but obtained with no owner signature and
+/// no API key. The client MUST follow up with an on-chain
+/// `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)`
+/// call — until that lands the PKP exists in MPC but is registered to no account,
+/// which makes an un-registered response equivalent to a discarded keypair.
+///
+/// NOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random
+/// derivation path). Retrying does not return the previous address; concurrent
+/// callers each get a different wallet with no server-side dedup. See
+/// `docs/management/api_direct.mdx` for the full concurrency semantics.
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct PrepareWalletResponse {
+ pub wallet_address: String,
+ /// 0x-prefixed lowercase hex (uint256). Pass through verbatim to
+ /// `registerWalletDerivation`'s `derivationPath` arg.
+ pub derivation_path: String,
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct LitActionResponse {
pub response: serde_json::Value,
diff --git a/plans/prepare-wallet-endpoint.md b/plans/prepare-wallet-endpoint.md
new file mode 100644
index 00000000..c8ce1069
--- /dev/null
+++ b/plans/prepare-wallet-endpoint.md
@@ -0,0 +1,155 @@
+# `prepare_wallet` — unsigned derived-wallet-address endpoint (Option A)
+
+**Status:** Implemented + codex-reviewed (see §7).
+**Scope:** Add a single new unauthenticated endpoint that returns a fresh `(wallet_address, derivation_path)` pair with **no owner signature**, so clients can obtain a PKP address before registering it on-chain and fold the whole owner ceremony into one signed bind UserOp. Unblocks [flows#532](https://github.com/LIT-Protocol/flows/issues/532) (new EVM wallet 3→1 prompts, first exchange connect 2→1).
+
+Related: security issue [chipotle#575](https://github.com/LIT-Protocol/chipotle/issues/575) (public derivation paths / per-account-only uniqueness) — **pre-existing, not caused or worsened by this endpoint**, tracked separately.
+
+---
+
+## 1. Background — why this is a small change
+
+The existing "mint" endpoints don't mint anything on-chain. `create_wallet_with_signature` (`lit-api-server/src/core/account_management.rs:238`) verifies an EIP-712 signature and then just calls `create_new_wallet()`, which:
+
+1. `generate_unique_derivation_path()` → a random 256-bit path (keccak of 32 CSPRNG bytes, `utils/mod.rs:16`),
+2. `get_client_key(path)` → deterministic secp256k1 secret from the TEE/dstack KDF (`dstack/v1/mod.rs`),
+3. derives the EVM address,
+4. returns `{ wallet_address, derivation_path }`.
+
+The client then does the real authorization step itself: an on-chain `registerWalletDerivation(adminHash, wallet_address, derivation_path, …)` inside its owner-signed UserOp (`WritesFacet.sol:664`). The EIP-712 signature authorizes **nothing durable** — the code's own comment (`core/eip712.rs`) states a replay "just produces an extra unattached PKP … compute cost only." The signature is purely the API's auth shape and is the source of the extra WebAuthn prompt.
+
+**Option A** removes that signature step: an unauthenticated endpoint that returns a fresh server-generated `(wallet_address, derivation_path)`. Same return shape as `create_wallet_with_signature`, minus the signature.
+
+### Why server-generated path only (Option A, not B)
+
+We deliberately do **not** accept a client-supplied `derivation_path` in this endpoint:
+
+- **Low-entropy footgun:** paths are a global namespace. Two clients passing a weak path (e.g. `0x1`) would derive the **same** key. Server-side `generate_unique_derivation_path` guarantees 256-bit CSPRNG entropy.
+- **Avoids widening issue #575:** client-supplied paths are exactly the cross-account collision vector in that issue. Keeping path generation server-side sidesteps it entirely.
+
+A client-supplied-path lookup (Option B, needed for the #450-style recovery flow) is a **separate future endpoint** and should land only alongside the #575 contract fix.
+
+---
+
+## 2. Semantics to be explicit about (docs + code comments)
+
+`prepare_wallet` is **not idempotent**, and callers must understand this:
+
+1. **Every call returns a brand-new wallet.** Each request generates a fresh random path → fresh unique address. Retrying does **not** return the previous address; it produces another candidate.
+2. **The response is ephemeral until registered.** A `(wallet_address, derivation_path)` that is never followed by an on-chain `registerWalletDerivation` is just a discarded keypair — it secures nothing and costs nothing (equivalent to a freshly generated keypair, same as today's `_with_signature` endpoints on replay). Register exactly once; treat un-registered responses as throwaway.
+3. **No server-side dedup for concurrent callers.** Two systems that both "ensure the account has a wallet" will each get a **different** address and each fire a **different** bind UserOp → the account ends up with **two** wallets, not one shared wallet. If concurrent callers must converge on one wallet, they must coordinate client-side (check `listPkps`/existing registry first, or designate a single writer).
+
+### Is there an accidental *collision*? No.
+
+- Distinct random paths per call ⇒ distinct addresses ⇒ no shared key to collide on.
+- Even in the pathological "register the same address twice" case, the contract reverts cleanly with `"PKP already registered"` (`WritesFacet.sol:681`) — no corruption. Concurrent `prepare_wallet` calls can't hit this because their addresses differ.
+
+So: concurrent callers on the same account cannot accidentally register the *same* derivation. The only surprise is the non-idempotency above (two wallets instead of one), which the docs must state plainly.
+
+---
+
+## 3. Implementation
+
+### 3.1 Handler — `lit-api-server/src/core/account_management.rs`
+
+Add `prepare_wallet()` next to `create_wallet_with_signature`. Reuses the existing private `create_new_wallet()`; discards the secret (never returned).
+
+```rust
+/// Return a fresh derived wallet address + derivation path with NO owner
+/// signature. The client is expected to register it on-chain itself via
+/// `registerWalletDerivation` inside its own owner-signed UserOp — that
+/// on-chain call is the real authorization boundary (see core::eip712 notes).
+///
+/// NOT IDEMPOTENT: every call returns a brand-new wallet. See PrepareWalletResponse
+/// docs and docs/management/api_direct.mdx for the concurrency semantics.
+pub async fn prepare_wallet() -> Result {
+ let (_public_key, wallet_address, _secret, derivation_u256) = create_new_wallet().await?;
+ Ok(PrepareWalletResponse {
+ wallet_address: bytes_to_0x_hex(wallet_address.as_slice()),
+ derivation_path: format!("0x{:x}", derivation_u256),
+ })
+}
+```
+
+### 3.2 Response model — `lit-api-server/src/core/v1/models/response.rs`
+
+Dedicated type (clearer OpenAPI than reusing the signature response):
+
+```rust
+/// Returned by `POST /prepare_wallet`. Same shape as
+/// `CreateWalletWithSignatureResponse` but obtained with no owner signature.
+/// The client MUST follow up with an on-chain
+/// `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)`;
+/// until that lands the PKP exists in MPC but is registered to no account.
+///
+/// NOT IDEMPOTENT — each call returns a new wallet; see api_direct.mdx.
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct PrepareWalletResponse {
+ pub wallet_address: String,
+ /// 0x-prefixed lowercase hex (uint256). Pass verbatim to
+ /// `registerWalletDerivation`'s `derivationPath` arg.
+ pub derivation_path: String,
+}
+```
+
+No request model: the endpoint takes no body (server generates everything).
+
+### 3.3 Route — `lit-api-server/src/core/v1/endpoints/account_management.rs`
+
+Unauthenticated (matches its `_with_signature` siblings — the ChainSecured ceremony has no API key at this point). POST (metered-style write semantics; also keeps it off prefetchers/link-previewers, per the `create_wallet` GET-deprecation note).
+
+```rust
+/// Return a fresh derived wallet address + derivation path — no signature, no API key.
+/// Intended to collapse the ChainSecured owner ceremony into a single signed bind
+/// UserOp: fetch the address here, then register it on-chain yourself.
+///
+/// NOT IDEMPOTENT: every call returns a new wallet. See docs/management/api_direct.mdx.
+#[openapi(tag = "Account Management")]
+#[post("/prepare_wallet")]
+pub(super) async fn prepare_wallet() -> OpenApiResponse {
+ OpenApiResponse {
+ response: ApiResult(account_management::prepare_wallet().await).into(),
+ }
+}
+```
+
+Add `PrepareWalletResponse` to the response `use` block.
+
+### 3.4 Register route — `lit-api-server/src/core/v1/endpoints/mod.rs`
+
+Add `prepare_wallet,` to the `openapi_get_routes_spec![...]` list.
+
+---
+
+## 4. Docs — `docs/management/api_direct.mdx`
+
+- Add a `#### POST /prepare_wallet` section near `create_wallet_with_signature`, documenting: no auth, no body, response shape, the mandatory on-chain `registerWalletDerivation` follow-up, and the **non-idempotency / concurrency** semantics from §2 (verbatim intent).
+- Cross-reference from the `create_wallet_with_signature` section that `prepare_wallet` is the no-signature equivalent that collapses the ceremony to one prompt.
+- Note it returns the equivalent of a fresh keypair until registered (compute-only cost).
+
+---
+
+## 5. Tests
+
+- Unit (`core/account_management.rs` or a test mod): `prepare_wallet` returns 0x-prefixed 20-byte address + 0x-prefixed uint256 path; two calls return **different** addresses (non-idempotency); the returned path canonicalizes identically to what the signing path (`pkp_id_to_derviation_path` / `u256_to_derviation_path`) expects. (dstack KDF may need the same test harness/mocking the existing wallet-creation tests use — match whatever `create_wallet_with_signature` tests do; if none exist, add a focused test that doesn't require live dstack, or gate behind the existing integration test setup.)
+- Ensure OpenAPI spec builds (route compiles into `openapi_get_routes_spec!`).
+
+---
+
+## 6. Out of scope / follow-ups
+
+- **Client-supplied-path lookup (Option B)** — for #450 recovery; land with the #575 contract fix.
+- **Deprecating `create_wallet_with_signature` / `add_usage_api_key_with_signature`** — once flows migrates to `prepare_wallet` + client-generated usage keys, these become redundant. Not removed here to avoid breaking current clients.
+- **#575 contract fix** (global first-owner binding) — tracked separately; independent of this endpoint.
+
+---
+
+## 7. Codex adversarial review — outcome
+
+Ran `codex challenge` (high effort) against the working-tree diff. Three findings, all addressed:
+
+1. **[P1] On-chain registration is not a robust authorization boundary (cross-account path hijack).** This is exactly chipotle#575 — pre-existing, and this endpoint does not introduce or widen it (paths + addresses are already public). Resolution: corrected the handler doc comment so it no longer implies the on-chain registration is airtight; it now states paths are public, explains the per-account uniqueness gap, and cross-references #575. Left the public API doc narrow (explains only that the signature is non-load-bearing) rather than publishing exploit detail for an unpatched hole.
+2. **[P1] Unauthenticated, unthrottled dstack-KDF trigger (DoS).** Valid: unlike the `_with_signature` siblings there's no EIP-712 verification in front of the KDF call. Resolution: added the `CpuAvailable` load-shedding guard (same one `lit_action` uses) to `prepare_wallet`, so the endpoint sheds with 429 under CPU saturation.
+3. **[P2] Docs overclaimed "no collision / clean revert".** Valid. Resolution: reworded the `Warning` block — collision is *cryptographically negligible* (not impossible), and the `"PKP already registered"` revert is explicitly per-account, not a cross-account guard.
+
+Not changed: the endpoint stays unauthenticated by design (the ChainSecured ceremony has no API key at this point), matching its `_with_signature` siblings.
diff --git a/spec.json b/spec.json
index bdc23cca..5e0ae9f9 100644
--- a/spec.json
+++ b/spec.json
@@ -310,6 +310,37 @@
}
}
},
+ "/prepare_wallet": {
+ "post": {
+ "tags": [
+ "Account Management"
+ ],
+ "description": "Return a fresh derived wallet address + derivation path — no signature, no API key.\n\nThe no-signature equivalent of `create_wallet_with_signature`: it collapses the ChainSecured owner ceremony into a single signed bind UserOp. Fetch the address here, then register it on-chain yourself with `registerWalletDerivation`.\n\nUnauthenticated, so it carries the same `CpuAvailable` load-shedding guard as `lit_action`: each request drives a dstack KDF call, and unlike the `_with_signature` siblings there is no EIP-712 verification in front of it, so the guard bounds how hard an anonymous caller can hammer the KDF path when the box is already saturated.\n\nNOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation path). Retrying returns a different address, and concurrent callers each get a separate wallet with no server-side dedup. See `docs/management/api_direct.mdx`.",
+ "operationId": "prepare_wallet",
+ "responses": {
+ "default": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/PrepareWalletResponse"
+ },
+ {
+ "$ref": "#/components/schemas/ErrMessage"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests — the node is CPU-overloaded and shedding load. Clients receiving this response should retry the request up to five times with exponential backoff."
+ }
+ }
+ }
+ },
"/delete_wallet": {
"post": {
"tags": [
@@ -2094,6 +2125,23 @@
}
}
},
+ "PrepareWalletResponse": {
+ "description": "Returned by `POST /prepare_wallet`. Same shape as `CreateWalletWithSignatureResponse` but obtained with no owner signature and no API key. The client MUST follow up with an on-chain `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)` call — until that lands the PKP exists in MPC but is registered to no account, which makes an un-registered response equivalent to a discarded keypair.\n\nNOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation path). Retrying does not return the previous address; concurrent callers each get a different wallet with no server-side dedup. See `docs/management/api_direct.mdx` for the full concurrency semantics.",
+ "type": "object",
+ "required": [
+ "derivation_path",
+ "wallet_address"
+ ],
+ "properties": {
+ "wallet_address": {
+ "type": "string"
+ },
+ "derivation_path": {
+ "description": "0x-prefixed lowercase hex (uint256). Pass through verbatim to `registerWalletDerivation`'s `derivationPath` arg.",
+ "type": "string"
+ }
+ }
+ },
"DeleteWalletRequest": {
"description": "Request for delete_wallet (AccountConfig.removeWalletDerivation). Master (account) API key via header — usage API keys are rejected on-chain (`NotMasterAccount`).\n\nHARD DELETE: permanently and irreversibly removes the wallet (PKP) and wipes its on-chain derivation path. Anything secured by the wallet becomes unrecoverable.",
"type": "object",