diff --git a/.changeset/true-actors-grow.md b/.changeset/true-actors-grow.md new file mode 100644 index 000000000..b6c43315e --- /dev/null +++ b/.changeset/true-actors-grow.md @@ -0,0 +1,5 @@ +--- +"@turnkey/iframe-stamper": minor +--- + +Add optional address parameter for methods intended to be used within the export-and-sign iframe. Also improves documentation (TypeDocs) diff --git a/examples/wallet-export-sign/README.md b/examples/wallet-export-sign/README.md index f357624f4..85de79b30 100644 --- a/examples/wallet-export-sign/README.md +++ b/examples/wallet-export-sign/README.md @@ -11,6 +11,16 @@ This example includes API stubs to make `whoami` requests and export wallets and

+### DevEx Summary + +1. Get iframe public key from export-and-sign iframe (located at export-and-sign.turnkey.com) via `iframeStamper.publicKey()` OR `await iframeStamper.getEmbeddedPublicKey()`. Generally, the latter is recommended. If it does not exist, you can initialize one via `await iframeStamper.initEmbeddedKey()`. +2. Use iframe public key in any `export` activity (e.g. `exportWalletAccount`). +3. Inject resulting bundle from (2) into the iframe via `await iframeStamper.injectKeyExportBundle()`. Note that the `address` param is optional, though recommended in case you would like multiple keys to live in the iframe at a time. + +- Note that this step wipes out the iframe's embedded key (as we only want to use it to decrypt one export bundle at most). This means that for subsequent `export` activities, you will need to re-initialize the embedded key via `await iframeStamper.initEmbeddedKey()`. + +4. Now, the iframe contains the key material you need to start performing signing. Note that this is stored strictly in-memory. Any page reload would wipe out this state, at which point we recommend starting from step (1). + ## Getting started ### 1/ Cloning the example diff --git a/examples/wallet-export-sign/src/components/Export.module.css b/examples/wallet-export-sign/src/components/Export.module.css new file mode 100644 index 000000000..4b9c19602 --- /dev/null +++ b/examples/wallet-export-sign/src/components/Export.module.css @@ -0,0 +1,269 @@ +.container { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 28px; + width: 100%; + max-width: 100%; + font-family: + Inter, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; + padding: 0; + box-sizing: border-box; +} + +.card { + padding: 0; + border-radius: 12px; + border: 1px solid #e5e7eb; + background: #ffffff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.card:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06); + border-color: #d1d5db; +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + background: linear-gradient(to bottom, #fafbfc, #f8f9fa); + border-bottom: 1px solid #e5e7eb; + gap: 16px; +} + +.cardTitle { + font-size: 15px; + font-weight: 600; + color: #111827; + margin: 0; + letter-spacing: -0.01em; +} + +.cardSubtitle { + font-size: 12px; + color: #6b7280; + font-weight: 500; + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.cardBody { + padding: 20px; +} + +.textarea { + width: 100%; + min-height: 110px; + padding: 14px 16px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 14px; + line-height: 1.6; + border: 1px solid #d1d5db; + border-radius: 8px; + resize: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + background: #fafafa; + box-sizing: border-box; +} + +.textarea:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); + background: #ffffff; +} + +.textarea::placeholder { + color: #9ca3af; +} + +.buttonGroup { + display: flex; + gap: 12px; + margin-top: 16px; + flex-wrap: wrap; +} + +.button { + padding: 11px 20px; + font-size: 14px; + font-weight: 500; + border-radius: 8px; + border: 1px solid #d1d5db; + background: #ffffff; + color: #374151; + cursor: pointer; + transition: all 0.15s ease; + font-family: Inter, system-ui, sans-serif; + letter-spacing: -0.01em; +} + +.button:hover:not(:disabled) { + background: #f9fafb; + border-color: #9ca3af; +} + +.button:active:not(:disabled) { + transform: translateY(1px); +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.buttonPrimary { + background: #050a0b; + color: #ffffff; + border-color: #050a0b; +} + +.buttonPrimary:hover:not(:disabled) { + background: #1a1f23; + border-color: #1a1f23; +} + +.outputSection { + margin-top: 24px; + padding: 20px; + background: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.outputLabel { + font-size: 12px; + font-weight: 600; + color: #6b7280; + margin-bottom: 12px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.monoBox { + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 12px; + padding: 14px 16px; + border-radius: 6px; + background: #ffffff; + border: 1px solid #e5e7eb; + word-break: break-all; + white-space: pre-wrap; + color: #1f2937; + line-height: 1.7; + max-height: 140px; + overflow-y: auto; + box-sizing: border-box; +} + +.monoBox::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.monoBox::-webkit-scrollbar-track { + background: #f1f3f5; + border-radius: 4px; +} + +.monoBox::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 4px; +} + +.monoBox::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +.copyButton { + padding: 8px 14px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + border: 1px solid #d1d5db; + background: #ffffff; + color: #374151; + cursor: pointer; + transition: all 0.15s ease; + margin-top: 12px; + letter-spacing: -0.01em; +} + +.copyButton:hover { + background: #f9fafb; + border-color: #9ca3af; +} + +.copyButton:active { + transform: translateY(1px); +} + +.iframeContainer { + margin-bottom: 20px; +} + +@media (min-width: 640px) { + .cardBody { + padding: 24px; + } + + .textarea { + min-height: 120px; + } +} + +@media (min-width: 768px) { + .cardHeader { + padding: 24px 28px; + } + + .cardBody { + padding: 28px; + } + + .cardTitle { + font-size: 16px; + } + + .textarea { + font-size: 14px; + padding: 16px 18px; + min-height: 130px; + } + + .container { + gap: 32px; + } +} + +@media (min-width: 1024px) { + .cardHeader { + padding: 24px 32px; + } + + .cardBody { + padding: 32px; + } +} diff --git a/examples/wallet-export-sign/src/components/Export.tsx b/examples/wallet-export-sign/src/components/Export.tsx index 0e865b8ba..e7fbb0c7e 100644 --- a/examples/wallet-export-sign/src/components/Export.tsx +++ b/examples/wallet-export-sign/src/components/Export.tsx @@ -6,6 +6,7 @@ import { MessageType, } from "@turnkey/iframe-stamper"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import styles from "./Export.module.css"; interface ExportProps { iframeUrl: string; @@ -13,50 +14,9 @@ interface ExportProps { iframeDisplay: string; setIframeStamper: Dispatch>; showSigning?: boolean; // Only show signing UI for wallet accounts, not wallets + walletAccountAddress?: string; // Address of the wallet account being exported } -const containerStyles: React.CSSProperties = { - marginTop: 16, - display: "flex", - flexDirection: "column", - gap: 16, - width: "100%", - maxWidth: "100%", - fontFamily: - "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial", - maxHeight: "min(70vh, 600px)", - overflowY: "auto", - overflowX: "hidden", - padding: "0", - boxSizing: "border-box", -}; - -const cardStyles: React.CSSProperties = { - padding: 16, - borderRadius: 12, - border: "1px solid rgba(216,219,227,1)", - background: "#ffffff", - boxShadow: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)", - transition: "box-shadow 0.2s ease", - width: "100%", - boxSizing: "border-box", - overflowX: "hidden", -}; - -const monoBox: React.CSSProperties = { - fontFamily: "monospace", - fontSize: 13, - padding: 12, - borderRadius: 8, - background: "#f8f9fa", - border: "1px solid #e0e3e7", - wordBreak: "break-word", - whiteSpace: "pre-wrap", - color: "#2b2f33", - maxHeight: "200px", - overflowY: "auto", -}; - const iframeCss = ` iframe { box-sizing: border-box; @@ -66,16 +26,16 @@ const iframeCss = ` border-radius: 12px; border-width: 1px; border-style: solid; - border-color: rgba(216, 219, 227, 1); - padding: 16px; + border-color: #e5e7eb; + padding: 20px; background: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); - margin-bottom: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + margin-bottom: 0; } @media (min-width: 768px) { iframe { - padding: 20px; + padding: 24px; } } `; @@ -97,7 +57,7 @@ export function Export(props: ExportProps) { const [txSerialized, setTxSerialized] = useState(""); const [txSigned, setTxSigned] = useState(""); - const [initializing, setInitializing] = useState(false); + const [_initializing, setInitializing] = useState(false); useEffect(() => { setIframeDisplay(props.iframeDisplay); @@ -146,32 +106,30 @@ export function Export(props: ExportProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.setIframeStamper, iframeStamper]); - // For the sake of demonstration, only allow signing one message at a time (embedded private key will be cleared after signature) - const signMessage = () => { + const signMessage = async () => { if (iframeStamper === null) { alert("Iframe not ready — reveal private key first."); return; } - iframeStamper - .signMessage({ message, type: MessageType.Solana }) - .then((sig: string) => { - setSignature(sig); - }) - .catch((error: Error) => { - console.error("Error signing message:", error); - alert("Error signing message: " + error.message); - }); - - // now clear the embedded private key from iframe's memory - iframeStamper.clearEmbeddedPrivateKey().catch((error: Error) => { - console.error("Error clearing embedded private key:", error); - alert("Error clearing embedded private key: " + error.message); - }); + // At this point, we're relying on having the decrypted (Solana) private key in-memory within the iframe for signing + // Note that the embedded key has been wiped out at this point as it was used once to initially decrypt. + try { + const signedMessage = await iframeStamper.signMessage( + { + message, + type: MessageType.Solana, + }, + props.walletAccountAddress, + ); + setSignature(signedMessage); + } catch (error: any) { + console.error("Error signing message:", error); + alert("Error signing message: " + error.message); + } }; - // For the sake of demonstration, only allow signing one message at a time (embedded private key will be cleared after signature) - const signTransaction = () => { + const signTransaction = async () => { if (iframeStamper === null) { alert("Iframe not ready — reveal private key first."); return; @@ -182,24 +140,22 @@ export function Export(props: ExportProps) { return; } - iframeStamper - .signTransaction({ - transaction: txSerialized, - type: TransactionType.Solana, - }) - .then((signed: string) => { - setTxSigned(signed); - }) - .catch((error: Error) => { - console.error("Error signing transaction:", error); - alert("Error signing transaction: " + error.message); - }); - - // now clear the embedded private key from iframe's memory - iframeStamper.clearEmbeddedPrivateKey().catch((error: Error) => { - console.error("Error clearing embedded private key:", error); - alert("Error clearing embedded private key: " + error.message); - }); + // At this point, we're relying on having the decrypted (Solana) private key in-memory within the iframe for signing + // Note that the embedded key has been wiped out at this point as it was used once to initially decrypt. + try { + const signedTransaction = await iframeStamper.signTransaction( + { + transaction: txSerialized, + type: TransactionType.Solana, + }, + props.walletAccountAddress, + ); + + setTxSigned(signedTransaction); + } catch (error: any) { + console.error("Error signing transaction:", error); + alert("Error signing transaction: " + error.message); + } }; const copyToClipboard = async (text: string) => { @@ -216,145 +172,116 @@ export function Export(props: ExportProps) { return (
{/* keep iframe mounted but visually controlled by parent */} -
+
{iframeDisplay === "block" && props.showSigning ? ( -
+
{/* Message signing */} -
-
- Sign arbitrary message - ed25519 signature +
+
+

Sign arbitrary message

+ ed25519
-