Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const signupOptions: AuthOptions = [
"password",
"rabby",
"phantom-evm",
"sms",
];

export const controllerConnector = new ControllerConnector({
Expand Down
5 changes: 4 additions & 1 deletion packages/controller/src/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,13 @@ const HEADLESS_AUTH_OPTIONS: AuthOption[] = [
"discord",
"walletconnect",
"password",
"sms",
"metamask",
"rabby",
"phantom-evm",
].filter((option) => IMPLEMENTED_AUTH_OPTIONS.includes(option));
].filter((option) =>
IMPLEMENTED_AUTH_OPTIONS.includes(option as AuthOption),
) as AuthOption[];

function normalizeSignerOptions(
signers: LookupSigner[] | undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const EMBEDDED_WALLETS = [
"discord",
"walletconnect",
"password",
"sms",
] as const;

export type EmbeddedWallet = (typeof EMBEDDED_WALLETS)[number];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
IconProps,
LockIcon,
MetaMaskIcon,
MobileIcon,
PasskeyIcon,
PhantomIcon,
RabbyIcon,
Expand Down Expand Up @@ -93,6 +94,10 @@ const OPTIONS: Partial<Record<string, LoginAuthConfig>> = {
Icon: LockIcon,
label: AUTH_METHODS_LABELS.password,
},
sms: {
Icon: (props: IconProps) => <MobileIcon {...props} variant="solid" />,
label: AUTH_METHODS_LABELS.sms,
},
};

export const AuthButton = forwardRef<HTMLButtonElement, AuthButtonProps>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
GoogleColorIcon,
IconProps,
MetaMaskColorIcon,
MobileIcon,
PasskeyIcon,
PhantomColorIcon,
RabbyColorIcon,
Expand Down Expand Up @@ -82,6 +83,11 @@ const OPTIONS: Partial<
Icon: LockIcon,
label: AUTH_METHODS_LABELS.password,
},
sms: {
variant: "secondary",
Icon: (props: IconProps) => <MobileIcon {...props} variant="solid" />,
label: AUTH_METHODS_LABELS.sms,
},
};

export const SignupButton = forwardRef<HTMLButtonElement, SignupButtonProps>(
Expand Down
154 changes: 154 additions & 0 deletions packages/keychain/src/components/connect/create/sms/SmsOtpForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Button, Input, MobileIcon } from "@cartridge/ui";
import { useCallback, useEffect, useRef, useState } from "react";

interface SmsOtpFormProps {
phoneNumber: string;
onSubmitPhone: (phoneNumber: string) => Promise<void>;
onSubmitOtp: (otpCode: string) => Promise<void>;
onBack: () => void;
isLoading: boolean;
error?: string;
}

export function SmsOtpForm({
phoneNumber: initialPhoneNumber,
onSubmitPhone,
onSubmitOtp,
onBack,
isLoading,
error,
}: SmsOtpFormProps) {
const [step, setStep] = useState<"phone" | "otp">(
initialPhoneNumber ? "otp" : "phone",
);
const [phoneNumber, setPhoneNumber] = useState(initialPhoneNumber);
const [otpCode, setOtpCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
inputRef.current?.focus();
}, [step]);

const handlePhoneSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!phoneNumber.trim()) return;
setSubmitting(true);
try {
await onSubmitPhone(phoneNumber.trim());
setStep("otp");
} finally {
setSubmitting(false);
}
},
[phoneNumber, onSubmitPhone],
);

const handleOtpSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!otpCode.trim()) return;
setSubmitting(true);
try {
await onSubmitOtp(otpCode.trim());
} finally {
setSubmitting(false);
}
},
[otpCode, onSubmitOtp],
);

const busy = isLoading || submitting;

