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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rude-signs-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@turnkey/sdk-types": minor
---

Added `WALLET_CONNECT_INITIALIZATION_ERROR` error code
10 changes: 10 additions & 0 deletions .changeset/six-laws-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@turnkey/core": minor
---

- Fixed `stamp*` methods for query endpoints in `httpClient` incorrectly formatting request body
- Parallelized stamper and session initialization
- Separated WalletConnect initialization from client init
- Optimized `fetchWallet` by reducing redundant queries and running wallet/user fetches in parallel
- Added optional `authenticatorAddresses` param to `fetchWalletAccounts()`
- Updated to latest `@walletconnect/sign-client` for performance improvements
6 changes: 6 additions & 0 deletions .changeset/wild-monkeys-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@turnkey/react-wallet-kit": minor
---

- Fixed unnecessary re-renders by ensuring all `useCallback` hooks include only direct dependencies
- ConnectWallet and Auth model updated to show WalletConnect loading state during initialization
4 changes: 3 additions & 1 deletion examples/with-sdk-js/e2e/auth-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ test.describe("auth with wallet", async () => {
await page
.getByTestId(walletKitSelectors.authComponent.walletAuthButton)
.click();
await expect(page.getByText("Select wallet provider")).toBeVisible();
await expect(
page.getByText(/Select wallet provider|Select chain/),
).toBeVisible();
});
});
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
"@turnkey/webauthn-stamper": "workspace:*",
"@wallet-standard/app": "^1.1.0",
"@wallet-standard/base": "^1.1.0",
"@walletconnect/sign-client": "^2.21.8",
"@walletconnect/types": "^2.21.8",
"@walletconnect/sign-client": "^2.23.0",
"@walletconnect/types": "^2.23.0",
"cross-fetch": "^3.1.5",
"ethers": "^6.10.0",
"jwt-decode": "4.0.0",
Expand Down
40 changes: 35 additions & 5 deletions packages/core/scripts/codegen.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ const generateSDKClientFromSwagger = async (
const inputType = `T${operationNameWithoutNamespace}Body`;
const responseType = `T${operationNameWithoutNamespace}Response`;

// For query methods
// for query methods, we use flat body structure
if (methodType === "query") {
codeBuffer.push(
`\n\t${methodName} = async (input: SdkTypes.${inputType}${
Expand Down Expand Up @@ -434,8 +434,38 @@ const generateSDKClientFromSwagger = async (
VERSIONED_ACTIVITY_TYPES[unversionedActivityType];

// generate a stamping method for each method
codeBuffer.push(
`\n\tstamp${operationNameWithoutNamespace} = async (input: SdkTypes.${inputType}, stampWith?: StamperType): Promise<TSignedRequest | undefined> => {

if (methodType === "noop") {
// we skip stamp method generation for noop methods
continue;
} else if (methodType === "query") {
// for query methods, we use flat body structure
codeBuffer.push(
`\n\tstamp${operationNameWithoutNamespace} = async (input: SdkTypes.${inputType}, stampWith?: StamperType): Promise<TSignedRequest | undefined> => {
const activeStamper = this.getStamper(stampWith);
if (!activeStamper) {
return undefined;
}

const fullUrl = this.config.apiBaseUrl + "${endpointPath}";
const body = {
...input,
organizationId: input.organizationId
};

const stringifiedBody = JSON.stringify(body);
const stamp = await activeStamper.stamp(stringifiedBody);
return {
body: stringifiedBody,
stamp: stamp,
url: fullUrl,
};
}`,
);
} else {
// for activity and activityDecision methods, use parameters wrapper and type field
codeBuffer.push(
`\n\tstamp${operationNameWithoutNamespace} = async (input: SdkTypes.${inputType}, stampWith?: StamperType): Promise<TSignedRequest | undefined> => {
const activeStamper = this.getStamper(stampWith);
if (!activeStamper) {
return undefined;
Expand All @@ -454,7 +484,6 @@ const generateSDKClientFromSwagger = async (
type: "${versionedActivityType ?? unversionedActivityType}"
};


const stringifiedBody = JSON.stringify(bodyWithType);
const stamp = await activeStamper.stamp(stringifiedBody);
return {
Expand All @@ -463,7 +492,8 @@ const generateSDKClientFromSwagger = async (
url: fullUrl,
};
}`,
);
);
}
}

for (const endpointPath in authProxySwaggerSpec.paths) {
Expand Down
138 changes: 105 additions & 33 deletions packages/core/src/__clients__/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type v1CreatePolicyIntentV3,
type v1BootProof,
ProxyTSignupResponse,
TGetWalletsResponse,
TGetUserResponse,
} from "@turnkey/sdk-types";
import {
DEFAULT_SESSION_EXPIRATION_IN_SECONDS,
Expand Down Expand Up @@ -191,22 +193,37 @@ export class TurnkeyClient {

// Initialize the API key stamper
this.apiKeyStamper = new CrossPlatformApiKeyStamper(this.storageManager);
await this.apiKeyStamper.init();

// we parallelize independent initializations:
// - API key stamper init
// - Passkey stamper creation and init (if configured)
// - Wallet manager creation (if configured)
const initTasks: Promise<void>[] = [this.apiKeyStamper.init()];

if (this.config.passkeyConfig) {
this.passkeyStamper = new CrossPlatformPasskeyStamper(
const passkeyStamper = new CrossPlatformPasskeyStamper(
this.config.passkeyConfig,
);
await this.passkeyStamper.init();
initTasks.push(
passkeyStamper.init().then(() => {
this.passkeyStamper = passkeyStamper;
}),
);
}

if (
this.config.walletConfig?.features?.auth ||
this.config.walletConfig?.features?.connecting
) {
this.walletManager = await createWalletManager(this.config.walletConfig);
initTasks.push(
createWalletManager(this.config.walletConfig).then((manager) => {
this.walletManager = manager;
}),
);
}

await Promise.all(initTasks);

// Initialize the HTTP client with the appropriate stampers
// Note: not passing anything here since we want to use the configured stampers and this.config
this.httpClient = this.createHttpClient();
Expand Down Expand Up @@ -566,12 +583,13 @@ export class TurnkeyClient {
expirationSeconds,
});

await this.apiKeyStamper?.deleteKeyPair(generatedPublicKey!);

await this.storeSession({
sessionToken: sessionResponse.session,
sessionKey,
});
await Promise.all([
this.apiKeyStamper?.deleteKeyPair(generatedPublicKey!),
this.storeSession({
sessionToken: sessionResponse.session,
sessionKey,
}),
]);

generatedPublicKey = undefined; // Key pair was successfully used, set to null to prevent cleanup

Expand Down Expand Up @@ -1119,12 +1137,13 @@ export class TurnkeyClient {
expirationSeconds,
});

await this.apiKeyStamper?.deleteKeyPair(generatedPublicKey!);

await this.storeSession({
sessionToken: sessionResponse.session,
sessionKey,
});
await Promise.all([
this.apiKeyStamper?.deleteKeyPair(generatedPublicKey!),
this.storeSession({
sessionToken: sessionResponse.session,
sessionKey,
}),
]);

generatedPublicKey = undefined; // Key pair was successfully used, set to null to prevent cleanup

Expand Down Expand Up @@ -1939,39 +1958,75 @@ export class TurnkeyClient {
async () => {
let embedded: EmbeddedWallet[] = [];

const organizationId =
organizationIdFromParams || session?.organizationId;
const userId = userIdFromParams || session?.userId;

// we start fetching user early if we have the required params (needed for connected wallets)
// this runs in parallel with the embedded wallet fetching below
let userPromise:
| Promise<{ ethereum: string[]; solana: string[] }>
| undefined;
if (organizationId && userId && this.walletManager?.connector) {
const signedUserRequest = await this.httpClient.stampGetUser(
{
userId,
organizationId,
},
stampWith,
);
if (!signedUserRequest) {
throw new TurnkeyError(
"Failed to stamp user request",
TurnkeyErrorCodes.INVALID_REQUEST,
);
}
userPromise = sendSignedRequest<TGetUserResponse>(
signedUserRequest,
).then((response) => getAuthenticatorAddresses(response.user));
}

// if connectedOnly is true, we skip fetching embedded wallets
if (!connectedOnly) {
const organizationId =
organizationIdFromParams || session?.organizationId;

if (!organizationId) {
throw new TurnkeyError(
"No organization ID provided and no active session found. Please log in first or pass in an organization ID.",
TurnkeyErrorCodes.INVALID_REQUEST,
);
}

const userId = userIdFromParams || session?.userId;
if (!userId) {
throw new TurnkeyError(
"No user ID provided and no active session found. Please log in first or pass in a user ID.",
TurnkeyErrorCodes.INVALID_REQUEST,
);
}

const accounts = await fetchAllWalletAccountsWithCursor(
this.httpClient,
organizationId,
stampWith,
);

const walletsRes = await this.httpClient.getWallets(
// we stamp the wallet request first
// this is done to avoid concurrent passkey prompts
const signedWalletsRequest = await this.httpClient.stampGetWallets(
{
organizationId,
},
stampWith,
);

if (!signedWalletsRequest) {
throw new TurnkeyError(
"Failed to stamp wallet request",
TurnkeyErrorCodes.INVALID_REQUEST,
);
}

const [accounts, walletsRes] = await Promise.all([
fetchAllWalletAccountsWithCursor(
this.httpClient,
organizationId,
stampWith,
),
sendSignedRequest<TGetWalletsResponse>(signedWalletsRequest),
]);

// create a map of walletId to EmbeddedWallet for easy lookup
const walletMap: Map<string, EmbeddedWallet> = new Map(
walletsRes.wallets.map((wallet) => [
Expand Down Expand Up @@ -2006,6 +2061,16 @@ export class TurnkeyClient {
groupedProviders.set(walletId, group);
}

// we fetch user once for all connected wallets to avoid duplicate `fetchUser` calls
// this is only done if we have `organizationId` and `userId`
// Note: this was started earlier in parallel with embedded wallet fetching for performance
let authenticatorAddresses:
| { ethereum: string[]; solana: string[] }
| undefined;
if (userPromise) {
authenticatorAddresses = await userPromise;
}

// has to be done in a for of loop so we can await each fetchWalletAccounts call individually
// otherwise await Promise.all would cause them all to fire at once breaking passkey only set ups
// (multiple wallet fetches at once causing "OperationError: A request is already pending.")
Expand All @@ -2032,6 +2097,7 @@ export class TurnkeyClient {
organizationId: organizationIdFromParams,
}),
...(userIdFromParams !== undefined && { userId: userIdFromParams }),
...(authenticatorAddresses && { authenticatorAddresses }),
});

wallet.accounts = accounts;
Expand Down Expand Up @@ -2064,6 +2130,7 @@ export class TurnkeyClient {
* @param params.stampWith - parameter to stamp the request with a specific stamper (StamperType.Passkey, StamperType.ApiKey, or StamperType.Wallet).
* @param params.organizationId - organization ID to target (defaults to the session's organization ID).
* @param params.userId - user ID to target (defaults to the session's user ID).
* @param params.authenticatorAddresses - optional authenticator addresses to avoid redundant user fetches (this is used for connected wallets to determine if a connected wallet is an authenticator)
*
* @returns A promise that resolves to an array of `v1WalletAccount` objects.
* @throws {TurnkeyError} If no active session is found or if there is an error fetching wallet accounts.
Expand Down Expand Up @@ -2159,9 +2226,12 @@ export class TurnkeyClient {
let ethereumAddresses: string[] = [];
let solanaAddresses: string[] = [];

// we only fetch the user if we have to the organizationId and userId
// if not that means `isAuthenticator` will always be false
if (organizationId && userId) {
if (params.authenticatorAddresses) {
({ ethereum: ethereumAddresses, solana: solanaAddresses } =
params.authenticatorAddresses);
} else if (organizationId && userId) {
// we only fetch the user if authenticator addresses aren't provided and we have the organizationId and userId
// if not, then that means `isAuthenticator` will always be false
const user = await this.fetchUser({
userId,
organizationId,
Expand Down Expand Up @@ -2680,7 +2750,7 @@ export class TurnkeyClient {
);
}

return userResponse.user as v1User;
return userResponse.user;
},
{
errorMessage: "Failed to fetch user",
Expand Down Expand Up @@ -4212,8 +4282,10 @@ export class TurnkeyClient {
async () => {
const session = await this.storageManager.getSession(sessionKey);
if (session) {
await this.apiKeyStamper?.deleteKeyPair(session.publicKey!);
await this.storageManager.clearSession(sessionKey);
await Promise.all([
this.apiKeyStamper?.deleteKeyPair(session.publicKey!),
this.storageManager.clearSession(sessionKey),
]);
} else {
throw new TurnkeyError(
`No session found with key: ${sessionKey}`,
Expand Down
Loading
Loading