From 17a898e69fcd580a3ab5dba3be1406db89b294b7 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 14 Jul 2026 15:12:46 -0400 Subject: [PATCH 1/2] feat(lit-api-server): unsigned `POST /prepare_wallet` derived-address endpoint Adds the no-signature equivalent of `create_wallet_with_signature`: returns a freshly derived `{wallet_address, derivation_path}` with no EIP-712 signature and no API key, so a ChainSecured client can obtain a PKP address before registering it on-chain and collapse the owner ceremony into a single signed bind UserOp (flows#532: new EVM wallet 3->1 prompts, first exchange connect 2->1). The EIP-712 signature on the sibling endpoint authorizes nothing durable (it only gates the mint API's shape and is trivially self-signed); the real attach step is the client's own on-chain `registerWalletDerivation`. Dropping it removes UX friction, not a security check. Server generates the derivation path (256-bit CSPRNG); clients cannot supply their own, which avoids low-entropy keys and the cross-account path-collision vector tracked in chipotle#575. The endpoint carries the same `CpuAvailable` load-shedding guard as `lit_action` since it is unauthenticated and drives a dstack KDF call per request. Docs + code comments call out that the endpoint is NOT idempotent: every call mints a new wallet, retries return a different address, and concurrent callers each get their own wallet with no server-side dedup. Codex adversarial review: DoS exposure (added CpuAvailable guard), auth-boundary overclaim (corrected comment, cross-ref #575), collision-doc overclaim (reworded). Co-Authored-By: Claude Fable 5 --- docs/management/api_direct.mdx | 36 ++++ e2e/fixtures/api-client.ts | 13 ++ e2e/tests/api/prepare-wallet.spec.ts | 34 ++++ lit-api-server/src/core/account_management.rs | 40 ++++- .../core/v1/endpoints/account_management.rs | 28 +++- lit-api-server/src/core/v1/endpoints/mod.rs | 1 + lit-api-server/src/core/v1/models/response.rs | 19 +++ plans/prepare-wallet-endpoint.md | 155 ++++++++++++++++++ 8 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/api/prepare-wallet.spec.ts create mode 100644 plans/prepare-wallet-endpoint.md diff --git a/docs/management/api_direct.mdx b/docs/management/api_direct.mdx index 4a076854..395dea51 100644 --- a/docs/management/api_direct.mdx +++ b/docs/management/api_direct.mdx @@ -380,6 +380,10 @@ 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. + +If you don't need the wallet-ownership signature step, [`POST /prepare_wallet`](#post-prepare-wallet) returns the same `{ wallet_address, derivation_path }` with no signature and no API key. Because the on-chain `registerWalletDerivation` (inside your owner-signed transaction) is the real authorization boundary, the signature here authorizes nothing durable — dropping it lets you collapse the whole ceremony into a single wallet prompt. + + ```bash curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet_with_signature" \ -H "Content-Type: application/json" \ @@ -404,6 +408,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/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 813c53bd..961ef780 100644 --- a/lit-api-server/src/core/v1/endpoints/mod.rs +++ b/lit-api-server/src/core/v1/endpoints/mod.rs @@ -22,6 +22,7 @@ pub fn routes_with_spec() -> (Vec, OpenApi) { create_wallet, create_wallet_post, create_wallet_with_signature, + prepare_wallet, delete_wallet, lit_action, get_lit_action_ipfs_id, diff --git a/lit-api-server/src/core/v1/models/response.rs b/lit-api-server/src/core/v1/models/response.rs index 9b77b1e5..a7f2365e 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. From 03507e15747416c30f68c906d05421559dc6511b Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Wed, 15 Jul 2026 11:44:56 -0400 Subject: [PATCH 2/2] chore(k6): regenerate OpenAPI spec + k6 client for /prepare_wallet Adds the prepare_wallet operation to spec.json and the generated k6 TypeScript client, fixing the openapi-to-k6 drift CI check. Co-Authored-By: Claude Fable 5 --- k6/litApiServer.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ spec.json | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/k6/litApiServer.ts b/k6/litApiServer.ts index 80aa3401..808072e7 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`). @@ -540,6 +551,8 @@ export type CreateWalletWithSignatureDefault = | CreateWalletWithSignatureResponse | ErrMessage; +export type PrepareWalletDefault = PrepareWalletResponse | ErrMessage; + export type DeleteWalletHeaders = { /** * Account or usage API key. Alternatively use Authorization: Bearer . @@ -1144,6 +1157,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/spec.json b/spec.json index 2361a9d2..6d8a5adf 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": [ @@ -1976,6 +2007,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",