return (
<div className="w-full h-full fixed top-0 left-0 flex flex-col items-center justify-center bg-translucent-dark-200 backdrop-blur-sm z-[10001] pointer-events-auto">
<div className="w-[320px] rounded-[16px] p-6 bg-background-100 shadow-[0px_4px_4px_0px_rgba(0,0,0,0.25)] flex flex-col gap-4">
{step === "phone" ? (
<form onSubmit={handlePhoneSubmit} className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<MobileIcon variant="solid" size="sm" />
<span className="text-sm font-medium text-foreground-200">
Enter your phone number
</span>
</div>
<Input
ref={inputRef}
type="tel"
placeholder="+1 234 567 8900"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
disabled={busy}
/>
{error && (
<span className="text-xs text-destructive-100">{error}</span>
)}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={onBack}
disabled={busy}
className="flex-1"
>
Back
</Button>
<Button
type="submit"
disabled={busy || !phoneNumber.trim()}
isLoading={busy}
className="flex-1"
>
Send code
</Button>
</div>
</form>
) : (
<form onSubmit={handleOtpSubmit} className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<MobileIcon variant="solid" size="sm" />
<span className="text-sm font-medium text-foreground-200">
Enter the code sent to {phoneNumber}
</span>
</div>
<Input
ref={inputRef}
type="text"
inputMode="numeric"
placeholder="Enter code"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
disabled={busy}
autoComplete="one-time-code"
/>
{error && (
<span className="text-xs text-destructive-100">{error}</span>
)}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setStep("phone");
setOtpCode("");
}}
disabled={busy}
className="flex-1"
>
Back
</Button>
<Button
type="submit"
disabled={busy || !otpCode.trim()}
isLoading={busy}
className="flex-1"
>
Verify
</Button>
</div>
</form>
)}
</div>
</div>
);
}
156 changes: 156 additions & 0 deletions packages/keychain/src/components/connect/create/sms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useConnection } from "@/hooks/connection";
import { fetchApi, getOrCreateWallet } from "@/wallets/social/turnkey_utils";
import { TurnkeyWallet } from "@/wallets/social/turnkey";
import { getIframePublicKey } from "@/wallets/social/turnkey";
import { WalletAdapter } from "@cartridge/controller";
import { useCallback } from "react";
import { Turnkey, TurnkeyIframeClient } from "@turnkey/sdk-browser";

type InitSmsResponse = {
otpId: string;
otpEncryptionTargetBundle: string;
};

type VerifySmsResponse = {
credentialBundle: string;
};

let iframeClientPromise: Promise<TurnkeyIframeClient> | null = null;

function getIframeClient(): Promise<TurnkeyIframeClient> {
if (iframeClientPromise) return iframeClientPromise;

const turnkeySdk = new Turnkey({
apiBaseUrl: import.meta.env.VITE_TURNKEY_BASE_URL,
defaultOrganizationId: import.meta.env.VITE_TURNKEY_ORGANIZATION_ID,
});

let container = document.getElementById("turnkey-sms-iframe-container");
if (!container) {
container = document.createElement("div");
container.style.display = "none";
container.id = "turnkey-sms-iframe-container";
document.body.appendChild(container);
}

iframeClientPromise = turnkeySdk
.iframeClient({
iframeContainer: container,
iframeUrl: import.meta.env.VITE_TURNKEY_IFRAME_URL,
})
.then(async (client: TurnkeyIframeClient) => {
await client.initEmbeddedKey();
return client;
});

return iframeClientPromise;
}

export const useSmsAuthentication = () => {
const { chainId, rpcUrl } = useConnection();

const initSms = useCallback(async (phoneNumber: string) => {
const response = await fetchApi<InitSmsResponse>("sms", {
phoneNumber,
});
return response;
}, []);

const completeSms = useCallback(
async (
username: string,
phoneNumber: string,
otpId: string,
otpCode: string,
) => {
if (!chainId) {
throw new Error("No chainId");
}

const iframeClient = await getIframeClient();

// Get iframe public key for the credential bundle
const targetPublicKey = await getIframePublicKey(iframeClient);

// Look up or create sub-org by phone number
const suborgResponse = await fetchApi<{ organizationIds: string[] }>(
"suborgs",
{
filterType: "PHONE_NUMBER",
filterValue: phoneNumber,
},
);

let subOrgId: string;
if (
!suborgResponse?.organizationIds ||
suborgResponse.organizationIds.length === 0
) {
const createResponse = await fetchApi<{ subOrganizationId: string }>(
"create-suborg",
{
rootUserUserName: username,
phoneNumber,
},
);
subOrgId = createResponse.subOrganizationId;
} else {
subOrgId = suborgResponse.organizationIds[0];
}

// Backend does verify OTP + login, returns credential bundle
const verifyResponse = await fetchApi<VerifySmsResponse>("sms/verify", {
otpId,
otpCode,
suborgID: subOrgId,
targetPublicKey,
});

if (!verifyResponse?.credentialBundle) {
throw new Error("SMS verification failed: no credential bundle");
}

// Inject credential bundle into iframe (same pattern as OAuth)
const injectResult = await iframeClient.injectCredentialBundle(
verifyResponse.credentialBundle,
);
if (!injectResult) {
throw new Error("Failed to inject credentials into Turnkey");
}

// Get or create wallet
const walletAddress = await getOrCreateWallet(
subOrgId,
username,
iframeClient,
);

// Register as embedded wallet for signing
const turnkeyWallet = new TurnkeyWallet(
username,
chainId,
rpcUrl,
undefined,
);
turnkeyWallet.account = walletAddress;
turnkeyWallet.subOrganizationId = subOrgId;

window.keychain_wallets?.addEmbeddedWallet(
walletAddress,
turnkeyWallet as unknown as WalletAdapter,
);

return {
address: walletAddress,
signer: { eip191: { address: walletAddress } },
type: "sms" as const,
};
},
[chainId, rpcUrl],
);

return {
initSms,
completeSms,
};
};
Loading
Loading