diff --git a/examples/wallet-export/.env.local.example b/examples/wallet-export/.env.local.example new file mode 100644 index 000000000..9a0f481d2 --- /dev/null +++ b/examples/wallet-export/.env.local.example @@ -0,0 +1,7 @@ +API_PUBLIC_KEY="" +API_PRIVATE_KEY="" +NEXT_PUBLIC_ORGANIZATION_ID="" +NEXT_PUBLIC_BASE_URL="https://api.turnkey.com" +# Can be changed to a localhost iframe if you're modifying the export flow +# For production, the URL should not be changed and point to the primary Turnkey domain. +NEXT_PUBLIC_EXPORT_IFRAME_URL="https://export.turnkey.com" \ No newline at end of file diff --git a/examples/wallet-export/.eslintrc.json b/examples/wallet-export/.eslintrc.json new file mode 100644 index 000000000..bffb357a7 --- /dev/null +++ b/examples/wallet-export/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/examples/wallet-export/.gitignore b/examples/wallet-export/.gitignore new file mode 100644 index 000000000..8f322f0d8 --- /dev/null +++ b/examples/wallet-export/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/wallet-export/README.md b/examples/wallet-export/README.md new file mode 100644 index 000000000..2c5902009 --- /dev/null +++ b/examples/wallet-export/README.md @@ -0,0 +1,52 @@ +# Example: `wallet-export` + +This example shows a wallet export flow. It contains a NextJS app with: + +- a frontend application +- a backend application + +This example includes API stubs to get your wallets and export your wallet as a mnemonic. The creation of the iframe is abstracted by our `@turnkey/iframe-stamper` package. + +## Getting started + +### 1/ Cloning the example + +Make sure you have `node` installed locally; we recommend using Node v16+. + +```bash +$ git clone https://github.com/tkhq/sdk +$ cd sdk/ +$ corepack enable # Install `pnpm` +$ pnpm install -r # Install dependencies +$ pnpm run build-all # Compile source code +$ cd examples/wallet-export/ +``` + +### 2/ Setting up Turnkey + +The first step is to set up your Turnkey organization and account. By following the [Quickstart](https://docs.turnkey.com/getting-started/quickstart) guide, you should have: + +- A public/private API key pair for Turnkey +- An organization ID + +Once you've gathered these values, add them to a new `.env.local` file. Notice that your API private key should be securely managed and **_never_** be committed to git. + +```bash +$ cp .env.local.example .env.local +``` + +Now open `.env.local` and add the missing environment variables: + +- `API_PUBLIC_KEY` +- `API_PRIVATE_KEY` +- `NEXT_PUBLIC_ORGANIZATION_ID` +- `NEXT_PUBLIC_BASE_URL` (the `NEXT_PUBLIC` prefix makes the env variable accessible to the frontend app) +- `NEXT_PUBLIC_EXPORT_IFRAME_URL` + +### 3/ Running the app + +```bash +$ pnpm run dev +``` + +This command will run a NextJS app on port 3000. If you navigate to http://localhost:3000 in your browser, you can follow the prompts to export a private key. diff --git a/examples/wallet-export/next.config.js b/examples/wallet-export/next.config.js new file mode 100644 index 000000000..658404ac6 --- /dev/null +++ b/examples/wallet-export/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = nextConfig; diff --git a/examples/wallet-export/package.json b/examples/wallet-export/package.json new file mode 100644 index 000000000..fddc6e51e --- /dev/null +++ b/examples/wallet-export/package.json @@ -0,0 +1,33 @@ +{ + "name": "@turnkey/example-wallet-export", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@turnkey/api-key-stamper": "workspace:*", + "@turnkey/http": "workspace:*", + "@turnkey/iframe-stamper": "workspace:*", + "@types/node": "20.3.1", + "@types/react": "18.2.14", + "@types/react-dom": "18.2.6", + "axios": "^1.4.0", + "classnames": "^2.3.2", + "encoding": "^0.1.13", + "eslint": "8.43.0", + "eslint-config-next": "13.4.7", + "esm": "^3.2.25", + "install": "^0.13.0", + "next": "13.4.7", + "npm": "^9.7.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.45.1", + "typescript": "5.1.3" + } +} diff --git a/examples/wallet-export/public/export.svg b/examples/wallet-export/public/export.svg new file mode 100644 index 000000000..4a9bd74c3 --- /dev/null +++ b/examples/wallet-export/public/export.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/wallet-export/public/favicon.svg b/examples/wallet-export/public/favicon.svg new file mode 100644 index 000000000..efedf634c --- /dev/null +++ b/examples/wallet-export/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/examples/wallet-export/public/fonts/inter/Inter-Bold.woff2 b/examples/wallet-export/public/fonts/inter/Inter-Bold.woff2 new file mode 100644 index 000000000..2846f29cc Binary files /dev/null and b/examples/wallet-export/public/fonts/inter/Inter-Bold.woff2 differ diff --git a/examples/wallet-export/public/fonts/inter/Inter-Regular.woff2 b/examples/wallet-export/public/fonts/inter/Inter-Regular.woff2 new file mode 100644 index 000000000..6c2b6893d Binary files /dev/null and b/examples/wallet-export/public/fonts/inter/Inter-Regular.woff2 differ diff --git a/examples/wallet-export/public/fonts/inter/Inter-SemiBold.woff2 b/examples/wallet-export/public/fonts/inter/Inter-SemiBold.woff2 new file mode 100644 index 000000000..611e90c95 Binary files /dev/null and b/examples/wallet-export/public/fonts/inter/Inter-SemiBold.woff2 differ diff --git a/examples/wallet-export/public/logo.svg b/examples/wallet-export/public/logo.svg new file mode 100644 index 000000000..f983fd29b --- /dev/null +++ b/examples/wallet-export/public/logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/examples/wallet-export/src/components/WalletsTable.tsx b/examples/wallet-export/src/components/WalletsTable.tsx new file mode 100644 index 000000000..74536defa --- /dev/null +++ b/examples/wallet-export/src/components/WalletsTable.tsx @@ -0,0 +1,71 @@ +import Image from "next/image"; +import styles from "../pages/index.module.css"; +import { TurnkeyApiTypes } from "@turnkey/http"; +import * as React from "react"; +import { Dispatch, SetStateAction } from "react"; +import cx from "classnames"; + +type TWallet = TurnkeyApiTypes["v1Wallet"]; + +type WalletsTableProps = { + wallets: TWallet[]; + setSelectedWallet: Dispatch>; +}; + +export function WalletsTable(props: WalletsTableProps) { + return ( +
+ + + + + + + + + + {props.wallets.length > 0 ? ( + props.wallets.map((val, key) => { + return ( + + + + + + ); + }) + ) : ( + + + + )} + +
+ Wallet name + + Wallet ID +
+ + +

