Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions account-kit/rn-signer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"dependencies": {
"@aa-sdk/core": "^4.60.1",
"@account-kit/signer": "^4.60.1",
"@turnkey/crypto": "^2.5.0",
"@turnkey/react-native-passkey-stamper": "^1.0.14",
"uuid": "^11.1.0",
"viem": "^2.29.2",
Expand Down
148 changes: 143 additions & 5 deletions account-kit/rn-signer/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
import "react-native-get-random-values";
import "./utils/buffer-polyfill";
import "./utils/mmkv-localstorage-polyfill";

/* eslint-disable import/extensions */
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is already set up above.

import { type ConnectionConfig } from "@aa-sdk/core";
import {
createPasskey,
Expand Down Expand Up @@ -37,6 +35,8 @@ import {
} from "@account-kit/signer";
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";
Expand All @@ -54,8 +54,22 @@ export const RNSignerClientParamsSchema = z.object({

export type RNSignerClientParams = z.input<typeof RNSignerClientParamsSchema>;

export enum ExportWalletAs {
PRIVATE_KEY = "PRIVATE_KEY",
SEED_PHRASE = "SEED_PHRASE",
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌈 can we revert this to not be an enum? no need


export type ExportWalletParams = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@florrdv making this type into a simple string (or the enum above) requires a larger refactor in the useWalletExport function too. I just didn't want to get into it

exportAs?: ExportWalletAs;
};

export type ExportWalletResult = string;

// TODO: need to emit events
export class RNSignerClient extends BaseSignerClient<undefined> {
export class RNSignerClient extends BaseSignerClient<
ExportWalletParams,
string
> {
private stamper = NativeTEKStamper;
oauthCallbackUrl: string;
rpId: string | undefined;
Expand Down Expand Up @@ -273,8 +287,132 @@ export class RNSignerClient extends BaseSignerClient<undefined> {
this.stamper.clear();
await this.stamper.init();
}
override exportWallet(_params: unknown): Promise<boolean> {
throw new Error("Method not implemented.");

/**
* Exports the wallet and returns the decrypted private key or seed phrase.
*
* @param {ExportWalletParams} params - Export parameters
* @returns {Promise<string>} The decrypted private key or seed phrase
* @throws {Error} If the user is not authenticated or export fails
*/
async exportWallet(params?: ExportWalletParams): Promise<string> {
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();

try {
let exportBundle: string;

if (exportAs === ExportWalletAs.PRIVATE_KEY) {
// Step 2a: 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 2b: Export as seed phrase (need to find the wallet first)
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 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;
}

// 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);

// 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({
ciphertextBuf: ciphertextBuf,
encappedKeyBuf: encappedKeyBuf,
receiverPriv: embeddedKey.privateKey,
});

// Step 4: Process the decrypted data based on export type
if (exportAs === ExportWalletAs.PRIVATE_KEY) {
return toHex(decryptedData);
} else {
return new TextDecoder().decode(decryptedData);
}
} finally {
// No cleanup needed - key is only in memory
}
}

override targetPublicKey(): Promise<string> {
Expand Down
1 change: 1 addition & 0 deletions account-kit/rn-signer/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export type * from "./signer";
export { RNAlchemySigner } from "./signer";
export { RNSignerClient } from "./client";
export type { ExportWalletAs, ExportWalletResult } from "./client";
15 changes: 9 additions & 6 deletions account-kit/signer/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ import {
import { assertNever } from "./utils/typeAssertions.js";
import { hashAuthorization } from "viem/utils";

export interface BaseAlchemySignerParams<TClient extends BaseSignerClient> {
export interface BaseAlchemySignerParams<
Copy link
Contributor

Choose a reason for hiding this comment

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

‼️ do we need this here? I think we have default generic values right?

TClient extends BaseSignerClient<any, any>,
> {
client: TClient;
sessionConfig?: Omit<SessionManagerParams, "client">;
initialError?: ErrorInfo;
Expand Down Expand Up @@ -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<TClient extends BaseSignerClient>
implements SmartAccountAuthenticator<AuthParams, User, TClient>
export abstract class BaseAlchemySigner<
TClient extends BaseSignerClient<any, any>,
Copy link
Contributor

Choose a reason for hiding this comment

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

same here, any any

> implements SmartAccountAuthenticator<AuthParams, User, TClient>
{
signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer";
inner: TClient;
Expand Down Expand Up @@ -960,11 +963,11 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
* ```
*
* @param {unknown} params export wallet parameters
* @returns {boolean} true if the wallet was exported successfully
* @returns {Promise<boolean | string>} the result of the wallet export operation
*/
exportWallet: (
exportWallet = async (
params: Parameters<(typeof this.inner)["exportWallet"]>[0],
) => Promise<boolean> = async (params) => {
) => {
return this.inner.exportWallet(params);
};

Expand Down
16 changes: 11 additions & 5 deletions account-kit/signer/src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ const withHexPrefix = (hex: string) => `0x${hex}` as const;
/**
* Base class for all Alchemy Signer clients
*/
export abstract class BaseSignerClient<TExportWalletParams = unknown> {
export abstract class BaseSignerClient<
TExportWalletParams = unknown,
TExportWalletOutput = boolean,
> {
private _user: User | undefined;
private connectionConfig: ConnectionConfig;
protected turnkeyClient: TurnkeyClient;
Expand Down Expand Up @@ -265,7 +268,9 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {

public abstract disconnect(): Promise<void>;

public abstract exportWallet(params: TExportWalletParams): Promise<boolean>;
public abstract exportWallet(
params: TExportWalletParams,
): Promise<TExportWalletOutput>;

public abstract targetPublicKey(): Promise<string>;

Expand Down Expand Up @@ -1150,17 +1155,18 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ export wallet parameters

## Returns

`boolean`
true if the wallet was exported successfully
`Promise<boolean | string>`
the result of the wallet export operation
60 changes: 17 additions & 43 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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==
Expand All @@ -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"
Expand Down Expand Up @@ -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==
Expand All @@ -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"
Expand Down
Loading