From 9f59b7e1bffd2e59ca0fd5b58001a8b3e626d0b9 Mon Sep 17 00:00:00 2001 From: tedison Date: Mon, 28 Jul 2025 17:42:56 +0200 Subject: [PATCH 1/2] feat: Add sms signer --- packages/controller/src/types.ts | 1 + packages/keychain/.storybook/provider.tsx | 14 +- packages/keychain/package.json | 2 + .../connect/buttons/signup-button.tsx | 15 +- .../components/connect/create/sms/index.ts | 47 ++++ .../connect/create/sms/otp-code-input.tsx | 243 ++++++++++++++++ .../connect/create/sms/phone-number-input.tsx | 243 ++++++++++++++++ .../connect/create/sms/sms-authentication.tsx | 76 +++++ .../components/connect/create/social/index.ts | 13 +- .../connect/create/useCreateController.ts | 18 ++ .../create/wallet-connect/qr-code-overlay.tsx | 8 +- .../src/components/provider/index.tsx | 35 +-- .../components/provider/keychain-wallets.tsx | 92 ++++++ .../keychain/src/components/provider/ui.tsx | 32 +-- .../signers/add-signer/add-signer.tsx | 155 ++++++---- .../settings/signers/signer-card.tsx | 7 +- packages/keychain/src/hooks/connection.ts | 23 +- .../src/utils/connection/constants.ts | 1 + packages/keychain/src/utils/controller.ts | 2 + packages/keychain/src/wallets/social/index.ts | 264 ++++++++++++++++++ .../keychain/src/wallets/social/sms-wallet.ts | 156 +++++++++++ .../keychain/src/wallets/social/turnkey.ts | 255 +---------------- .../src/wallets/social/turnkey_utils.ts | 87 +++++- pnpm-lock.yaml | 17 +- 24 files changed, 1417 insertions(+), 389 deletions(-) create mode 100644 packages/keychain/src/components/connect/create/sms/index.ts create mode 100644 packages/keychain/src/components/connect/create/sms/otp-code-input.tsx create mode 100644 packages/keychain/src/components/connect/create/sms/phone-number-input.tsx create mode 100644 packages/keychain/src/components/connect/create/sms/sms-authentication.tsx create mode 100644 packages/keychain/src/components/provider/keychain-wallets.tsx create mode 100644 packages/keychain/src/wallets/social/index.ts create mode 100644 packages/keychain/src/wallets/social/sms-wallet.ts diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index 293bd99e0c..439c36c990 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -31,6 +31,7 @@ export type KeychainSession = { }; export type AuthOption = + | "sms" | "google" | "webauthn" | "discord" diff --git a/packages/keychain/.storybook/provider.tsx b/packages/keychain/.storybook/provider.tsx index 10fa11d647..c6958e7335 100644 --- a/packages/keychain/.storybook/provider.tsx +++ b/packages/keychain/.storybook/provider.tsx @@ -1,24 +1,24 @@ -import React, { PropsWithChildren } from "react"; -import { QueryClient, QueryClientProvider } from "react-query"; import { mainnet } from "@starknet-react/chains"; import { StarknetConfig, cartridge, publicProvider, } from "@starknet-react/core"; +import React, { PropsWithChildren } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; import { BrowserRouter } from "react-router-dom"; import { ConnectionContext } from "../src/components/provider/connection"; +import { PostHogProvider } from "../src/components/provider/posthog"; +import { TokensProvider } from "../src/components/provider/tokens"; import { UIProvider } from "../src/components/provider/ui"; +import { NavigationProvider } from "../src/context/navigation"; +import { FeatureProvider } from "../src/hooks/features"; +import { WalletsProvider } from "../src/hooks/wallets"; import { MockUpgradeProvider, StoryParameters, useMockedConnection, } from "./mock"; -import { TokensProvider } from "../src/components/provider/tokens"; -import { PostHogProvider } from "../src/components/provider/posthog"; -import { WalletsProvider } from "../src/hooks/wallets"; -import { FeatureProvider } from "../src/hooks/features"; -import { NavigationProvider } from "../src/context/navigation"; export function Provider({ children, diff --git a/packages/keychain/package.json b/packages/keychain/package.json index 792c8e13ed..2cd68d8965 100644 --- a/packages/keychain/package.json +++ b/packages/keychain/package.json @@ -76,6 +76,8 @@ "history": "^5.3.0", "inapp-spy": "4.2.1", "jwt-decode": "^4.0.0", + "libphonenumber-js": "^1.12.10", + "lodash": "catalog:", "p-throttle": "^6.2.0", "posthog-js-lite": "3.2.1", "qrcode.react": "catalog:", diff --git a/packages/keychain/src/components/connect/buttons/signup-button.tsx b/packages/keychain/src/components/connect/buttons/signup-button.tsx index 2a19ab0d87..36dcab3239 100644 --- a/packages/keychain/src/components/connect/buttons/signup-button.tsx +++ b/packages/keychain/src/components/connect/buttons/signup-button.tsx @@ -5,13 +5,14 @@ import { DiscordColorIcon, GoogleColorIcon, IconProps, + LockIcon, MetaMaskColorIcon, + MobileIcon, PasskeyIcon, PhantomColorIcon, RabbyColorIcon, Spinner, WalletConnectColorIcon, - LockIcon, } from "@cartridge/ui"; import { cn } from "@cartridge/ui/utils"; @@ -24,7 +25,8 @@ const OPTIONS: Partial< string, { variant: "primary" | "secondary"; - Icon: React.ComponentType; + Icon?: React.ComponentType; + icon?: React.ReactNode; label: string; className?: string; } @@ -76,6 +78,11 @@ const OPTIONS: Partial< Icon: LockIcon, label: AUTH_METHODS_LABELS.password, }, + sms: { + variant: "secondary", + icon: , + label: AUTH_METHODS_LABELS.sms, + }, }; export function SignupButton({ authMethod, ...props }: SignupButtonProps) { @@ -88,7 +95,7 @@ export function SignupButton({ authMethod, ...props }: SignupButtonProps) { return null; } - const { Icon, label, ...restOptionProps } = option; + const { Icon, icon, label, ...restOptionProps } = option; return ( ); diff --git a/packages/keychain/src/components/connect/create/sms/index.ts b/packages/keychain/src/components/connect/create/sms/index.ts new file mode 100644 index 0000000000..6e6d8ea9cb --- /dev/null +++ b/packages/keychain/src/components/connect/create/sms/index.ts @@ -0,0 +1,47 @@ +import { useKeychainWallets } from "@/components/provider/keychain-wallets"; +import { SmsWallet } from "@/wallets/social/sms-wallet"; +import { AuthOption, WalletAdapter } from "@cartridge/controller"; +import { useCallback } from "react"; + +export const useSmsAuthentication = () => { + const { setOtpDisplayType } = useKeychainWallets(); + + const signup = useCallback( + async ( + connectType: "signup" | "login" | "add-signer", + username: string, + ) => { + const smsWallet = new SmsWallet(); + + setOtpDisplayType(connectType); + + const response = await smsWallet.connect(username, connectType); + + if (!response.success || !response.account) { + throw new Error(response.error); + } + + if (!window.keychain_wallets) { + throw new Error("No keychain_wallets found"); + } + + window.keychain_wallets.addEmbeddedWallet( + response.account, + smsWallet as unknown as WalletAdapter, + ); + setOtpDisplayType(null); + + return { + address: response.account, + signer: { eip191: { address: response.account } }, + type: "sms" as AuthOption, + }; + }, + [setOtpDisplayType], + ); + + return { + signup: (username: string) => signup("signup", username), + login: (username: string) => signup("login", username), + }; +}; diff --git a/packages/keychain/src/components/connect/create/sms/otp-code-input.tsx b/packages/keychain/src/components/connect/create/sms/otp-code-input.tsx new file mode 100644 index 0000000000..d8f1365976 --- /dev/null +++ b/packages/keychain/src/components/connect/create/sms/otp-code-input.tsx @@ -0,0 +1,243 @@ +import { Button, LayoutContent } from "@cartridge/ui"; +import { useEffect, useRef, useState } from "react"; + +export const OtpCodeInput = ({ + phoneNumber, + onBack, + onFinalize, +}: { + phoneNumber: string; + onBack: () => void; + onFinalize: (otpCode: string) => void; +}) => { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const [otpCode, setOtpCode] = useState(["", "", "", "", "", ""]); + const [activeIndex, setActiveIndex] = useState(null); + + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + const activeElement = document.activeElement; + const isInputFocused = + activeElement?.tagName === "INPUT" || + activeElement?.tagName === "TEXTAREA"; + + if (!isInputFocused && /^\d$/.test(e.key)) { + e.preventDefault(); + const firstEmptyIndex = otpCode.findIndex((val) => val === ""); + const targetIndex = firstEmptyIndex !== -1 ? firstEmptyIndex : 0; + + if (firstEmptyIndex === -1) { + setOtpCode(["", "", "", "", "", ""]); + } + + const targetInput = inputRefs.current[targetIndex]; + if (targetInput) { + targetInput.focus(); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(targetInput, e.key); + const inputEvent = new Event("input", { bubbles: true }); + targetInput.dispatchEvent(inputEvent); + } + } + + if (e.key === "Enter" && otpCode.every((digit) => digit !== "")) { + e.preventDefault(); + onFinalize(otpCode.join("")); + } + }; + + window.addEventListener("keydown", handleGlobalKeyDown); + + return () => { + window.removeEventListener("keydown", handleGlobalKeyDown); + }; + }, [otpCode, onFinalize]); + + const handleChange = ( + e: React.ChangeEvent, + index: number, + ) => { + const value = e.target.value; + + if (value.length > 1) { + const digits = value.slice(0, 6).split(""); + const newOtpCode = [...otpCode]; + digits.forEach((digit, i) => { + if (index + i < 6 && /^\d$/.test(digit)) { + newOtpCode[index + i] = digit; + } + }); + setOtpCode(newOtpCode); + + const nextEmptyIndex = newOtpCode.findIndex( + (val, i) => i > index && val === "", + ); + const focusIndex = + nextEmptyIndex !== -1 + ? nextEmptyIndex + : Math.min(index + digits.length, 5); + inputRefs.current[focusIndex]?.focus(); + return; + } + + if (/^\d$/.test(value)) { + const newOtpCode = [...otpCode]; + newOtpCode[index] = value; + setOtpCode(newOtpCode); + + if (index < 5) { + inputRefs.current[index + 1]?.focus(); + } + } else if (value === "") { + const newOtpCode = [...otpCode]; + newOtpCode[index] = ""; + setOtpCode(newOtpCode); + } + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + index: number, + ) => { + if (e.key === "Backspace" && otpCode[index] === "" && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + + if (e.key === "ArrowLeft" && index > 0) { + e.preventDefault(); + inputRefs.current[index - 1]?.focus(); + } + if (e.key === "ArrowRight" && index < 5) { + e.preventDefault(); + inputRefs.current[index + 1]?.focus(); + } + + if (e.key === "Enter" && otpCode.every((digit) => digit !== "")) { + e.preventDefault(); + onFinalize(otpCode.join("")); + } + }; + + const handleFocus = (index: number) => { + setActiveIndex(index); + }; + + const handleBlur = () => { + setActiveIndex(null); + }; + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + return ( + <> + +

+ Please check {phoneNumber} for a message from Cartridge and enter your + code below. +

+
+ {otpCode.map((value, index) => ( + handleChange(e, index)} + onKeyDown={(e) => handleKeyDown(e, index)} + onFocus={() => handleFocus(index)} + onBlur={handleBlur} + inputRef={(el) => { + inputRefs.current[index] = el; + }} + /> + ))} +
+
+
+
+ + +
+
+ + ); +}; + +const NumberCard = ({ + value = "", + hasValue = false, + error = false, + active = false, + onChange, + onKeyDown, + onFocus, + onBlur, + inputRef, +}: { + value?: string; + hasValue?: boolean; + error?: boolean; + active?: boolean; + onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: () => void; + onBlur?: () => void; + inputRef?: (el: HTMLInputElement | null) => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx b/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx new file mode 100644 index 0000000000..35b0b4e234 --- /dev/null +++ b/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx @@ -0,0 +1,243 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Header, + Input, + LayoutContent, +} from "@cartridge/ui"; +import { + AsYouType, + CountryCode, + getCountries, + getCountryCallingCode, + getExampleNumber, + ParseError, + parsePhoneNumberFromString, + validatePhoneNumberLength, +} from "libphonenumber-js"; +import examples from "libphonenumber-js/mobile/examples"; +import { useEffect, useState } from "react"; + +export const PhoneNumberInput = ({ + onSubmit, + onCancel, + phoneNumber, + setPhoneNumber, +}: { + onSubmit: (phoneNumber: string) => void; + onCancel: () => void; + phoneNumber: string; + setPhoneNumber: (phoneNumber: string) => void; +}) => { + const [country, setCountry] = useState("US"); + const [error, setError] = useState(null); + const [isValid, setIsValid] = useState(false); + + const countries = getCountries().map((c: CountryCode) => { + const callingCode = getCountryCallingCode(c); + return { + codeLabel: `+${callingCode}`, + code: c, + label: `${c} (+${callingCode})`, + }; + }); + + const getPlaceholder = (countryCode: CountryCode) => { + const example = getExampleNumber(countryCode, examples); + if (example) { + return example.formatNational(); + } + return ""; + }; + + const validatePhoneNumber = (number: string, countryCode: CountryCode) => { + if (!number) { + setError(null); + setIsValid(false); + return; + } + + try { + const phoneNumber = parsePhoneNumberFromString(number, countryCode); + + if (!phoneNumber && number.length > 1) { + setError("Invalid phone number format"); + setIsValid(false); + return; + } + + if (phoneNumber && phoneNumber?.country !== countryCode) { + setError( + `This number doesn't match the selected country (${countryCode})`, + ); + setIsValid(false); + return; + } + + if (phoneNumber && !phoneNumber.isValid()) { + const lengthValidation = validatePhoneNumberLength( + phoneNumber.number, + countryCode, + ); + if (lengthValidation === "TOO_LONG") { + setError("Phone number is too long"); + return; + } + } + + setError(null); + setIsValid(true); + } catch (e) { + if (e instanceof ParseError) { + switch ((e as ParseError).message) { + case "TOO_SHORT": + break; + case "TOO_LONG": + setError("Phone number is too long"); + break; + case "INVALID_COUNTRY": + setError("Invalid country code"); + break; + default: + setError("Invalid phone number"); + } + } else { + setError("Invalid phone number"); + } + setIsValid(false); + } + }; + + const handlePhoneChange = (e: React.ChangeEvent) => { + const input = e.target.value; + + if (!/^[0-9+\-() ]*$/.test(input)) return; + + setPhoneNumber(input); + validatePhoneNumber(input, country); + }; + + const formatPhoneNumber = (phoneNumber: string | undefined) => { + if (!phoneNumber) return ""; + const formatter = new AsYouType(country); + return formatter.input(phoneNumber); + }; + + const handleCountryChange = (newCountry: CountryCode) => { + setCountry(newCountry); + + if (phoneNumber) { + const parsed = parsePhoneNumberFromString(phoneNumber); + + if (parsed && parsed.country === newCountry) { + setPhoneNumber(parsed.formatNational()); + } else if (parsed && parsed.country !== newCountry) { + setPhoneNumber(""); + setError("Please enter a new number for the selected country"); + } else { + const nationalParsed = parsePhoneNumberFromString( + phoneNumber, + newCountry, + ); + if (nationalParsed && nationalParsed.isValid()) { + setPhoneNumber(nationalParsed.formatNational()); + } else { + setPhoneNumber(""); + } + } + } + }; + + useEffect(() => { + if (phoneNumber) { + validatePhoneNumber(phoneNumber, country); + } + }, [country]); + + const handleSubmit = () => { + const parsed = parsePhoneNumberFromString(phoneNumber, country); + + if (parsed && parsed.isValid()) { + const e164Format = parsed.format("E.164"); + onSubmit(e164Format); + } else { + setError("Please enter a valid phone number"); + } + }; + + return ( + <> + +
+
+ + + + + + {countries.map( + (c: { + code: CountryCode; + codeLabel: string; + label: string; + }) => ( + handleCountryChange(c.code as CountryCode)} + > + {c.label} + + ), + )} + + +
+
+
+ { + if (e.key === "Enter" && isValid) { + handleSubmit(); + } + }} + /> +
+
+
+
+ + +
+
+ + ); +}; diff --git a/packages/keychain/src/components/connect/create/sms/sms-authentication.tsx b/packages/keychain/src/components/connect/create/sms/sms-authentication.tsx new file mode 100644 index 0000000000..c45c36731b --- /dev/null +++ b/packages/keychain/src/components/connect/create/sms/sms-authentication.tsx @@ -0,0 +1,76 @@ +import { LayoutContainer, LayoutHeader, MobileIcon } from "@cartridge/ui"; +import { useCallback, useState } from "react"; +import { OtpCodeInput } from "./otp-code-input"; +import { PhoneNumberInput } from "./phone-number-input"; + +export type DisplayType = "signup" | "login" | "add-signer"; + +export const SmsAuthentication = ({ + displayType, + setOtpDisplayType, +}: { + displayType: DisplayType; + setOtpDisplayType: (otpDisplayType: DisplayType | null) => void; +}) => { + const [step, setStep] = useState<"phone-number" | "otp-code">("phone-number"); + const [phoneNumber, setPhoneNumber] = useState(undefined); + + const onPhoneNumberSubmit = useCallback((phoneNumber: string) => { + setPhoneNumber(phoneNumber); + setStep("otp-code"); + window.dispatchEvent( + new CustomEvent("sms-signer-phone-number", { + detail: { + phoneNumber: phoneNumber, + }, + }), + ); + }, []); + + const onFinalize = useCallback((otpCode: string) => { + window.dispatchEvent( + new CustomEvent("sms-signer-otp-code", { + detail: { + otpCode: otpCode, + }, + }), + ); + }, []); + + return ( + + } + variant="compressed" + title={ + displayType === "signup" + ? "Signup with SMS" + : displayType === "login" + ? "Login with SMS" + : "Add SMS Signer" + } + onBack={() => { + setOtpDisplayType(null); + }} + hideSettings + /> + {step === "phone-number" && ( + { + setOtpDisplayType(null); + }} + phoneNumber={phoneNumber!} + setPhoneNumber={setPhoneNumber} + /> + )} + {step === "otp-code" && ( + setStep("phone-number")} + onFinalize={onFinalize} + /> + )} + + ); +}; diff --git a/packages/keychain/src/components/connect/create/social/index.ts b/packages/keychain/src/components/connect/create/social/index.ts index 5361906e48..f5521c933f 100644 --- a/packages/keychain/src/components/connect/create/social/index.ts +++ b/packages/keychain/src/components/connect/create/social/index.ts @@ -1,5 +1,5 @@ import { useConnection } from "@/hooks/connection"; -import { TurnkeyWallet } from "@/wallets/social/turnkey"; +import { OAuthWallet } from "@/wallets/social/turnkey"; import { SocialProvider } from "@/wallets/social/turnkey_utils"; import { WalletAdapter } from "@cartridge/controller"; import { useCallback } from "react"; @@ -19,13 +19,13 @@ export const useSocialAuthentication = ( throw new Error("No chainId"); } - const turnkeyWallet = new TurnkeyWallet( + const oauthWallet = new OAuthWallet( username, chainId, rpcUrl, socialProvider, ); - const { account, error, success } = await turnkeyWallet.connect(isSignup); + const { account, error, success } = await oauthWallet.connect(isSignup); if (error?.includes("Account mismatch")) { setChangeWallet?.(true); return; @@ -41,10 +41,13 @@ export const useSocialAuthentication = ( if (!account) { throw new Error("No account found"); } + if (!window.keychain_wallets) { + throw new Error("No keychain_wallets found"); + } - window.keychain_wallets?.addEmbeddedWallet( + window.keychain_wallets.addEmbeddedWallet( account, - turnkeyWallet as unknown as WalletAdapter, + oauthWallet as unknown as WalletAdapter, ); return { diff --git a/packages/keychain/src/components/connect/create/useCreateController.ts b/packages/keychain/src/components/connect/create/useCreateController.ts index 42150a77c5..bec8d4eeab 100644 --- a/packages/keychain/src/components/connect/create/useCreateController.ts +++ b/packages/keychain/src/components/connect/create/useCreateController.ts @@ -26,6 +26,7 @@ import { } from "../types"; import { useExternalWalletAuthentication } from "./external-wallet"; import { usePasswordAuthentication } from "./password"; +import { useSmsAuthentication } from "./sms"; import { useSocialAuthentication } from "./social"; import { AuthenticationStep, fetchController } from "./utils"; import { useWalletConnectAuthentication } from "./wallet-connect"; @@ -73,6 +74,7 @@ export function useCreateController({ isSlot }: { isSlot?: boolean }) { useWalletConnectAuthentication(); const passwordAuth = usePasswordAuthentication(); const { supportedWalletsForAuth } = useWallets(); + const { signup: signupWithSms, login: loginWithSms } = useSmsAuthentication(); const handleAccountQuerySuccess = useCallback( async (data: AccountQuery) => { @@ -163,6 +165,7 @@ export function useCreateController({ isSlot }: { isSlot?: boolean }) { ...supportedWalletsForAuth, "discord" as AuthOption, "google" as AuthOption, + "sms" as AuthOption, "walletconnect" as AuthOption, "password" as AuthOption, ].filter( @@ -267,6 +270,16 @@ export function useCreateController({ isSlot }: { isSlot?: boolean }) { }), }; break; + case "sms": + signupResponse = await signupWithSms(username); + signer = { + type: SignerType.Eip191, + credential: JSON.stringify({ + provider: authenticationMode, + eth_address: signupResponse.address, + }), + }; + break; case "metamask": case "phantom": case "argent": @@ -444,6 +457,11 @@ export function useCreateController({ isSlot }: { isSlot?: boolean }) { } break; } + case "sms": { + setWaitingForConfirmation(true); + loginResponse = await loginWithSms(username); + break; + } case "rabby": case "metamask": { setWaitingForConfirmation(true); diff --git a/packages/keychain/src/components/connect/create/wallet-connect/qr-code-overlay.tsx b/packages/keychain/src/components/connect/create/wallet-connect/qr-code-overlay.tsx index a5a442b93d..789c5baf41 100644 --- a/packages/keychain/src/components/connect/create/wallet-connect/qr-code-overlay.tsx +++ b/packages/keychain/src/components/connect/create/wallet-connect/qr-code-overlay.tsx @@ -65,8 +65,8 @@ export const QRCodeOverlay = ({ - )} - {!wallets && !signerPending && ( - - )} - - + + {(wallets || signerPending?.error) && ( + + )} + {!wallets && !signerPending && ( + + )} + + + ); } @@ -314,7 +318,7 @@ const RegularAuths = ({ throw new Error("No username"); } - const turnkeyWallet = new TurnkeyWallet( + const turnkeyWallet = new OAuthWallet( controller.username(), controller.chainId(), controller.rpcUrl(), @@ -361,7 +365,7 @@ const RegularAuths = ({ throw new Error("No username"); } - const turnkeyWallet = new TurnkeyWallet( + const turnkeyWallet = new OAuthWallet( controller.username(), controller.chainId(), controller.rpcUrl(), @@ -399,10 +403,47 @@ const RegularAuths = ({ }); }} /> - {/* {}} - /> */} + { + if (!controller?.username()) { + throw new Error("No username"); + } + const smsWallet = new SmsWallet(); + const response = await smsWallet.connect( + controller.username()!, + "add-signer", + ); + if (!response || !response.success || !response.account) { + throw new Error(response?.error || "Wallet auth: unknown error"); + } + if (response.error?.includes("Account mismatch")) { + throw new Error("Account mismatch"); + } + window.keychain_wallets?.addEmbeddedWallet( + response.account, + smsWallet as unknown as WalletAdapter, + ); + if ( + currentSigners?.find( + (signer) => credentialToAddress(signer) === response.account, + ) + ) { + return response.account; + } + await controller?.addOwner( + { eip191: { address: response.account } }, + { + type: "eip191", + credential: JSON.stringify({ + provider: "sms", + eth_address: response.account, + }), + }, + null, + ); + }} + /> { diff --git a/packages/keychain/src/components/settings/signers/signer-card.tsx b/packages/keychain/src/components/settings/signers/signer-card.tsx index 7cb7387623..e0be268feb 100644 --- a/packages/keychain/src/components/settings/signers/signer-card.tsx +++ b/packages/keychain/src/components/settings/signers/signer-card.tsx @@ -8,6 +8,7 @@ import { DiscordIcon, GoogleIcon, MetaMaskIcon, + MobileIcon, PhantomIcon, RabbyIcon, Sheet, @@ -199,7 +200,9 @@ const SignerIcon = React.memo( case "google": return ; case "walletconnect": - return ; + return ; + case "sms": + return ; default: return ; } @@ -220,6 +223,8 @@ const getSignerIdentifyingInfo = async ( case "google": // return await getOauthProvider(controllerUsername, "google"); return undefined; + case "sms": + return undefined; default: return formatAddress(credentialToAddress(signer)!, { size: "xs" }); } diff --git a/packages/keychain/src/hooks/connection.ts b/packages/keychain/src/hooks/connection.ts index 6894d787b5..109501d2a9 100644 --- a/packages/keychain/src/hooks/connection.ts +++ b/packages/keychain/src/hooks/connection.ts @@ -6,7 +6,7 @@ import { } from "@/components/provider/connection"; import { useNavigation } from "@/context/navigation"; import { ConnectionCtx, connectToController } from "@/utils/connection"; -import { TurnkeyWallet } from "@/wallets/social/turnkey"; +import { OAuthWallet } from "@/wallets/social/turnkey"; import { WalletConnectWallet } from "@/wallets/wallet-connect"; import { AuthOptions, @@ -177,7 +177,7 @@ export function useConnectionValue() { const [context, setContext] = useState(); const [origin, setOrigin] = useState(window.location.origin); const [rpcUrl, setRpcUrl] = useState( - import.meta.env.VITE_RPC_SEPOLIA, + window.controller?.rpcUrl() || import.meta.env.VITE_RPC_SEPOLIA, ); const [policies, setPolicies] = useState(); const [isSessionActive, setIsSessionActive] = useState(false); @@ -271,6 +271,12 @@ export function useConnectionValue() { } }, [rpcUrl]); + useEffect(() => { + if (controller) { + setRpcUrl(controller.rpcUrl()); + } + }, [controller]); + useEffect(() => { if ( !controller?.username() || @@ -331,23 +337,20 @@ export function useConnectionValue() { walletConnectWallet as WalletAdapter, ); } else if (provider === "discord" || provider === "google") { - const turnkeyWallet = new TurnkeyWallet( + const oauthWallet = new OAuthWallet( controller.username(), chainId, controller.rpcUrl(), provider, ); - if (!turnkeyWallet) { + if (!oauthWallet) { throw new Error("Embedded Turnkey wallet not found"); } - turnkeyWallet.account = getAddress(ethAddress); - turnkeyWallet.subOrganizationId = undefined; + oauthWallet.account = getAddress(ethAddress); + oauthWallet.subOrganizationId = undefined; - window.keychain_wallets!.addEmbeddedWallet( - ethAddress, - turnkeyWallet as unknown as WalletAdapter, - ); + window.keychain_wallets!.addEmbeddedWallet(ethAddress, oauthWallet); } } } catch (error) { diff --git a/packages/keychain/src/utils/connection/constants.ts b/packages/keychain/src/utils/connection/constants.ts index c0b7f60fcf..d9a8e1a13e 100644 --- a/packages/keychain/src/utils/connection/constants.ts +++ b/packages/keychain/src/utils/connection/constants.ts @@ -12,4 +12,5 @@ export const AUTH_METHODS_LABELS: Record = { google: "Google", base: "Base Wallet", password: "Password", + sms: "SMS", }; diff --git a/packages/keychain/src/utils/controller.ts b/packages/keychain/src/utils/controller.ts index 68491bad4d..4be81a37e0 100644 --- a/packages/keychain/src/utils/controller.ts +++ b/packages/keychain/src/utils/controller.ts @@ -404,6 +404,8 @@ export default class Controller { appId, import.meta.env.VITE_CARTRIDGE_API_URL, ); + console.log("appId", appId); + console.log("cartridgeWithMeta", cartridgeWithMeta); if (!cartridgeWithMeta) { return; } diff --git a/packages/keychain/src/wallets/social/index.ts b/packages/keychain/src/wallets/social/index.ts new file mode 100644 index 0000000000..5488789832 --- /dev/null +++ b/packages/keychain/src/wallets/social/index.ts @@ -0,0 +1,264 @@ +import { + ExternalPlatform, + ExternalWallet, + ExternalWalletResponse, + ExternalWalletType, + WalletAdapter, +} from "@cartridge/controller"; +import { Turnkey, TurnkeyIframeClient } from "@turnkey/sdk-browser"; +import { ethers, getBytes, Signature } from "ethers"; +import { publicKeyFromIframe } from "./turnkey_utils"; + +export abstract class TurnkeyWallet implements WalletAdapter { + type: ExternalWalletType = "turnkey" as ExternalWalletType; + readonly platform: ExternalPlatform = "ethereum"; + + account: string | undefined = undefined; + subOrganizationId: string | undefined = undefined; + turnkeyIframePromise: Promise | undefined = undefined; + + constructor() { + const randomId = Math.random().toString(36).substring(2, 15); + const turnkeyIframe = document.getElementById( + `turnkey-iframe-container-${randomId}`, + ); + if (turnkeyIframe) { + document.body.removeChild(turnkeyIframe); + } + const turnkeySdk = new Turnkey({ + apiBaseUrl: import.meta.env.VITE_TURNKEY_BASE_URL, + defaultOrganizationId: import.meta.env.VITE_TURNKEY_ORGANIZATION_ID, + }); + const iframeContainer = document.createElement("div"); + iframeContainer.style.display = "none"; + iframeContainer.id = "turnkey-iframe-container"; + document.body.appendChild(iframeContainer); + + this.turnkeyIframePromise = turnkeySdk + .iframeClient({ + iframeContainer: iframeContainer, + iframeUrl: import.meta.env.VITE_TURNKEY_IFRAME_URL, + }) + .then(async (turnkeyIframeClient: TurnkeyIframeClient) => { + await turnkeyIframeClient.initEmbeddedKey(); + return turnkeyIframeClient; + }); + } + + abstract isAvailable(): boolean; + + abstract getInfo(): ExternalWallet; + + abstract connect(isSignup?: boolean): Promise; + + getConnectedAccounts(): string[] { + return this.account ? [this.account] : []; + } + + async signTransaction( + transaction: string, + ): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + + const result = ( + await turnkeyIframeClient.signTransaction({ + organizationId: this.subOrganizationId, + signWith: this.account, + unsignedTransaction: transaction, + type: "TRANSACTION_TYPE_ETHEREUM", + }) + ).signedTransaction; + + return { + success: true, + wallet: this.type, + result: result, + }; + } catch (error) { + console.error(`Error signing transaction with Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + async signMessage(message: string): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + if (!this.subOrganizationId) { + const { success, error, account } = await this.connect(false); + if (!success) { + throw new Error(error); + } + if (account !== this.account) { + throw new Error("Account mismatch"); + } + } + + const paddedMessage = `0x${message.replace("0x", "").padStart(64, "0")}`; + const messageBytes = getBytes(paddedMessage); + const messageHash = ethers.hashMessage(messageBytes); + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + + const { r, s, v } = await turnkeyIframeClient.signRawPayload({ + organizationId: this.subOrganizationId, + signWith: this.account, + payload: messageHash, + encoding: "PAYLOAD_ENCODING_HEXADECIMAL", + hashFunction: "HASH_FUNCTION_NO_OP", + }); + + const rHex = r.startsWith("0x") ? r : "0x" + r; + const sHex = s.startsWith("0x") ? s : "0x" + s; + + const vNumber = parseInt(v, 16); + + if (isNaN(vNumber)) { + console.error(`Invalid recovery ID (v) received from Turnkey: ${v}`); + throw new Error(`Invalid recovery ID (v) received: ${v}`); + } + + const signature = Signature.from({ + r: rHex, + s: sHex, + v: vNumber, + }); + + return { + success: true, + wallet: this.type, + result: signature.serialized, + account: this.account, + }; + } catch (error) { + console.error(`Error signing message with Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + async signTypedData(data: string): Promise> { + return this.signMessage(data); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async sendTransaction(_txn: string): Promise { + return { + success: false, + wallet: this.type, + error: "Not implemented", + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async switchChain(_chainId: string): Promise { + return false; + } + + async waitForTransaction( + _txHash: string, + _timeoutMs?: number, + ): Promise> { + return { + success: false, + wallet: this.type, + error: "waitForTransaction not supported for Argent wallet", + }; + } + + async getBalance( + tokenAddress?: string, + ): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + if (tokenAddress) { + return { + success: false, + wallet: this.type, + error: "Not implemented for ERC20", + }; + } else { + return { success: true, wallet: this.type, result: "0" }; + } + } catch (error) { + console.error(`Error getting balance from Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + pollIframePublicKey = async (pollTimeMs: number): Promise => { + const intervalMs = 200; + let elapsedTime = 0; + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + const iFramePublicKey = await turnkeyIframeClient.getEmbeddedPublicKey(); + if (iFramePublicKey) { + return iFramePublicKey; + } + + return new Promise((resolve, reject) => { + const intervalId = setInterval(async () => { + const iFramePublicKey = + await turnkeyIframeClient.getEmbeddedPublicKey(); + if (iFramePublicKey) { + clearInterval(intervalId); + resolve(iFramePublicKey); + } else { + elapsedTime += intervalMs; + if (elapsedTime >= pollTimeMs) { + clearInterval(intervalId); + reject(new Error("Timeout waiting for Turnkey iframe public key.")); + } + } + }, intervalMs); + }); + }; + + async getTurnkeyIframeClient( + timeoutMs: number, + ): Promise { + if (!this.turnkeyIframePromise) { + throw new Error("Turnkey iframe client not initialized"); + } + return this.getPromiseResult(this.turnkeyIframePromise, timeoutMs); + } + + async getPromiseResult( + promise: Promise, + timeoutMs: number, + ): Promise { + const timeoutId = setTimeout(() => { + throw new Error("Timeout waiting for promise"); + }, timeoutMs); + + const result = await promise; + clearTimeout(timeoutId); + + return result; + } + + getIframePublicKey = async (): Promise => { + return publicKeyFromIframe(await this.getTurnkeyIframeClient(10_000)); + }; +} diff --git a/packages/keychain/src/wallets/social/sms-wallet.ts b/packages/keychain/src/wallets/social/sms-wallet.ts new file mode 100644 index 0000000000..815089daa6 --- /dev/null +++ b/packages/keychain/src/wallets/social/sms-wallet.ts @@ -0,0 +1,156 @@ +import { DisplayType } from "@/components/connect/create/sms/sms-authentication"; +import { getPromiseWithResolvers } from "@/utils/promises"; +import { + ExternalWallet, + ExternalWalletResponse, + ExternalWalletType, +} from "@cartridge/controller"; +import { OtpType } from "@turnkey/sdk-react"; +import { TurnkeyWallet } from "."; +import { + createSmsSuborg, + getOrCreateWallet, + getSmsSuborg, + initOtpAuth, + otpAuth, +} from "./turnkey_utils"; + +export class SmsWallet extends TurnkeyWallet { + private phoneNumberPromise: Promise | undefined = undefined; + private smsOtpPromise: Promise | undefined = undefined; + + constructor() { + super(); + this.type = "sms" as ExternalWalletType; + } + + isAvailable(): boolean { + return ( + typeof window !== "undefined" && + !!this.turnkeyIframePromise && + !!this.phoneNumberPromise && + !!this.smsOtpPromise + ); + } + + getInfo(): ExternalWallet { + return { + type: "sms" as ExternalWalletType, + available: this.isAvailable(), + name: "SMS", + platform: "ethereum", + }; + } + + async connect( + username: string, + connectType: "signup" | "login" | "add-signer", + ): Promise { + const { + promise: phoneNumberPromise, + resolve: phoneNumberResolve, + reject: phoneNumberReject, + } = getPromiseWithResolvers(); + const { + promise: smsOtpPromise, + resolve: otpResolve, + reject: otpReject, + } = getPromiseWithResolvers(); + + const handlePhoneNumber = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail.error) { + phoneNumberReject(new Error(customEvent.detail.error)); + } else { + phoneNumberResolve(customEvent.detail.phoneNumber); + } + }; + const handleOtpCode = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail.error) { + otpReject(new Error(customEvent.detail.error)); + } else { + otpResolve(customEvent.detail.otpCode); + } + }; + + this.phoneNumberPromise = phoneNumberPromise; + this.smsOtpPromise = smsOtpPromise; + + window.dispatchEvent( + new CustomEvent("show-sms-authentication", { + detail: connectType as DisplayType, + }), + ); + + window.addEventListener("sms-signer-phone-number", handlePhoneNumber); + window.addEventListener("sms-signer-otp-code", handleOtpCode); + + try { + const phoneNumber = await this.phoneNumberPromise; + let subOrganizationId: string | undefined = undefined; + if (connectType === "login" || connectType === "add-signer") { + subOrganizationId = await getSmsSuborg(username, phoneNumber); + } else { + subOrganizationId = await createSmsSuborg(username, phoneNumber); + } + if (!subOrganizationId) { + throw new Error("No subOrganizationId"); + } + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + const iframePublicKey = await this.getIframePublicKey(); + const otpResponse = await initOtpAuth( + OtpType.Sms, + phoneNumber, + subOrganizationId, + ); + const otpCode = await this.smsOtpPromise; + const otpResult = await otpAuth( + otpResponse.otpId, + otpCode, + iframePublicKey, + subOrganizationId, + ); + if (!otpResult.credentialBundle) { + throw new Error("No credentialBundle"); + } + const injectResponse = await turnkeyIframeClient.injectCredentialBundle( + otpResult.credentialBundle, + ); + if (!injectResponse) { + throw new Error("Failed to inject credentials into Turnkey"); + } + + const turnkeyAddress = await getOrCreateWallet( + subOrganizationId, + username, + turnkeyIframeClient!, + ); + + this.account = turnkeyAddress; + this.subOrganizationId = subOrganizationId; + + return { + success: true, + wallet: this.type, + account: turnkeyAddress, + }; + } catch (error) { + window.dispatchEvent( + new CustomEvent("show-sms-authentication", { + detail: null, + }), + ); + console.error(error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + wallet: this.type, + }; + } finally { + window.removeEventListener("sms-signer-phone-number", handlePhoneNumber); + window.removeEventListener("sms-signer-otp-code", handleOtpCode); + } + } +} diff --git a/packages/keychain/src/wallets/social/turnkey.ts b/packages/keychain/src/wallets/social/turnkey.ts index f4a46a3380..c7b77da48f 100644 --- a/packages/keychain/src/wallets/social/turnkey.ts +++ b/packages/keychain/src/wallets/social/turnkey.ts @@ -8,8 +8,8 @@ import { import { isIframe } from "@cartridge/ui/utils"; import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex } from "@noble/hashes/utils"; -import { Turnkey, TurnkeyIframeClient } from "@turnkey/sdk-browser"; -import { ethers, getAddress, getBytes, Signature } from "ethers"; +import { getAddress } from "ethers"; +import { TurnkeyWallet } from "."; import { authenticateToTurnkey, getAuth0OidcToken, @@ -20,9 +20,13 @@ import { SocialProvider, } from "./turnkey_utils"; -export const Auth0SocialProviderName: Record = { +export const Auth0SocialProviderName: Record< + SocialProvider, + string | undefined +> = { discord: "discord", google: "google-oauth2", + sms: undefined, }; let AUTH0_CLIENT_PROMISE: Promise | null = null; @@ -30,13 +34,9 @@ let AUTH0_CLIENT_PROMISE: Promise | null = null; const URL_PARAMS_KEY = "auth0-url-params"; const RPC_URL_KEY = "rpc-url-tk-storage"; -export class TurnkeyWallet { +export class OAuthWallet extends TurnkeyWallet { readonly type: ExternalWalletType = "turnkey" as ExternalWalletType; readonly platform: ExternalPlatform = "ethereum"; - account: string | undefined = undefined; - subOrganizationId: string | undefined = undefined; - private turnkeyIframePromise: Promise | undefined = - undefined; constructor( private username: string, @@ -53,32 +53,7 @@ export class TurnkeyWallet { useRefreshTokens: true, }); } - - const randomId = Math.random().toString(36).substring(2, 15); - const turnkeyIframe = document.getElementById( - `turnkey-iframe-container-${randomId}`, - ); - if (turnkeyIframe) { - document.body.removeChild(turnkeyIframe); - } - const turnkeySdk = new Turnkey({ - apiBaseUrl: import.meta.env.VITE_TURNKEY_BASE_URL, - defaultOrganizationId: import.meta.env.VITE_TURNKEY_ORGANIZATION_ID, - }); - const iframeContainer = document.createElement("div"); - iframeContainer.style.display = "none"; - iframeContainer.id = "turnkey-iframe-container"; - document.body.appendChild(iframeContainer); - - this.turnkeyIframePromise = turnkeySdk - .iframeClient({ - iframeContainer: iframeContainer, - iframeUrl: import.meta.env.VITE_TURNKEY_IFRAME_URL, - }) - .then(async (turnkeyIframeClient: TurnkeyIframeClient) => { - await turnkeyIframeClient.initEmbeddedKey(); - return turnkeyIframeClient; - }); + super(); } isAvailable(): boolean { @@ -288,226 +263,14 @@ export class TurnkeyWallet { }; } - getConnectedAccounts(): string[] { - return this.account ? [this.account] : []; - } - - async signTransaction( - transaction: string, - ): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); - - const result = ( - await turnkeyIframeClient.signTransaction({ - organizationId: this.subOrganizationId, - signWith: this.account, - unsignedTransaction: transaction, - type: "TRANSACTION_TYPE_ETHEREUM", - }) - ).signedTransaction; - - return { - success: true, - wallet: this.type, - result: result, - }; - } catch (error) { - console.error(`Error signing transaction with Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - async signMessage(message: string): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - if (!this.subOrganizationId) { - const { success, error, account } = await this.connect(false); - if (!success) { - throw new Error(error); - } - if (account !== this.account) { - throw new Error("Account mismatch"); - } - } - - const paddedMessage = `0x${message.replace("0x", "").padStart(64, "0")}`; - const messageBytes = getBytes(paddedMessage); - const messageHash = ethers.hashMessage(messageBytes); - - const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); - - const { r, s, v } = await turnkeyIframeClient.signRawPayload({ - organizationId: this.subOrganizationId, - signWith: this.account, - payload: messageHash, - encoding: "PAYLOAD_ENCODING_HEXADECIMAL", - hashFunction: "HASH_FUNCTION_NO_OP", - }); - - const rHex = r.startsWith("0x") ? r : "0x" + r; - const sHex = s.startsWith("0x") ? s : "0x" + s; - - const vNumber = parseInt(v, 16); - - if (isNaN(vNumber)) { - console.error(`Invalid recovery ID (v) received from Turnkey: ${v}`); - throw new Error(`Invalid recovery ID (v) received: ${v}`); - } - - const signature = Signature.from({ - r: rHex, - s: sHex, - v: vNumber, - }); - - return { - success: true, - wallet: this.type, - result: signature.serialized, - account: this.account, - }; - } catch (error) { - console.error(`Error signing message with Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - async signTypedData(data: string): Promise> { - return this.signMessage(data); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async sendTransaction(_txn: string): Promise { - return { - success: false, - wallet: this.type, - error: "Not implemented", - }; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async switchChain(_chainId: string): Promise { - return false; - } - - async getBalance( - tokenAddress?: string, - ): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - if (tokenAddress) { - return { - success: false, - wallet: this.type, - error: "Not implemented for ERC20", - }; - } else { - return { success: true, wallet: this.type, result: "0" }; - } - } catch (error) { - console.error(`Error getting balance from Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - private pollIframePublicKey = async (pollTimeMs: number): Promise => { - const intervalMs = 200; - let elapsedTime = 0; - - const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); - const iFramePublicKey = await turnkeyIframeClient.getEmbeddedPublicKey(); - if (iFramePublicKey) { - return iFramePublicKey; - } - - return new Promise((resolve, reject) => { - const intervalId = setInterval(async () => { - const iFramePublicKey = - await turnkeyIframeClient.getEmbeddedPublicKey(); - if (iFramePublicKey) { - clearInterval(intervalId); - resolve(iFramePublicKey); - } else { - elapsedTime += intervalMs; - if (elapsedTime >= pollTimeMs) { - clearInterval(intervalId); - reject(new Error("Timeout waiting for Turnkey iframe public key.")); - } - } - }, intervalMs); - }); - }; - - private async getTurnkeyIframeClient( - timeoutMs: number, - ): Promise { - if (!this.turnkeyIframePromise) { - throw new Error("Turnkey iframe client not initialized"); - } - return this.getPromiseResult(this.turnkeyIframePromise, timeoutMs); - } - private async getAuth0Client(timeoutMs: number): Promise { if (!AUTH0_CLIENT_PROMISE) { throw new Error("Auth0 client not initialized"); } return this.getPromiseResult(AUTH0_CLIENT_PROMISE, timeoutMs); } - - private async getPromiseResult( - promise: Promise, - timeoutMs: number, - ): Promise { - const timeoutId = setTimeout(() => { - throw new Error("Timeout waiting for promise"); - }, timeoutMs); - - const result = await promise; - clearTimeout(timeoutId); - - return result; - } } -const resetIframePublicKey = async (authIframeClient: TurnkeyIframeClient) => { - await authIframeClient.clearEmbeddedKey(); - await authIframeClient.initEmbeddedKey(); -}; - -export const getIframePublicKey = async ( - authIframeClient: TurnkeyIframeClient, -) => { - const iframePublicKey = await authIframeClient.getEmbeddedPublicKey(); - if (!iframePublicKey) { - await resetIframePublicKey(authIframeClient); - throw new Error("No iframe public key, please try again"); - } - return iframePublicKey; -}; - const openPopup = (url: string) => { const popup = window.open( url, diff --git a/packages/keychain/src/wallets/social/turnkey_utils.ts b/packages/keychain/src/wallets/social/turnkey_utils.ts index 682a1570c7..d644cb7d9e 100644 --- a/packages/keychain/src/wallets/social/turnkey_utils.ts +++ b/packages/keychain/src/wallets/social/turnkey_utils.ts @@ -1,9 +1,9 @@ import { IdToken } from "@auth0/auth0-react"; import { AuthOption } from "@cartridge/controller"; import { fetchApiCreator } from "@cartridge/ui/utils"; -import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; +import { TurnkeyIframeClient, TurnkeySDKApiTypes } from "@turnkey/sdk-browser"; +import { OtpType } from "@turnkey/sdk-react"; import { jwtDecode, JwtPayload } from "jwt-decode"; -import { getIframePublicKey } from "./turnkey"; export const getWallet = async ( subOrgId: string, @@ -110,7 +110,7 @@ interface DecodedIdToken extends JwtPayload { tknonce?: string; } -export type SocialProvider = Extract; +export type SocialProvider = Extract; export const fetchApi = fetchApiCreator( `${import.meta.env.VITE_CARTRIDGE_API_URL}/oauth2`, @@ -179,7 +179,7 @@ export const authenticateToTurnkey = async ( oidcToken: string, authIframeClient: TurnkeyIframeClient, ) => { - const iframePublicKey = await getIframePublicKey(authIframeClient); + const iframePublicKey = await publicKeyFromIframe(authIframeClient); const authResponse = await fetchApi( `auth`, @@ -202,6 +202,25 @@ export const authenticateToTurnkey = async ( } }; +const resetIframePublicKey = async ( + turnkeyIframeClient: TurnkeyIframeClient, +): Promise => { + await turnkeyIframeClient.clearEmbeddedKey(); + await turnkeyIframeClient.initEmbeddedKey(); +}; + +export const publicKeyFromIframe = async ( + turnkeyIframeClient: TurnkeyIframeClient, +): Promise => { + const iframePublicKey = await turnkeyIframeClient.getEmbeddedPublicKey(); + + if (!iframePublicKey) { + await resetIframePublicKey(turnkeyIframeClient); + throw new Error("No iframe public key, please try again"); + } + return iframePublicKey; +}; + export type GetSuborgsResponse = { organizationIds: string[]; }; @@ -213,3 +232,63 @@ type CreateSuborgResponse = { type AuthResponse = { credentialBundle: string; }; + +export const createSmsSuborg = async ( + username: string, + phoneNumber: string, +) => { + return ( + await fetchApi("create-suborg", { + rootUserUsername: username, + userPhoneNumber: phoneNumber, + }) + ).subOrganizationId; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const getSmsSuborg = async (username: string, _phoneNumber: string) => { + const getSuborgsResponse = await fetchApi("suborgs", { + filterType: "NAME", + filterValue: username, + }); + + if (getSuborgsResponse.organizationIds.length === 0) { + throw new Error("No suborgs found"); + } + + return getSuborgsResponse.organizationIds[0]; +}; + +export const initOtpAuth = async ( + otpType: OtpType, + contact: string, + suborgID: string, +) => { + const otpAuthResponse = + await fetchApi("init-otp", { + otpType, + contact, + suborgID, + }); + + return otpAuthResponse; +}; + +export const otpAuth = async ( + otpId: string, + otpCode: string, + targetPublicKey: string, + suborgID: string, +) => { + const otpAuthResponse = await fetchApi( + "verify-otp", + { + otpId, + otpCode, + suborgID, + targetPublicKey, + }, + ); + + return otpAuthResponse; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d12a3bbb62..38a98212b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ catalogs: jest-image-snapshot: specifier: ^6.4.0 version: 6.4.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 playwright: specifier: ^1.49.1 version: 1.52.0 @@ -591,6 +594,12 @@ importers: jwt-decode: specifier: ^4.0.0 version: 4.0.0 + libphonenumber-js: + specifier: ^1.12.10 + version: 1.12.17 + lodash: + specifier: 'catalog:' + version: 4.17.21 micro-sol-signer: specifier: ^0.5.0 version: 0.5.0 @@ -6830,8 +6839,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.7: - resolution: {integrity: sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==} + libphonenumber-js@1.12.17: + resolution: {integrity: sha512-bsxi8FoceAYR/bjHcLYc2ShJ/aVAzo5jaxAYiMHF0BD+NTp47405CGuPNKYpw+lHadN9k/ClFGc9X5vaZswIrA==} lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} @@ -13093,7 +13102,7 @@ snapshots: '@turnkey/sdk-server': 3.1.0(@babel/core@7.27.1)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4) '@turnkey/wallet-stamper': 1.0.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4) '@types/react': 18.3.20 - libphonenumber-js: 1.12.7 + libphonenumber-js: 1.12.17 next: 15.3.1(@babel/core@7.27.1)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-apple-login: 1.1.6(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -17553,7 +17562,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.7: {} + libphonenumber-js@1.12.17: {} lilconfig@2.1.0: {} From 7183d0edfd36f29652e4b7a71acdde6e16158326 Mon Sep 17 00:00:00 2001 From: tedison Date: Fri, 19 Sep 2025 11:13:40 +0200 Subject: [PATCH 2/2] Finish rebase and continue work --- .../components/providers/StarknetProvider.tsx | 9 ----- packages/controller/src/types.ts | 34 ++++++++++++++----- packages/controller/src/wallets/types.ts | 17 ++++++---- .../connect/create/CreateController.tsx | 16 ++++----- .../components/connect/create/sms/index.ts | 12 ++----- .../connect/create/sms/phone-number-input.tsx | 4 +-- .../connect/create/sms/sms-authentication.tsx | 24 ++----------- .../connect/create/useCreateController.ts | 25 +++++++------- .../components/provider/keychain-wallets.tsx | 29 ++++++---------- .../signers/add-signer/add-signer.tsx | 7 ++-- packages/keychain/src/hooks/connection.ts | 9 +++-- .../keychain/src/utils/connection/connect.ts | 1 + packages/keychain/src/wallets/social/index.ts | 4 ++- .../keychain/src/wallets/social/sms-wallet.ts | 21 ++++++------ .../src/wallets/social/turnkey_utils.ts | 7 ++-- 15 files changed, 98 insertions(+), 121 deletions(-) diff --git a/examples/next/src/components/providers/StarknetProvider.tsx b/examples/next/src/components/providers/StarknetProvider.tsx index 53f91a0499..00803fc234 100644 --- a/examples/next/src/components/providers/StarknetProvider.tsx +++ b/examples/next/src/components/providers/StarknetProvider.tsx @@ -179,15 +179,6 @@ const controller = new ControllerConnector({ // However, if you want to use custom RPC URLs, you can still specify them: chains: controllerConnectorChains, url: keychainUrl, - signupOptions: [ - "google", - "webauthn", - "discord", - "walletconnect", - "metamask", - "rabby", - "password", - ], slot: "arcade-pistols", namespace: "pistols", preset: "pistols", diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index 439c36c990..746fc7baa9 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -17,6 +17,7 @@ import { ExternalWallet, ExternalWalletResponse, ExternalWalletType, + externalWalletTypes, } from "./wallets/types"; export type KeychainSession = { @@ -30,16 +31,31 @@ export type KeychainSession = { }; }; -export type AuthOption = - | "sms" - | "google" - | "webauthn" - | "discord" - | "walletconnect" - | "password" - | ExternalWalletType; +export const EMBEDDED_WALLETS = [ + "sms", + "google", + "webauthn", + "discord", + "walletconnect", + "password", +] as const; -export type AuthOptions = Omit[]; +export type EmbeddedWallet = (typeof EMBEDDED_WALLETS)[number]; + +export const ALL_AUTH_OPTIONS = [ + ...EMBEDDED_WALLETS, + ...externalWalletTypes, +] as const; + +export type AuthOption = (typeof ALL_AUTH_OPTIONS)[number]; + +export const VALID_AUTH_OPTIONS = [ + ...ALL_AUTH_OPTIONS.filter( + (x) => x !== "phantom" && x !== "argent" && x !== "braavos" && x !== "base", + ), +] as const; + +export type AuthOptions = (typeof VALID_AUTH_OPTIONS)[number][]; export enum ResponseCodes { SUCCESS = "SUCCESS", diff --git a/packages/controller/src/wallets/types.ts b/packages/controller/src/wallets/types.ts index fcae498df3..fa85cd9c28 100644 --- a/packages/controller/src/wallets/types.ts +++ b/packages/controller/src/wallets/types.ts @@ -1,10 +1,13 @@ -export type ExternalWalletType = - | "argent" - | "braavos" - | "metamask" - | "phantom" - | "rabby" - | "base"; +export const externalWalletTypes = [ + "argent", + "braavos", + "metamask", + "phantom", + "rabby", + "base", +] as const; +export type ExternalWalletType = (typeof externalWalletTypes)[number]; + export type ExternalPlatform = | "starknet" | "ethereum" diff --git a/packages/keychain/src/components/connect/create/CreateController.tsx b/packages/keychain/src/components/connect/create/CreateController.tsx index 3bc3ae19c7..68a59add9c 100644 --- a/packages/keychain/src/components/connect/create/CreateController.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.tsx @@ -5,7 +5,7 @@ import { usePostHog } from "@/components/provider/posthog"; import { useControllerTheme } from "@/hooks/connection"; import { useDebounce } from "@/hooks/debounce"; import { allUseSameAuth } from "@/utils/controller"; -import { AuthOption } from "@cartridge/controller"; +import { AuthOption, AuthOptions } from "@cartridge/controller"; import { CartridgeLogo, ControllerIcon, @@ -46,7 +46,7 @@ interface CreateControllerViewProps { waitingForConfirmation: boolean; changeWallet: boolean; setChangeWallet: (value: boolean) => void; - authOptions: AuthOption[]; + authOptions: AuthOptions; authMethod: AuthOption | undefined; } @@ -253,7 +253,7 @@ export function CreateController({ waitingForConfirmation, changeWallet, setChangeWallet, - signupOptions, + supportedSignupOptions, authMethod, } = useCreateController({ isSlot, @@ -285,14 +285,14 @@ export function CreateController({ if ( authenticationMode === undefined && !accountExists && - signupOptions.length > 1 + supportedSignupOptions.length > 1 ) { setAuthenticationStep(AuthenticationStep.ChooseMethod); return; } const authenticationMethod = - signupOptions.length === 1 && !accountExists - ? signupOptions[0] + supportedSignupOptions.length === 1 && !accountExists + ? supportedSignupOptions[0] : validation.signers && (validation.signers.length == 1 || allUseSameAuth(validation.signers)) @@ -321,7 +321,7 @@ export function CreateController({ validation.status, validation.signers, setAuthenticationStep, - signupOptions, + supportedSignupOptions, ], ); @@ -384,7 +384,7 @@ export function CreateController({ waitingForConfirmation={waitingForConfirmation} changeWallet={changeWallet} setChangeWallet={setChangeWallet} - authOptions={signupOptions} + authOptions={supportedSignupOptions} authMethod={authMethod} /> {overlay} diff --git a/packages/keychain/src/components/connect/create/sms/index.ts b/packages/keychain/src/components/connect/create/sms/index.ts index 6e6d8ea9cb..c48ca3bfc4 100644 --- a/packages/keychain/src/components/connect/create/sms/index.ts +++ b/packages/keychain/src/components/connect/create/sms/index.ts @@ -1,21 +1,16 @@ -import { useKeychainWallets } from "@/components/provider/keychain-wallets"; import { SmsWallet } from "@/wallets/social/sms-wallet"; import { AuthOption, WalletAdapter } from "@cartridge/controller"; import { useCallback } from "react"; export const useSmsAuthentication = () => { - const { setOtpDisplayType } = useKeychainWallets(); - const signup = useCallback( async ( connectType: "signup" | "login" | "add-signer", username: string, ) => { - const smsWallet = new SmsWallet(); - - setOtpDisplayType(connectType); + const smsWallet = new SmsWallet(username, connectType); - const response = await smsWallet.connect(username, connectType); + const response = await smsWallet.connect(); if (!response.success || !response.account) { throw new Error(response.error); @@ -29,7 +24,6 @@ export const useSmsAuthentication = () => { response.account, smsWallet as unknown as WalletAdapter, ); - setOtpDisplayType(null); return { address: response.account, @@ -37,7 +31,7 @@ export const useSmsAuthentication = () => { type: "sms" as AuthOption, }; }, - [setOtpDisplayType], + [], ); return { diff --git a/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx b/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx index 35b0b4e234..c7f7d78886 100644 --- a/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx +++ b/packages/keychain/src/components/connect/create/sms/phone-number-input.tsx @@ -23,12 +23,10 @@ import { useEffect, useState } from "react"; export const PhoneNumberInput = ({ onSubmit, - onCancel, phoneNumber, setPhoneNumber, }: { onSubmit: (phoneNumber: string) => void; - onCancel: () => void; phoneNumber: string; setPhoneNumber: (phoneNumber: string) => void; }) => { @@ -225,7 +223,7 @@ export const PhoneNumberInput = ({
-