{val.walletName}

+
+

{val.walletId}

+
+

You have not created any wallets.

+
+
+ ); +} diff --git a/examples/wallet-export/src/pages/_document.tsx b/examples/wallet-export/src/pages/_document.tsx new file mode 100644 index 000000000..52d1c89a1 --- /dev/null +++ b/examples/wallet-export/src/pages/_document.tsx @@ -0,0 +1,19 @@ +import Document, { Html, Head, Main, NextScript } from "next/document"; + +class Example extends Document { + render() { + return ( + + + + + +
+ + + + ); + } +} + +export default Example; diff --git a/examples/wallet-export/src/pages/api/exportWallet.ts b/examples/wallet-export/src/pages/api/exportWallet.ts new file mode 100644 index 000000000..db70673be --- /dev/null +++ b/examples/wallet-export/src/pages/api/exportWallet.ts @@ -0,0 +1,70 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { TurnkeyClient, createActivityPoller } from "@turnkey/http"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; + +type ExportWalletRequest = { + walletId: string; + targetPublicKey: string; +}; + +type ExportWalletResponse = { + walletId: string; + exportBundle: string; +}; + +type ErrorMessage = { + message: string; +}; + +export default async function exportWallet( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const request = req.body as ExportWalletRequest; + const turnkeyClient = new TurnkeyClient( + { baseUrl: process.env.NEXT_PUBLIC_BASE_URL! }, + new ApiKeyStamper({ + apiPublicKey: process.env.API_PUBLIC_KEY!, + apiPrivateKey: process.env.API_PRIVATE_KEY!, + }) + ); + + const activityPoller = createActivityPoller({ + client: turnkeyClient, + requestFn: turnkeyClient.exportWallet, + }); + + const completedActivity = await activityPoller({ + type: "ACTIVITY_TYPE_EXPORT_WALLET", + timestampMs: String(Date.now()), + organizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!, + parameters: { + walletId: request.walletId, + targetPublicKey: request.targetPublicKey, + }, + }); + + const walletId = completedActivity.result.exportWalletResult?.walletId; + if (!walletId) { + throw new Error("Expected a non-null wallet ID!"); + } + + const exportBundle = + completedActivity.result.exportWalletResult?.exportBundle; + if (!exportBundle) { + throw new Error("Expected a non-null export bundle!"); + } + + res.status(200).json({ + walletId, + exportBundle, + }); + } catch (e) { + console.error(e); + + res.status(500).json({ + message: "Something went wrong.", + }); + } +} diff --git a/examples/wallet-export/src/pages/api/getWallets.ts b/examples/wallet-export/src/pages/api/getWallets.ts new file mode 100644 index 000000000..97bfae11d --- /dev/null +++ b/examples/wallet-export/src/pages/api/getWallets.ts @@ -0,0 +1,52 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { TurnkeyApiTypes, TurnkeyClient } from "@turnkey/http"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; + +type TWallet = TurnkeyApiTypes["v1Wallet"]; + +type GetWalletsRequest = { + organizationId: string; +}; + +type GetWalletsResponse = { + wallets: TWallet[]; +}; + +type ErrorMessage = { + message: string; +}; + +export default async function getWallets( + req: NextApiRequest, + res: NextApiResponse +) { + const getWalletsRequest = req.body as GetWalletsRequest; + + const turnkeyClient = new TurnkeyClient( + { baseUrl: process.env.NEXT_PUBLIC_BASE_URL! }, + new ApiKeyStamper({ + apiPublicKey: process.env.API_PUBLIC_KEY!, + apiPrivateKey: process.env.API_PRIVATE_KEY!, + }) + ); + + const organizationId = getWalletsRequest.organizationId; + + try { + const walletsResponse = await turnkeyClient.getWallets({ + organizationId, + }); + + res.status(200).json({ + wallets: walletsResponse.wallets, + }); + } catch (e) { + console.error(e); + + res.status(500).json({ + message: "Something went wrong.", + }); + } + + res.json; +} diff --git a/examples/wallet-export/src/pages/index.module.css b/examples/wallet-export/src/pages/index.module.css new file mode 100644 index 000000000..38925db50 --- /dev/null +++ b/examples/wallet-export/src/pages/index.module.css @@ -0,0 +1,169 @@ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../../public/fonts/inter/Inter-Regular.woff2?v=3.19") + format("woff2"); +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../../public/fonts/inter/Inter-SemiBold.woff2?v=3.19") + format("woff2"); +} + +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; +} + +.table { + border-collapse: collapse; + width: 100%; + font-family: "Inter"; + margin-top: 60px; +} + +.tableHeader { + background-color: #f9fbff; + border: 1px solid #ebedf2; + margin: 0; +} + +.tableRow { + border: 1px solid #ebedf2; + border-top: none; +} + +.tableRow:hover { + background-color: #f9fbff; +} + +.tableRowClickable { + cursor: pointer; +} + +.tableHeaderCell { + padding: 14px; + font-weight: 500; + font-size: 0.875rem; + line-height: 1.25rem; + color: #6c727e; + text-align: left; + align-items: center; +} + +.cell { + padding: 14px; +} + +.cell p { + color: #555b64; + font-weight: 400; + font-size: 0.875rem; + line-height: 1.25rem; + margin: 0; + padding: 0; + display: block; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; +} + +.noWallets { + width: 100%; + text-align: center; +} + +.exportCol { + width: 55px; +} +.walletNameCol { + width: 150px; +} + +.walletIdCol { + width: 350px; +} + +.addressCell, +.idCell { + display: flex; + gap: 12px; + justify-content: baseline; + font-family: "iA Writer Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, + Consolas, "Liberation Mono", monospace; +} + +.exportButton { + display: flex; + align-items: center; + justify-content: center; + background-color: #f1f3f6; + border: none; + border-radius: 50%; + width: 25px; + height: 25px; + cursor: pointer; + outline: none; + transition: background-color 0.2s; +} + +.exportButton:hover { + background-color: #ebedf2; +} + +.reveal { + display: flex; + justify-content: center; + align-items: center; +} +.revealButton { + background-color: #050a0b; + border: none; + border-radius: 8px; + width: 90px; + height: 40px; + cursor: pointer; + outline: none; + font-family: "Inter"; + border: 1px solid #050a0b; + margin-bottom: 20px; + color: #fff; +} + +.copyKey { + display: flex; + flex-direction: column; + align-items: center; +} + +.copyKey h2 { + text-align: center; + font-family: "Inter"; + padding-top: 40px; +} + +.copyKey ul { + margin-top: 0; +} + +.copyKey p { + font-family: "Inter"; + font-size: 14px; + color: #555b64; +} + +.walletIframe iframe { + border: none; + width: 600px; + height: 600px; +} diff --git a/examples/wallet-export/src/pages/index.tsx b/examples/wallet-export/src/pages/index.tsx new file mode 100644 index 000000000..5de98ec82 --- /dev/null +++ b/examples/wallet-export/src/pages/index.tsx @@ -0,0 +1,136 @@ +import Image from "next/image"; +import styles from "./index.module.css"; +import { TurnkeyApiTypes } from "@turnkey/http"; +import { IframeStamper } from "@turnkey/iframe-stamper"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import axios from "axios"; +import { WalletsTable } from "@/components/WalletsTable"; + +type TWallet = TurnkeyApiTypes["v1Wallet"]; + +const TurnkeyIframeContainerId = "turnkey-iframe-container-id"; +const TurnkeyIframeElementId = "turnkey-iframe-element-id"; + +export default function ExportPage() { + const [wallets, setWallets] = useState([]); + const [iframeStamper, setIframeStamper] = useState( + null + ); + const [showWallet, setShowWallet] = useState(false); + const [selectedWallet, setSelectedWallet] = useState(null); + + // Initialize the iframeStamper + useEffect(() => { + if (!iframeStamper) { + const iframeStamper = new IframeStamper({ + iframeUrl: process.env.NEXT_PUBLIC_EXPORT_IFRAME_URL!, + iframeContainerId: TurnkeyIframeContainerId, + iframeElementId: TurnkeyIframeElementId, + }); + iframeStamper.init().then(() => { + setIframeStamper(iframeStamper); + }); + } + + return () => { + if (iframeStamper) { + iframeStamper.clear(); + setIframeStamper(null); + } + }; + }, [iframeStamper]); + + // Get wallets + useEffect(() => { + getWallets(); + }, []); + + // Get the organization's wallets + const getWallets = async () => { + const organizationId = process.env.NEXT_PUBLIC_ORGANIZATION_ID!; + const res = await axios.post("/api/getWallets", { organizationId }); + + setWallets(res.data.wallets); + }; + + // Export the selected wallet and set the iframe to be visible + const exportWallet = async (walletId: string) => { + if (iframeStamper === null) { + throw new Error("cannot export wallet without an iframe"); + } + + const response = await axios.post("/api/exportWallet", { + walletId: walletId, + targetPublicKey: iframeStamper.publicKey(), + }); + + let injected = await iframeStamper.injectWalletExportBundle( + response.data["exportBundle"] + ); + if (injected !== true) { + throw new Error("unexpected error while injecting export bundle"); + } + + setShowWallet(true); + }; + + return ( +
+ + Turnkey Logo + + + {!iframeStamper &&

Loading...

} + {iframeStamper && iframeStamper.publicKey() && ( + + )} + {selectedWallet && ( +
+

Wallet seedphrase

+

+ You are about to reveal your wallet seedphrase. By revealing this + seedphrase you understand that: +

+
    +
  • +

    Your seedphrase is the only way to recover your funds.

    +
  • +
  • +

    Do not let anyone see your seedphrase.

    +
  • +
  • +

    Never share your seedphrase with anyone, including Turnkey.

    +
  • +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/examples/wallet-export/tsconfig.json b/examples/wallet-export/tsconfig.json new file mode 100644 index 000000000..0c7555fa7 --- /dev/null +++ b/examples/wallet-export/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.client.ts b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.client.ts index ccc325355..7a6bc999b 100644 --- a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.client.ts +++ b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.client.ts @@ -30,6 +30,7 @@ import type { TGetPrivateKeyResponse, } from "./public_api.fetcher"; import type { TGetUserBody, TGetUserResponse } from "./public_api.fetcher"; +import type { TGetWalletBody, TGetWalletResponse } from "./public_api.fetcher"; import type { TGetActivitiesBody, TGetActivitiesResponse, @@ -43,6 +44,14 @@ import type { TGetPrivateKeysResponse, } from "./public_api.fetcher"; import type { TGetUsersBody, TGetUsersResponse } from "./public_api.fetcher"; +import type { + TGetWalletAccountsBody, + TGetWalletAccountsResponse, +} from "./public_api.fetcher"; +import type { + TGetWalletsBody, + TGetWalletsResponse, +} from "./public_api.fetcher"; import type { TGetWhoamiBody, TGetWhoamiResponse } from "./public_api.fetcher"; import type { TApproveActivityBody, @@ -426,6 +435,33 @@ export class TurnkeyClient { }; }; + /** + * Get details about a Wallet + * + * Sign the provided `TGetWalletBody` with the client's `stamp` function, and submit the request (POST /public/v1/query/get_wallet). + * + * See also {@link stampGetWallet}. + */ + getWallet = async (input: TGetWalletBody): Promise => { + return this.request("/public/v1/query/get_wallet", input); + }; + + /** + * Produce a `SignedRequest` from `TGetWalletBody` by using the client's `stamp` function. + * + * See also {@link GetWallet}. + */ + stampGetWallet = async (input: TGetWalletBody): Promise => { + const fullUrl = this.config.baseUrl + "/public/v1/query/get_wallet"; + const body = JSON.stringify(input); + const stamp = await this.stamper.stamp(body); + return { + body: body, + stamp: stamp, + url: fullUrl, + }; + }; + /** * List all Activities within an Organization * @@ -546,6 +582,65 @@ export class TurnkeyClient { }; }; + /** + * List all Accounts wirhin a Wallet + * + * Sign the provided `TGetWalletAccountsBody` with the client's `stamp` function, and submit the request (POST /public/v1/query/list_wallet_accounts). + * + * See also {@link stampGetWalletAccounts}. + */ + getWalletAccounts = async ( + input: TGetWalletAccountsBody + ): Promise => { + return this.request("/public/v1/query/list_wallet_accounts", input); + }; + + /** + * Produce a `SignedRequest` from `TGetWalletAccountsBody` by using the client's `stamp` function. + * + * See also {@link GetWalletAccounts}. + */ + stampGetWalletAccounts = async ( + input: TGetWalletAccountsBody + ): Promise => { + const fullUrl = + this.config.baseUrl + "/public/v1/query/list_wallet_accounts"; + const body = JSON.stringify(input); + const stamp = await this.stamper.stamp(body); + return { + body: body, + stamp: stamp, + url: fullUrl, + }; + }; + + /** + * List all Wallets within an Organization + * + * Sign the provided `TGetWalletsBody` with the client's `stamp` function, and submit the request (POST /public/v1/query/list_wallets). + * + * See also {@link stampGetWallets}. + */ + getWallets = async (input: TGetWalletsBody): Promise => { + return this.request("/public/v1/query/list_wallets", input); + }; + + /** + * Produce a `SignedRequest` from `TGetWalletsBody` by using the client's `stamp` function. + * + * See also {@link GetWallets}. + */ + stampGetWallets = async (input: TGetWalletsBody): Promise => { + const fullUrl = this.config.baseUrl + "/public/v1/query/list_wallets"; + const body = JSON.stringify(input); + const stamp = await this.stamper.stamp(body); + return { + body: body, + stamp: stamp, + url: fullUrl, + }; + }; + /** * Get basic information about your current API or WebAuthN user and their organization. Affords Sub-Organization look ups via Parent Organization for WebAuthN users. * diff --git a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.fetcher.ts b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.fetcher.ts index 2b56da8b3..d98ccdb9b 100644 --- a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.fetcher.ts +++ b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.fetcher.ts @@ -341,6 +341,52 @@ export const signGetUser = ( options, }); +/** + * `POST /public/v1/query/get_wallet` + */ +export type TGetWalletResponse = + operations["PublicApiService_GetWallet"]["responses"]["200"]["schema"]; + +/** + * `POST /public/v1/query/get_wallet` + */ +export type TGetWalletInput = { body: TGetWalletBody }; + +/** + * `POST /public/v1/query/get_wallet` + */ +export type TGetWalletBody = + operations["PublicApiService_GetWallet"]["parameters"]["body"]["body"]; + +/** + * Get Wallet + * + * Get details about a Wallet + * + * `POST /public/v1/query/get_wallet` + */ +export const getWallet = (input: TGetWalletInput) => + request({ + uri: "/public/v1/query/get_wallet", + method: "POST", + body: input.body, + }); + +/** + * Request a WebAuthn assertion and return a signed `GetWallet` request, ready to be POSTed to Turnkey. + * + * See {@link GetWallet} + */ +export const signGetWallet = ( + input: TGetWalletInput, + options?: TurnkeyCredentialRequestOptions +) => + signedRequest({ + uri: "/public/v1/query/get_wallet", + body: input.body, + options, + }); + /** * `POST /public/v1/query/list_activities` */ @@ -525,6 +571,104 @@ export const signGetUsers = ( options, }); +/** + * `POST /public/v1/query/list_wallet_accounts` + */ +export type TGetWalletAccountsResponse = + operations["PublicApiService_GetWalletAccounts"]["responses"]["200"]["schema"]; + +/** + * `POST /public/v1/query/list_wallet_accounts` + */ +export type TGetWalletAccountsInput = { body: TGetWalletAccountsBody }; + +/** + * `POST /public/v1/query/list_wallet_accounts` + */ +export type TGetWalletAccountsBody = + operations["PublicApiService_GetWalletAccounts"]["parameters"]["body"]["body"]; + +/** + * List Wallets Accounts + * + * List all Accounts wirhin a Wallet + * + * `POST /public/v1/query/list_wallet_accounts` + */ +export const getWalletAccounts = (input: TGetWalletAccountsInput) => + request< + TGetWalletAccountsResponse, + TGetWalletAccountsBody, + never, + never, + never + >({ + uri: "/public/v1/query/list_wallet_accounts", + method: "POST", + body: input.body, + }); + +/** + * Request a WebAuthn assertion and return a signed `GetWalletAccounts` request, ready to be POSTed to Turnkey. + * + * See {@link GetWalletAccounts} + */ +export const signGetWalletAccounts = ( + input: TGetWalletAccountsInput, + options?: TurnkeyCredentialRequestOptions +) => + signedRequest({ + uri: "/public/v1/query/list_wallet_accounts", + body: input.body, + options, + }); + +/** + * `POST /public/v1/query/list_wallets` + */ +export type TGetWalletsResponse = + operations["PublicApiService_GetWallets"]["responses"]["200"]["schema"]; + +/** + * `POST /public/v1/query/list_wallets` + */ +export type TGetWalletsInput = { body: TGetWalletsBody }; + +/** + * `POST /public/v1/query/list_wallets` + */ +export type TGetWalletsBody = + operations["PublicApiService_GetWallets"]["parameters"]["body"]["body"]; + +/** + * List Wallets + * + * List all Wallets within an Organization + * + * `POST /public/v1/query/list_wallets` + */ +export const getWallets = (input: TGetWalletsInput) => + request({ + uri: "/public/v1/query/list_wallets", + method: "POST", + body: input.body, + }); + +/** + * Request a WebAuthn assertion and return a signed `GetWallets` request, ready to be POSTed to Turnkey. + * + * See {@link GetWallets} + */ +export const signGetWallets = ( + input: TGetWalletsInput, + options?: TurnkeyCredentialRequestOptions +) => + signedRequest({ + uri: "/public/v1/query/list_wallets", + body: input.body, + options, + }); + /** * `POST /public/v1/query/whoami` */ diff --git a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.swagger.json b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.swagger.json index f62f3d673..5c7ba6f43 100644 --- a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.swagger.json +++ b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.swagger.json @@ -292,6 +292,38 @@ "tags": ["Users"] } }, + "/public/v1/query/get_wallet": { + "post": { + "summary": "Get Wallet", + "description": "Get details about a Wallet", + "operationId": "PublicApiService_GetWallet", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetWalletResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1GetWalletRequest" + } + } + ], + "tags": ["Wallets"] + } + }, "/public/v1/query/list_activities": { "post": { "summary": "List Activities", @@ -420,6 +452,70 @@ "tags": ["Users"] } }, + "/public/v1/query/list_wallet_accounts": { + "post": { + "summary": "List Wallets Accounts", + "description": "List all Accounts wirhin a Wallet", + "operationId": "PublicApiService_GetWalletAccounts", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetWalletAccountsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1GetWalletAccountsRequest" + } + } + ], + "tags": ["Wallets"] + } + }, + "/public/v1/query/list_wallets": { + "post": { + "summary": "List Wallets", + "description": "List all Wallets within an Organization", + "operationId": "PublicApiService_GetWallets", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetWalletsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1GetWalletsRequest" + } + } + ], + "tags": ["Wallets"] + } + }, "/public/v1/query/whoami": { "post": { "summary": "Who am I?", @@ -3866,6 +3962,82 @@ }, "required": ["users"] }, + "v1GetWalletAccountsRequest": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Unique identifier for a given Organization." + }, + "walletId": { + "type": "string", + "description": "Unique identifier for a given Wallet." + } + }, + "required": ["organizationId", "walletId"] + }, + "v1GetWalletAccountsResponse": { + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1WalletAccount" + }, + "description": "A list of Accounts generated from a Wallet that share a common seed" + } + }, + "required": ["accounts"] + }, + "v1GetWalletRequest": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Unique identifier for a given Organization." + }, + "walletId": { + "type": "string", + "description": "Unique identifier for a given Wallet." + } + }, + "required": ["organizationId", "walletId"] + }, + "v1GetWalletResponse": { + "type": "object", + "properties": { + "wallet": { + "$ref": "#/definitions/v1Wallet", + "description": "A collection of deterministically generated cryptographic public / private key pairs that share a common seed" + } + }, + "required": ["wallet"] + }, + "v1GetWalletsRequest": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Unique identifier for a given Organization." + } + }, + "required": ["organizationId"] + }, + "v1GetWalletsResponse": { + "type": "object", + "properties": { + "wallets": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1Wallet" + }, + "description": "A list of Wallets." + } + }, + "required": ["wallets"] + }, "v1GetWhoamiRequest": { "type": "object", "properties": { @@ -5666,6 +5838,56 @@ "exported" ] }, + "v1WalletAccount": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "The Organization the Account belongs to." + }, + "walletId": { + "type": "string", + "description": "The Wallet the Account was derived from." + }, + "curve": { + "$ref": "#/definitions/immutablecommonv1Curve", + "description": "Cryptographic curve used to generate the Account." + }, + "pathFormat": { + "$ref": "#/definitions/v1PathFormat", + "description": "Path format used to generate the Account." + }, + "path": { + "type": "string", + "description": "Path used to generate the Account." + }, + "addressFormat": { + "$ref": "#/definitions/immutablecommonv1AddressFormat", + "description": "Address format used to generate the Acccount." + }, + "address": { + "type": "string", + "description": "Address generated using the Wallet seed and Account parameters." + }, + "createdAt": { + "$ref": "#/definitions/externaldatav1Timestamp" + }, + "updatedAt": { + "$ref": "#/definitions/externaldatav1Timestamp" + } + }, + "required": [ + "organizationId", + "walletId", + "curve", + "pathFormat", + "path", + "addressFormat", + "address", + "createdAt", + "updatedAt" + ] + }, "v1WalletAccountParams": { "type": "object", "properties": { diff --git a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.types.ts b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.types.ts index e0012dea3..7cf5edad7 100644 --- a/packages/http/src/__generated__/services/coordinator/public/v1/public_api.types.ts +++ b/packages/http/src/__generated__/services/coordinator/public/v1/public_api.types.ts @@ -32,6 +32,10 @@ export type paths = { /** Get details about a User */ post: operations["PublicApiService_GetUser"]; }; + "/public/v1/query/get_wallet": { + /** Get details about a Wallet */ + post: operations["PublicApiService_GetWallet"]; + }; "/public/v1/query/list_activities": { /** List all Activities within an Organization */ post: operations["PublicApiService_GetActivities"]; @@ -48,6 +52,14 @@ export type paths = { /** List all Users within an Organization */ post: operations["PublicApiService_GetUsers"]; }; + "/public/v1/query/list_wallet_accounts": { + /** List all Accounts wirhin a Wallet */ + post: operations["PublicApiService_GetWalletAccounts"]; + }; + "/public/v1/query/list_wallets": { + /** List all Wallets within an Organization */ + post: operations["PublicApiService_GetWallets"]; + }; "/public/v1/query/whoami": { /** Get basic information about your current API or WebAuthN user and their organization. Affords Sub-Organization look ups via Parent Organization for WebAuthN users. */ post: operations["PublicApiService_GetWhoami"]; @@ -1158,6 +1170,34 @@ export type definitions = { /** @description A list of Users. */ users: definitions["v1User"][]; }; + v1GetWalletAccountsRequest: { + /** @description Unique identifier for a given Organization. */ + organizationId: string; + /** @description Unique identifier for a given Wallet. */ + walletId: string; + }; + v1GetWalletAccountsResponse: { + /** @description A list of Accounts generated from a Wallet that share a common seed */ + accounts: definitions["v1WalletAccount"][]; + }; + v1GetWalletRequest: { + /** @description Unique identifier for a given Organization. */ + organizationId: string; + /** @description Unique identifier for a given Wallet. */ + walletId: string; + }; + v1GetWalletResponse: { + /** @description A collection of deterministically generated cryptographic public / private key pairs that share a common seed */ + wallet: definitions["v1Wallet"]; + }; + v1GetWalletsRequest: { + /** @description Unique identifier for a given Organization. */ + organizationId: string; + }; + v1GetWalletsResponse: { + /** @description A list of Wallets. */ + wallets: definitions["v1Wallet"][]; + }; v1GetWhoamiRequest: { /** @description Unique identifier for a given Organization. If the request is being made by a WebAuthN user and their Sub-Organization ID is unknown, this can be the Parent Organization ID; using the Sub-Organization ID when possible is preferred due to performance reasons. */ organizationId: string; @@ -1817,6 +1857,24 @@ export type definitions = { /** @description True when a given Wallet is exported, false otherwise. */ exported: boolean; }; + v1WalletAccount: { + /** @description The Organization the Account belongs to. */ + organizationId: string; + /** @description The Wallet the Account was derived from. */ + walletId: string; + /** @description Cryptographic curve used to generate the Account. */ + curve: definitions["immutablecommonv1Curve"]; + /** @description Path format used to generate the Account. */ + pathFormat: definitions["v1PathFormat"]; + /** @description Path used to generate the Account. */ + path: string; + /** @description Address format used to generate the Acccount. */ + addressFormat: definitions["immutablecommonv1AddressFormat"]; + /** @description Address generated using the Wallet seed and Account parameters. */ + address: string; + createdAt: definitions["externaldatav1Timestamp"]; + updatedAt: definitions["externaldatav1Timestamp"]; + }; v1WalletAccountParams: { /** @description Cryptographic curve used to generate a wallet Account. */ curve: definitions["immutablecommonv1Curve"]; @@ -1977,6 +2035,24 @@ export type operations = { }; }; }; + /** Get details about a Wallet */ + PublicApiService_GetWallet: { + parameters: { + body: { + body: definitions["v1GetWalletRequest"]; + }; + }; + responses: { + /** A successful response. */ + 200: { + schema: definitions["v1GetWalletResponse"]; + }; + /** An unexpected error response. */ + default: { + schema: definitions["rpcStatus"]; + }; + }; + }; /** List all Activities within an Organization */ PublicApiService_GetActivities: { parameters: { @@ -2049,6 +2125,42 @@ export type operations = { }; }; }; + /** List all Accounts wirhin a Wallet */ + PublicApiService_GetWalletAccounts: { + parameters: { + body: { + body: definitions["v1GetWalletAccountsRequest"]; + }; + }; + responses: { + /** A successful response. */ + 200: { + schema: definitions["v1GetWalletAccountsResponse"]; + }; + /** An unexpected error response. */ + default: { + schema: definitions["rpcStatus"]; + }; + }; + }; + /** List all Wallets within an Organization */ + PublicApiService_GetWallets: { + parameters: { + body: { + body: definitions["v1GetWalletsRequest"]; + }; + }; + responses: { + /** A successful response. */ + 200: { + schema: definitions["v1GetWalletsResponse"]; + }; + /** An unexpected error response. */ + default: { + schema: definitions["rpcStatus"]; + }; + }; + }; /** Get basic information about your current API or WebAuthN user and their organization. Affords Sub-Organization look ups via Parent Organization for WebAuthN users. */ PublicApiService_GetWhoami: { parameters: { diff --git a/packages/iframe-stamper/README.md b/packages/iframe-stamper/README.md index ddce0363b..cbad9eff5 100644 --- a/packages/iframe-stamper/README.md +++ b/packages/iframe-stamper/README.md @@ -6,6 +6,8 @@ This package contains functions to stamp a Turnkey request through credentials c Usage: +Recovery + ```ts import { IframeStamper } from "@turnkey/iframe-stamper"; import { TurnkeyClient } from "@turnkey/http"; @@ -31,3 +33,25 @@ const httpClient = new TurnkeyClient( iframeStamper ); ``` + +Key or Wallet Export + +```ts +import { IframeStamper } from "@turnkey/iframe-stamper"; +import { TurnkeyClient } from "@turnkey/http"; + +const TurnkeyIframeContainerId = "turnkey-iframe-container"; +const TurnkeyIframeElementId = "turnkey-iframe"; + +const iframeStamper = new IframeStamper({ + iframeUrl: process.env.IFRAME_URL!, + iframeContainerId: TurnkeyIframeContainerId, + iframeElementId: TurnkeyIframeElementId, +}); + +// This inserts the iframe in the DOM and returns the public key +const publicKey = await iframeStamper.init(); + +// Injects a new private key in the iframe +const injected = await iframeStamper.injectKeyExportBundle(exportBundle); +``` diff --git a/packages/iframe-stamper/src/index.ts b/packages/iframe-stamper/src/index.ts index a17110641..d9adcb974 100644 --- a/packages/iframe-stamper/src/index.ts +++ b/packages/iframe-stamper/src/index.ts @@ -11,6 +11,12 @@ export enum IframeEventType { // Event sent by the parent to inject a recovery bundle into the iframe. // Value: the bundle to inject InjectRecoveryBundle = "INJECT_RECOVERY_BUNDLE", + // Event sent by the parent to inject a private key export bundle into the iframe. + // Value: the bundle to inject + InjectKeyExportBundle = "INJECT_KEY_EXPORT_BUNDLE", + // Event sent by the parent to inject a wallet export bundle into the iframe. + // Value: the bundle to inject + InjectWalletExportBundle = "INJECT_WALLET_EXPORT_BUNDLE", // Event sent by the iframe to its parent when `InjectBundle` is successful // Value: true (boolean) BundleInjected = "BUNDLE_INJECTED", @@ -69,8 +75,8 @@ export class IframeStamper { let iframe = window.document.createElement("iframe"); iframe.id = config.iframeElementId; iframe.src = config.iframeUrl; - this.iframe = iframe; + this.iframe = iframe; const iframeUrl = new URL(config.iframeUrl); this.iframeOrigin = iframeUrl.origin; @@ -149,6 +155,72 @@ export class IframeStamper { }); } + /** + * Function to inject an export bundle into the iframe + * The bundle should be encrypted to the iframe's initial public key + * Encryption should be performed with HPKE (RFC 9180). + * This is used during export flows. + */ + async injectKeyExportBundle(bundle: string): Promise { + this.iframe.contentWindow?.postMessage( + { + type: IframeEventType.InjectKeyExportBundle, + value: bundle, + }, + "*" + ); + + return new Promise((resolve, _reject) => { + window.addEventListener( + "message", + (event) => { + if (event.origin !== this.iframeOrigin) { + // There might be other things going on in the window, for example: react dev tools, other extensions, etc. + // Instead of erroring out we simply return. Not our event! + return; + } + if (event.data?.type === IframeEventType.BundleInjected) { + resolve(event.data["value"]); + } + }, + false + ); + }); + } + + /** + * Function to inject an export bundle into the iframe + * The bundle should be encrypted to the iframe's initial public key + * Encryption should be performed with HPKE (RFC 9180). + * This is used during export flows. + */ + async injectWalletExportBundle(bundle: string): Promise { + this.iframe.contentWindow?.postMessage( + { + type: IframeEventType.InjectWalletExportBundle, + value: bundle, + }, + "*" + ); + + return new Promise((resolve, _reject) => { + window.addEventListener( + "message", + (event) => { + if (event.origin !== this.iframeOrigin) { + // There might be other things going on in the window, for example: react dev tools, other extensions, etc. + // Instead of erroring out we simply return. Not our event! + return; + } + if (event.data?.type === IframeEventType.BundleInjected) { + resolve(event.data["value"]); + } + }, + false + ); + }); + } + /** * Function to sign a payload with the underlying iframe */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7308e1471..700d80fa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,66 @@ importers: specifier: ^2.4.2 version: 2.4.2 + examples/wallet-export: + dependencies: + '@turnkey/api-key-stamper': + specifier: workspace:* + version: link:../../packages/api-key-stamper + '@turnkey/http': + specifier: workspace:* + version: link:../../packages/http + '@turnkey/iframe-stamper': + specifier: workspace:* + version: link:../../packages/iframe-stamper + '@types/node': + specifier: 20.3.1 + version: 20.3.1 + '@types/react': + specifier: 18.2.14 + version: 18.2.14 + '@types/react-dom': + specifier: 18.2.6 + version: 18.2.6 + axios: + specifier: ^1.4.0 + version: 1.4.0 + classnames: + specifier: ^2.3.2 + version: 2.3.2 + encoding: + specifier: ^0.1.13 + version: 0.1.13 + eslint: + specifier: 8.43.0 + version: 8.43.0 + eslint-config-next: + specifier: 13.4.7 + version: 13.4.7(eslint@8.43.0)(typescript@5.1.3) + esm: + specifier: ^3.2.25 + version: 3.2.25 + install: + specifier: ^0.13.0 + version: 0.13.0 + next: + specifier: 13.4.7 + version: 13.4.7(@babel/core@7.22.20)(react-dom@18.2.0)(react@18.2.0) + npm: + specifier: ^9.7.2 + version: 9.7.2 + react: + specifier: 18.2.0 + version: 18.2.0 + react-dom: + specifier: 18.2.0 + version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.45.1 + version: 7.45.1(react@18.2.0) + typescript: + specifier: 5.1.3 + version: 5.1.3 + examples/with-cosmjs: dependencies: '@cosmjs/encoding': @@ -5446,6 +5506,10 @@ packages: napi-macros: 2.0.0 node-gyp-build: 4.6.0 + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'}