diff --git a/.changeset/sixty-ladybugs-dance.md b/.changeset/sixty-ladybugs-dance.md new file mode 100644 index 000000000..7a53a7eab --- /dev/null +++ b/.changeset/sixty-ladybugs-dance.md @@ -0,0 +1,7 @@ +--- +"@crossmint/client-sdk-react-ui": minor +"@crossmint/client-sdk-react-base": minor +"@crossmint/wallets-sdk": minor +--- + +Added Shadow Signers for avoiding redundant OTP Verification diff --git a/packages/wallets/src/signers/evm-external-wallet.ts b/packages/wallets/src/signers/evm-external-wallet.ts index e44a85a76..601619a17 100644 --- a/packages/wallets/src/signers/evm-external-wallet.ts +++ b/packages/wallets/src/signers/evm-external-wallet.ts @@ -1,30 +1,18 @@ import type { Account, EIP1193Provider as ViemEIP1193Provider } from "viem"; -import type { GenericEIP1193Provider, Signer, ExternalWalletInternalSignerConfig } from "./types"; +import type { GenericEIP1193Provider, ExternalWalletInternalSignerConfig } from "./types"; import type { EVMChain } from "@/chains/chains"; +import { ExternalWalletSigner } from "./external-wallet-signer"; -export class EVMExternalWalletSigner implements Signer { - type = "external-wallet" as const; - private _address: string; +export class EVMExternalWalletSigner extends ExternalWalletSigner { provider?: GenericEIP1193Provider | ViemEIP1193Provider; viemAccount?: Account; - constructor(private config: ExternalWalletInternalSignerConfig) { - if (config.address == null) { - throw new Error("Please provide an address for the External Wallet Signer"); - } - this._address = config.address; + constructor(config: ExternalWalletInternalSignerConfig) { + super(config); this.provider = config.provider; this.viemAccount = config.viemAccount; } - address() { - return this._address; - } - - locator() { - return this.config.locator; - } - async signMessage(message: string) { if (this.provider != null) { const signature = await this.provider.request({ diff --git a/packages/wallets/src/signers/external-wallet-signer.ts b/packages/wallets/src/signers/external-wallet-signer.ts new file mode 100644 index 000000000..f471ee422 --- /dev/null +++ b/packages/wallets/src/signers/external-wallet-signer.ts @@ -0,0 +1,25 @@ +import type { ExternalWalletInternalSignerConfig, Signer } from "./types"; +import type { Chain } from "../chains/chains"; + +export abstract class ExternalWalletSigner implements Signer { + type = "external-wallet" as const; + protected _address: string; + + constructor(protected config: ExternalWalletInternalSignerConfig) { + if (config.address == null) { + throw new Error("Please provide an address for the External Wallet Signer"); + } + this._address = config.address; + } + + address() { + return this._address; + } + + locator() { + return this.config.locator; + } + + abstract signMessage(message: string): Promise<{ signature: string }>; + abstract signTransaction(transaction: string): Promise<{ signature: string }>; +} diff --git a/packages/wallets/src/signers/index.ts b/packages/wallets/src/signers/index.ts index 4dfe0ec05..93058a3f9 100644 --- a/packages/wallets/src/signers/index.ts +++ b/packages/wallets/src/signers/index.ts @@ -8,15 +8,19 @@ import type { Chain } from "../chains/chains"; import type { InternalSignerConfig, Signer } from "./types"; import { StellarExternalWalletSigner } from "./stellar-external-wallet"; -export function assembleSigner(chain: C, config: InternalSignerConfig): Signer { +export function assembleSigner( + chain: C, + config: InternalSignerConfig, + walletAddress: string +): Signer { switch (config.type) { case "email": case "phone": if (chain === "solana") { - return new SolanaNonCustodialSigner(config); + return new SolanaNonCustodialSigner(config, walletAddress); } if (chain === "stellar") { - return new StellarNonCustodialSigner(config); + return new StellarNonCustodialSigner(config, walletAddress); } return new EVMNonCustodialSigner(config); case "api-key": diff --git a/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts index ecb5ab480..1890397f0 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts @@ -1,7 +1,12 @@ -import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types"; +import type { + EmailInternalSignerConfig, + ExternalWalletInternalSignerConfig, + PhoneInternalSignerConfig, +} from "../types"; import { NonCustodialSigner, DEFAULT_EVENT_OPTIONS } from "./ncs-signer"; import { PersonalMessage } from "ox"; import { isHex, toHex, type Hex } from "viem"; +import type { EVMChain } from "../../chains/chains"; export class EVMNonCustodialSigner extends NonCustodialSigner { constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) { @@ -64,4 +69,8 @@ export class EVMNonCustodialSigner extends NonCustodialSigner { ); } } + + protected getShadowSignerConfig(): ExternalWalletInternalSignerConfig { + throw new Error("Shadow signer not implemented for EVM chains"); + } } diff --git a/packages/wallets/src/signers/non-custodial/ncs-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index 35f92a3f4..fb6083e8e 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -1,8 +1,17 @@ -import type { BaseSignResult, EmailInternalSignerConfig, PhoneInternalSignerConfig, Signer } from "../types"; +import type { + BaseSignResult, + EmailInternalSignerConfig, + ExternalWalletInternalSignerConfig, + PhoneInternalSignerConfig, + Signer, +} from "../types"; import { AuthRejectedError } from "../types"; import { NcsIframeManager } from "./ncs-iframe-manager"; import { validateAPIKey } from "@crossmint/common-sdk-base"; import type { SignerOutputEvent } from "@crossmint/client-signers"; +import { getShadowSigner, hasShadowSigner, type ShadowSignerData } from "../shadow-signer"; +import type { Chain } from "../../chains/chains"; +import type { ExternalWalletSigner } from "../external-wallet-signer"; export abstract class NonCustodialSigner implements Signer { public readonly type: "email" | "phone"; @@ -13,6 +22,7 @@ export abstract class NonCustodialSigner implements Signer { reject: (error: Error) => void; } | null = null; private _initializationPromise: Promise | null = null; + protected shadowSigner: ExternalWalletSigner | null = null; constructor(protected config: EmailInternalSignerConfig | PhoneInternalSignerConfig) { this.initialize(); @@ -20,10 +30,16 @@ export abstract class NonCustodialSigner implements Signer { } locator() { + if (this.shadowSigner != null) { + return this.shadowSigner.locator(); + } return this.config.locator; } address() { + if (this.shadowSigner != null) { + return this.shadowSigner.address(); + } return this.config.address; } @@ -83,6 +99,10 @@ export abstract class NonCustodialSigner implements Signer { } protected async handleAuthRequired() { + if (this.shadowSigner != null) { + return; + } + const clientTEEConnection = await this.getTEEConnection(); if (this.config.onAuthRequired == null) { @@ -267,6 +287,25 @@ export abstract class NonCustodialSigner implements Signer { this._authPromise?.reject(error); throw error; } + + protected abstract getShadowSignerConfig( + shadowSigner: ShadowSignerData, + walletAddress: string + ): ExternalWalletInternalSignerConfig; + + protected initializeShadowSigner( + walletAddress: string, + ExternalWalletSignerClass: new (config: ExternalWalletInternalSignerConfig) => ExternalWalletSigner + ) { + if (hasShadowSigner(walletAddress)) { + const shadowSigner = getShadowSigner(walletAddress); + if (shadowSigner != null && this.config.shadowSigner?.enabled !== false) { + this.shadowSigner = new ExternalWalletSignerClass( + this.getShadowSignerConfig(shadowSigner, walletAddress) as ExternalWalletInternalSignerConfig + ); + } + } + } } export const DEFAULT_EVENT_OPTIONS = { diff --git a/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts index ddf96fa4d..0c3b343f8 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts @@ -1,11 +1,19 @@ -import { VersionedTransaction } from "@solana/web3.js"; +import { PublicKey, VersionedTransaction } from "@solana/web3.js"; import base58 from "bs58"; -import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types"; +import type { + EmailInternalSignerConfig, + ExternalWalletInternalSignerConfig, + PhoneInternalSignerConfig, +} from "../types"; import { NonCustodialSigner, DEFAULT_EVENT_OPTIONS } from "./ncs-signer"; +import { getShadowSignerPrivateKey, type ShadowSignerData } from "../shadow-signer"; +import { SolanaExternalWalletSigner } from "../solana-external-wallet"; +import type { SolanaChain } from "../../chains/chains"; export class SolanaNonCustodialSigner extends NonCustodialSigner { - constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) { + constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig, walletAddress: string) { super(config); + this.initializeShadowSigner(walletAddress, SolanaExternalWalletSigner); } async signMessage() { @@ -13,6 +21,9 @@ export class SolanaNonCustodialSigner extends NonCustodialSigner { } async signTransaction(transaction: string): Promise<{ signature: string }> { + if (this.shadowSigner != null) { + return await this.shadowSigner.signTransaction(transaction); + } await this.handleAuthRequired(); const jwt = this.getJwtOrThrow(); @@ -60,4 +71,29 @@ export class SolanaNonCustodialSigner extends NonCustodialSigner { ); } } + + protected getShadowSignerConfig( + shadowData: ShadowSignerData, + walletAddress: string + ): ExternalWalletInternalSignerConfig { + return { + type: "external-wallet", + address: shadowData.publicKey, + locator: `external-wallet:${shadowData.publicKey}`, + onSignTransaction: async (transaction) => { + const privateKey = await getShadowSignerPrivateKey(walletAddress); + if (privateKey == null) { + throw new Error("Shadow signer private key not found"); + } + + const messageBytes = new Uint8Array(transaction.message.serialize()); + const signatureBuffer = await window.crypto.subtle.sign({ name: "Ed25519" }, privateKey, messageBytes); + + const signature = new Uint8Array(signatureBuffer); + transaction.addSignature(new PublicKey(shadowData.publicKey), signature); + + return transaction; + }, + }; + } } diff --git a/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts index dc0660b98..b75dc98fc 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts @@ -1,9 +1,17 @@ -import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types"; +import { getShadowSignerPrivateKey, type ShadowSignerData } from "../shadow-signer"; +import type { + EmailInternalSignerConfig, + ExternalWalletInternalSignerConfig, + PhoneInternalSignerConfig, +} from "../types"; import { DEFAULT_EVENT_OPTIONS, NonCustodialSigner } from "./ncs-signer"; +import { StellarExternalWalletSigner } from "../stellar-external-wallet"; +import type { StellarChain } from "../../chains/chains"; export class StellarNonCustodialSigner extends NonCustodialSigner { - constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) { + constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig, walletAddress: string) { super(config); + this.initializeShadowSigner(walletAddress, StellarExternalWalletSigner); } async signMessage() { @@ -11,6 +19,9 @@ export class StellarNonCustodialSigner extends NonCustodialSigner { } async signTransaction(payload: string): Promise<{ signature: string }> { + if (this.shadowSigner != null) { + return await this.shadowSigner.signTransaction(payload); + } await this.handleAuthRequired(); const jwt = this.getJwtOrThrow(); @@ -58,4 +69,31 @@ export class StellarNonCustodialSigner extends NonCustodialSigner { ); } } + + protected getShadowSignerConfig( + shadowData: ShadowSignerData, + walletAddress: string + ): ExternalWalletInternalSignerConfig { + return { + type: "external-wallet", + address: shadowData.publicKey, + locator: `external-wallet:${shadowData.publicKey}`, + onSignStellarTransaction: async (payload) => { + const privateKey = await getShadowSignerPrivateKey(walletAddress); + if (privateKey == null) { + throw new Error("Shadow signer private key not found"); + } + + const transactionString = typeof payload === "string" ? payload : (payload as any).tx; + + const messageBytes = Uint8Array.from(atob(transactionString), (c) => c.charCodeAt(0)); + + const signatureBuffer = await window.crypto.subtle.sign({ name: "Ed25519" }, privateKey, messageBytes); + + const signature = new Uint8Array(signatureBuffer); + const signatureBase64 = btoa(String.fromCharCode(...signature)); + return signatureBase64; + }, + }; + } } diff --git a/packages/wallets/src/signers/shadow-signer/index.ts b/packages/wallets/src/signers/shadow-signer/index.ts new file mode 100644 index 000000000..165d06a20 --- /dev/null +++ b/packages/wallets/src/signers/shadow-signer/index.ts @@ -0,0 +1,134 @@ +import { encode as encodeBase58 } from "bs58"; +import { StrKey } from "@stellar/stellar-sdk"; +import type { Chain } from "@/chains/chains"; +import type { BaseExternalWalletSignerConfig } from "@crossmint/common-sdk-base"; + +const SHADOW_SIGNER_STORAGE_KEY = "crossmint_shadow_signer"; +const SHADOW_SIGNER_DB_NAME = "crossmint_shadow_keys"; +const SHADOW_SIGNER_DB_STORE = "keys"; + +export type ShadowSignerData = { + chain: Chain; + walletAddress: string; + publicKey: string; + createdAt: number; +}; + +export type ShadowSignerResult = { + shadowSigner: BaseExternalWalletSignerConfig; + publicKey: string; + privateKey: CryptoKey; +}; + +async function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(SHADOW_SIGNER_DB_NAME, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(SHADOW_SIGNER_DB_STORE)) { + db.createObjectStore(SHADOW_SIGNER_DB_STORE); + } + }; + }); +} + +export async function generateShadowSigner(chain: C): Promise> { + if (chain === "solana" || chain === "stellar") { + const keyPair = (await window.crypto.subtle.generateKey( + { + name: "Ed25519", + namedCurve: "Ed25519", + } as any, + false, + ["sign", "verify"] + )) as CryptoKeyPair; + + const publicKeyBuffer = await window.crypto.subtle.exportKey("raw", keyPair.publicKey); + const publicKeyBytes = new Uint8Array(publicKeyBuffer); + + let encodedPublicKey: string; + if (chain === "stellar") { + // Stellar uses StrKey encoding (Base32 with version byte and checksum) + encodedPublicKey = StrKey.encodeEd25519PublicKey(Buffer.from(publicKeyBytes)); + } else { + // Solana uses Base58 encoding + encodedPublicKey = encodeBase58(publicKeyBytes); + } + + return { + shadowSigner: { + type: "external-wallet", + address: encodedPublicKey, + }, + publicKey: encodedPublicKey, + privateKey: keyPair.privateKey, + }; + } + // TODO: Add support for EVM chains + throw new Error("Unsupported chain"); +} + +export async function storeShadowSigner( + walletAddress: string, + chain: Chain, + publicKey: string, + privateKey: CryptoKey +): Promise { + if (typeof localStorage === "undefined" || typeof indexedDB === "undefined") { + return; + } + + const db = await openDB(); + const tx = db.transaction([SHADOW_SIGNER_DB_STORE], "readwrite"); + const store = tx.objectStore(SHADOW_SIGNER_DB_STORE); + store.put(privateKey, walletAddress); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + const data: ShadowSignerData = { + chain, + walletAddress, + publicKey, + createdAt: Date.now(), + }; + + localStorage.setItem(`${SHADOW_SIGNER_STORAGE_KEY}_${walletAddress}`, JSON.stringify(data)); +} + +export function getShadowSigner(walletAddress: string): ShadowSignerData | null { + if (typeof localStorage === "undefined") { + return null; + } + const stored = localStorage.getItem(`${SHADOW_SIGNER_STORAGE_KEY}_${walletAddress}`); + return stored ? JSON.parse(stored) : null; +} + +export async function getShadowSignerPrivateKey(walletAddress: string): Promise { + if (typeof indexedDB === "undefined") { + return null; + } + + try { + const db = await openDB(); + const tx = db.transaction([SHADOW_SIGNER_DB_STORE], "readonly"); + const store = tx.objectStore(SHADOW_SIGNER_DB_STORE); + const request = store.get(walletAddress); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.warn("Failed to retrieve shadow signer private key:", error); + return null; + } +} + +export function hasShadowSigner(walletAddress: string): boolean { + return getShadowSigner(walletAddress) !== null && getShadowSignerPrivateKey(walletAddress) !== null; +} diff --git a/packages/wallets/src/signers/solana-external-wallet.ts b/packages/wallets/src/signers/solana-external-wallet.ts index 5a65af5f1..8ae3f6fb8 100644 --- a/packages/wallets/src/signers/solana-external-wallet.ts +++ b/packages/wallets/src/signers/solana-external-wallet.ts @@ -1,30 +1,18 @@ import { PublicKey, VersionedTransaction } from "@solana/web3.js"; import base58 from "bs58"; -import type { ExternalWalletInternalSignerConfig, Signer } from "./types"; +import type { ExternalWalletInternalSignerConfig } from "./types"; import { TransactionFailedError } from "../utils/errors"; import type { SolanaChain } from "@/chains/chains"; +import { ExternalWalletSigner } from "./external-wallet-signer"; -export class SolanaExternalWalletSigner implements Signer { - type = "external-wallet" as const; - private _address: string; +export class SolanaExternalWalletSigner extends ExternalWalletSigner { onSignTransaction?: (transaction: VersionedTransaction) => Promise; - constructor(private config: ExternalWalletInternalSignerConfig) { - if (config.address == null) { - throw new Error("Please provide an address for the External Wallet Signer"); - } - this._address = config.address; + constructor(config: ExternalWalletInternalSignerConfig) { + super(config); this.onSignTransaction = config.onSignTransaction; } - address() { - return this._address; - } - - locator() { - return this.config.locator; - } - async signMessage() { return await Promise.reject(new Error("signMessage method not implemented for solana external wallet signer")); } diff --git a/packages/wallets/src/signers/stellar-external-wallet.ts b/packages/wallets/src/signers/stellar-external-wallet.ts index 4484cea71..9b79c3ac3 100644 --- a/packages/wallets/src/signers/stellar-external-wallet.ts +++ b/packages/wallets/src/signers/stellar-external-wallet.ts @@ -1,27 +1,15 @@ -import type { ExternalWalletInternalSignerConfig, Signer } from "./types"; +import type { ExternalWalletInternalSignerConfig } from "./types"; import type { StellarChain } from "@/chains/chains"; +import { ExternalWalletSigner } from "./external-wallet-signer"; -export class StellarExternalWalletSigner implements Signer { - type = "external-wallet" as const; - private _address: string; +export class StellarExternalWalletSigner extends ExternalWalletSigner { onSignStellarTransaction?: (payload: string) => Promise; - constructor(private config: ExternalWalletInternalSignerConfig) { - if (config.address == null) { - throw new Error("Please provide an address for the External Wallet Signer"); - } - this._address = config.address; + constructor(config: ExternalWalletInternalSignerConfig) { + super(config); this.onSignStellarTransaction = config.onSignStellarTransaction; } - address() { - return this._address; - } - - locator() { - return this.config.locator; - } - async signMessage() { return await Promise.reject(new Error("signMessage method not implemented for stellar external wallet signer")); } diff --git a/packages/wallets/src/signers/types.ts b/packages/wallets/src/signers/types.ts index f15b6f851..c558df2f6 100644 --- a/packages/wallets/src/signers/types.ts +++ b/packages/wallets/src/signers/types.ts @@ -29,6 +29,9 @@ export class AuthRejectedError extends Error { export type EmailSignerConfig = { type: "email"; email?: string; + shadowSigner?: { + enabled: boolean; + }; onAuthRequired?: ( needsAuth: boolean, sendEmailWithOtp: () => Promise, @@ -40,6 +43,9 @@ export type EmailSignerConfig = { export type PhoneSignerConfig = { type: "phone"; phone?: string; + shadowSigner?: { + enabled: boolean; + }; onAuthRequired?: ( needsAuth: boolean, sendEmailWithOtp: () => Promise, diff --git a/packages/wallets/src/wallets/wallet-factory.ts b/packages/wallets/src/wallets/wallet-factory.ts index e4774e79e..c8464ef87 100644 --- a/packages/wallets/src/wallets/wallet-factory.ts +++ b/packages/wallets/src/wallets/wallet-factory.ts @@ -11,11 +11,22 @@ import type { } from "../api"; import { WalletCreationError, WalletNotAvailableError } from "../utils/errors"; import type { Chain } from "../chains/chains"; -import type { InternalSignerConfig, PasskeySignerConfig, SignerConfigForChain } from "../signers/types"; +import type { + ApiKeyInternalSignerConfig, + EmailInternalSignerConfig, + EmailSignerConfig, + InternalSignerConfig, + PasskeyInternalSignerConfig, + PasskeySignerConfig, + PhoneInternalSignerConfig, + PhoneSignerConfig, + SignerConfigForChain, +} from "../signers/types"; import { Wallet } from "./wallet"; import { assembleSigner } from "../signers"; import type { DelegatedSigner, WalletArgsFor, WalletCreateArgs, WalletOptions } from "./types"; import { compareSignerConfigs } from "../utils/signer-validation"; +import { generateShadowSigner, storeShadowSigner } from "../signers/shadow-signer"; const DELEGATED_SIGNER_MISMATCH_ERROR = "When 'delegatedSigners' is provided to a method that may fetch an existing wallet, each specified delegated signer must exist in that wallet's configuration."; @@ -90,18 +101,9 @@ export class WalletFactory { await args.options?.experimental_callbacks?.onWalletCreationStart?.(); let adminSignerConfig = args.onCreateConfig?.adminSigner ?? args.signer; - const delegatedSigners = await Promise.all( - args.onCreateConfig?.delegatedSigners?.map( - async (signer): Promise => { - if (signer.type === "passkey") { - if (signer.id == null) { - return { signer: await this.createPasskeySigner(signer) }; - } - return { signer }; - } - return { signer: this.getSignerLocator(signer) }; - } - ) ?? [] + const { delegatedSigners, shadowSignerPublicKey, shadowSignerPrivateKey } = await this.buildDelegatedSigners( + adminSignerConfig, + args ); const tempArgs = { ...args, signer: adminSignerConfig }; @@ -128,6 +130,10 @@ export class WalletFactory { throw new WalletCreationError(JSON.stringify(walletResponse)); } + if (shadowSignerPublicKey != null && shadowSignerPrivateKey != null) { + await storeShadowSigner(walletResponse.address, args.chain, shadowSignerPublicKey, shadowSignerPrivateKey); + } + return this.createWalletInstance(walletResponse, args); } @@ -136,14 +142,13 @@ export class WalletFactory { args: WalletArgsFor ): Wallet { this.validateExistingWalletConfig(walletResponse, args); - const signerConfig = this.toInternalSignerConfig(walletResponse, args.signer, args.options); return new Wallet( { chain: args.chain, address: walletResponse.address, owner: walletResponse.owner, - signer: assembleSigner(args.chain, signerConfig), + signer: assembleSigner(args.chain, signerConfig, walletResponse.address), options: args.options, }, this.apiClient @@ -171,22 +176,23 @@ export class WalletFactory { switch (signerArgs.type) { case "api-key": { - const walletSigner = this.getWalletSigner(walletResponse, "api-key"); - return { type: "api-key", - address: walletSigner.address, - locator: walletSigner.locator, - }; + locator: this.getSignerLocator(signerArgs), + address: walletResponse.address, + } as ApiKeyInternalSignerConfig; } case "external-wallet": { - const walletSigner = this.getWalletSigner(walletResponse, "external-wallet"); + const walletSigner = this.getWalletSigner(walletResponse, this.getSignerLocator(signerArgs)); return { ...walletSigner, ...signerArgs } as InternalSignerConfig; } case "passkey": { - const walletSigner = this.getWalletSigner(walletResponse, "passkey"); + const walletSigner = this.getWalletSigner( + walletResponse, + this.getSignerLocator(signerArgs) + ) as PasskeySignerConfig; return { type: "passkey", @@ -195,10 +201,13 @@ export class WalletFactory { locator: walletSigner.locator, onCreatePasskey: signerArgs.onCreatePasskey, onSignWithPasskey: signerArgs.onSignWithPasskey, - }; + } as PasskeyInternalSignerConfig; } case "email": { - const walletSigner = this.getWalletSigner(walletResponse, "email"); + const walletSigner = this.getWalletSigner( + walletResponse, + this.getSignerLocator(signerArgs) + ) as EmailSignerConfig; return { type: "email", @@ -208,11 +217,14 @@ export class WalletFactory { crossmint: this.apiClient.crossmint, onAuthRequired: signerArgs.onAuthRequired, clientTEEConnection: options?.clientTEEConnection, - }; + } as EmailInternalSignerConfig; } case "phone": { - const walletSigner = this.getWalletSigner(walletResponse, "phone"); + const walletSigner = this.getWalletSigner( + walletResponse, + this.getSignerLocator(signerArgs) + ) as PhoneSignerConfig; return { type: "phone", @@ -222,7 +234,7 @@ export class WalletFactory { crossmint: this.apiClient.crossmint, onAuthRequired: signerArgs.onAuthRequired, clientTEEConnection: options?.clientTEEConnection, - }; + } as PhoneInternalSignerConfig; } default: @@ -230,21 +242,21 @@ export class WalletFactory { } } - private getWalletSigner["type"]>( + private getWalletSigner( wallet: GetWalletSuccessResponse, - signerType: T - ): Extract { + signerLocator: string + ): AdminSignerConfig | DelegatedSignerResponse | PasskeySignerConfig { const config = wallet.config as SmartWalletConfig; const adminSigner = config?.adminSigner; const delegatedSigners = config?.delegatedSigners || []; - if (adminSigner?.type === signerType) { - return adminSigner as Extract; + if ("locator" in adminSigner && adminSigner.locator === signerLocator) { + return adminSigner; } - const delegatedSigner = delegatedSigners.find((ds) => ds.type === signerType); + const delegatedSigner = delegatedSigners.find((ds) => ds.locator === signerLocator); if (delegatedSigner != null) { - return delegatedSigner as Extract; + return delegatedSigner; } - throw new WalletCreationError(`${signerType} signer does not match the wallet's signer type`); + throw new WalletCreationError(`${signerLocator} signer does not match the wallet's signer type`); } private async createPasskeySigner( @@ -455,6 +467,90 @@ export class WalletFactory { return false; } + private isShadowSignerEnabled( + chain: C, + adminSigner: SignerConfigForChain, + delegatedSigners: Array> = [] + ): boolean { + const ncSigners = [adminSigner, ...delegatedSigners].filter( + (signer) => signer.type === "email" || signer.type === "phone" + ) as Array; + return ( + !this.apiClient.isServerSide && + (chain === "solana" || chain === "stellar") && + ncSigners.length > 0 && + ncSigners.some((signer) => signer.shadowSigner?.enabled !== false) + ); + } + + private async buildDelegatedSigners( + adminSigner: SignerConfigForChain, + args: WalletCreateArgs + ): Promise<{ + delegatedSigners: Array; + shadowSignerPublicKey: string | null; + shadowSignerPrivateKey: CryptoKey | null; + }> { + const { delegatedSigners, shadowSignerPublicKey, shadowSignerPrivateKey } = + await this.addShadowSignerToDelegatedSignersIfNeeded( + args, + adminSigner, + args.onCreateConfig?.delegatedSigners + ); + + const registeredDelegatedSigners = await this.registerDelegatedSigners(delegatedSigners); + + return { delegatedSigners: registeredDelegatedSigners, shadowSignerPublicKey, shadowSignerPrivateKey }; + } + + private async registerDelegatedSigners( + delegatedSigners?: Array> + ): Promise> { + return await Promise.all( + delegatedSigners?.map( + async (signer): Promise => { + if (signer.type === "passkey") { + if (signer.id == null) { + return { signer: await this.createPasskeySigner(signer) }; + } + return { signer }; + } + return { signer: this.getSignerLocator(signer) }; + } + ) ?? [] + ); + } + + private async addShadowSignerToDelegatedSignersIfNeeded( + args: WalletCreateArgs, + adminSigner: SignerConfigForChain, + delegatedSigners?: Array> + ): Promise<{ + delegatedSigners: Array> | undefined; + shadowSignerPublicKey: string | null; + shadowSignerPrivateKey: CryptoKey | null; + }> { + if (this.isShadowSignerEnabled(args.chain, adminSigner, delegatedSigners)) { + try { + const { shadowSigner, publicKey, privateKey } = await generateShadowSigner(args.chain); + + return { + delegatedSigners: [...(delegatedSigners ?? []), shadowSigner as SignerConfigForChain], + shadowSignerPublicKey: publicKey, + shadowSignerPrivateKey: privateKey, + }; + } catch (error) { + console.warn("Failed to create shadow signer:", error); + } + } + + return { + delegatedSigners, + shadowSignerPublicKey: null, + shadowSignerPrivateKey: null, + }; + } + private getChainType(chain: Chain): "solana" | "evm" | "stellar" { if (chain === "solana") { return "solana"; diff --git a/packages/wallets/vitest.config.ts b/packages/wallets/vitest.config.ts new file mode 100644 index 000000000..6792b687e --- /dev/null +++ b/packages/wallets/vitest.config.ts @@ -0,0 +1,10 @@ +import { resolve } from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // This is needed because we are using the @ symbol to import from the src folder. + // Otherwise, Vitest will yell at us. + alias: [{ find: "@", replacement: resolve(__dirname, "./src") }], + }, +});