diff --git a/apps/user_dashboard/src/app/export_private_key/page.module.scss b/apps/user_dashboard/src/app/export_private_key/page.module.scss
index 7277424a7..6db4d4a98 100644
--- a/apps/user_dashboard/src/app/export_private_key/page.module.scss
+++ b/apps/user_dashboard/src/app/export_private_key/page.module.scss
@@ -5,6 +5,7 @@
flex-wrap: wrap;
gap: 64px;
align-items: flex-start;
+ max-width: 1280px;
}
.heading {
@@ -109,66 +110,92 @@
/* Step 2 styles */
-.privateKeySection {
+.keySection {
display: flex;
flex-direction: column;
}
-.privateKeyLabel {
- margin-bottom: 12px;
+.sectionHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.sectionKeyIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ color: var(--fg-quaternary);
}
-.privateKeyField {
- position: relative;
- min-height: 68px;
+.keyIframe {
width: 100%;
- cursor: pointer;
+ border: none;
+ display: block;
+}
- &:hover .privateKeyBg {
- background-color: var(--bg-secondary-hover, #f5f5f5);
- }
+.chainsList {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 8px;
+ border-radius: 12px;
}
-.privateKeyBg {
+.chainsTitle {
display: flex;
align-items: center;
- justify-content: center;
- padding: 12px 16px;
- background-color: var(--bg-secondary);
- border-radius: 12px;
- overflow: hidden;
- word-break: break-all;
+ gap: 12px;
+ flex-shrink: 0;
}
-.privateKeyTextBlurred {
- filter: blur(4px);
- opacity: 0.5;
+.chainsSeparator {
+ width: 1px;
+ height: 12px;
+ background-color: var(--border-primary);
+ flex-shrink: 0;
}
-.privateKeyHint {
- position: absolute;
- inset: 0;
+.chainsItems {
display: flex;
align-items: center;
- gap: 12px;
- padding: 12px 16px;
+ gap: 8px;
+ flex-wrap: wrap;
}
-.eyeOffIcon {
+.chainItem {
display: flex;
align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- flex-shrink: 0;
- color: var(--fg-primary);
+ gap: 4px;
+}
+
+.divider {
+ border: none;
+ border-top: 1px solid var(--border-primary);
+ margin: 32px 0;
}
-.copyButtonIcon {
+.infoBox {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ background-color: var(--bg-secondary);
+ border-radius: 12px;
+}
+
+.infoBoxTitle {
display: flex;
align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
+ gap: 8px;
+}
+
+.infoBoxIcon {
+ width: 20px;
+ height: 20px;
flex-shrink: 0;
}
diff --git a/apps/user_dashboard/src/app/export_private_key/page.tsx b/apps/user_dashboard/src/app/export_private_key/page.tsx
index 033ff40d1..12fc0be1a 100644
--- a/apps/user_dashboard/src/app/export_private_key/page.tsx
+++ b/apps/user_dashboard/src/app/export_private_key/page.tsx
@@ -1,19 +1,27 @@
"use client";
import { Button } from "@oko-wallet/oko-common-ui/button";
+import { ArbitrumIcon } from "@oko-wallet/oko-common-ui/icons/arbitrum_icon";
+import { BaseIcon } from "@oko-wallet/oko-common-ui/icons/base_icon";
+import { CosmosIcon } from "@oko-wallet/oko-common-ui/icons/cosmos_icon";
import { DiscordIcon } from "@oko-wallet/oko-common-ui/icons/discord_icon";
+import { EthereumIcon } from "@oko-wallet/oko-common-ui/icons/ethereum_icon";
import { GoogleIcon } from "@oko-wallet/oko-common-ui/icons/google_icon";
+import { InfoCircleIcon } from "@oko-wallet/oko-common-ui/icons/info_circle";
+import { InitiaIcon } from "@oko-wallet/oko-common-ui/icons/initia_icon";
import { MailboxIcon } from "@oko-wallet/oko-common-ui/icons/mailbox";
+import { RialoIcon } from "@oko-wallet/oko-common-ui/icons/rialo_icon";
+import { SolanaCircleIcon } from "@oko-wallet/oko-common-ui/icons/solana_circle_icon";
import { TelegramIcon } from "@oko-wallet/oko-common-ui/icons/telegram_icon";
import { XIcon } from "@oko-wallet/oko-common-ui/icons/x_icon";
+import { ZigchainIcon } from "@oko-wallet/oko-common-ui/icons/zigchain_icon";
import { Typography } from "@oko-wallet/oko-common-ui/typography";
import type { AuthType } from "@oko-wallet/oko-types/auth";
-import { type ReactNode, useCallback, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
-import type { OkoWalletMsgExportPrivateKeyAck } from "../../../../../sdk/oko_sdk_core/dist/types";
+import type { OkoWalletMsgExportPrivateKeyAck } from "@oko-wallet/oko-sdk-core";
import styles from "./page.module.scss";
import { displayToast } from "@oko-wallet-user-dashboard/components/toast";
-import { useCopyToClipboard } from "@oko-wallet-user-dashboard/hooks/use_copy_to_clipboard";
import {
selectCosmosSDK,
useSDKState,
@@ -102,11 +110,11 @@ const KeyIcon = () => {
);
};
-const CopyIcon = () => {
+const SectionKeyIcon = () => {
return (
);
};
-const EyeOffIcon = () => {
+const EVM_COSMOS_CHAINS = [
+ { name: "Ethereum", icon: },
+ { name: "Base", icon: },
+ { name: "Arbitrum", icon: },
+ { name: "Cosmos Hub", icon: },
+ { name: "Initia", icon: },
+ { name: "Zigchain", icon: },
+];
+
+const SVM_CHAINS = [
+ { name: "Solana", icon: },
+ { name: "Rialo", icon: },
+];
+
+const ChainsList = ({
+ chains,
+}: {
+ chains: { name: string; icon: ReactNode }[];
+}) => {
return (
-
+
+
+
+ Examples
+
+
+
+
+ {chains.map((chain) => (
+
+ {chain.icon}
+
+ {chain.name}
+
+
+ ))}
+
+
);
};
@@ -218,140 +249,141 @@ const Step1Content = ({
};
const Step2Content = ({
- privateKeys,
- revealedKeys,
- onToggleReveal,
- onCopy,
+ attachedOrigin,
+ onReady,
}: {
- privateKeys: { secp256k1: string; ed25519: string };
- revealedKeys: { secp256k1: boolean; ed25519: boolean };
- onToggleReveal: (key: "secp256k1" | "ed25519") => void;
- onCopy: (key: string) => void;
+ attachedOrigin: string;
+ onReady?: () => void;
}) => {
+ const [secpIframeHeight, setSecpIframeHeight] = useState(0);
+ const [edIframeHeight, setEdIframeHeight] = useState(0);
+
+ useEffect(() => {
+ const handler = (event: MessageEvent) => {
+ if (event.origin !== attachedOrigin) {
+ return;
+ }
+ const { data } = event;
+ if (data?.target !== "oko_user_dashboard") {
+ return;
+ }
+
+ if (data.msg_type === "__export_display_resize__") {
+ if (data.key_type === "secp256k1") {
+ setSecpIframeHeight(data.height);
+ } else if (data.key_type === "ed25519") {
+ setEdIframeHeight(data.height);
+ }
+ } else if (data.msg_type === "__export_display_copied__") {
+ displayToast({ variant: "success", title: "Copied!" });
+ } else if (data.msg_type === "__export_display_copy_failed__") {
+ displayToast({
+ variant: "confirm",
+ title: "Copy Failed",
+ description: "Could not copy to clipboard.",
+ });
+ }
+ };
+
+ window.addEventListener("message", handler);
+ return () => {
+ window.removeEventListener("message", handler);
+ };
+ }, [attachedOrigin]);
+
+ const iframesReady = secpIframeHeight > 0 && edIframeHeight > 0;
+
+ const onReadyFired = useRef(false);
+ useEffect(() => {
+ if (!onReady || onReadyFired.current) return;
+ if (iframesReady) {
+ onReadyFired.current = true;
+ onReady();
+ return;
+ }
+ const timer = setTimeout(() => {
+ if (!onReadyFired.current) {
+ onReadyFired.current = true;
+ onReady();
+ }
+ }, 5000);
+ return () => clearTimeout(timer);
+ }, [iframesReady, onReady]);
+
return (
- <>
+
- View and copy your private keys
+ View and copy your private key
-
+
-
-
- EVM/Cosmos Private Key
-
-
onToggleReveal("secp256k1")}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- onToggleReveal("secp256k1");
- }
- }}
- >
-
-
- {privateKeys.secp256k1}
-
-
- {!revealedKeys.secp256k1 && (
-
-
-
-
-
- Click or tap to reveal your private key.
-
- Ensure no one else can see your screen.
-
-
- )}
+ {/* EVM & Cosmos Section */}
+
+
+
+
+
+
+ EVM & Cosmos
+
-
-
+
-
+
-
+
+
-
-
- SVM Private Key
-
-
onToggleReveal("ed25519")}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- onToggleReveal("ed25519");
- }
- }}
- >
-
-
- {privateKeys.ed25519}
-
-
- {!revealedKeys.ed25519 && (
-
-
-
-
-
- Click or tap to reveal your private key.
-
- Ensure no one else can see your screen.
-
-
- )}
+
+
+ {/* Solana & SVM Section */}
+
+
+
+
+
+
+ Solana & SVM
+
+
+
+
+
+
+
-
+
-
- >
+ {/* Info Box */}
+
+
+
+
+ Why are there two keys?
+
+
+
+ Different ecosystems use different cryptographic curves, so their
+ private keys are generated differently.
+
+
+
);
};
@@ -382,18 +414,19 @@ const Page = () => {
const displayIdentifier = usesName ? name : email;
const okoWallet = useSDKState(selectCosmosSDK)?.okoWallet;
+ const attachedOrigin = okoWallet
+ ? new URL(okoWallet.sdkEndpoint).origin
+ : null;
const [step, setStep] = useState<1 | 2>(1);
const [isLoading, setIsLoading] = useState(false);
- const [revealedKeys, setRevealedKeys] = useState({
- secp256k1: false,
- ed25519: false,
- });
- const [privateKeys, setPrivateKeys] = useState<{
- secp256k1: string;
- ed25519: string;
- } | null>(null);
- const { copy } = useCopyToClipboard();
+ const popupRef = useRef
(null);
+
+ const handlePopupClose = useCallback(() => {
+ popupRef.current?.close();
+ popupRef.current = null;
+ setIsLoading(false);
+ }, []);
const handleContinue = useCallback(async () => {
if (!okoWallet || !authType) {
@@ -402,6 +435,7 @@ const Page = () => {
let popup: Window | null = null;
let reauthHandler: ((event: MessageEvent) => void) | null = null;
+ let exportSucceeded = false;
try {
setIsLoading(true);
@@ -471,18 +505,20 @@ const Page = () => {
popupClosePromise,
timeoutPromise,
]);
- popup?.close();
- // 5. Parse result
+ // 7. Parse result
const resAny = res as unknown as OkoWalletMsgExportPrivateKeyAck;
if (
resAny.msg_type === "__export_private_key_ack__" &&
resAny.payload.success
) {
- setPrivateKeys(resAny.payload.data);
+ // Keep popup open — Step2Content.onReady will close it
+ exportSucceeded = true;
+ popupRef.current = popup;
setStep(2);
} else {
+ popup?.close();
const errorType = !resAny.payload.success
? resAny.payload.error.type
: "unknown";
@@ -511,24 +547,12 @@ const Page = () => {
if (reauthHandler) {
window.removeEventListener("message", reauthHandler);
}
- setIsLoading(false);
+ if (!exportSucceeded) {
+ setIsLoading(false);
+ }
}
}, [okoWallet, authType]);
- const handleCopy = useCallback(
- async (key: string) => {
- const success = await copy(key);
- if (success) {
- displayToast({ variant: "success", title: "Copied!" });
- }
- },
- [copy],
- );
-
- const handleToggleReveal = useCallback((key: "secp256k1" | "ed25519") => {
- setRevealedKeys((prev) => ({ ...prev, [key]: !prev[key] }));
- }, []);
-
return (
@@ -555,10 +579,8 @@ const Page = () => {
/>
) : (
)}
diff --git a/embed/oko_attached/src/components/discord_callback/use_callback.tsx b/embed/oko_attached/src/components/discord_callback/use_callback.tsx
index 91c0c0462..25a00158e 100644
--- a/embed/oko_attached/src/components/discord_callback/use_callback.tsx
+++ b/embed/oko_attached/src/components/discord_callback/use_callback.tsx
@@ -19,6 +19,15 @@ export function useDiscordCallback() {
const cbRes = await handleDiscordCallback();
if (cbRes.success) {
+ const stateParam = new URLSearchParams(window.location.search).get("state");
+ if (stateParam) {
+ try {
+ const oauthState = JSON.parse(atob(stateParam));
+ if (oauthState.apiKey === "export_key_reauth") {
+ return; // Parent will close popup when iframes are ready
+ }
+ } catch { /* ignore parse errors */ }
+ }
window.close();
} else {
if (cbRes.err.type === "login_canceled_by_user") {
diff --git a/embed/oko_attached/src/components/email_callback/use_callback.tsx b/embed/oko_attached/src/components/email_callback/use_callback.tsx
index 4ae9479f5..2c225e3a1 100644
--- a/embed/oko_attached/src/components/email_callback/use_callback.tsx
+++ b/embed/oko_attached/src/components/email_callback/use_callback.tsx
@@ -22,9 +22,28 @@ export function useEmailCallback(): { error: string | null } {
useEffect(() => {
async function fn() {
try {
+ // Read state before handleEmailCallback clears the hash via replaceState
+ let isReauth = false;
+ const hash = window.location.hash;
+ if (hash) {
+ try {
+ const params = new URLSearchParams(hash.substring(1));
+ const stateStr = params.get("state");
+ if (stateStr) {
+ const oauthState = JSON.parse(stateStr);
+ if (oauthState.apiKey === "export_key_reauth") {
+ isReauth = true;
+ }
+ }
+ } catch { /* ignore parse errors */ }
+ }
+
const cbRes = await handleEmailCallback();
if (cbRes.success) {
+ if (isReauth) {
+ return; // Parent will close popup when iframes are ready
+ }
window.close();
} else {
throw new Error(cbRes.err.type);
diff --git a/embed/oko_attached/src/components/export/email_reauth.tsx b/embed/oko_attached/src/components/export/email_reauth.tsx
index 5b97d36e1..3f70d1fe2 100644
--- a/embed/oko_attached/src/components/export/email_reauth.tsx
+++ b/embed/oko_attached/src/components/export/email_reauth.tsx
@@ -53,10 +53,10 @@ export const EmailReauth = () => {
const nonce = useMemo(() => generateNonce(), []);
const oauthState = useMemo
(
() => ({
- apiKey: "reauth",
+ apiKey: "export_key_reauth",
targetOrigin: window.location.origin,
provider: "auth0",
- modalId: "reauth",
+ modalId: "export_key_reauth",
}),
[],
);
@@ -132,7 +132,7 @@ export const EmailReauth = () => {
setIsSubmitting(true);
setErrorMessage(null);
- const callbackUrl = `${window.location.origin}/email/callback?modal_id=reauth`;
+ const callbackUrl = `${window.location.origin}/email/callback?modal_id=export_key_reauth`;
console.log(`${LOG_PREFIX} verifying OTP for`, email.trim());
diff --git a/embed/oko_attached/src/components/export/export_display.module.scss b/embed/oko_attached/src/components/export/export_display.module.scss
new file mode 100644
index 000000000..702a5d290
--- /dev/null
+++ b/embed/oko_attached/src/components/export/export_display.module.scss
@@ -0,0 +1,132 @@
+:root {
+ --bg-secondary: #fafafa;
+ --bg-secondary-hover: #f5f5f5;
+ --text-primary: #181d27;
+ --text-secondary: #414651;
+ --fg-primary: #181d27;
+}
+
+html {
+ margin: 0;
+ padding: 0;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+body {
+ margin: 0;
+ padding: 0;
+}
+
+.container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.privateKeyField {
+ position: relative;
+ min-height: 68px;
+ width: 100%;
+ cursor: pointer;
+
+ &:hover .privateKeyBg {
+ background-color: var(--bg-secondary-hover, #f5f5f5);
+ }
+}
+
+.privateKeyBg {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background-color: var(--bg-secondary);
+ border-radius: 12px;
+ overflow: hidden;
+ word-break: break-all;
+}
+
+.privateKeyText {
+ flex: 1;
+ font-family: var(--font-family-body, Inter, sans-serif);
+ font-weight: 500;
+ font-size: var(--font-size-text-md, 15px);
+ line-height: var(--line-height-text-md, 22px);
+ color: var(--text-secondary);
+}
+
+.privateKeyTextBlurred {
+ filter: blur(4px);
+ opacity: 0.5;
+}
+
+.privateKeyHint {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+}
+
+.eyeOffIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ color: var(--fg-primary);
+}
+
+.hintText {
+ flex: 1;
+ font-family: var(--font-family-body, Inter, sans-serif);
+ font-weight: 500;
+ font-size: var(--font-size-text-md, 15px);
+ line-height: var(--line-height-text-md, 22px);
+ color: var(--text-primary);
+}
+
+.copyButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: 100%;
+ padding: 10px 16px;
+ border: 2px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background-color: #181d27;
+ color: white;
+ cursor: pointer;
+ font-family: var(--font-family-body, Inter, sans-serif);
+ font-weight: 600;
+ font-size: var(--font-size-text-md, 15px);
+ line-height: var(--line-height-text-md, 22px);
+ box-shadow: 0 1px 2px 0 rgba(10, 13, 18, 0.05);
+}
+
+.copyButton:hover {
+ opacity: 0.9;
+}
+
+.copyButtonIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+}
+
+.error {
+ padding: 16px;
+ text-align: center;
+ font-family: var(--font-family-body, Inter, sans-serif);
+ font-size: var(--font-size-text-sm, 14px);
+ color: var(--text-secondary);
+}
diff --git a/embed/oko_attached/src/components/export/export_display.tsx b/embed/oko_attached/src/components/export/export_display.tsx
new file mode 100644
index 000000000..662ca7be2
--- /dev/null
+++ b/embed/oko_attached/src/components/export/export_display.tsx
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import {
+ type ExportedKeys,
+ getExportedKeys,
+ requestExportedKeys,
+} from "@oko-wallet-attached/window_msgs/export_key_store";
+
+import styles from "./export_display.module.scss";
+
+type KeyType = "secp256k1" | "ed25519";
+
+const PARENT_MSG_TARGET = "oko_user_dashboard";
+
+function getParentOrigin(): string {
+ const raw = new URLSearchParams(window.location.search).get("parent_origin");
+ if (!raw) {
+ return "*";
+ }
+ try {
+ return new URL(raw).origin;
+ } catch {
+ return "*";
+ }
+}
+
+const parentOrigin = getParentOrigin();
+
+function postToParent(msgType: string, data?: Record) {
+ window.parent.postMessage(
+ { target: PARENT_MSG_TARGET, msg_type: msgType, ...data },
+ parentOrigin,
+ );
+}
+
+const EyeOffIcon = () => {
+ return (
+
+ );
+};
+
+const CopyIcon = () => {
+ return (
+
+ );
+};
+
+const VALID_KEY_TYPES: ReadonlySet = new Set(["secp256k1", "ed25519"]);
+
+export const ExportDisplay = () => {
+ const keyType = useMemo(() => {
+ const raw = new URLSearchParams(window.location.search).get("key_type");
+ return raw && VALID_KEY_TYPES.has(raw) ? (raw as KeyType) : null;
+ }, []);
+
+ const [revealed, setRevealed] = useState(false);
+ const [keys, setKeys] = useState(null);
+ const [error, setError] = useState(null);
+ const [containerEl, setContainerEl] = useState(null);
+
+ // Read keys: try local first, then request from hidden iframe via BroadcastChannel
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadKeys = async () => {
+ let stored = getExportedKeys();
+ if (!stored) {
+ stored = await requestExportedKeys();
+ }
+ if (cancelled) {
+ return;
+ }
+ if (!stored) {
+ setError("No exported keys found.");
+ return;
+ }
+ if (!keyType || !(keyType in stored)) {
+ setError(`Invalid key_type: ${keyType}`);
+ return;
+ }
+ setKeys(stored);
+ };
+
+ loadKeys();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [keyType]);
+
+ // ResizeObserver → notify parent of height changes
+ useEffect(() => {
+ if (!containerEl) {
+ return;
+ }
+
+ const report = () => {
+ postToParent("__export_display_resize__", {
+ height: document.documentElement.scrollHeight,
+ key_type: keyType,
+ });
+ };
+ const observer = new ResizeObserver(report);
+ observer.observe(containerEl);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [keyType, containerEl]);
+
+ const handleToggleReveal = useCallback(() => {
+ setRevealed((prev) => !prev);
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ if (!keys || !keyType) {
+ return;
+ }
+ const keyValue = keys[keyType];
+ try {
+ await navigator.clipboard.writeText(keyValue);
+ postToParent("__export_display_copied__", { key_type: keyType });
+ } catch {
+ postToParent("__export_display_copy_failed__", { key_type: keyType });
+ }
+ }, [keys, keyType]);
+
+ if (error) {
+ return {error}
;
+ }
+
+ if (!keys || !keyType) {
+ return null;
+ }
+
+ const keyValue = keys[keyType];
+
+ return (
+
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ handleToggleReveal();
+ }
+ }}
+ >
+
+
+ {keyValue}
+
+
+ {!revealed && (
+
+
+
+
+
+ Click or tap to reveal your private key.
+
+ Ensure no one else can see your screen.
+
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/embed/oko_attached/src/components/export/export_reauth.tsx b/embed/oko_attached/src/components/export/export_reauth.tsx
index bcc0fa51a..a68edbb76 100644
--- a/embed/oko_attached/src/components/export/export_reauth.tsx
+++ b/embed/oko_attached/src/components/export/export_reauth.tsx
@@ -1,14 +1,23 @@
-import { useEffect, useState } from "react";
+import { useEffect, useLayoutEffect, useState } from "react";
+import { ThemeProvider } from "@oko-wallet/oko-common-ui/theme";
import type { AuthType } from "@oko-wallet/oko-types/auth";
-import { useExportReauth } from "./use_export_reauth";
+import { setColorScheme } from "@oko-wallet-attached/components/attached_initialized/color_scheme";
+import { getSystemTheme } from "@oko-wallet-attached/components/google_callback/theme";
import { EmailReauth } from "./email_reauth";
import { TelegramReauth } from "./telegram_reauth";
+import { useExportReauth } from "./use_export_reauth";
type ReauthStatus = "loading" | "redirecting" | "error";
export const ExportReauth = () => {
+ const theme = getSystemTheme();
+
+ useLayoutEffect(() => {
+ setColorScheme(theme);
+ }, [theme]);
+
const params = new URLSearchParams(window.location.search);
const authType = params.get("auth_type") as AuthType | null;
@@ -16,21 +25,25 @@ export const ExportReauth = () => {
return Error: auth_type parameter is required
;
}
- switch (authType) {
- case "google":
- case "x":
- case "discord":
- return ;
+ const content = (() => {
+ switch (authType) {
+ case "google":
+ case "x":
+ case "discord":
+ return ;
- case "auth0":
- return ;
+ case "auth0":
+ return ;
- case "telegram":
- return ;
+ case "telegram":
+ return ;
- default:
- return Error: unsupported auth_type: {authType}
;
- }
+ default:
+ return Error: unsupported auth_type: {authType}
;
+ }
+ })();
+
+ return {content};
}
const OAuthRedirect = ({ authType }: { authType: "google" | "x" | "discord" }) => {
diff --git a/embed/oko_attached/src/components/export/telegram_reauth.tsx b/embed/oko_attached/src/components/export/telegram_reauth.tsx
index 7491e0455..5105b048a 100644
--- a/embed/oko_attached/src/components/export/telegram_reauth.tsx
+++ b/embed/oko_attached/src/components/export/telegram_reauth.tsx
@@ -25,7 +25,7 @@ export const TelegramReauth = () => {
// Build OAuthState for the callback to parse
const oauthState = useMemo(
() => ({
- apiKey: "reauth",
+ apiKey: "export_key_reauth",
targetOrigin: window.location.origin,
provider: "telegram",
}),
diff --git a/embed/oko_attached/src/components/export/use_export_reauth.ts b/embed/oko_attached/src/components/export/use_export_reauth.ts
index d1fe328d9..5ff8f752c 100644
--- a/embed/oko_attached/src/components/export/use_export_reauth.ts
+++ b/embed/oko_attached/src/components/export/use_export_reauth.ts
@@ -15,6 +15,18 @@ export function generateNonce(length = 8) {
.join("");
}
+function toBase64Url(base64: string): string {
+ return base64.replace(/[+/]|(=+)$/g, (match) => {
+ if (match === "+") {
+ return "-";
+ }
+ if (match === "/") {
+ return "_";
+ }
+ return "";
+ });
+}
+
function generateRandomString(length = 64): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
@@ -24,16 +36,7 @@ function generateRandomString(length = 64): string {
binary += String.fromCharCode(array[i]);
}
- const base64 = btoa(binary);
- return base64.replace(/[+\/]|(=+)$/g, (match) => {
- if (match === "+") {
- return "-";
- }
- if (match === "/") {
- return "_";
- }
- return "";
- });
+ return toBase64Url(btoa(binary));
}
async function sha256(input: string): Promise {
@@ -48,16 +51,7 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
- const base64 = btoa(binary);
- return base64.replace(/[+\/]|(=+)$/g, (match) => {
- if (match === "+") {
- return "-";
- }
- if (match === "/") {
- return "_";
- }
- return "";
- });
+ return toBase64Url(btoa(binary));
}
async function createPkcePair(): Promise<{
@@ -111,7 +105,7 @@ function buildGoogleOAuthUrl(nonce: string): string {
const redirectUri = `${window.location.origin}/google/callback`;
const oauthState: OAuthState = {
- apiKey: "reauth",
+ apiKey: "export_key_reauth",
targetOrigin: window.location.origin,
provider: "google",
};
@@ -132,7 +126,7 @@ function buildXOAuthUrl(codeChallenge: string): string {
const redirectUri = `${window.location.origin}/x/callback`;
const oauthState: OAuthState = {
- apiKey: "reauth",
+ apiKey: "export_key_reauth",
targetOrigin: window.location.origin,
provider: "x",
};
@@ -154,7 +148,7 @@ function buildDiscordOAuthUrl(codeChallenge: string): string {
const redirectUri = `${window.location.origin}/discord/callback`;
const oauthState: OAuthState = {
- apiKey: "reauth",
+ apiKey: "export_key_reauth",
targetOrigin: window.location.origin,
provider: "discord",
};
diff --git a/embed/oko_attached/src/components/google_callback/use_callback.tsx b/embed/oko_attached/src/components/google_callback/use_callback.tsx
index 8dd80e04d..b0d25e92f 100644
--- a/embed/oko_attached/src/components/google_callback/use_callback.tsx
+++ b/embed/oko_attached/src/components/google_callback/use_callback.tsx
@@ -17,6 +17,10 @@ export function useGoogleCallback() {
const cbRes = await handleGoogleCallback();
if (cbRes.success) {
+ const oauthState = getOAuthStateFromUrl();
+ if (oauthState.apiKey === "export_key_reauth") {
+ return; // Parent will close popup when iframes are ready
+ }
window.close();
}
} catch (err) {
diff --git a/embed/oko_attached/src/components/telegram_callback/use_callback.tsx b/embed/oko_attached/src/components/telegram_callback/use_callback.tsx
index 96d329847..720a8b5cd 100644
--- a/embed/oko_attached/src/components/telegram_callback/use_callback.tsx
+++ b/embed/oko_attached/src/components/telegram_callback/use_callback.tsx
@@ -21,6 +21,15 @@ export function useTelegramCallback() {
const cbRes = await handleTelegramCallback();
if (cbRes.success) {
+ const stateParam = new URLSearchParams(window.location.search).get(RedirectUriSearchParamsKey.STATE);
+ if (stateParam) {
+ try {
+ const oauthState = JSON.parse(stateParam);
+ if (oauthState.apiKey === "export_key_reauth") {
+ return; // Parent will close popup when iframes are ready
+ }
+ } catch { /* ignore parse errors */ }
+ }
window.close();
} else {
setError(cbRes.err.type);
diff --git a/embed/oko_attached/src/components/x_callback/use_callback.tsx b/embed/oko_attached/src/components/x_callback/use_callback.tsx
index 35ce94d37..4634f07a9 100644
--- a/embed/oko_attached/src/components/x_callback/use_callback.tsx
+++ b/embed/oko_attached/src/components/x_callback/use_callback.tsx
@@ -19,6 +19,15 @@ export function useXCallback() {
const cbRes = await handleXCallback();
if (cbRes.success) {
+ const stateParam = new URLSearchParams(window.location.search).get("state");
+ if (stateParam) {
+ try {
+ const oauthState = JSON.parse(atob(stateParam));
+ if (oauthState.apiKey === "export_key_reauth") {
+ return; // Parent will close popup when iframes are ready
+ }
+ } catch { /* ignore parse errors */ }
+ }
window.close();
} else {
if (cbRes.err.type === "login_canceled_by_user") {
diff --git a/embed/oko_attached/src/routeTree.gen.ts b/embed/oko_attached/src/routeTree.gen.ts
index 1994b25bf..2908582b9 100644
--- a/embed/oko_attached/src/routeTree.gen.ts
+++ b/embed/oko_attached/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as XCallbackIndexRouteImport } from './routes/x/callback/index'
import { Route as TelegramCallbackIndexRouteImport } from './routes/telegram/callback/index'
import { Route as GoogleCallbackIndexRouteImport } from './routes/google/callback/index'
import { Route as ExportReauthIndexRouteImport } from './routes/export/reauth/index'
+import { Route as ExportDisplayIndexRouteImport } from './routes/export/display/index'
import { Route as EmailCallbackIndexRouteImport } from './routes/email/callback/index'
import { Route as DiscordCallbackIndexRouteImport } from './routes/discord/callback/index'
@@ -54,6 +55,11 @@ const ExportReauthIndexRoute = ExportReauthIndexRouteImport.update({
path: '/export/reauth/',
getParentRoute: () => rootRouteImport,
} as any)
+const ExportDisplayIndexRoute = ExportDisplayIndexRouteImport.update({
+ id: '/export/display/',
+ path: '/export/display/',
+ getParentRoute: () => rootRouteImport,
+} as any)
const EmailCallbackIndexRoute = EmailCallbackIndexRouteImport.update({
id: '/email/callback/',
path: '/email/callback/',
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/telegram/': typeof TelegramIndexRoute
'/discord/callback/': typeof DiscordCallbackIndexRoute
'/email/callback/': typeof EmailCallbackIndexRoute
+ '/export/display/': typeof ExportDisplayIndexRoute
'/export/reauth/': typeof ExportReauthIndexRoute
'/google/callback/': typeof GoogleCallbackIndexRoute
'/telegram/callback/': typeof TelegramCallbackIndexRoute
@@ -82,6 +89,7 @@ export interface FileRoutesByTo {
'/telegram': typeof TelegramIndexRoute
'/discord/callback': typeof DiscordCallbackIndexRoute
'/email/callback': typeof EmailCallbackIndexRoute
+ '/export/display': typeof ExportDisplayIndexRoute
'/export/reauth': typeof ExportReauthIndexRoute
'/google/callback': typeof GoogleCallbackIndexRoute
'/telegram/callback': typeof TelegramCallbackIndexRoute
@@ -94,6 +102,7 @@ export interface FileRoutesById {
'/telegram/': typeof TelegramIndexRoute
'/discord/callback/': typeof DiscordCallbackIndexRoute
'/email/callback/': typeof EmailCallbackIndexRoute
+ '/export/display/': typeof ExportDisplayIndexRoute
'/export/reauth/': typeof ExportReauthIndexRoute
'/google/callback/': typeof GoogleCallbackIndexRoute
'/telegram/callback/': typeof TelegramCallbackIndexRoute
@@ -107,6 +116,7 @@ export interface FileRouteTypes {
| '/telegram/'
| '/discord/callback/'
| '/email/callback/'
+ | '/export/display/'
| '/export/reauth/'
| '/google/callback/'
| '/telegram/callback/'
@@ -118,6 +128,7 @@ export interface FileRouteTypes {
| '/telegram'
| '/discord/callback'
| '/email/callback'
+ | '/export/display'
| '/export/reauth'
| '/google/callback'
| '/telegram/callback'
@@ -129,6 +140,7 @@ export interface FileRouteTypes {
| '/telegram/'
| '/discord/callback/'
| '/email/callback/'
+ | '/export/display/'
| '/export/reauth/'
| '/google/callback/'
| '/telegram/callback/'
@@ -141,6 +153,7 @@ export interface RootRouteChildren {
TelegramIndexRoute: typeof TelegramIndexRoute
DiscordCallbackIndexRoute: typeof DiscordCallbackIndexRoute
EmailCallbackIndexRoute: typeof EmailCallbackIndexRoute
+ ExportDisplayIndexRoute: typeof ExportDisplayIndexRoute
ExportReauthIndexRoute: typeof ExportReauthIndexRoute
GoogleCallbackIndexRoute: typeof GoogleCallbackIndexRoute
TelegramCallbackIndexRoute: typeof TelegramCallbackIndexRoute
@@ -198,6 +211,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ExportReauthIndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/export/display/': {
+ id: '/export/display/'
+ path: '/export/display'
+ fullPath: '/export/display/'
+ preLoaderRoute: typeof ExportDisplayIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/email/callback/': {
id: '/email/callback/'
path: '/email/callback'
@@ -221,6 +241,7 @@ const rootRouteChildren: RootRouteChildren = {
TelegramIndexRoute: TelegramIndexRoute,
DiscordCallbackIndexRoute: DiscordCallbackIndexRoute,
EmailCallbackIndexRoute: EmailCallbackIndexRoute,
+ ExportDisplayIndexRoute: ExportDisplayIndexRoute,
ExportReauthIndexRoute: ExportReauthIndexRoute,
GoogleCallbackIndexRoute: GoogleCallbackIndexRoute,
TelegramCallbackIndexRoute: TelegramCallbackIndexRoute,
diff --git a/embed/oko_attached/src/routes/export/display/index.tsx b/embed/oko_attached/src/routes/export/display/index.tsx
new file mode 100644
index 000000000..e8a9a76e5
--- /dev/null
+++ b/embed/oko_attached/src/routes/export/display/index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { ExportDisplay } from "@oko-wallet-attached/components/export/export_display";
+
+export const Route = createFileRoute("/export/display/")({
+ component: ExportDisplay,
+});
diff --git a/embed/oko_attached/src/window_msgs/export_key_store.ts b/embed/oko_attached/src/window_msgs/export_key_store.ts
new file mode 100644
index 000000000..164576295
--- /dev/null
+++ b/embed/oko_attached/src/window_msgs/export_key_store.ts
@@ -0,0 +1,76 @@
+export interface ExportedKeys {
+ secp256k1: string;
+ ed25519: string;
+}
+
+const CHANNEL_NAME = "__oko_export_keys__";
+const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
+
+let storedKeys: ExportedKeys | null = null;
+let cleanupTimer: ReturnType | null = null;
+
+function clearCleanupTimer(): void {
+ if (cleanupTimer !== null) {
+ clearTimeout(cleanupTimer);
+ cleanupTimer = null;
+ }
+}
+
+function startCleanupTimer(): void {
+ clearCleanupTimer();
+ cleanupTimer = setTimeout(() => {
+ storedKeys = null;
+ cleanupTimer = null;
+ }, CLEANUP_TIMEOUT_MS);
+}
+
+// Respond to key requests and clear signals from other same-origin contexts (visible iframe)
+const bc = new BroadcastChannel(CHANNEL_NAME);
+bc.onmessage = (event: MessageEvent) => {
+ if (event.data?.type === "request_keys" && storedKeys) {
+ bc.postMessage({ type: "keys", keys: storedKeys });
+ } else if (event.data?.type === "clear_keys") {
+ storedKeys = null;
+ clearCleanupTimer();
+ }
+};
+
+export function setExportedKeys(keys: ExportedKeys): void {
+ storedKeys = keys;
+ startCleanupTimer();
+}
+
+export function getExportedKeys(): ExportedKeys | null {
+ return storedKeys;
+}
+
+/**
+ * Request keys from another same-origin context via BroadcastChannel.
+ * Used by the visible iframe to fetch keys stored in the hidden iframe.
+ */
+export function requestExportedKeys(): Promise {
+ return new Promise((resolve) => {
+ const reqBc = new BroadcastChannel(CHANNEL_NAME);
+ const timeout = setTimeout(() => {
+ reqBc.close();
+ resolve(null);
+ }, 2000);
+
+ reqBc.onmessage = (event: MessageEvent) => {
+ if (event.data?.type === "keys") {
+ clearTimeout(timeout);
+ // Signal the hidden iframe to clear keys from memory
+ reqBc.postMessage({ type: "clear_keys" });
+ reqBc.close();
+ resolve(event.data.keys);
+ }
+ };
+
+ reqBc.postMessage({ type: "request_keys" });
+ });
+}
+
+export function clearExportedKeys(): void {
+ storedKeys = null;
+ clearCleanupTimer();
+}
diff --git a/embed/oko_attached/src/window_msgs/export_private_key.ts b/embed/oko_attached/src/window_msgs/export_private_key.ts
index 4582d5a3c..740b23820 100644
--- a/embed/oko_attached/src/window_msgs/export_private_key.ts
+++ b/embed/oko_attached/src/window_msgs/export_private_key.ts
@@ -11,6 +11,7 @@ import type {
} from "@oko-wallet/oko-types/user";
import bs58 from "bs58";
+import { setExportedKeys } from "./export_key_store";
import {
type ReAuthCredentials,
setReAuthResolver,
@@ -194,15 +195,13 @@ export async function handleExportPrivateKey(
keypairBytes.set(pubkeyBytes, seedBytes.length);
const ed25519Keypair = bs58.encode(keypairBytes);
- // 9. Return result
- console.log(`${LOG_PREFIX} export complete`);
- sendAck({
- success: true,
- data: {
- secp256k1: secp256k1PrivateKey,
- ed25519: ed25519Keypair,
- },
+ // 9. Store keys in module-level memory and signal success (no key data sent)
+ setExportedKeys({
+ secp256k1: secp256k1PrivateKey,
+ ed25519: ed25519Keypair,
});
+ console.log(`${LOG_PREFIX} export complete, keys stored`);
+ sendAck({ success: true });
} catch (err) {
console.error(`${LOG_PREFIX} unexpected error`, err);
sendAck({
diff --git a/embed/oko_attached/src/window_msgs/index.ts b/embed/oko_attached/src/window_msgs/index.ts
index beb762b9b..f3bf40622 100644
--- a/embed/oko_attached/src/window_msgs/index.ts
+++ b/embed/oko_attached/src/window_msgs/index.ts
@@ -3,7 +3,6 @@ import type {
OkoWalletMsgExportPrivateKey,
OkoWalletMsgGetConnectedApps,
} from "@oko-wallet/oko-sdk-core";
-import type { AuthType } from "@oko-wallet/oko-types/auth";
import { handleExportPrivateKey } from "./export_private_key";
import { handleGetAuthType } from "./get_auth_type";
@@ -37,6 +36,14 @@ export function makeMsgHandler() {
data?.target === "oko_attached" &&
data?.msg_type === "set_reauth_params"
) {
+ // set_reauth_params is sent from the re-auth popup (same attached origin)
+ if (event.origin !== window.location.origin) {
+ console.warn(
+ "[attached] set_reauth_params rejected from origin:",
+ event.origin,
+ );
+ return;
+ }
const appState = useAppState.getState();
const payload = data.payload as
| { nonce?: string; code_verifier?: string }
diff --git a/sdk/oko_sdk_core/src/types/msg/export_priv_key.ts b/sdk/oko_sdk_core/src/types/msg/export_priv_key.ts
index d0551ed19..f45482196 100644
--- a/sdk/oko_sdk_core/src/types/msg/export_priv_key.ts
+++ b/sdk/oko_sdk_core/src/types/msg/export_priv_key.ts
@@ -16,7 +16,7 @@ export type ExportPrivateKeyError =
| { type: "REAUTH_ERROR"; error: string };
export type ExportPrivateKeyAckPayload =
- | { success: true; data: { secp256k1: string; ed25519: string } }
+ | { success: true }
| { success: false; error: ExportPrivateKeyError };
export interface OkoWalletMsgExportPrivateKeyAck {
diff --git a/ui/oko_common_ui/src/icons/arbitrum_icon.tsx b/ui/oko_common_ui/src/icons/arbitrum_icon.tsx
new file mode 100644
index 000000000..f1080180e
--- /dev/null
+++ b/ui/oko_common_ui/src/icons/arbitrum_icon.tsx
@@ -0,0 +1,22 @@
+import { type FC } from "react";
+
+import { s3BucketURL } from "./paths";
+
+export const ArbitrumIcon: FC = ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface ArbitrumIconProps {
+ width?: number;
+ height?: number;
+}
diff --git a/ui/oko_common_ui/src/icons/base_icon.tsx b/ui/oko_common_ui/src/icons/base_icon.tsx
new file mode 100644
index 000000000..599988a23
--- /dev/null
+++ b/ui/oko_common_ui/src/icons/base_icon.tsx
@@ -0,0 +1,22 @@
+import { type FC } from "react";
+
+import { s3BucketURL } from "./paths";
+
+export const BaseIcon: FC = ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface BaseIconProps {
+ width?: number;
+ height?: number;
+}
diff --git a/ui/oko_common_ui/src/icons/ethereum_icon.tsx b/ui/oko_common_ui/src/icons/ethereum_icon.tsx
index 01b357297..21081ca08 100644
--- a/ui/oko_common_ui/src/icons/ethereum_icon.tsx
+++ b/ui/oko_common_ui/src/icons/ethereum_icon.tsx
@@ -8,7 +8,7 @@ export const EthereumIcon: FC = ({
}) => {
return (
= ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface InitiaIconProps {
+ width?: number;
+ height?: number;
+}
diff --git a/ui/oko_common_ui/src/icons/rialo_icon.tsx b/ui/oko_common_ui/src/icons/rialo_icon.tsx
new file mode 100644
index 000000000..b913f8974
--- /dev/null
+++ b/ui/oko_common_ui/src/icons/rialo_icon.tsx
@@ -0,0 +1,22 @@
+import { type FC } from "react";
+
+import { s3BucketURL } from "./paths";
+
+export const RialoIcon: FC = ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface RialoIconProps {
+ width?: number;
+ height?: number;
+}
diff --git a/ui/oko_common_ui/src/icons/solana_circle_icon.tsx b/ui/oko_common_ui/src/icons/solana_circle_icon.tsx
new file mode 100644
index 000000000..8d60af4d3
--- /dev/null
+++ b/ui/oko_common_ui/src/icons/solana_circle_icon.tsx
@@ -0,0 +1,22 @@
+import { type FC } from "react";
+
+import { s3BucketURL } from "./paths";
+
+export const SolanaCircleIcon: FC = ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface SolanaCircleIconProps {
+ width?: number;
+ height?: number;
+}
diff --git a/ui/oko_common_ui/src/icons/zigchain_icon.tsx b/ui/oko_common_ui/src/icons/zigchain_icon.tsx
new file mode 100644
index 000000000..e63a5f78b
--- /dev/null
+++ b/ui/oko_common_ui/src/icons/zigchain_icon.tsx
@@ -0,0 +1,22 @@
+import { type FC } from "react";
+
+import { s3BucketURL } from "./paths";
+
+export const ZigchainIcon: FC = ({
+ width = 16,
+ height = 16,
+}) => {
+ return (
+
+ );
+};
+
+export interface ZigchainIconProps {
+ width?: number;
+ height?: number;
+}