-
Notifications
You must be signed in to change notification settings - Fork 197
feat: export wallet #1858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: export wallet #1858
Changes from 15 commits
1dd8cec
2e13070
378034c
9ff728e
c84ccd7
83190e4
0debc01
5146a98
94aad61
dca93ca
5b5eaa0
48f5e81
9f9255b
81fd2fd
9d43e94
c5df40f
ce9fb34
f2d5483
e1cc5f7
c91692f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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"; | ||
|
@@ -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", | ||
} | ||
|
||
|
||
export type ExportWalletParams = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @florrdv making this type into a simple |
||
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; | ||
|
@@ -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 | ||
florrdv marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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> { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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< | ||
|
||
TClient extends BaseSignerClient<any, any>, | ||
> { | ||
client: TClient; | ||
sessionConfig?: Omit<SessionManagerParams, "client">; | ||
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<TClient extends BaseSignerClient> | ||
implements SmartAccountAuthenticator<AuthParams, User, TClient> | ||
export abstract class BaseAlchemySigner< | ||
TClient extends BaseSignerClient<any, any>, | ||
|
||
> implements SmartAccountAuthenticator<AuthParams, User, TClient> | ||
{ | ||
signerType: "alchemy-signer" | "rn-alchemy-signer" = "alchemy-signer"; | ||
inner: TClient; | ||
|
@@ -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); | ||
}; | ||
|
||
|
+0 −29 | fern/api-reference/alchemy-rollups/rollup-basics.mdx | |
+3 −0 | fern/docs.yml | |
+2 −2 | src/openapi/solana-das/solana-das.yaml | |
+12 −0 | src/openrpc/chains/solana/solana.yaml |
There was a problem hiding this comment.
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.