From 1dd8cecafef97a0da09810bcf3908b6af72bd2e3 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Mon, 11 Aug 2025 18:49:40 +0100 Subject: [PATCH 01/18] feat: export wallet --- .../examples/export-wallet-example.ts | 92 ++++++++++++++++++ account-kit/rn-signer/src/client.ts | 96 ++++++++++++++++++- account-kit/rn-signer/src/index.tsx | 1 + account-kit/rn-signer/src/signer.ts | 12 +++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 account-kit/rn-signer/examples/export-wallet-example.ts diff --git a/account-kit/rn-signer/examples/export-wallet-example.ts b/account-kit/rn-signer/examples/export-wallet-example.ts new file mode 100644 index 0000000000..cde9cd73a1 --- /dev/null +++ b/account-kit/rn-signer/examples/export-wallet-example.ts @@ -0,0 +1,92 @@ +import { + RNAlchemySigner, + type ExportWalletResult, +} from "@account-kit/react-native-signer"; +import { Alert } from "react-native"; + +// Example usage of wallet export in React Native + +async function exportPrivateKey() { + // Initialize the signer + const signer = RNAlchemySigner({ + client: { + connection: { + apiKey: "YOUR_API_KEY", + }, + rpId: "your-app.com", // Required for passkey support + }, + }); + + try { + // Method 1: Basic export (returns boolean) + // This is compatible with the base class interface + const success = await signer.exportWallet(); + if (success) { + console.log("Wallet export initiated successfully"); + // Note: This doesn't return the actual export bundle + } + + // Method 2: Export with result (recommended for React Native) + // This returns the encrypted export bundle that needs to be decrypted + const exportResult: ExportWalletResult = + await signer.exportWalletWithResult(); + + // The export bundle is encrypted and needs to be decrypted + // using the stamper's private key + console.log("Export bundle received:", exportResult.exportBundle); + console.log("Wallet address:", exportResult.address); + console.log("Organization ID:", exportResult.orgId); + + // IMPORTANT: Handle the export bundle securely + // Options for handling the export bundle: + // 1. Display in a secure modal/screen that prevents screenshots + // 2. Store in secure device storage (iOS Keychain, Android Keystore) + // 3. Allow user to copy to clipboard with security warnings + + // Example: Show alert with security warning + Alert.alert( + "⚠️ Private Key Export", + "Your private key export bundle has been generated. This is encrypted data that contains your private key.\n\n" + + "Keep this information secure and never share it with anyone.", + [ + { + text: "I Understand", + style: "default", + }, + ], + ); + + return exportResult; + } catch (error) { + console.error("Failed to export wallet:", error); + Alert.alert( + "Export Failed", + "Unable to export wallet. Please ensure you are authenticated and try again.", + ); + throw error; + } +} + +// Example of using the client directly +async function exportWithClient() { + const { RNSignerClient } = await import("@account-kit/react-native-signer"); + + const client = new RNSignerClient({ + connection: { + apiKey: "YOUR_API_KEY", + }, + rpId: "your-app.com", + }); + + // First authenticate the user (example with email) + await client.initEmailAuth({ + email: "user@example.com", + }); + + // After authentication... + const exportResult = await client.exportWalletWithResult(); + + return exportResult; +} + +export { exportPrivateKey, exportWithClient }; diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 8d01c7c237..8f396eca24 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -55,6 +55,12 @@ export const RNSignerClientParamsSchema = z.object({ export type RNSignerClientParams = z.input; +export type ExportWalletResult = { + exportBundle: string; + address: string; + orgId: string; +}; + // TODO: need to emit events export class RNSignerClient extends BaseSignerClient { private stamper = NativeTEKStamper; @@ -274,8 +280,94 @@ export class RNSignerClient extends BaseSignerClient { this.stamper.clear(); await this.stamper.init(); } - override exportWallet(_params: unknown): Promise { - throw new Error("Method not implemented."); + + /** + * Exports the wallet's private key for the authenticated user. + * + * Note: This implementation returns true on success. To get the actual export + * bundle, use the `exportWalletWithResult()` method instead. React Native + * doesn't support iframe-based secure export like the web version. + * + * @returns {Promise} Returns true on successful export + * @throws {Error} If the user is not authenticated or export fails + */ + override async exportWallet(): Promise { + if (!this.user) { + throw new Error("User must be authenticated to export wallet"); + } + + // Since we can't use iframe stamper in React Native, we need to export + // the private key using the regular Turnkey client with the native stamper + const targetPublicKey = await this.stamper.init(); + + const { activity } = await this.turnkeyClient.exportWalletAccount({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", + timestampMs: Date.now().toString(), + parameters: { + address: this.user.address, + targetPublicKey, + }, + }); + + // Poll for activity completion + const result = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletAccountResult", + ); + + if (!result.exportBundle) { + throw new Error("Failed to export wallet: no export bundle returned"); + } + + // In React Native, we can't use an iframe to securely display the key + // The app developer should use exportWalletWithResult() to get the bundle directly + return true; + } + + /** + * Exports the wallet and returns the export bundle directly. + * This is an alternative to the base exportWallet method that provides + * more flexibility for React Native apps. + * + * @returns {Promise} The export bundle and metadata + * @throws {Error} If the user is not authenticated or export fails + */ + async exportWalletWithResult(): Promise { + if (!this.user) { + throw new Error("User must be authenticated to export wallet"); + } + + const targetPublicKey = await this.stamper.init(); + + const { activity } = await this.turnkeyClient.exportWalletAccount({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", + timestampMs: Date.now().toString(), + parameters: { + address: this.user.address, + targetPublicKey, + }, + }); + + const result = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletAccountResult", + ); + + if (!result.exportBundle) { + throw new Error("Failed to export wallet: no export bundle returned"); + } + + const exportResult: ExportWalletResult = { + exportBundle: result.exportBundle, + address: this.user.address, + orgId: this.user.orgId, + }; + + return exportResult; } override targetPublicKey(): Promise { diff --git a/account-kit/rn-signer/src/index.tsx b/account-kit/rn-signer/src/index.tsx index b24716b86e..c5e9a0a046 100644 --- a/account-kit/rn-signer/src/index.tsx +++ b/account-kit/rn-signer/src/index.tsx @@ -2,3 +2,4 @@ export type * from "./signer"; export { RNAlchemySigner } from "./signer"; export { RNSignerClient } from "./client"; +export type { ExportWalletResult } from "./client"; diff --git a/account-kit/rn-signer/src/signer.ts b/account-kit/rn-signer/src/signer.ts index dc0cb62c90..ec5517170e 100644 --- a/account-kit/rn-signer/src/signer.ts +++ b/account-kit/rn-signer/src/signer.ts @@ -52,6 +52,18 @@ export class RNAlchemySignerSingleton extends BaseAlchemySigner } return this.instance; } + + /** + * Exports the wallet and returns the export bundle directly. + * This provides more flexibility than the base exportWallet method + * for React Native apps that need to handle the export bundle. + * + * @returns {Promise} The export bundle and metadata + * @throws {Error} If the user is not authenticated or export fails + */ + async exportWalletWithResult() { + return this.inner.exportWalletWithResult(); + } } /** From 2e13070fb4beabb441c51d98911b847ec9306fe2 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Mon, 25 Aug 2025 15:21:19 +0100 Subject: [PATCH 02/18] RN export --- .../examples/export-wallet-example.ts | 187 ++++++++++++---- account-kit/rn-signer/package.json | 1 + account-kit/rn-signer/src/client.ts | 206 ++++++++++++------ account-kit/rn-signer/src/index.tsx | 2 +- account-kit/rn-signer/src/signer.ts | 14 +- 5 files changed, 283 insertions(+), 127 deletions(-) diff --git a/account-kit/rn-signer/examples/export-wallet-example.ts b/account-kit/rn-signer/examples/export-wallet-example.ts index cde9cd73a1..aab3c33344 100644 --- a/account-kit/rn-signer/examples/export-wallet-example.ts +++ b/account-kit/rn-signer/examples/export-wallet-example.ts @@ -1,13 +1,16 @@ -import { - RNAlchemySigner, - type ExportWalletResult, -} from "@account-kit/react-native-signer"; +import { RNAlchemySigner, type ExportWalletResult } from "@account-kit/react-native-signer"; import { Alert } from "react-native"; -// Example usage of wallet export in React Native - +/** + * Example: Export private key using the local storage approach + * + * This implementation uses Turnkey's recommended approach for mobile: + * 1. Generate a P256 key pair locally + * 2. Request export encrypted to the public key + * 3. Decrypt the bundle using HPKE with the private key + * 4. Clean up the key from storage + */ async function exportPrivateKey() { - // Initialize the signer const signer = RNAlchemySigner({ client: { connection: { @@ -18,59 +21,124 @@ async function exportPrivateKey() { }); try { - // Method 1: Basic export (returns boolean) - // This is compatible with the base class interface - const success = await signer.exportWallet(); - if (success) { - console.log("Wallet export initiated successfully"); - // Note: This doesn't return the actual export bundle - } - - // Method 2: Export with result (recommended for React Native) - // This returns the encrypted export bundle that needs to be decrypted - const exportResult: ExportWalletResult = - await signer.exportWalletWithResult(); - - // The export bundle is encrypted and needs to be decrypted - // using the stamper's private key - console.log("Export bundle received:", exportResult.exportBundle); - console.log("Wallet address:", exportResult.address); - console.log("Organization ID:", exportResult.orgId); - - // IMPORTANT: Handle the export bundle securely - // Options for handling the export bundle: - // 1. Display in a secure modal/screen that prevents screenshots - // 2. Store in secure device storage (iOS Keychain, Android Keystore) - // 3. Allow user to copy to clipboard with security warnings - - // Example: Show alert with security warning + // Export as private key + const result: ExportWalletResult = await signer.exportWalletWithResult({ + exportAs: "PRIVATE_KEY" + }); + + console.log("Private key exported successfully"); + console.log("Address:", result.address); + console.log("Private key:", result.privateKey); + + // IMPORTANT: Handle the private key securely Alert.alert( - "⚠️ Private Key Export", - "Your private key export bundle has been generated. This is encrypted data that contains your private key.\n\n" + - "Keep this information secure and never share it with anyone.", + "⚠️ Private Key Exported", + "Your private key has been exported. Keep this information extremely secure:\n\n" + + `Address: ${result.address}\n\n` + + "Never share your private key with anyone!", [ { text: "I Understand", style: "default", + onPress: () => { + // Optionally copy to secure clipboard or save securely + } }, - ], + ] ); - return exportResult; + return result; } catch (error) { - console.error("Failed to export wallet:", error); + console.error("Failed to export private key:", error); Alert.alert( "Export Failed", - "Unable to export wallet. Please ensure you are authenticated and try again.", + "Unable to export wallet. Please ensure you are authenticated and try again." ); throw error; } } -// Example of using the client directly +/** + * Example: Export seed phrase + */ +async function exportSeedPhrase() { + const signer = RNAlchemySigner({ + client: { + connection: { + apiKey: "YOUR_API_KEY", + }, + rpId: "your-app.com", + }, + }); + + try { + // Export as seed phrase + const result: ExportWalletResult = await signer.exportWalletWithResult({ + exportAs: "SEED_PHRASE" + }); + + console.log("Seed phrase exported successfully"); + console.log("Address:", result.address); + console.log("Seed phrase:", result.seedPhrase); + + Alert.alert( + "🔐 Seed Phrase Exported", + "Your recovery phrase has been exported. This is the ONLY way to recover your wallet:\n\n" + + "• Write it down on paper\n" + + "• Store it in a secure location\n" + + "• Never share it with anyone\n" + + "• Never store it digitally unless encrypted", + [ + { + text: "I've Secured My Phrase", + style: "default", + }, + ] + ); + + return result; + } catch (error) { + console.error("Failed to export seed phrase:", error); + throw error; + } +} + +/** + * Example: Using the base exportWallet method (returns boolean) + */ +async function exportWalletBasic() { + const signer = RNAlchemySigner({ + client: { + connection: { + apiKey: "YOUR_API_KEY", + }, + rpId: "your-app.com", + }, + }); + + try { + // This method returns true/false but doesn't give you the actual key + // Use exportWalletWithResult() to get the decrypted data + const success = await signer.exportWallet({ exportAs: "PRIVATE_KEY" }); + + if (success) { + console.log("Export completed successfully"); + // Note: You don't get the actual key with this method + } + + return success; + } catch (error) { + console.error("Export failed:", error); + throw error; + } +} + +/** + * Example: Direct client usage + */ async function exportWithClient() { const { RNSignerClient } = await import("@account-kit/react-native-signer"); - + const client = new RNSignerClient({ connection: { apiKey: "YOUR_API_KEY", @@ -82,11 +150,34 @@ async function exportWithClient() { await client.initEmailAuth({ email: "user@example.com", }); - - // After authentication... - const exportResult = await client.exportWalletWithResult(); - - return exportResult; + + // After authentication, export the wallet + const result = await client.exportWalletWithResult({ + exportAs: "PRIVATE_KEY" + }); + + return result; } -export { exportPrivateKey, exportWithClient }; +/** + * Security Best Practices: + * + * 1. Never log private keys in production + * 2. Use secure storage if you need to persist keys + * 3. Implement screenshot prevention during export + * 4. Add user authentication before allowing export + * 5. Consider using biometric authentication + * 6. Warn users about the risks of exporting keys + * 7. Clear clipboard after copying sensitive data + * + * The export uses HPKE encryption with a locally generated P256 key pair, + * ensuring that neither Turnkey nor your app backend can access the + * decrypted private key - only the user's device has access. + */ + +export { + exportPrivateKey, + exportSeedPhrase, + exportWalletBasic, + exportWithClient +}; \ No newline at end of file diff --git a/account-kit/rn-signer/package.json b/account-kit/rn-signer/package.json index d715b4d773..16e7f4a732 100644 --- a/account-kit/rn-signer/package.json +++ b/account-kit/rn-signer/package.json @@ -144,6 +144,7 @@ "dependencies": { "@aa-sdk/core": "^4.59.1", "@account-kit/signer": "^4.59.1", + "@turnkey/crypto": "^2.5.0", "@turnkey/react-native-passkey-stamper": "^1.0.14", "uuid": "^11.1.0", "viem": "^2.29.2" diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 8f396eca24..c23211cdf1 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -38,6 +38,8 @@ import { } from "@account-kit/signer"; import { InAppBrowser } from "react-native-inappbrowser-reborn"; import { z } from "zod"; +import { generateP256KeyPair, hpkeDecrypt } from "@turnkey/crypto"; +import { MMKV } from "react-native-mmkv"; import { InAppBrowserUnavailableError } from "./errors"; import NativeTEKStamper from "./NativeTEKStamper"; import { parseSearchParams } from "./utils/parseUrlParams"; @@ -55,15 +57,21 @@ export const RNSignerClientParamsSchema = z.object({ export type RNSignerClientParams = z.input; +export type ExportWalletParams = { + exportAs?: "PRIVATE_KEY" | "SEED_PHRASE"; +}; + export type ExportWalletResult = { - exportBundle: string; + privateKey?: string; + seedPhrase?: string; address: string; - orgId: string; + exportAs: "PRIVATE_KEY" | "SEED_PHRASE"; }; // TODO: need to emit events -export class RNSignerClient extends BaseSignerClient { +export class RNSignerClient extends BaseSignerClient { private stamper = NativeTEKStamper; + private storage = new MMKV(); oauthCallbackUrl: string; rpId: string | undefined; private validAuthenticatingTypes: AuthenticatingEventMetadata["type"][] = [ @@ -283,91 +291,147 @@ export class RNSignerClient extends BaseSignerClient { /** * Exports the wallet's private key for the authenticated user. + * + * This uses the local storage approach recommended by Turnkey for mobile contexts. + * A P256 key pair is generated locally, the public key is used to encrypt the export, + * and the private key is used to decrypt the bundle locally. * - * Note: This implementation returns true on success. To get the actual export - * bundle, use the `exportWalletWithResult()` method instead. React Native - * doesn't support iframe-based secure export like the web version. - * + * @param {ExportWalletParams} params Export parameters + * @param {string} params.exportAs Whether to export as PRIVATE_KEY or SEED_PHRASE (defaults to PRIVATE_KEY) * @returns {Promise} Returns true on successful export * @throws {Error} If the user is not authenticated or export fails */ - override async exportWallet(): Promise { - if (!this.user) { - throw new Error("User must be authenticated to export wallet"); - } - - // Since we can't use iframe stamper in React Native, we need to export - // the private key using the regular Turnkey client with the native stamper - const targetPublicKey = await this.stamper.init(); - - const { activity } = await this.turnkeyClient.exportWalletAccount({ - organizationId: this.user.orgId, - type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", - timestampMs: Date.now().toString(), - parameters: { - address: this.user.address, - targetPublicKey, - }, - }); - - // Poll for activity completion - const result = await this.pollActivityCompletion( - activity, - this.user.orgId, - "exportWalletAccountResult", - ); - - if (!result.exportBundle) { - throw new Error("Failed to export wallet: no export bundle returned"); - } - - // In React Native, we can't use an iframe to securely display the key - // The app developer should use exportWalletWithResult() to get the bundle directly + override async exportWallet(params?: ExportWalletParams): Promise { + await this.exportWalletWithResult(params); return true; } /** - * Exports the wallet and returns the export bundle directly. - * This is an alternative to the base exportWallet method that provides - * more flexibility for React Native apps. - * - * @returns {Promise} The export bundle and metadata + * Exports the wallet and returns the decrypted private key or seed phrase. + * + * @param {ExportWalletParams} params Export parameters + * @returns {Promise} The decrypted export data * @throws {Error} If the user is not authenticated or export fails */ - async exportWalletWithResult(): Promise { + async exportWalletWithResult(params?: ExportWalletParams): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } - const targetPublicKey = await this.stamper.init(); - - const { activity } = await this.turnkeyClient.exportWalletAccount({ - organizationId: this.user.orgId, - type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", - timestampMs: Date.now().toString(), - parameters: { - address: this.user.address, - targetPublicKey, - }, - }); + const exportAs = params?.exportAs || "PRIVATE_KEY"; + + // Step 1: Generate a P256 key pair for encryption + const embeddedKey = generateP256KeyPair(); + + // Step 2: Save the private key in secure storage + const keyId = `export_key_${Date.now()}`; + this.storage.set(keyId, embeddedKey.privateKey); + + try { + let exportBundle: string; + + if (exportAs === "PRIVATE_KEY") { + // Step 3a: Export as private key + const { activity } = await this.turnkeyClient.exportWalletAccount({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", + timestampMs: Date.now().toString(), + parameters: { + address: this.user.address, + targetPublicKey: embeddedKey.publicKeyUncompressed, + }, + }); + + const result = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletAccountResult", + ); + + if (!result.exportBundle) { + throw new Error("Failed to export wallet: no export bundle returned"); + } + + exportBundle = result.exportBundle; + } else { + // Step 3b: Export as seed phrase (need to find the wallet first) + const { wallets } = await this.turnkeyClient.getWallets({ + organizationId: this.user.orgId, + }); + + const walletAccounts = await Promise.all( + wallets.map(({ walletId }) => + this.turnkeyClient.getWalletAccounts({ + organizationId: this.user!.orgId, + walletId, + }), + ), + ).then((x) => x.flatMap((x) => x.accounts)); + + const walletAccount = walletAccounts.find( + (x) => x.address === this.user!.address, + ); + + if (!walletAccount) { + throw new Error("Could not find wallet account"); + } + + const { activity } = await this.turnkeyClient.exportWallet({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET", + timestampMs: Date.now().toString(), + parameters: { + walletId: walletAccount.walletId, + targetPublicKey: embeddedKey.publicKeyUncompressed, + }, + }); + + const result = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletResult", + ); + + if (!result.exportBundle) { + throw new Error("Failed to export wallet: no export bundle returned"); + } + + exportBundle = result.exportBundle; + } - const result = await this.pollActivityCompletion( - activity, - this.user.orgId, - "exportWalletAccountResult", - ); + // Step 4: Decrypt the export bundle using HPKE + // The export bundle is hex-encoded, so we need to convert it to bytes + const bundleBytes = Buffer.from(exportBundle, 'hex'); + const encappedKeyBuf = bundleBytes.slice(0, 65); + const ciphertextBuf = bundleBytes.slice(65); + + const decryptedData = hpkeDecrypt({ + encappedKeyBuf, + ciphertextBuf, + receiverPriv: embeddedKey.privateKey, + }); - if (!result.exportBundle) { - throw new Error("Failed to export wallet: no export bundle returned"); + // Step 5: Parse the decrypted data + const exportData = JSON.parse(new TextDecoder().decode(decryptedData)); + + // Return the result based on export type + const result: ExportWalletResult = { + address: this.user.address, + exportAs, + }; + + if (exportAs === "PRIVATE_KEY") { + result.privateKey = exportData.privateKey; + } else { + result.seedPhrase = exportData.mnemonic; + } + + return result; + + } finally { + // Step 6: Clean up - remove the embedded key from storage + this.storage.delete(keyId); } - - const exportResult: ExportWalletResult = { - exportBundle: result.exportBundle, - address: this.user.address, - orgId: this.user.orgId, - }; - - return exportResult; } override targetPublicKey(): Promise { diff --git a/account-kit/rn-signer/src/index.tsx b/account-kit/rn-signer/src/index.tsx index c5e9a0a046..2e5a5cb749 100644 --- a/account-kit/rn-signer/src/index.tsx +++ b/account-kit/rn-signer/src/index.tsx @@ -2,4 +2,4 @@ export type * from "./signer"; export { RNAlchemySigner } from "./signer"; export { RNSignerClient } from "./client"; -export type { ExportWalletResult } from "./client"; +export type { ExportWalletParams, ExportWalletResult } from "./client"; diff --git a/account-kit/rn-signer/src/signer.ts b/account-kit/rn-signer/src/signer.ts index ec5517170e..da5eab90e5 100644 --- a/account-kit/rn-signer/src/signer.ts +++ b/account-kit/rn-signer/src/signer.ts @@ -54,15 +54,15 @@ export class RNAlchemySignerSingleton extends BaseAlchemySigner } /** - * Exports the wallet and returns the export bundle directly. - * This provides more flexibility than the base exportWallet method - * for React Native apps that need to handle the export bundle. - * - * @returns {Promise} The export bundle and metadata + * Exports the wallet and returns the decrypted private key or seed phrase. + * This is the recommended method for React Native apps. + * + * @param {import("./client").ExportWalletParams} params Export parameters + * @returns {Promise} The decrypted export data * @throws {Error} If the user is not authenticated or export fails */ - async exportWalletWithResult() { - return this.inner.exportWalletWithResult(); + async exportWalletWithResult(params?: import("./client").ExportWalletParams) { + return this.inner.exportWalletWithResult(params); } } From 378034cb8d6fed06390de9747af143984117a059 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Mon, 25 Aug 2025 16:00:50 +0100 Subject: [PATCH 03/18] Fix yarn --- yarn.lock | 60 ++++++++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6bc7d90a7b..cb0533f570 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,20 +1227,7 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.1.tgz#4db772902b133bbddd1c4f7a7ee47761c1b9f291" - integrity sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.27.1" - "@babel/parser" "^7.27.1" - "@babel/template" "^7.27.1" - "@babel/types" "^7.27.1" - debug "^4.3.1" - globals "^11.1.0" - -"@babel/traverse@^7.18.9", "@babel/traverse@^7.20.0", "@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.18.9", "@babel/traverse@^7.20.0", "@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.1.tgz#4db772902b133bbddd1c4f7a7ee47761c1b9f291" integrity sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg== @@ -8237,6 +8224,18 @@ react-native-quick-base64 "2.1.2" typescript "5.0.4" +"@turnkey/crypto@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@turnkey/crypto/-/crypto-2.5.0.tgz#1498d7c131078c9e506d24ac2a13e7d44a621743" + integrity sha512-aeYPO9rPFlM6eG+hjDiE6BKi9O6xcSDSIoq3mlw6KaaDgg6T2wFVapquIhAvwdTn+SMemDhcw2XaK5jsrQvsdQ== + dependencies: + "@noble/ciphers" "1.3.0" + "@noble/curves" "1.9.0" + "@noble/hashes" "1.8.0" + "@turnkey/encoding" "0.5.0" + bs58 "6.0.0" + bs58check "4.0.0" + "@turnkey/encoding@0.2.1": version "0.2.1" resolved "https://registry.yarnpkg.com/@turnkey/encoding/-/encoding-0.2.1.tgz#ccde2afe97d0ab39911fe005ce473faa5a681e92" @@ -11528,7 +11527,7 @@ bs58check@3.0.1, bs58check@^3.0.1: "@noble/hashes" "^1.2.0" bs58 "^5.0.0" -bs58check@^4.0.0: +bs58check@4.0.0, bs58check@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-4.0.0.tgz#46cda52a5713b7542dcb78ec2efdf78f5bf1d23c" integrity sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g== @@ -23182,16 +23181,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23317,7 +23307,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -23331,13 +23321,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -25156,7 +25139,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -25174,15 +25157,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 9ff728ec081515ea1302f97a4cfdd774a8073439 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Mon, 25 Aug 2025 18:09:51 +0100 Subject: [PATCH 04/18] Remove test file --- .../examples/export-wallet-example.ts | 183 ------------------ 1 file changed, 183 deletions(-) delete mode 100644 account-kit/rn-signer/examples/export-wallet-example.ts diff --git a/account-kit/rn-signer/examples/export-wallet-example.ts b/account-kit/rn-signer/examples/export-wallet-example.ts deleted file mode 100644 index aab3c33344..0000000000 --- a/account-kit/rn-signer/examples/export-wallet-example.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { RNAlchemySigner, type ExportWalletResult } from "@account-kit/react-native-signer"; -import { Alert } from "react-native"; - -/** - * Example: Export private key using the local storage approach - * - * This implementation uses Turnkey's recommended approach for mobile: - * 1. Generate a P256 key pair locally - * 2. Request export encrypted to the public key - * 3. Decrypt the bundle using HPKE with the private key - * 4. Clean up the key from storage - */ -async function exportPrivateKey() { - const signer = RNAlchemySigner({ - client: { - connection: { - apiKey: "YOUR_API_KEY", - }, - rpId: "your-app.com", // Required for passkey support - }, - }); - - try { - // Export as private key - const result: ExportWalletResult = await signer.exportWalletWithResult({ - exportAs: "PRIVATE_KEY" - }); - - console.log("Private key exported successfully"); - console.log("Address:", result.address); - console.log("Private key:", result.privateKey); - - // IMPORTANT: Handle the private key securely - Alert.alert( - "⚠️ Private Key Exported", - "Your private key has been exported. Keep this information extremely secure:\n\n" + - `Address: ${result.address}\n\n` + - "Never share your private key with anyone!", - [ - { - text: "I Understand", - style: "default", - onPress: () => { - // Optionally copy to secure clipboard or save securely - } - }, - ] - ); - - return result; - } catch (error) { - console.error("Failed to export private key:", error); - Alert.alert( - "Export Failed", - "Unable to export wallet. Please ensure you are authenticated and try again." - ); - throw error; - } -} - -/** - * Example: Export seed phrase - */ -async function exportSeedPhrase() { - const signer = RNAlchemySigner({ - client: { - connection: { - apiKey: "YOUR_API_KEY", - }, - rpId: "your-app.com", - }, - }); - - try { - // Export as seed phrase - const result: ExportWalletResult = await signer.exportWalletWithResult({ - exportAs: "SEED_PHRASE" - }); - - console.log("Seed phrase exported successfully"); - console.log("Address:", result.address); - console.log("Seed phrase:", result.seedPhrase); - - Alert.alert( - "🔐 Seed Phrase Exported", - "Your recovery phrase has been exported. This is the ONLY way to recover your wallet:\n\n" + - "• Write it down on paper\n" + - "• Store it in a secure location\n" + - "• Never share it with anyone\n" + - "• Never store it digitally unless encrypted", - [ - { - text: "I've Secured My Phrase", - style: "default", - }, - ] - ); - - return result; - } catch (error) { - console.error("Failed to export seed phrase:", error); - throw error; - } -} - -/** - * Example: Using the base exportWallet method (returns boolean) - */ -async function exportWalletBasic() { - const signer = RNAlchemySigner({ - client: { - connection: { - apiKey: "YOUR_API_KEY", - }, - rpId: "your-app.com", - }, - }); - - try { - // This method returns true/false but doesn't give you the actual key - // Use exportWalletWithResult() to get the decrypted data - const success = await signer.exportWallet({ exportAs: "PRIVATE_KEY" }); - - if (success) { - console.log("Export completed successfully"); - // Note: You don't get the actual key with this method - } - - return success; - } catch (error) { - console.error("Export failed:", error); - throw error; - } -} - -/** - * Example: Direct client usage - */ -async function exportWithClient() { - const { RNSignerClient } = await import("@account-kit/react-native-signer"); - - const client = new RNSignerClient({ - connection: { - apiKey: "YOUR_API_KEY", - }, - rpId: "your-app.com", - }); - - // First authenticate the user (example with email) - await client.initEmailAuth({ - email: "user@example.com", - }); - - // After authentication, export the wallet - const result = await client.exportWalletWithResult({ - exportAs: "PRIVATE_KEY" - }); - - return result; -} - -/** - * Security Best Practices: - * - * 1. Never log private keys in production - * 2. Use secure storage if you need to persist keys - * 3. Implement screenshot prevention during export - * 4. Add user authentication before allowing export - * 5. Consider using biometric authentication - * 6. Warn users about the risks of exporting keys - * 7. Clear clipboard after copying sensitive data - * - * The export uses HPKE encryption with a locally generated P256 key pair, - * ensuring that neither Turnkey nor your app backend can access the - * decrypted private key - only the user's device has access. - */ - -export { - exportPrivateKey, - exportSeedPhrase, - exportWalletBasic, - exportWithClient -}; \ No newline at end of file From c84ccd7e75a7e56baf20c5134eced2f5fff6d085 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Fri, 29 Aug 2025 20:50:25 +0100 Subject: [PATCH 05/18] feat: export wallet --- account-kit/rn-signer/src/client.ts | 75 +++++++++++++++++------------ account-kit/rn-signer/src/signer.ts | 2 +- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index c23211cdf1..a71bccae55 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -2,8 +2,6 @@ import "react-native-get-random-values"; import "./utils/buffer-polyfill"; import "./utils/mmkv-localstorage-polyfill"; - -/* eslint-disable import/extensions */ import { type ConnectionConfig } from "@aa-sdk/core"; import { createPasskey, @@ -291,7 +289,7 @@ export class RNSignerClient extends BaseSignerClient { /** * Exports the wallet's private key for the authenticated user. - * + * * This uses the local storage approach recommended by Turnkey for mobile contexts. * A P256 key pair is generated locally, the public key is used to encrypt the export, * and the private key is used to decrypt the bundle locally. @@ -308,28 +306,30 @@ export class RNSignerClient extends BaseSignerClient { /** * Exports the wallet and returns the decrypted private key or seed phrase. - * + * * @param {ExportWalletParams} params Export parameters * @returns {Promise} The decrypted export data * @throws {Error} If the user is not authenticated or export fails */ - async exportWalletWithResult(params?: ExportWalletParams): Promise { + async exportWalletWithResult(params?: ExportWalletParams): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } const exportAs = params?.exportAs || "PRIVATE_KEY"; - + // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); - + // Step 2: Save the private key in secure storage const keyId = `export_key_${Date.now()}`; this.storage.set(keyId, embeddedKey.privateKey); - + + console.log("exportWalletWithResult"); + try { let exportBundle: string; - + if (exportAs === "PRIVATE_KEY") { // Step 3a: Export as private key const { activity } = await this.turnkeyClient.exportWalletAccount({ @@ -347,18 +347,18 @@ export class RNSignerClient extends BaseSignerClient { this.user.orgId, "exportWalletAccountResult", ); - + if (!result.exportBundle) { throw new Error("Failed to export wallet: no export bundle returned"); } - + exportBundle = result.exportBundle; } else { // Step 3b: Export as seed phrase (need to find the wallet first) const { wallets } = await this.turnkeyClient.getWallets({ organizationId: this.user.orgId, }); - + const walletAccounts = await Promise.all( wallets.map(({ walletId }) => this.turnkeyClient.getWalletAccounts({ @@ -367,7 +367,7 @@ export class RNSignerClient extends BaseSignerClient { }), ), ).then((x) => x.flatMap((x) => x.accounts)); - + const walletAccount = walletAccounts.find( (x) => x.address === this.user!.address, ); @@ -391,43 +391,54 @@ export class RNSignerClient extends BaseSignerClient { this.user.orgId, "exportWalletResult", ); - + if (!result.exportBundle) { throw new Error("Failed to export wallet: no export bundle returned"); } - + exportBundle = result.exportBundle; } - // Step 4: Decrypt the export bundle using HPKE - // The export bundle is hex-encoded, so we need to convert it to bytes - const bundleBytes = Buffer.from(exportBundle, 'hex'); - const encappedKeyBuf = bundleBytes.slice(0, 65); - const ciphertextBuf = bundleBytes.slice(65); - + // Step 4: Parse the export bundle and decrypt using HPKE + // The export bundle is a JSON string containing version, data, etc. + const bundleJson = JSON.parse(exportBundle); + + // The data field contains another JSON string that's hex-encoded + const innerDataHex = bundleJson.data; + const innerDataJson = JSON.parse( + Buffer.from(innerDataHex, "hex").toString(), + ); + + // Extract the encapped public key and ciphertext from the inner data + const encappedPublicKeyHex = innerDataJson.encappedPublic; + const ciphertextHex = innerDataJson.ciphertext; + + const encappedKeyBuf = Buffer.from(encappedPublicKeyHex, "hex"); + const ciphertextBuf = Buffer.from(ciphertextHex, "hex"); + + // Decrypt the data using HPKE const decryptedData = hpkeDecrypt({ - encappedKeyBuf, - ciphertextBuf, + ciphertextBuf: ciphertextBuf, + encappedKeyBuf: encappedKeyBuf, receiverPriv: embeddedKey.privateKey, }); - // Step 5: Parse the decrypted data - const exportData = JSON.parse(new TextDecoder().decode(decryptedData)); - - // Return the result based on export type + // Step 5: Process the decrypted data based on export type const result: ExportWalletResult = { address: this.user.address, exportAs, }; - + if (exportAs === "PRIVATE_KEY") { - result.privateKey = exportData.privateKey; + // For private key, the decrypted data is the raw private key bytes + // Convert to hex string with 0x prefix + result.privateKey = "0x" + Buffer.from(decryptedData).toString("hex"); } else { - result.seedPhrase = exportData.mnemonic; + // For seed phrase, the decrypted data is the mnemonic string + result.seedPhrase = new TextDecoder().decode(decryptedData); } - + return result; - } finally { // Step 6: Clean up - remove the embedded key from storage this.storage.delete(keyId); diff --git a/account-kit/rn-signer/src/signer.ts b/account-kit/rn-signer/src/signer.ts index da5eab90e5..a17b711150 100644 --- a/account-kit/rn-signer/src/signer.ts +++ b/account-kit/rn-signer/src/signer.ts @@ -56,7 +56,7 @@ export class RNAlchemySignerSingleton extends BaseAlchemySigner /** * Exports the wallet and returns the decrypted private key or seed phrase. * This is the recommended method for React Native apps. - * + * * @param {import("./client").ExportWalletParams} params Export parameters * @returns {Promise} The decrypted export data * @throws {Error} If the user is not authenticated or export fails From 83190e4285b0b8c02d40b2936f59dc0a978e4f4c Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Fri, 29 Aug 2025 20:51:51 +0100 Subject: [PATCH 06/18] chore: cleanup --- account-kit/rn-signer/src/client.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index a71bccae55..47c2a8b87a 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -325,8 +325,6 @@ export class RNSignerClient extends BaseSignerClient { const keyId = `export_key_${Date.now()}`; this.storage.set(keyId, embeddedKey.privateKey); - console.log("exportWalletWithResult"); - try { let exportBundle: string; From 0debc0197dcdbf8632deb036445d43f3cddea40d Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Tue, 2 Sep 2025 19:44:11 +0100 Subject: [PATCH 07/18] chore: keep private key in memory --- account-kit/rn-signer/src/client.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 47c2a8b87a..067cd4412f 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -37,7 +37,6 @@ import { import { InAppBrowser } from "react-native-inappbrowser-reborn"; import { z } from "zod"; import { generateP256KeyPair, hpkeDecrypt } from "@turnkey/crypto"; -import { MMKV } from "react-native-mmkv"; import { InAppBrowserUnavailableError } from "./errors"; import NativeTEKStamper from "./NativeTEKStamper"; import { parseSearchParams } from "./utils/parseUrlParams"; @@ -69,7 +68,6 @@ export type ExportWalletResult = { // TODO: need to emit events export class RNSignerClient extends BaseSignerClient { private stamper = NativeTEKStamper; - private storage = new MMKV(); oauthCallbackUrl: string; rpId: string | undefined; private validAuthenticatingTypes: AuthenticatingEventMetadata["type"][] = [ @@ -290,9 +288,8 @@ export class RNSignerClient extends BaseSignerClient { /** * Exports the wallet's private key for the authenticated user. * - * This uses the local storage approach recommended by Turnkey for mobile contexts. * A P256 key pair is generated locally, the public key is used to encrypt the export, - * and the private key is used to decrypt the bundle locally. + * and the private key is kept in memory to decrypt the bundle locally. * * @param {ExportWalletParams} params Export parameters * @param {string} params.exportAs Whether to export as PRIVATE_KEY or SEED_PHRASE (defaults to PRIVATE_KEY) @@ -321,15 +318,11 @@ export class RNSignerClient extends BaseSignerClient { // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); - // Step 2: Save the private key in secure storage - const keyId = `export_key_${Date.now()}`; - this.storage.set(keyId, embeddedKey.privateKey); - try { let exportBundle: string; if (exportAs === "PRIVATE_KEY") { - // Step 3a: Export as private key + // Step 2a: Export as private key const { activity } = await this.turnkeyClient.exportWalletAccount({ organizationId: this.user.orgId, type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", @@ -352,7 +345,7 @@ export class RNSignerClient extends BaseSignerClient { exportBundle = result.exportBundle; } else { - // Step 3b: Export as seed phrase (need to find the wallet first) + // Step 2b: Export as seed phrase (need to find the wallet first) const { wallets } = await this.turnkeyClient.getWallets({ organizationId: this.user.orgId, }); @@ -397,7 +390,7 @@ export class RNSignerClient extends BaseSignerClient { exportBundle = result.exportBundle; } - // Step 4: Parse the export bundle and decrypt using HPKE + // Step 3: Parse the export bundle and decrypt using HPKE // The export bundle is a JSON string containing version, data, etc. const bundleJson = JSON.parse(exportBundle); @@ -421,7 +414,7 @@ export class RNSignerClient extends BaseSignerClient { receiverPriv: embeddedKey.privateKey, }); - // Step 5: Process the decrypted data based on export type + // Step 4: Process the decrypted data based on export type const result: ExportWalletResult = { address: this.user.address, exportAs, @@ -438,8 +431,7 @@ export class RNSignerClient extends BaseSignerClient { return result; } finally { - // Step 6: Clean up - remove the embedded key from storage - this.storage.delete(keyId); + // No cleanup needed - key is only in memory } } From 5146a9870f32ec6c2a0a1460ab9ef4d625cb3dd0 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Wed, 3 Sep 2025 19:04:23 +0100 Subject: [PATCH 08/18] chore: change the exportWallet export signature --- account-kit/rn-signer/src/client.ts | 23 +++++------------------ account-kit/rn-signer/src/signer.ts | 12 ------------ account-kit/signer/src/base.ts | 15 +++++++++------ account-kit/signer/src/client/base.ts | 9 +++++++-- docs-site | 2 +- 5 files changed, 22 insertions(+), 39 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 067cd4412f..522f2f2066 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -66,7 +66,10 @@ export type ExportWalletResult = { }; // TODO: need to emit events -export class RNSignerClient extends BaseSignerClient { +export class RNSignerClient extends BaseSignerClient< + ExportWalletParams, + ExportWalletResult +> { private stamper = NativeTEKStamper; oauthCallbackUrl: string; rpId: string | undefined; @@ -285,22 +288,6 @@ export class RNSignerClient extends BaseSignerClient { await this.stamper.init(); } - /** - * Exports the wallet's private key for the authenticated user. - * - * A P256 key pair is generated locally, the public key is used to encrypt the export, - * and the private key is kept in memory to decrypt the bundle locally. - * - * @param {ExportWalletParams} params Export parameters - * @param {string} params.exportAs Whether to export as PRIVATE_KEY or SEED_PHRASE (defaults to PRIVATE_KEY) - * @returns {Promise} Returns true on successful export - * @throws {Error} If the user is not authenticated or export fails - */ - override async exportWallet(params?: ExportWalletParams): Promise { - await this.exportWalletWithResult(params); - return true; - } - /** * Exports the wallet and returns the decrypted private key or seed phrase. * @@ -308,7 +295,7 @@ export class RNSignerClient extends BaseSignerClient { * @returns {Promise} The decrypted export data * @throws {Error} If the user is not authenticated or export fails */ - async exportWalletWithResult(params?: ExportWalletParams): Promise { + async exportWallet(params?: ExportWalletParams): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } diff --git a/account-kit/rn-signer/src/signer.ts b/account-kit/rn-signer/src/signer.ts index a17b711150..dc0cb62c90 100644 --- a/account-kit/rn-signer/src/signer.ts +++ b/account-kit/rn-signer/src/signer.ts @@ -52,18 +52,6 @@ export class RNAlchemySignerSingleton extends BaseAlchemySigner } return this.instance; } - - /** - * Exports the wallet and returns the decrypted private key or seed phrase. - * This is the recommended method for React Native apps. - * - * @param {import("./client").ExportWalletParams} params Export parameters - * @returns {Promise} The decrypted export data - * @throws {Error} If the user is not authenticated or export fails - */ - async exportWalletWithResult(params?: import("./client").ExportWalletParams) { - return this.inner.exportWalletWithResult(params); - } } /** diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index fdb263130c..e20fc0ac5e 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -59,7 +59,9 @@ import { import { assertNever } from "./utils/typeAssertions.js"; import { hashAuthorization } from "viem/utils"; -export interface BaseAlchemySignerParams { +export interface BaseAlchemySignerParams< + TClient extends BaseSignerClient, +> { client: TClient; sessionConfig?: Omit; initialError?: ErrorInfo; @@ -118,8 +120,9 @@ type GetUserParams = * Base abstract class for Alchemy Signer, providing authentication and session management for smart accounts. * Implements the `SmartAccountAuthenticator` interface and handles various signer events. */ -export abstract class BaseAlchemySigner - implements SmartAccountAuthenticator +export abstract class BaseAlchemySigner< + TClient extends BaseSignerClient, +> implements SmartAccountAuthenticator { signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer"; inner: TClient; @@ -960,11 +963,11 @@ export abstract class BaseAlchemySigner * ``` * * @param {unknown} params export wallet parameters - * @returns {boolean} true if the wallet was exported successfully + * @returns {Promise} the result of the wallet export operation */ - exportWallet: ( + exportWallet = async ( params: Parameters<(typeof this.inner)["exportWallet"]>[0], - ) => Promise = async (params) => { + ) => { return this.inner.exportWallet(params); }; diff --git a/account-kit/signer/src/client/base.ts b/account-kit/signer/src/client/base.ts index 1cfdde60ce..9995c9c10f 100644 --- a/account-kit/signer/src/client/base.ts +++ b/account-kit/signer/src/client/base.ts @@ -81,7 +81,10 @@ const withHexPrefix = (hex: string) => `0x${hex}` as const; /** * Base class for all Alchemy Signer clients */ -export abstract class BaseSignerClient { +export abstract class BaseSignerClient< + TExportWalletParams = unknown, + TExportWalletOutput = boolean, +> { private _user: User | undefined; private connectionConfig: ConnectionConfig; protected turnkeyClient: TurnkeyClient; @@ -264,7 +267,9 @@ export abstract class BaseSignerClient { public abstract disconnect(): Promise; - public abstract exportWallet(params: TExportWalletParams): Promise; + public abstract exportWallet( + params: TExportWalletParams, + ): Promise; public abstract targetPublicKey(): Promise; diff --git a/docs-site b/docs-site index 2c2b137352..e94a82c80a 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit 2c2b13735277eb878f660d16fb098cf78b362cbf +Subproject commit e94a82c80a36d0fb18ef83bee08cc93a5861d496 From dca93ca408d99a93f9c78a3606eb6cc51c9e1861 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 00:36:14 +0100 Subject: [PATCH 09/18] chore: switch the return signature of exportWallet on RN --- account-kit/rn-signer/src/client.ts | 24 ++++++------------------ account-kit/signer/src/base.ts | 2 +- docs-site | 2 +- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 8f44effc7e..c7a156bd13 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -57,17 +57,12 @@ export type ExportWalletParams = { exportAs?: "PRIVATE_KEY" | "SEED_PHRASE"; }; -export type ExportWalletResult = { - privateKey?: string; - seedPhrase?: string; - address: string; - exportAs: "PRIVATE_KEY" | "SEED_PHRASE"; -}; +export type ExportWalletResult = string; // TODO: need to emit events export class RNSignerClient extends BaseSignerClient< ExportWalletParams, - ExportWalletResult + string > { private stamper = NativeTEKStamper; oauthCallbackUrl: string; @@ -291,10 +286,10 @@ export class RNSignerClient extends BaseSignerClient< * Exports the wallet and returns the decrypted private key or seed phrase. * * @param {ExportWalletParams} params Export parameters - * @returns {Promise} The decrypted export data + * @returns {Promise} The decrypted private key or seed phrase * @throws {Error} If the user is not authenticated or export fails */ - async exportWallet(params?: ExportWalletParams): Promise { + async exportWallet(params?: ExportWalletParams): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } @@ -401,21 +396,14 @@ export class RNSignerClient extends BaseSignerClient< }); // Step 4: Process the decrypted data based on export type - const result: ExportWalletResult = { - address: this.user.address, - exportAs, - }; - if (exportAs === "PRIVATE_KEY") { // For private key, the decrypted data is the raw private key bytes // Convert to hex string with 0x prefix - result.privateKey = "0x" + Buffer.from(decryptedData).toString("hex"); + return "0x" + Buffer.from(decryptedData).toString("hex"); } else { // For seed phrase, the decrypted data is the mnemonic string - result.seedPhrase = new TextDecoder().decode(decryptedData); + return new TextDecoder().decode(decryptedData); } - - return result; } finally { // No cleanup needed - key is only in memory } diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index e20fc0ac5e..865bec7afe 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -963,7 +963,7 @@ export abstract class BaseAlchemySigner< * ``` * * @param {unknown} params export wallet parameters - * @returns {Promise} the result of the wallet export operation + * @returns {Promise} the result of the wallet export operation */ exportWallet = async ( params: Parameters<(typeof this.inner)["exportWallet"]>[0], diff --git a/docs-site b/docs-site index e94a82c80a..b8a4769a31 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit e94a82c80a36d0fb18ef83bee08cc93a5861d496 +Subproject commit b8a4769a317f12ec39d011eeb58945d97f1d61fa From 5b5eaa0ce6a29394da235a3916063f54e681a81c Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 00:37:09 +0100 Subject: [PATCH 10/18] chore: dont mix and match async/await and try/catch --- account-kit/rn-signer/src/client.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index c7a156bd13..9236a2c68d 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -331,14 +331,17 @@ export class RNSignerClient extends BaseSignerClient< organizationId: this.user.orgId, }); - const walletAccounts = await Promise.all( + const walletAccountResponses = await Promise.all( wallets.map(({ walletId }) => this.turnkeyClient.getWalletAccounts({ organizationId: this.user!.orgId, walletId, }), ), - ).then((x) => x.flatMap((x) => x.accounts)); + ); + const walletAccounts = walletAccountResponses.flatMap( + (x) => x.accounts, + ); const walletAccount = walletAccounts.find( (x) => x.address === this.user!.address, From 48f5e81bfae69ec7e394d515b487f2fa0da5115b Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 00:39:18 +0100 Subject: [PATCH 11/18] chore: case-insensitive address checking just to be safe --- account-kit/rn-signer/src/client.ts | 2 +- account-kit/signer/src/client/base.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 9236a2c68d..c7fd8d9c3f 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -344,7 +344,7 @@ export class RNSignerClient extends BaseSignerClient< ); const walletAccount = walletAccounts.find( - (x) => x.address === this.user!.address, + (x) => x.address.toLowerCase() === this.user!.address.toLowerCase(), ); if (!walletAccount) { diff --git a/account-kit/signer/src/client/base.ts b/account-kit/signer/src/client/base.ts index 589bd8e27b..8a48a43268 100644 --- a/account-kit/signer/src/client/base.ts +++ b/account-kit/signer/src/client/base.ts @@ -1155,17 +1155,18 @@ export abstract class BaseSignerClient< organizationId: this.user.orgId, }); - const walletAccounts = await Promise.all( + const walletAccountResponses = await Promise.all( wallets.map(({ walletId }) => this.turnkeyClient.getWalletAccounts({ organizationId: this.user!.orgId, walletId, }), ), - ).then((x) => x.flatMap((x) => x.accounts)); + ); + const walletAccounts = walletAccountResponses.flatMap((x) => x.accounts); const walletAccount = walletAccounts.find( - (x) => x.address === this.user!.address, + (x) => x.address.toLowerCase() === this.user!.address.toLowerCase(), ); if (!walletAccount) { From 9f9255bccd633158b88aa33b6a08450e46ba0983 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 01:05:27 +0100 Subject: [PATCH 12/18] chore: tohex --- account-kit/rn-signer/src/client.ts | 28 ++++++++++++---------------- account-kit/rn-signer/src/index.tsx | 2 +- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index c7fd8d9c3f..b11bbfbb45 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -36,6 +36,7 @@ import { import { InAppBrowser } from "react-native-inappbrowser-reborn"; import { z } from "zod"; import { generateP256KeyPair, hpkeDecrypt } from "@turnkey/crypto"; +import { toHex } from "viem"; import { InAppBrowserUnavailableError } from "./errors"; import NativeTEKStamper from "./NativeTEKStamper"; import { parseSearchParams } from "./utils/parseUrlParams"; @@ -53,17 +54,15 @@ export const RNSignerClientParamsSchema = z.object({ export type RNSignerClientParams = z.input; -export type ExportWalletParams = { - exportAs?: "PRIVATE_KEY" | "SEED_PHRASE"; -}; +export enum ExportWalletAs { + PRIVATE_KEY = "PRIVATE_KEY", + SEED_PHRASE = "SEED_PHRASE", +} export type ExportWalletResult = string; // TODO: need to emit events -export class RNSignerClient extends BaseSignerClient< - ExportWalletParams, - string -> { +export class RNSignerClient extends BaseSignerClient { private stamper = NativeTEKStamper; oauthCallbackUrl: string; rpId: string | undefined; @@ -285,24 +284,24 @@ export class RNSignerClient extends BaseSignerClient< /** * Exports the wallet and returns the decrypted private key or seed phrase. * - * @param {ExportWalletParams} params Export parameters + * @param {ExportWalletAs} exportAs - The format to export the wallet as, either PRIVATE_KEY or SEED_PHRASE. Defaults to PRIVATE_KEY. * @returns {Promise} The decrypted private key or seed phrase * @throws {Error} If the user is not authenticated or export fails */ - async exportWallet(params?: ExportWalletParams): Promise { + async exportWallet( + exportAs: ExportWalletAs = ExportWalletAs.PRIVATE_KEY, + ): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } - const exportAs = params?.exportAs || "PRIVATE_KEY"; - // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); try { let exportBundle: string; - if (exportAs === "PRIVATE_KEY") { + if (exportAs === ExportWalletAs.PRIVATE_KEY) { // Step 2a: Export as private key const { activity } = await this.turnkeyClient.exportWalletAccount({ organizationId: this.user.orgId, @@ -400,11 +399,8 @@ export class RNSignerClient extends BaseSignerClient< // Step 4: Process the decrypted data based on export type if (exportAs === "PRIVATE_KEY") { - // For private key, the decrypted data is the raw private key bytes - // Convert to hex string with 0x prefix - return "0x" + Buffer.from(decryptedData).toString("hex"); + return toHex(decryptedData); } else { - // For seed phrase, the decrypted data is the mnemonic string return new TextDecoder().decode(decryptedData); } } finally { diff --git a/account-kit/rn-signer/src/index.tsx b/account-kit/rn-signer/src/index.tsx index 2e5a5cb749..2d5f9cb9f1 100644 --- a/account-kit/rn-signer/src/index.tsx +++ b/account-kit/rn-signer/src/index.tsx @@ -2,4 +2,4 @@ export type * from "./signer"; export { RNAlchemySigner } from "./signer"; export { RNSignerClient } from "./client"; -export type { ExportWalletParams, ExportWalletResult } from "./client"; +export type { ExportWalletAs, ExportWalletResult } from "./client"; From 81fd2fdca042439124e5358f23176ae88ce3fd19 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 01:15:45 +0100 Subject: [PATCH 13/18] chore: bring back the params --- account-kit/rn-signer/src/client.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index b11bbfbb45..12d20d49bf 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -59,10 +59,17 @@ export enum ExportWalletAs { SEED_PHRASE = "SEED_PHRASE", } +export type ExportWalletParams = { + exportAs?: ExportWalletAs; +}; + export type ExportWalletResult = string; // TODO: need to emit events -export class RNSignerClient extends BaseSignerClient { +export class RNSignerClient extends BaseSignerClient< + ExportWalletParams, + string +> { private stamper = NativeTEKStamper; oauthCallbackUrl: string; rpId: string | undefined; @@ -284,17 +291,17 @@ export class RNSignerClient extends BaseSignerClient { /** * Exports the wallet and returns the decrypted private key or seed phrase. * - * @param {ExportWalletAs} exportAs - The format to export the wallet as, either PRIVATE_KEY or SEED_PHRASE. Defaults to PRIVATE_KEY. + * @param {ExportWalletParams} params - Export parameters * @returns {Promise} The decrypted private key or seed phrase * @throws {Error} If the user is not authenticated or export fails */ - async exportWallet( - exportAs: ExportWalletAs = ExportWalletAs.PRIVATE_KEY, - ): Promise { + async exportWallet(params?: ExportWalletParams): Promise { if (!this.user) { throw new Error("User must be authenticated to export wallet"); } + const exportAs = params?.exportAs || ExportWalletAs.PRIVATE_KEY; + // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); @@ -398,7 +405,7 @@ export class RNSignerClient extends BaseSignerClient { }); // Step 4: Process the decrypted data based on export type - if (exportAs === "PRIVATE_KEY") { + if (exportAs === ExportWalletAs.PRIVATE_KEY) { return toHex(decryptedData); } else { return new TextDecoder().decode(decryptedData); From 9d43e94922003c2a90627a741d2c5f93af7341e0 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 01:23:25 +0100 Subject: [PATCH 14/18] chore: fix docs --- .../signer/classes/BaseAlchemySigner/exportWallet.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx b/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx index bb750bee9a..f1c1f6e5ef 100644 --- a/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx +++ b/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx @@ -44,5 +44,5 @@ export wallet parameters ## Returns -`boolean` -true if the wallet was exported successfully +`Promise` +the result of the wallet export operation From c5df40f3647e1406be24198bfb61829016a1e9d7 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 09:38:38 +0100 Subject: [PATCH 15/18] chore: remove enum --- account-kit/rn-signer/src/client.ts | 13 ++++--------- account-kit/rn-signer/src/index.tsx | 2 +- account-kit/signer/src/base.ts | 9 +++------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 12d20d49bf..1fc3c06b8b 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -54,13 +54,8 @@ export const RNSignerClientParamsSchema = z.object({ export type RNSignerClientParams = z.input; -export enum ExportWalletAs { - PRIVATE_KEY = "PRIVATE_KEY", - SEED_PHRASE = "SEED_PHRASE", -} - export type ExportWalletParams = { - exportAs?: ExportWalletAs; + format?: "PRIVATE_KEY" | "SEED_PHRASE"; }; export type ExportWalletResult = string; @@ -300,7 +295,7 @@ export class RNSignerClient extends BaseSignerClient< throw new Error("User must be authenticated to export wallet"); } - const exportAs = params?.exportAs || ExportWalletAs.PRIVATE_KEY; + const exportAs = params?.format || "PRIVATE_KEY"; // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); @@ -308,7 +303,7 @@ export class RNSignerClient extends BaseSignerClient< try { let exportBundle: string; - if (exportAs === ExportWalletAs.PRIVATE_KEY) { + if (exportAs === "PRIVATE_KEY") { // Step 2a: Export as private key const { activity } = await this.turnkeyClient.exportWalletAccount({ organizationId: this.user.orgId, @@ -405,7 +400,7 @@ export class RNSignerClient extends BaseSignerClient< }); // Step 4: Process the decrypted data based on export type - if (exportAs === ExportWalletAs.PRIVATE_KEY) { + if (exportAs === "PRIVATE_KEY") { return toHex(decryptedData); } else { return new TextDecoder().decode(decryptedData); diff --git a/account-kit/rn-signer/src/index.tsx b/account-kit/rn-signer/src/index.tsx index 2d5f9cb9f1..2e5a5cb749 100644 --- a/account-kit/rn-signer/src/index.tsx +++ b/account-kit/rn-signer/src/index.tsx @@ -2,4 +2,4 @@ export type * from "./signer"; export { RNAlchemySigner } from "./signer"; export { RNSignerClient } from "./client"; -export type { ExportWalletAs, ExportWalletResult } from "./client"; +export type { ExportWalletParams, ExportWalletResult } from "./client"; diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index 865bec7afe..74188fc1c2 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -59,9 +59,7 @@ import { import { assertNever } from "./utils/typeAssertions.js"; import { hashAuthorization } from "viem/utils"; -export interface BaseAlchemySignerParams< - TClient extends BaseSignerClient, -> { +export interface BaseAlchemySignerParams { client: TClient; sessionConfig?: Omit; initialError?: ErrorInfo; @@ -120,9 +118,8 @@ type GetUserParams = * Base abstract class for Alchemy Signer, providing authentication and session management for smart accounts. * Implements the `SmartAccountAuthenticator` interface and handles various signer events. */ -export abstract class BaseAlchemySigner< - TClient extends BaseSignerClient, -> implements SmartAccountAuthenticator +export abstract class BaseAlchemySigner + implements SmartAccountAuthenticator { signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer"; inner: TClient; From ce9fb3466d62e0c79bf9b0fa5a2767e5797dbf68 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 09:45:38 +0100 Subject: [PATCH 16/18] chore: lint --- account-kit/signer/src/base.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index 74188fc1c2..865bec7afe 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -59,7 +59,9 @@ import { import { assertNever } from "./utils/typeAssertions.js"; import { hashAuthorization } from "viem/utils"; -export interface BaseAlchemySignerParams { +export interface BaseAlchemySignerParams< + TClient extends BaseSignerClient, +> { client: TClient; sessionConfig?: Omit; initialError?: ErrorInfo; @@ -118,8 +120,9 @@ type GetUserParams = * Base abstract class for Alchemy Signer, providing authentication and session management for smart accounts. * Implements the `SmartAccountAuthenticator` interface and handles various signer events. */ -export abstract class BaseAlchemySigner - implements SmartAccountAuthenticator +export abstract class BaseAlchemySigner< + TClient extends BaseSignerClient, +> implements SmartAccountAuthenticator { signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer"; inner: TClient; From f2d548341f112759bb67baf21d7a6b38c12544e9 Mon Sep 17 00:00:00 2001 From: Flor Ronsmans De Vry <25546429+florrdv@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:31:38 -0700 Subject: [PATCH 17/18] chore: cleanup --- .../react/src/hooks/useExportAccount.ts | 12 +- account-kit/rn-signer/src/client.ts | 4 +- account-kit/signer/src/base.ts | 15 +-- account-kit/signer/src/client/base.ts | 115 +--------------- account-kit/signer/src/client/index.ts | 125 +++++++++++++++++- account-kit/signer/src/client/types.ts | 2 + docs-site | 2 +- .../BaseAlchemySigner/exportWallet.mdx | 2 +- 8 files changed, 142 insertions(+), 135 deletions(-) diff --git a/account-kit/react/src/hooks/useExportAccount.ts b/account-kit/react/src/hooks/useExportAccount.ts index 3046ef867b..6adf95564e 100644 --- a/account-kit/react/src/hooks/useExportAccount.ts +++ b/account-kit/react/src/hooks/useExportAccount.ts @@ -1,7 +1,11 @@ "use client"; import { DEFAULT_IFRAME_CONTAINER_ID } from "@account-kit/core"; -import type { ExportWalletParams as ExportAccountParams } from "@account-kit/signer"; +import type { + AlchemyWebSigner, + ExportWalletParams as ExportAccountParams, + ExportWalletOutput, +} from "@account-kit/signer"; import { useMutation, type UseMutateFunction } from "@tanstack/react-query"; import { createElement, useCallback, type CSSProperties } from "react"; import { useAlchemyAccountContext } from "./useAlchemyAccountContext.js"; @@ -10,7 +14,7 @@ import { useSigner } from "./useSigner.js"; export type UseExportAccountMutationArgs = { params?: ExportAccountParams; -} & BaseHookMutationArgs; +} & BaseHookMutationArgs; /** * Props for the `ExportAccountComponent` component. This component is @@ -30,7 +34,7 @@ export type ExportAccountComponentProps = { }; export type UseExportAccountResult = { - exportAccount: UseMutateFunction; + exportAccount: UseMutateFunction; isExported: boolean; isExporting: boolean; error: Error | null; @@ -66,7 +70,7 @@ export function useExportAccount( ): UseExportAccountResult { const { params, ...mutationArgs } = args ?? {}; const { queryClient } = useAlchemyAccountContext(); - const signer = useSigner(); + const signer = useSigner(); const { iframeContainerId } = params ?? { iframeContainerId: DEFAULT_IFRAME_CONTAINER_ID, }; diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 1fc3c06b8b..bd6c66b388 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -55,7 +55,7 @@ export const RNSignerClientParamsSchema = z.object({ export type RNSignerClientParams = z.input; export type ExportWalletParams = { - format?: "PRIVATE_KEY" | "SEED_PHRASE"; + exportAs?: "PRIVATE_KEY" | "SEED_PHRASE"; }; export type ExportWalletResult = string; @@ -295,7 +295,7 @@ export class RNSignerClient extends BaseSignerClient< throw new Error("User must be authenticated to export wallet"); } - const exportAs = params?.format || "PRIVATE_KEY"; + const exportAs = params?.exportAs || "PRIVATE_KEY"; // Step 1: Generate a P256 key pair for encryption const embeddedKey = generateP256KeyPair(); diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index 865bec7afe..aebb3e76f2 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -59,9 +59,7 @@ import { import { assertNever } from "./utils/typeAssertions.js"; import { hashAuthorization } from "viem/utils"; -export interface BaseAlchemySignerParams< - TClient extends BaseSignerClient, -> { +export interface BaseAlchemySignerParams { client: TClient; sessionConfig?: Omit; initialError?: ErrorInfo; @@ -120,9 +118,8 @@ type GetUserParams = * Base abstract class for Alchemy Signer, providing authentication and session management for smart accounts. * Implements the `SmartAccountAuthenticator` interface and handles various signer events. */ -export abstract class BaseAlchemySigner< - TClient extends BaseSignerClient, -> implements SmartAccountAuthenticator +export abstract class BaseAlchemySigner + implements SmartAccountAuthenticator { signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer"; inner: TClient; @@ -963,11 +960,9 @@ export abstract class BaseAlchemySigner< * ``` * * @param {unknown} params export wallet parameters - * @returns {Promise} the result of the wallet export operation + * @returns {Promise} the result of the wallet export operation */ - exportWallet = async ( - params: Parameters<(typeof this.inner)["exportWallet"]>[0], - ) => { + exportWallet: TClient["exportWallet"] = async (params) => { return this.inner.exportWallet(params); }; diff --git a/account-kit/signer/src/client/base.ts b/account-kit/signer/src/client/base.ts index 8a48a43268..d95538f4e5 100644 --- a/account-kit/signer/src/client/base.ts +++ b/account-kit/signer/src/client/base.ts @@ -15,7 +15,6 @@ import { getDefaultProviderCustomization } from "../oauth.js"; import type { OauthMode } from "../signer.js"; import { base64UrlEncode } from "../utils/base64UrlEncode.js"; import { resolveRelativeUrl } from "../utils/resolveRelativeUrl.js"; -import { assertNever } from "../utils/typeAssertions.js"; import type { AlchemySignerClientEvent, AlchemySignerClientEvents, @@ -83,7 +82,7 @@ const withHexPrefix = (hex: string) => `0x${hex}` as const; */ export abstract class BaseSignerClient< TExportWalletParams = unknown, - TExportWalletOutput = boolean, + TExportWalletOutput = unknown, > { private _user: User | undefined; private connectionConfig: ConnectionConfig; @@ -140,29 +139,6 @@ export abstract class BaseSignerClient< this.turnkeyClient.stamper = stamper; } - /** - * Exports wallet credentials based on the specified type, either as a SEED_PHRASE or PRIVATE_KEY. - * - * @param {object} params The parameters for exporting the wallet - * @param {ExportWalletStamper} params.exportStamper The stamper used for exporting the wallet - * @param {"SEED_PHRASE" | "PRIVATE_KEY"} params.exportAs Specifies the format for exporting the wallet, either as a SEED_PHRASE or PRIVATE_KEY - * @returns {Promise} A promise that resolves to true if the export is successful - */ - protected exportWalletInner(params: { - exportStamper: ExportWalletStamper; - exportAs: "SEED_PHRASE" | "PRIVATE_KEY"; - }): Promise { - const { exportAs } = params; - switch (exportAs) { - case "PRIVATE_KEY": - return this.exportAsPrivateKey(params.exportStamper); - case "SEED_PHRASE": - return this.exportAsSeedPhrase(params.exportStamper); - default: - assertNever(exportAs, `Unknown export mode: ${exportAs}`); - } - } - /** * Authenticates the user by either email or passkey account creation flow. Emits events during the process. * @@ -1146,95 +1122,6 @@ export abstract class BaseSignerClient< // #endregion // #region PRIVATE METHODS - private exportAsSeedPhrase = async (stamper: ExportWalletStamper) => { - if (!this.user) { - throw new NotAuthenticatedError(); - } - - const { wallets } = await this.turnkeyClient.getWallets({ - organizationId: this.user.orgId, - }); - - const walletAccountResponses = await Promise.all( - wallets.map(({ walletId }) => - this.turnkeyClient.getWalletAccounts({ - organizationId: this.user!.orgId, - walletId, - }), - ), - ); - const walletAccounts = walletAccountResponses.flatMap((x) => x.accounts); - - const walletAccount = walletAccounts.find( - (x) => x.address.toLowerCase() === this.user!.address.toLowerCase(), - ); - - if (!walletAccount) { - throw new Error( - `Could not find wallet associated with ${this.user.address}`, - ); - } - - const { activity } = await this.turnkeyClient.exportWallet({ - organizationId: this.user.orgId, - type: "ACTIVITY_TYPE_EXPORT_WALLET", - timestampMs: Date.now().toString(), - parameters: { - walletId: walletAccount!.walletId, - targetPublicKey: stamper.publicKey()!, - }, - }); - - const { exportBundle } = await this.pollActivityCompletion( - activity, - this.user.orgId, - "exportWalletResult", - ); - - const result = await stamper.injectWalletExportBundle( - exportBundle, - this.user.orgId, - ); - - if (!result) { - throw new Error("Failed to inject wallet export bundle"); - } - - return result; - }; - - private exportAsPrivateKey = async (stamper: ExportWalletStamper) => { - if (!this.user) { - throw new NotAuthenticatedError(); - } - - const { activity } = await this.turnkeyClient.exportWalletAccount({ - organizationId: this.user.orgId, - type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", - timestampMs: Date.now().toString(), - parameters: { - address: this.user.address, - targetPublicKey: stamper.publicKey()!, - }, - }); - - const { exportBundle } = await this.pollActivityCompletion( - activity, - this.user.orgId, - "exportWalletAccountResult", - ); - - const result = await stamper.injectKeyExportBundle( - exportBundle, - this.user.orgId, - ); - - if (!result) { - throw new Error("Failed to inject wallet export bundle"); - } - - return result; - }; /** * Returns the authentication url for the selected OAuth Proivder diff --git a/account-kit/signer/src/client/index.ts b/account-kit/signer/src/client/index.ts index 1ed0368245..4020e29f11 100644 --- a/account-kit/signer/src/client/index.ts +++ b/account-kit/signer/src/client/index.ts @@ -5,7 +5,9 @@ import { WebauthnStamper } from "@turnkey/webauthn-stamper"; import { z } from "zod"; import type { AuthParams } from "../signer.js"; import { generateRandomBuffer } from "../utils/generateRandomBuffer.js"; -import { BaseSignerClient } from "./base.js"; +import { assertNever } from "../utils/typeAssertions.js"; +import { BaseSignerClient, type ExportWalletStamper } from "./base.js"; +import { NotAuthenticatedError } from "../errors.js"; import type { AlchemySignerClientEvents, AuthenticatingEventMetadata, @@ -22,6 +24,7 @@ import type { GetWebAuthnAttestationResult, IdTokenOnly, SmsAuthParams, + ExportWalletOutput, } from "./types.js"; import { MfaRequiredError } from "../errors.js"; import { parseMfaError } from "../utils/parseMfaError.js"; @@ -54,7 +57,10 @@ export type AlchemySignerClientParams = z.input< * A lower level client used by the AlchemySigner used to communicate with * Alchemy's signer service. */ -export class AlchemySignerWebClient extends BaseSignerClient { +export class AlchemySignerWebClient extends BaseSignerClient< + ExportWalletParams, + ExportWalletOutput +> { private iframeStamper: IframeStamper; private webauthnStamper: WebauthnStamper; oauthCallbackUrl: string; @@ -389,7 +395,7 @@ export class AlchemySignerWebClient extends BaseSignerClient public override exportWallet = async ({ iframeContainerId, iframeElementId = "turnkey-export-iframe", - }: ExportWalletParams) => { + }: ExportWalletParams): Promise => { const exportWalletIframeStamper = new IframeStamper({ iframeContainer: document.getElementById(iframeContainerId), iframeElementId: iframeElementId, @@ -410,6 +416,29 @@ export class AlchemySignerWebClient extends BaseSignerClient }); }; + /** + * Exports wallet credentials based on the specified type, either as a SEED_PHRASE or PRIVATE_KEY. + * + * @param {object} params The parameters for exporting the wallet + * @param {ExportWalletStamper} params.exportStamper The stamper used for exporting the wallet + * @param {"SEED_PHRASE" | "PRIVATE_KEY"} params.exportAs Specifies the format for exporting the wallet, either as a SEED_PHRASE or PRIVATE_KEY + * @returns {Promise} A promise that resolves to true if the export is successful + */ + protected exportWalletInner(params: { + exportStamper: ExportWalletStamper; + exportAs: "SEED_PHRASE" | "PRIVATE_KEY"; + }): Promise { + const { exportAs } = params; + switch (exportAs) { + case "PRIVATE_KEY": + return this.exportAsPrivateKey(params.exportStamper); + case "SEED_PHRASE": + return this.exportAsSeedPhrase(params.exportStamper); + default: + assertNever(exportAs, `Unknown export mode: ${exportAs}`); + } + } + /** * Asynchronous function that clears the user and resets the iframe stamper. * @@ -748,6 +777,96 @@ export class AlchemySignerWebClient extends BaseSignerClient ]; } } + + private exportAsSeedPhrase = async (stamper: ExportWalletStamper) => { + if (!this.user) { + throw new NotAuthenticatedError(); + } + + const { wallets } = await this.turnkeyClient.getWallets({ + organizationId: this.user.orgId, + }); + + const walletAccountResponses = await Promise.all( + wallets.map(({ walletId }) => + this.turnkeyClient.getWalletAccounts({ + organizationId: this.user!.orgId, + walletId, + }), + ), + ); + const walletAccounts = walletAccountResponses.flatMap((x) => x.accounts); + + const walletAccount = walletAccounts.find( + (x) => x.address.toLowerCase() === this.user!.address.toLowerCase(), + ); + + if (!walletAccount) { + throw new Error( + `Could not find wallet associated with ${this.user.address}`, + ); + } + + const { activity } = await this.turnkeyClient.exportWallet({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET", + timestampMs: Date.now().toString(), + parameters: { + walletId: walletAccount!.walletId, + targetPublicKey: stamper.publicKey()!, + }, + }); + + const { exportBundle } = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletResult", + ); + + const result = await stamper.injectWalletExportBundle( + exportBundle, + this.user.orgId, + ); + + if (!result) { + throw new Error("Failed to inject wallet export bundle"); + } + + return result; + }; + + private exportAsPrivateKey = async (stamper: ExportWalletStamper) => { + if (!this.user) { + throw new NotAuthenticatedError(); + } + + const { activity } = await this.turnkeyClient.exportWalletAccount({ + organizationId: this.user.orgId, + type: "ACTIVITY_TYPE_EXPORT_WALLET_ACCOUNT", + timestampMs: Date.now().toString(), + parameters: { + address: this.user.address, + targetPublicKey: stamper.publicKey()!, + }, + }); + + const { exportBundle } = await this.pollActivityCompletion( + activity, + this.user.orgId, + "exportWalletAccountResult", + ); + + const result = await stamper.injectKeyExportBundle( + exportBundle, + this.user.orgId, + ); + + if (!result) { + throw new Error("Failed to inject wallet export bundle"); + } + + return result; + }; } /** diff --git a/account-kit/signer/src/client/types.ts b/account-kit/signer/src/client/types.ts index 33734ecc88..d02fd6d6a6 100644 --- a/account-kit/signer/src/client/types.ts +++ b/account-kit/signer/src/client/types.ts @@ -30,6 +30,8 @@ export type ExportWalletParams = { iframeElementId?: string; }; +export type ExportWalletOutput = boolean; + export type CreateAccountParams = | { type: "email"; diff --git a/docs-site b/docs-site index b8a4769a31..3cc6ab352d 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit b8a4769a317f12ec39d011eeb58945d97f1d61fa +Subproject commit 3cc6ab352da5141ae2801ef7ec487bfc6e0a3b01 diff --git a/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx b/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx index f1c1f6e5ef..ddd86faa45 100644 --- a/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx +++ b/docs/pages/reference/account-kit/signer/classes/BaseAlchemySigner/exportWallet.mdx @@ -44,5 +44,5 @@ export wallet parameters ## Returns -`Promise` +`Promise` the result of the wallet export operation From e1cc5f795a01924543901f0d93c5433a43d91c55 Mon Sep 17 00:00:00 2001 From: Andrew Webb Date: Thu, 4 Sep 2025 20:33:08 +0100 Subject: [PATCH 18/18] chore: empty commit to trigger workflows --- docs-site | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site b/docs-site index 3cc6ab352d..98479ac380 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit 3cc6ab352da5141ae2801ef7ec487bfc6e0a3b01 +Subproject commit 98479ac380b479cd79c4a54bf4ffbaa66f914e8b