Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/management/api_direct.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want any additional documentation around "why" you might do this ?

It's very much a flows thing ATM ...

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.
</Note>

```bash
curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet_with_signature" \
-H "Content-Type: application/json" \
Expand All @@ -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"
```

<Warning>
**`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.
</Warning>

#### `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.
Expand Down
13 changes: 13 additions & 0 deletions e2e/fixtures/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +119,14 @@ export class LitApiClient {
return this.request<NewAccountResponse>('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<PrepareWalletResponse> {
return this.request<PrepareWalletResponse>('POST', '/prepare_wallet');
}

addUsageApiKey(
apiKey: string,
input: {
Expand Down
34 changes: 34 additions & 0 deletions e2e/tests/api/prepare-wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
52 changes: 52 additions & 0 deletions k6/litApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down Expand Up @@ -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 <key>.
Expand Down Expand Up @@ -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`).
*/
Expand Down
40 changes: 39 additions & 1 deletion lit-api-server/src/core/account_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PrepareWalletResponse, ApiStatus> {
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`
Expand Down
28 changes: 27 additions & 1 deletion lit-api-server/src/core/v1/endpoints/account_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -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<PrepareWalletResponse, ErrMessage> {
OpenApiResponse {
response: ApiResult(account_management::prepare_wallet().await).into(),
}
}

#[openapi(tag = "Account Management")]
#[post("/add_group", format = "json", data = "<req>")]
pub(super) async fn add_group(
Expand Down
1 change: 1 addition & 0 deletions lit-api-server/src/core/v1/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub fn routes_with_spec() -> (Vec<Route>, OpenApi) {
create_wallet,
create_wallet_post,
create_wallet_with_signature,
prepare_wallet,
delete_wallet,
lit_action,
lit_binary_action,
Expand Down
19 changes: 19 additions & 0 deletions lit-api-server/src/core/v1/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading