diff --git a/apps/demo_web/package.json b/apps/demo_web/package.json index 0029df80f..b33880ef5 100644 --- a/apps/demo_web/package.json +++ b/apps/demo_web/package.json @@ -23,6 +23,7 @@ "@oko-wallet/oko-sdk-core": "^0.0.6-rc.130", "@oko-wallet/oko-sdk-cosmos": "^0.0.6-rc.157", "@oko-wallet/oko-sdk-eth": "^0.0.6-rc.145", + "@oko-wallet/oko-sdk-sol": "workspace:*", "@oko-wallet/stdlib-js": "^0.0.2-rc.44", "@types/node": "^24.10.1", "@types/react": "^19.2.7", diff --git a/apps/demo_web/src/components/oko_provider/use_oko.ts b/apps/demo_web/src/components/oko_provider/use_oko.ts index 18bff523f..6e56d1b85 100644 --- a/apps/demo_web/src/components/oko_provider/use_oko.ts +++ b/apps/demo_web/src/components/oko_provider/use_oko.ts @@ -5,14 +5,19 @@ import { useSDKState } from "@oko-wallet-demo-web/state/sdk"; export function useInitOko() { const initOkoCosmos = useSDKState((state) => state.initOkoCosmos); const initOkoEth = useSDKState((state) => state.initOkoEth); + const initOkoSol = useSDKState((state) => state.initOkoSol); const isInitialized = useSDKState( - (state) => state.oko_cosmos !== null && state.oko_eth !== null, + (state) => + state.oko_cosmos !== null && + state.oko_eth !== null && + state.oko_sol !== null, ); useEffect(() => { initOkoCosmos(); initOkoEth(); + initOkoSol(); }, []); return { isInitialized }; diff --git a/apps/demo_web/src/components/preview_panel/preview_panel.tsx b/apps/demo_web/src/components/preview_panel/preview_panel.tsx index 6c7a2d894..2d5f882fa 100644 --- a/apps/demo_web/src/components/preview_panel/preview_panel.tsx +++ b/apps/demo_web/src/components/preview_panel/preview_panel.tsx @@ -11,12 +11,17 @@ import { CosmosOnchainSignWidget } from "@oko-wallet-demo-web/components/widgets import { CosmosOffChainSignWidget } from "@oko-wallet-demo-web/components/widgets/cosmos_offchain_sign_widget/cosmos_offchain_sign_widget"; import { EthereumOnchainSignWidget } from "@oko-wallet-demo-web/components/widgets/ethereum_onchain_sign_widget/ethereum_onchain_sign_widget"; import { EthereumOffchainSignWidget } from "@oko-wallet-demo-web/components/widgets/ethereum_offchain_sign_widget/ethereum_offchain_sign_widget"; +import { SolanaOffchainSignWidget } from "@oko-wallet-demo-web/components/widgets/solana_offchain_sign_widget/solana_offchain_sign_widget"; +import { SolanaOnchainSignWidget } from "@oko-wallet-demo-web/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget"; import { useUserInfoState } from "@oko-wallet-demo-web/state/user_info"; import { useSDKState } from "@oko-wallet-demo-web/state/sdk"; export const PreviewPanel: React.FC = () => { const isLazyInitialized = useSDKState( - (st) => st.isCosmosLazyInitialized && st.isEthLazyInitialized, + (st) => + st.isCosmosLazyInitialized && + st.isEthLazyInitialized && + st.isSolLazyInitialized, ); const isSignedIn = useUserInfoState((state) => state.isSignedIn); @@ -42,6 +47,12 @@ export const PreviewPanel: React.FC = () => { )} + {isSignedIn && ( +
+ + +
+ )} ) : (
diff --git a/apps/demo_web/src/components/widgets/solana_offchain_sign_widget/solana_offchain_sign_widget.tsx b/apps/demo_web/src/components/widgets/solana_offchain_sign_widget/solana_offchain_sign_widget.tsx new file mode 100644 index 000000000..4373584e3 --- /dev/null +++ b/apps/demo_web/src/components/widgets/solana_offchain_sign_widget/solana_offchain_sign_widget.tsx @@ -0,0 +1,36 @@ +import { SolanaIcon } from "@oko-wallet/oko-common-ui/icons/solana_icon"; + +import { SignWidget } from "@oko-wallet-demo-web/components/widgets/sign_widget/sign_widget"; +import { useSDKState } from "@oko-wallet-demo-web/state/sdk"; + +export const SolanaOffchainSignWidget = () => { + const okoSol = useSDKState((state) => state.oko_sol); + + const handleClickSolOffchainSign = async () => { + if (okoSol === null) { + throw new Error("okoSol is not initialized"); + } + + // Connect if not already connected + if (!okoSol.connected) { + await okoSol.connect(); + } + + const message = "Welcome to Oko! Try generating an Ed25519 MPC signature."; + const messageBytes = new TextEncoder().encode(message); + + const signature = await okoSol.signMessage(messageBytes); + + // Log signature for demo purposes + console.log("Solana signature:", Buffer.from(signature).toString("hex")); + }; + + return ( + } + signType="offchain" + signButtonOnClick={handleClickSolOffchainSign} + /> + ); +}; diff --git a/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.module.scss b/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.module.scss new file mode 100644 index 000000000..3c0a9661a --- /dev/null +++ b/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.module.scss @@ -0,0 +1,3 @@ +.checkboxContainer { + margin: -4px 0 20px 20px; +} diff --git a/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.tsx b/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.tsx new file mode 100644 index 000000000..a7477a144 --- /dev/null +++ b/apps/demo_web/src/components/widgets/solana_onchain_sign_widget/solana_onchain_sign_widget.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import { + Connection, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction, + LAMPORTS_PER_SOL, +} from "@solana/web3.js"; +import { SolanaIcon } from "@oko-wallet/oko-common-ui/icons/solana_icon"; +import { Checkbox } from "@oko-wallet/oko-common-ui/checkbox"; + +import styles from "./solana_onchain_sign_widget.module.scss"; +import { SignWidget } from "@oko-wallet-demo-web/components/widgets/sign_widget/sign_widget"; +import { useSDKState } from "@oko-wallet-demo-web/state/sdk"; + +const SOLANA_RPC_URL = "https://api.devnet.solana.com"; + +export const SolanaOnchainSignWidget = () => { + const okoSol = useSDKState((state) => state.oko_sol); + const [isLegacy, setIsLegacy] = useState(false); + + const handleClickSolOnchainSignV0 = useCallback(async () => { + if (okoSol === null) { + throw new Error("okoSol is not initialized"); + } + + if (!okoSol.connected) { + await okoSol.connect(); + } + + if (!okoSol.publicKey) { + throw new Error("No public key available"); + } + + const connection = new Connection(SOLANA_RPC_URL); + + const toAddress = new PublicKey( + "11111111111111111111111111111111", + ); + + const { blockhash } = await connection.getLatestBlockhash(); + + const instructions = [ + SystemProgram.transfer({ + fromPubkey: okoSol.publicKey, + toPubkey: toAddress, + lamports: 0.001 * LAMPORTS_PER_SOL, + }), + ]; + + const messageV0 = new TransactionMessage({ + payerKey: okoSol.publicKey, + recentBlockhash: blockhash, + instructions, + }).compileToV0Message(); + + const versionedTransaction = new VersionedTransaction(messageV0); + + const signedTransaction = + await okoSol.signTransaction(versionedTransaction); + + console.log( + "Solana v0 signed transaction:", + Buffer.from(signedTransaction.signatures[0]).toString("hex"), + ); + }, [okoSol]); + + const handleClickSolOnchainSignLegacy = useCallback(async () => { + if (okoSol === null) { + throw new Error("okoSol is not initialized"); + } + + if (!okoSol.connected) { + await okoSol.connect(); + } + + if (!okoSol.publicKey) { + throw new Error("No public key available"); + } + + const connection = new Connection(SOLANA_RPC_URL); + + const toAddress = new PublicKey( + "11111111111111111111111111111111", + ); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: okoSol.publicKey, + toPubkey: toAddress, + lamports: 0.001 * LAMPORTS_PER_SOL, + }), + ); + + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = okoSol.publicKey; + + const signedTransaction = await okoSol.signTransaction(transaction); + + console.log( + "Solana legacy signed transaction:", + signedTransaction.signatures.map((sig) => + sig.signature ? Buffer.from(sig.signature).toString("hex") : null, + ), + ); + }, [okoSol]); + + return ( + } + signType="onchain" + signButtonOnClick={ + isLegacy ? handleClickSolOnchainSignLegacy : handleClickSolOnchainSignV0 + } + renderBottom={() => ( +
+ setIsLegacy((prevState) => !prevState)} + label="Legacy Transaction" + /> +
+ )} + /> + ); +}; diff --git a/apps/demo_web/src/state/sdk.ts b/apps/demo_web/src/state/sdk.ts index ea30bdb7c..e3b62e6d0 100644 --- a/apps/demo_web/src/state/sdk.ts +++ b/apps/demo_web/src/state/sdk.ts @@ -6,6 +6,10 @@ import { OkoEthWallet, type OkoEthWalletInterface, } from "@oko-wallet/oko-sdk-eth"; +import { + OkoSolWallet, + type OkoSolWalletInterface, +} from "@oko-wallet/oko-sdk-sol"; import { create } from "zustand"; import { combine } from "zustand/middleware"; @@ -14,28 +18,37 @@ import { useUserInfoState } from "@oko-wallet-demo-web/state/user_info"; interface SDKState { oko_eth: OkoEthWalletInterface | null; oko_cosmos: OkoCosmosWalletInterface | null; + oko_sol: OkoSolWalletInterface | null; isEthInitializing: boolean; isEthLazyInitialized: boolean; isCosmosInitializing: boolean; isCosmosLazyInitialized: boolean; + + isSolInitializing: boolean; + isSolLazyInitialized: boolean; } interface SDKActions { initOkoEth: () => Promise; initOkoCosmos: () => Promise; + initOkoSol: () => Promise; } const initialState: SDKState = { oko_eth: null, oko_cosmos: null, + oko_sol: null, isEthInitializing: false, isEthLazyInitialized: false, isCosmosInitializing: false, isCosmosLazyInitialized: false, + + isSolInitializing: false, + isSolLazyInitialized: false, }; export const useSDKState = create( @@ -127,6 +140,59 @@ export const useSDKState = create( return null; } }, + initOkoSol: async () => { + const state = get(); + + if (state.oko_sol || state.isSolInitializing) { + console.log("Sol SDK already initialized or initializing, skipping..."); + return state.oko_sol; + } + + try { + console.log("Initializing Sol SDK..."); + set({ + isSolInitializing: true, + }); + + const initRes = OkoSolWallet.init({ + api_key: + "72bd2afd04374f86d563a40b814b7098e5ad6c7f52d3b8f84ab0c3d05f73ac6c", + sdk_endpoint: process.env.NEXT_PUBLIC_OKO_SDK_ENDPOINT, + }); + + if (initRes.success) { + console.log("Sol SDK initialized"); + + const okoSol = initRes.data; + set({ + oko_sol: okoSol, + isSolInitializing: false, + }); + + try { + await okoSol.waitUntilInitialized; + console.log("Sol SDK lazy initialized"); + set({ + isSolLazyInitialized: true, + }); + } catch (e) { + console.error("Sol SDK lazy init failed:", e); + set({ isSolLazyInitialized: true }); // Still mark as done to not block + } + + return okoSol; + } else { + console.error("Sol sdk init fail, err: %s", initRes.err); + set({ isSolInitializing: false, isSolLazyInitialized: true }); + + return null; + } + } catch (e) { + console.error("Sol SDK init error:", e); + set({ isSolInitializing: false, isSolLazyInitialized: true }); + return null; + } + }, })), ); @@ -134,9 +200,16 @@ function setupCosmosListener(cosmosSDK: OkoCosmosWalletInterface) { const setUserInfo = useUserInfoState.getState().setUserInfo; if (cosmosSDK) { + console.log("[Demo] Setting up Cosmos accountsChanged listener"); cosmosSDK.on({ type: "accountsChanged", handler: ({ authType, email, publicKey, name }) => { + console.log("[Demo] accountsChanged event received:", { + authType, + email, + publicKey: publicKey ? "exists" : "null", + name, + }); setUserInfo({ authType: authType || null, email: email || null, diff --git a/apps/docs_web/docs/v0/sdk-usage/sdk-overview.md b/apps/docs_web/docs/v0/sdk-usage/sdk-overview.md index aa66cdeb4..fae937c5b 100644 --- a/apps/docs_web/docs/v0/sdk-usage/sdk-overview.md +++ b/apps/docs_web/docs/v0/sdk-usage/sdk-overview.md @@ -23,6 +23,9 @@ npm install @oko-wallet/oko-sdk-cosmos # For Ethereum/EVM chains npm install @oko-wallet/oko-sdk-eth +# For Solana +npm install @oko-wallet/oko-sdk-sol + # Core SDK (for custom integration) npm install @oko-wallet/oko-sdk-core ``` @@ -56,10 +59,25 @@ const ethWallet = initRes.data; const provider = await ethWallet.getEthereumProvider(); ``` +### Solana + +```typescript +import { OkoSolWallet } from "@oko-wallet/oko-sdk-sol"; + +const initRes = OkoSolWallet.init(config); +if (!initRes.success) { + throw new Error(`Solana wallet initialization failed: ${initRes.err}`); +} + +const solWallet = initRes.data; +await solWallet.connect(); +``` + ## Next Steps - **[Cosmos Integration](./cosmos-integration)** - Complete Cosmos setup - **[Ethereum Integration](./ethereum-integration)** - Complete Ethereum setup +- **[Solana Integration](./solana-integration)** - Complete Solana setup - **[React Integration](./react-integration)** - React patterns - **[RainbowKit Integration](./rainbow-kit-integration)** - RainbowKit integration diff --git a/apps/docs_web/docs/v0/sdk-usage/solana-integration.md b/apps/docs_web/docs/v0/sdk-usage/solana-integration.md new file mode 100644 index 000000000..af76700e4 --- /dev/null +++ b/apps/docs_web/docs/v0/sdk-usage/solana-integration.md @@ -0,0 +1,198 @@ +--- +title: Solana Integration +sidebar_position: 4 +--- + +# Solana Integration + +Complete guide for integrating Oko with Solana. + + +:::tip Wallet Standard Support +Oko Solana SDK fully supports the [Wallet Standard](https://github.com/anza-xyz/wallet-standard), making it compatible with most Solana dApps automatically. +::: + +## Installation + +```bash +npm install @oko-wallet/oko-sdk-sol @solana/web3.js +``` + +## Basic Setup + +```typescript +import { OkoSolWallet } from "@oko-wallet/oko-sdk-sol"; + +// Initialize Solana wallet +const initRes = OkoSolWallet.init({ + api_key: "your-api-key", +}); + +if (!initRes.success) { + throw new Error(`Solana wallet initialization failed: ${initRes.err}`); +} + +const wallet = initRes.data; + +// Connect to get public key +await wallet.connect(); +console.log("Connected:", wallet.publicKey?.toBase58()); +``` + +## On-chain Signing + +### Send Transaction + +```typescript +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from "@solana/web3.js"; + +const connection = new Connection("https://api.mainnet-beta.solana.com"); +const recipientAddress = new PublicKey("..."); + +// Create transaction +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: wallet.publicKey!, + toPubkey: recipientAddress, + lamports: 0.1 * LAMPORTS_PER_SOL, + }) +); + +// Get recent blockhash +const { blockhash } = await connection.getLatestBlockhash(); +transaction.recentBlockhash = blockhash; +transaction.feePayer = wallet.publicKey!; + +// Sign and send +const signature = await wallet.sendTransaction(transaction, connection); +console.log("Transaction sent:", signature); +``` + +### Sign and Send Transaction (Phantom-compatible) + +```typescript +// Alternative method that returns { signature } +const { signature } = await wallet.signAndSendTransaction( + transaction, + connection +); +``` + +### Sign Multiple Transactions + +```typescript +const transactions = [transaction1, transaction2, transaction3]; +const signedTransactions = await wallet.signAllTransactions(transactions); + +// Send each signed transaction +for (const signed of signedTransactions) { + const sig = await connection.sendRawTransaction(signed.serialize()); + console.log("Sent:", sig); +} +``` + +## Off-chain Signing + +### Sign Message + +```typescript +const message = new TextEncoder().encode("Welcome to Oko!"); +const signature = await wallet.signMessage(message); + +console.log("Signature:", Buffer.from(signature).toString("base64")); +``` + +## Event Handling + +```typescript +// Listen for connection events +wallet.on("connect", (publicKey) => { + console.log("Connected:", publicKey.toBase58()); +}); + +wallet.on("disconnect", () => { + console.log("Disconnected"); +}); + +// Listen for account changes +wallet.on("accountChanged", (publicKey) => { + if (publicKey) { + console.log("Account changed:", publicKey.toBase58()); + } else { + console.log("Account disconnected"); + } +}); + +// Remove listener +const unsubscribe = wallet.on("connect", handler); +unsubscribe(); // or wallet.off("connect", handler); +``` + +## Wallet Standard Integration + +Register Oko wallet for automatic discovery by dApps using `@solana/wallet-adapter`: + +```typescript +import { OkoSolWallet, registerOkoWallet } from "@oko-wallet/oko-sdk-sol"; + +const initRes = OkoSolWallet.init({ api_key: "your-api-key" }); +if (initRes.success) { + const wallet = initRes.data; + + // Register with wallet-standard + registerOkoWallet(wallet); + + // Now dApps using getWallets() will discover Oko automatically +} +``` + +### Supported Features + +| Feature | Description | +|---------|-------------| +| `standard:connect` | Connect to wallet | +| `standard:disconnect` | Disconnect from wallet | +| `standard:events` | Subscribe to wallet events | +| `solana:signMessage` | Sign arbitrary messages | +| `solana:signTransaction` | Sign transactions | +| `solana:signAndSendTransaction` | Sign and broadcast transactions | + +## Versioned Transactions + +Oko supports both legacy and versioned transactions: + +```typescript +import { VersionedTransaction, TransactionMessage } from "@solana/web3.js"; + +// Create versioned transaction +const messageV0 = new TransactionMessage({ + payerKey: wallet.publicKey!, + recentBlockhash: blockhash, + instructions: [transferInstruction], +}).compileToV0Message(); + +const versionedTx = new VersionedTransaction(messageV0); + +// Sign versioned transaction +const signedTx = await wallet.signTransaction(versionedTx); +``` + +## Disconnect + +```typescript +await wallet.disconnect(); +console.log("Wallet disconnected"); +``` + +## Next Steps + +- **[Ethereum Integration](./ethereum-integration)** - Add Ethereum support +- **[Cosmos Integration](./cosmos-integration)** - Add Cosmos support +- **[React Integration](./react-integration)** - React patterns +- **[Error Handling](./error-handling)** - Error handling diff --git a/backend/tss_api/src/api/keygen_ed25519/index.test.ts b/backend/tss_api/src/api/keygen_ed25519/index.test.ts index 3eb81146b..c748e8d24 100644 --- a/backend/tss_api/src/api/keygen_ed25519/index.test.ts +++ b/backend/tss_api/src/api/keygen_ed25519/index.test.ts @@ -4,8 +4,8 @@ import { Participant } from "@oko-wallet/teddsa-interface"; import { runKeygenCentralizedEd25519 } from "@oko-wallet/teddsa-addon/src/server"; import { createPgConn } from "@oko-wallet/postgres-lib"; import { insertKSNode } from "@oko-wallet/oko-pg-interface/ks_nodes"; -import { createUser } from "@oko-wallet/oko-pg-interface/ewallet_users"; -import { createWallet } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +import { createUser } from "@oko-wallet/oko-pg-interface/oko_users"; +import { createWallet } from "@oko-wallet/oko-pg-interface/oko_wallets"; import { insertKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; import type { WalletStatus } from "@oko-wallet/oko-types/wallets"; @@ -46,16 +46,17 @@ async function setUpKeyShareNodeMeta(pool: Pool): Promise { function generateKeygenRequest( keygenResult: ReturnType, - email: string = TEST_EMAIL, + user_identifier: string = TEST_EMAIL, ): KeygenEd25519Request { const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; return { auth_type: "google", - email, + user_identifier, keygen_2: { ...serverKeygenOutput, public_key: [...keygenResult.public_key], }, + email: user_identifier, }; } @@ -214,11 +215,12 @@ describe("Ed25519 Keygen", () => { const request: KeygenEd25519Request = { auth_type: "google", - email: TEST_EMAIL, + user_identifier: TEST_EMAIL, keygen_2: { ...serverKeygenOutput, public_key: [...keygenResult.public_key], }, + email: TEST_EMAIL, name: "Test User", }; @@ -247,11 +249,12 @@ describe("Ed25519 Keygen", () => { const request: KeygenEd25519Request = { auth_type: authTypes[i], - email: `authtype-test-${i}@test.com`, + user_identifier: `authtype-test-${i}@test.com`, keygen_2: { ...serverKeygenOutput, public_key: [...keygenResult.public_key], }, + email: `authtype-test-${i}@test.com`, }; const result = await runKeygenEd25519( diff --git a/backend/tss_api/src/api/keygen_ed25519/index.ts b/backend/tss_api/src/api/keygen_ed25519/index.ts index d2842c336..650bcb609 100644 --- a/backend/tss_api/src/api/keygen_ed25519/index.ts +++ b/backend/tss_api/src/api/keygen_ed25519/index.ts @@ -2,7 +2,7 @@ import { Pool } from "pg"; import { createUser, getUserByEmailAndAuthType, -} from "@oko-wallet/oko-pg-interface/ewallet_users"; +} from "@oko-wallet/oko-pg-interface/oko_users"; import type { Result } from "@oko-wallet/stdlib-js"; import { encryptDataAsync } from "@oko-wallet/crypto-js/node"; import { Bytes, type Bytes32 } from "@oko-wallet/bytes"; @@ -14,7 +14,7 @@ import { createWallet, getActiveWalletByUserIdAndCurveType, getWalletByPublicKey, -} from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +} from "@oko-wallet/oko-pg-interface/oko_wallets"; import { createWalletKSNodes, getActiveKSNodes, @@ -33,9 +33,13 @@ export async function runKeygenEd25519( encryptionSecret: string, ): Promise> { try { - const { auth_type, email, keygen_2, name } = keygenRequest; + const { auth_type, user_identifier, keygen_2, email, name } = keygenRequest; - const getUserRes = await getUserByEmailAndAuthType(db, email, auth_type); + const getUserRes = await getUserByEmailAndAuthType( + db, + user_identifier, + auth_type, + ); if (getUserRes.success === false) { return { success: false, @@ -68,7 +72,7 @@ export async function runKeygenEd25519( }; } } else { - const createUserRes = await createUser(db, email, auth_type); + const createUserRes = await createUser(db, user_identifier, auth_type); if (createUserRes.success === false) { return { success: false, @@ -202,7 +206,7 @@ export async function runKeygenEd25519( const tokenResult = generateUserToken({ wallet_id: secp256k1WalletId, wallet_id_ed25519: wallet.wallet_id, - email: email, + email: user_identifier, jwt_config: jwtConfig, }); @@ -219,9 +223,10 @@ export async function runKeygenEd25519( data: { token: tokenResult.data.token, user: { - email: email, wallet_id: wallet.wallet_id, public_key: publicKeyHex, + user_identifier, + email: email ?? null, name: name ?? null, }, }, diff --git a/backend/tss_api/src/api/sign_ed25519/index.test.ts b/backend/tss_api/src/api/sign_ed25519/index.test.ts index 3d37f99f2..9c99a7940 100644 --- a/backend/tss_api/src/api/sign_ed25519/index.test.ts +++ b/backend/tss_api/src/api/sign_ed25519/index.test.ts @@ -17,8 +17,8 @@ import { import { createPgConn } from "@oko-wallet/postgres-lib"; import type { WalletStatus } from "@oko-wallet/oko-types/wallets"; import { insertKSNode } from "@oko-wallet/oko-pg-interface/ks_nodes"; -import { createWallet } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; -import { createUser } from "@oko-wallet/oko-pg-interface/ewallet_users"; +import { createWallet } from "@oko-wallet/oko-pg-interface/oko_wallets"; +import { createUser } from "@oko-wallet/oko-pg-interface/oko_users"; import { insertKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; import { insertCustomer } from "@oko-wallet/oko-pg-interface/customers"; import { encryptDataAsync } from "@oko-wallet/crypto-js/node"; diff --git a/backend/tss_api/src/api/wallet_ed25519/index.ts b/backend/tss_api/src/api/wallet_ed25519/index.ts index af7584618..bdb47a935 100644 --- a/backend/tss_api/src/api/wallet_ed25519/index.ts +++ b/backend/tss_api/src/api/wallet_ed25519/index.ts @@ -1,7 +1,7 @@ import { Pool } from "pg"; import { decryptDataAsync } from "@oko-wallet/crypto-js/node"; -import { getActiveWalletByUserIdAndCurveType } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; -import { getUserByEmailAndAuthType } from "@oko-wallet/oko-pg-interface/ewallet_users"; +import { getActiveWalletByUserIdAndCurveType } from "@oko-wallet/oko-pg-interface/oko_wallets"; +import { getUserByEmailAndAuthType } from "@oko-wallet/oko-pg-interface/oko_users"; import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; import type { AuthType } from "@oko-wallet/oko-types/auth"; import { @@ -10,7 +10,7 @@ import { } from "@oko-wallet/teddsa-interface"; export interface WalletEd25519PublicInfoRequest { - email: string; + user_identifier: string; auth_type: AuthType; } @@ -31,10 +31,10 @@ export async function getWalletEd25519PublicInfo( request: WalletEd25519PublicInfoRequest, ): Promise> { try { - const { email, auth_type } = request; + const { user_identifier, auth_type } = request; // Get user - const getUserRes = await getUserByEmailAndAuthType(db, email, auth_type); + const getUserRes = await getUserByEmailAndAuthType(db, user_identifier, auth_type); if (getUserRes.success === false) { return { success: false, diff --git a/backend/tss_api/src/routes/keygen_ed25519.ts b/backend/tss_api/src/routes/keygen_ed25519.ts index 0e634935e..be9ab8df4 100644 --- a/backend/tss_api/src/routes/keygen_ed25519.ts +++ b/backend/tss_api/src/routes/keygen_ed25519.ts @@ -91,11 +91,13 @@ export function setKeygenEd25519Routes(router: Router) { const auth_type = oauthUser.type as AuthType; const body = req.body; - if (!oauthUser?.email) { + const user_identifier = oauthUser.user_identifier; + + if (!user_identifier) { res.status(401).json({ success: false, code: "UNAUTHORIZED", - msg: "User email not found", + msg: "User identifier not found", }); return; } @@ -110,8 +112,9 @@ export function setKeygenEd25519Routes(router: Router) { jwtConfig, { auth_type, - email: oauthUser.email.toLowerCase(), + user_identifier, keygen_2: body.keygen_2, + email: oauthUser.email, name: oauthUser.name, }, state.encryption_secret, diff --git a/backend/tss_api/src/routes/wallet_ed25519.ts b/backend/tss_api/src/routes/wallet_ed25519.ts index d5f29446b..92ba2cca1 100644 --- a/backend/tss_api/src/routes/wallet_ed25519.ts +++ b/backend/tss_api/src/routes/wallet_ed25519.ts @@ -82,11 +82,12 @@ export function setWalletEd25519Routes(router: Router) { const state = req.app.locals; const oauthUser = res.locals.oauth_user; - if (!oauthUser?.email) { + const user_identifier = oauthUser?.user_identifier; + if (!user_identifier) { res.status(401).json({ success: false, code: "UNAUTHORIZED", - msg: "User email not found", + msg: "User identifier not found", }); return; } @@ -95,7 +96,7 @@ export function setWalletEd25519Routes(router: Router) { state.db, state.encryption_secret, { - email: oauthUser.email.toLowerCase(), + user_identifier, auth_type: oauthUser.type, }, ); diff --git a/common/oko_types/src/tss/auth.ts b/common/oko_types/src/tss/auth.ts index cc2913138..4339602e3 100644 --- a/common/oko_types/src/tss/auth.ts +++ b/common/oko_types/src/tss/auth.ts @@ -1,5 +1,5 @@ export interface UserTokenPayload { - email: string; + email?: string; wallet_id: string; wallet_id_ed25519?: string; type: "user"; @@ -8,7 +8,7 @@ export interface UserTokenPayload { export interface GenerateUserTokenArgs { wallet_id: string; wallet_id_ed25519?: string; - email: string; + email?: string; jwt_config: { secret: string; expires_in: string; diff --git a/common/oko_types/src/tss/keygen_ed25519.ts b/common/oko_types/src/tss/keygen_ed25519.ts index 31d969507..cd885fb4a 100644 --- a/common/oko_types/src/tss/keygen_ed25519.ts +++ b/common/oko_types/src/tss/keygen_ed25519.ts @@ -8,8 +8,9 @@ export interface TeddsaKeygenOutputWithPublicKey extends TeddsaKeygenOutput { export interface KeygenEd25519Request { auth_type: AuthType; - email: string; + user_identifier: string; keygen_2: TeddsaKeygenOutputWithPublicKey; + email?: string; name?: string; } diff --git a/crypto/teddsa/api_lib/src/index.ts b/crypto/teddsa/api_lib/src/index.ts index 51ef79981..f608e1ed8 100644 --- a/crypto/teddsa/api_lib/src/index.ts +++ b/crypto/teddsa/api_lib/src/index.ts @@ -6,6 +6,10 @@ import type { SignEd25519Round2Response, SignEd25519AggregateBody, SignEd25519AggregateResponse, + PresignEd25519Body, + PresignEd25519Response, + SignEd25519Body, + SignEd25519Response, } from "@oko-wallet/oko-types/tss"; import type { SignInResponse } from "@oko-wallet/oko-types/user"; import type { @@ -75,6 +79,7 @@ async function makePostRequest( path: string, payload: T, authToken?: string, + apiKey?: string, ): Promise { const url = `${endpoint}/${path}`; @@ -86,6 +91,10 @@ async function makePostRequest( headers.Authorization = `Bearer ${authToken}`; } + if (apiKey) { + headers["x-api-key"] = apiKey; + } + const ret = await fetch(url, { headers, method: "POST", @@ -169,3 +178,33 @@ export async function reqSignEd25519Aggregate( ); return resp; } + +export async function reqPresignEd25519( + endpoint: string, + payload: PresignEd25519Body, + apiKey: string, + authToken: string, +) { + const resp: OkoApiResponse = await makePostRequest( + endpoint, + "presign_ed25519", + payload, + authToken, + apiKey, + ); + return resp; +} + +export async function reqSignEd25519( + endpoint: string, + payload: SignEd25519Body, + authToken: string, +) { + const resp: OkoApiResponse = await makePostRequest( + endpoint, + "sign_ed25519", + payload, + authToken, + ); + return resp; +} diff --git a/crypto/teddsa/teddsa_hooks/package.json b/crypto/teddsa/teddsa_hooks/package.json index 6da1116df..a0f6b6ee8 100644 --- a/crypto/teddsa/teddsa_hooks/package.json +++ b/crypto/teddsa/teddsa_hooks/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "dependencies": { "@oko-wallet/bytes": "^0.0.3-alpha.62", + "@oko-wallet/frost-ed25519-keplr-wasm": "workspace:*", "@oko-wallet/stdlib-js": "^0.0.2-rc.42", "@oko-wallet/teddsa-interface": "workspace:*", "@oko-wallet/teddsa-wasm-mock": "workspace:*" @@ -12,4 +13,4 @@ "@types/node": "^24.10.1", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/crypto/teddsa/teddsa_hooks/src/keygen.ts b/crypto/teddsa/teddsa_hooks/src/keygen.ts index 508c9b788..9dd76a808 100644 --- a/crypto/teddsa/teddsa_hooks/src/keygen.ts +++ b/crypto/teddsa/teddsa_hooks/src/keygen.ts @@ -1,4 +1,4 @@ -import { wasmModule } from "@oko-wallet/teddsa-wasm"; +import { wasmModule } from "@oko-wallet/frost-ed25519-keplr-wasm"; import { Bytes } from "@oko-wallet/bytes"; import type { Bytes32 } from "@oko-wallet/bytes"; import type { Result } from "@oko-wallet/stdlib-js"; diff --git a/crypto/teddsa/teddsa_hooks/src/sign.ts b/crypto/teddsa/teddsa_hooks/src/sign.ts index 737c8ecbc..da69b159c 100644 --- a/crypto/teddsa/teddsa_hooks/src/sign.ts +++ b/crypto/teddsa/teddsa_hooks/src/sign.ts @@ -1,4 +1,4 @@ -import { wasmModule } from "@oko-wallet/teddsa-wasm"; +import { wasmModule } from "@oko-wallet/frost-ed25519-keplr-wasm"; import type { Result } from "@oko-wallet/stdlib-js"; import type { TeddsaSignRound1Output, diff --git a/embed/oko_attached/package.json b/embed/oko_attached/package.json index 13fd27cc5..216ae9102 100644 --- a/embed/oko_attached/package.json +++ b/embed/oko_attached/package.json @@ -6,7 +6,7 @@ "scripts": { "create_env": "tsx src/bin/create_env.ts", "clean": "del-cli ./public/pkg", - "copy_wasm": "yarn run clean && cp -R ../../crypto/tecdsa/cait_sith_keplr_wasm/pkg ./public", + "copy_wasm": "yarn run clean && cp -R ../../crypto/tecdsa/cait_sith_keplr_wasm/pkg ./public && cp ../../crypto/teddsa/frost_ed25519_keplr_wasm/pkg/frost_ed25519_keplr_wasm_bg.wasm ./public/pkg/", "dev": "tsx ./src/bin/launch.ts", "build": "vite build && tsc --noEmit", "serve": "vite preview", @@ -35,6 +35,7 @@ "@oko-wallet/cait-sith-keplr-wasm": "workspace:*", "@oko-wallet/crypto-js": "^0.0.2-alpha.8", "@oko-wallet/dotenv": "^0.0.2-alpha.29", + "@oko-wallet/frost-ed25519-keplr-wasm": "workspace:*", "@oko-wallet/ksn-interface": "^0.0.2-alpha.60", "@oko-wallet/oko-common-ui": "workspace:*", "@oko-wallet/oko-sdk-core": "^0.0.6-rc.130", @@ -42,12 +43,18 @@ "@oko-wallet/oko-sdk-eth": "^0.0.6-rc.145", "@oko-wallet/stdlib-js": "^0.0.2-rc.44", "@oko-wallet/tecdsa-interface": "0.0.2-alpha.22", + "@oko-wallet/teddsa-hooks": "workspace:*", + "@oko-wallet/teddsa-interface": "workspace:*", + "@solana/web3.js": "^1.98.0", + "@solanafm/explorer-kit": "^1.2.0", + "@solanafm/explorer-kit-idls": "^1.1.4", "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.136.8", "@tanstack/react-router-devtools": "^1.136.8", "@tanstack/router-plugin": "^1.136.8", "auth0-js": "^9.29.0", "bitcoinjs-lib": "^6.1.7", + "bs58": "^6.0.0", "chalk": "^5.5.0", "cosmjs-types": "^0.9.0", "jsonwebtoken": "^9.0.2", diff --git a/embed/oko_attached/src/analytics/events.ts b/embed/oko_attached/src/analytics/events.ts index 06426063c..eaff2fd4f 100644 --- a/embed/oko_attached/src/analytics/events.ts +++ b/embed/oko_attached/src/analytics/events.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import type { ParsedInstruction } from "@oko-wallet-attached/tx-parsers/sol"; import type { EthTxAction } from "@oko-wallet-attached/components/modal_variants/eth/tx_sig/actions/types"; import { trackEvent } from "./amplitude"; @@ -48,24 +49,30 @@ export function useTrackTxSummaryView(args: UseTrackTxSummaryViewArgs) { } export function trackTxButtonEvent(args: TrackTxButtonEventArgs) { - const { hostOrigin, chainType, chainId, eventType } = args; + const { hostOrigin, chainType, eventType } = args; + + let txTypes: string[]; + let chainId: string | undefined; - let txTypes; switch (chainType) { case "eth": { - const { actions } = args; + const { actions, chainId: ethChainId } = args; txTypes = classifyEthTxType(actions); + chainId = ethChainId; break; } case "cosmos": { - const { messages } = args; + const { messages, chainId: cosmosChainId } = args; txTypes = classifyCosmosTxType(messages); + chainId = cosmosChainId; break; } - default: { - throw new Error("invalid chain type"); + case "solana": { + const { instructions } = args; + txTypes = classifySolanaTxType(instructions); + break; } } @@ -96,3 +103,11 @@ function classifyCosmosTxType(messages: CosmosMsgs) { return messages.map((msg) => ("typeUrl" in msg ? msg.typeUrl : msg.type)); } + +function classifySolanaTxType(instructions: ParsedInstruction[] | null) { + if (instructions === null || instructions.length === 0) { + return ["unknown"]; + } + + return instructions.map((ix) => ix.instructionName); +} diff --git a/embed/oko_attached/src/analytics/types.ts b/embed/oko_attached/src/analytics/types.ts index eac34608e..50354a249 100644 --- a/embed/oko_attached/src/analytics/types.ts +++ b/embed/oko_attached/src/analytics/types.ts @@ -1,13 +1,14 @@ import type { Msg } from "@keplr-wallet/types"; import type { Any } from "@keplr-wallet/proto-types/google/protobuf/any"; import type { AminoMsg } from "@cosmjs/amino"; +import type { ParsedInstruction } from "@oko-wallet-attached/tx-parsers/sol"; import type { EthTxAction } from "@oko-wallet-attached/components/modal_variants/eth/tx_sig/actions/types"; import type { UnpackedMsgForView } from "@oko-wallet-attached/types/cosmos_msg"; export type TxType = string; -export type ChainType = "eth" | "cosmos"; +export type ChainType = "eth" | "cosmos" | "solana"; export type CosmosMsgs = | Any[] @@ -45,4 +46,10 @@ export type TrackTxButtonEventArgs = chainType: "eth"; chainId: string; actions: EthTxAction[]; + } + | { + eventType: "approve" | "reject"; + hostOrigin: string; + chainType: "solana"; + instructions: ParsedInstruction[] | null; }; diff --git a/embed/oko_attached/src/components/modal/modal_dialog.tsx b/embed/oko_attached/src/components/modal/modal_dialog.tsx index 9908f757d..1040952b1 100644 --- a/embed/oko_attached/src/components/modal/modal_dialog.tsx +++ b/embed/oko_attached/src/components/modal/modal_dialog.tsx @@ -3,6 +3,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { MakeSignatureCosmosModal } from "@oko-wallet-attached/components/modal_variants/cosmos/make_signature_cosmos_modal"; import { MakeSignatureEthModal } from "@oko-wallet-attached/components/modal_variants/eth/make_sig_eth_modal"; +import { MakeSignatureSolModal } from "@oko-wallet-attached/components/modal_variants/sol/make_signature_sol_modal"; import type { ModalRequest } from "@oko-wallet-attached/store/memory/types"; import { ErrorModal } from "@oko-wallet-attached/components/modal_variants/error/error_modal"; import { useMemoryState } from "@oko-wallet-attached/store/memory"; @@ -91,6 +92,17 @@ export const ModalDialog: FC = ({ modalRequest }) => { break; } + case "sol/make_signature": { + component = ( + + ); + break; + } + // case "auth/email_login": { // component = ( // diff --git a/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/make_all_tx_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/make_all_tx_sig_modal.tsx new file mode 100644 index 000000000..7a7193fe6 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/make_all_tx_sig_modal.tsx @@ -0,0 +1,94 @@ +import type { FC } from "react"; +import type { MakeSolAllTxSignData } from "@oko-wallet/oko-sdk-core"; +import { XCloseIcon } from "@oko-wallet/oko-common-ui/icons/x_close"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Button } from "@oko-wallet/oko-common-ui/button"; + +import styles from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_signature_modal.module.scss"; +import { CommonModal } from "@oko-wallet-attached/components/modal_variants/common/common_modal"; +import { DemoView } from "@oko-wallet-attached/components/modal_variants/common/make_signature/demo_view"; +import { SignWithOkoBox } from "@oko-wallet-attached/components/sign_with_oko_box/sign_with_oko_box"; +import { useAllTxSigModal } from "./use_all_tx_sig_modal"; +import { SolanaAllTxSignatureContent } from "./sol_all_tx_signature_content"; + +export interface MakeAllTxSigModalProps { + getIsAborted: () => boolean; + modalId: string; + data: MakeSolAllTxSignData; +} + +export const MakeAllTxSigModal: FC = ({ + getIsAborted, + data, + modalId, +}) => { + const { + onReject, + onApprove, + isLoading, + isApproveEnabled, + isDemo, + theme, + txCount, + signingProgress, + } = useAllTxSigModal({ + getIsAborted, + data, + modalId, + }); + + function handleRejectClick() { + onReject(); + } + + function handleApproveClick() { + onApprove(); + } + + const buttonText = isLoading + ? `Signing ${signingProgress}/${txCount}...` + : "Approve All"; + + return ( +
+ +
+ +
+ +
+ +
+ + + +
+ + +
+ + + +
+ + {isDemo && } +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/sol_all_tx_signature_content.tsx b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/sol_all_tx_signature_content.tsx new file mode 100644 index 000000000..c404f5b3d --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/sol_all_tx_signature_content.tsx @@ -0,0 +1,104 @@ +import type { FC } from "react"; +import type { SolanaAllTxSignPayload } from "@oko-wallet/oko-sdk-core"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; + +import styles from "../common/signature_content.module.scss"; +import { Avatar } from "@oko-wallet-attached/components/avatar/avatar"; +import { SignerAddressOrEmail } from "@oko-wallet-attached/components/modal_variants/common/metadata_content/signer_address_or_email/signer_address_or_email"; +import { SOLANA_LOGO_URL } from "@oko-wallet-attached/constants/urls"; + +interface SolanaAllTxSignatureContentProps { + payload: SolanaAllTxSignPayload; +} + +function getFaviconUrl(origin: string): string { + if (!origin) return ""; + try { + const parsed = new URL(origin); + return `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + parsed.origin, + )}`; + } catch { + return ""; + } +} + +export const SolanaAllTxSignatureContent: FC< + SolanaAllTxSignatureContentProps +> = ({ payload }) => { + const { origin, signer, data } = payload; + const faviconUrl = getFaviconUrl(origin); + const txCount = data.serialized_transactions.length; + + return ( +
+
+ Solana + + Solana +
+ + + +
+ Sign {txCount} Solana Transactions +
+ + + +
+
+ {faviconUrl && faviconUrl.length > 0 && ( + favicon + )} + + {origin.replace(/^https?:\/\//, "")} + +
+ + + +
+
+ + requested your + +
+ + + Solana signatures + +
+
+ + +
+
+ + + +
+ + This request will sign {txCount} transactions at once. Please review + carefully before approving. + +
+
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/use_all_tx_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/use_all_tx_sig_modal.tsx new file mode 100644 index 000000000..daa98918d --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/all_tx_sig/use_all_tx_sig_modal.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import type { MakeSolAllTxSignData } from "@oko-wallet/oko-sdk-core"; +import { base64ToUint8Array } from "@oko-wallet-attached/utils/base64"; +import { + useSolSignatureBase, + signMessageToHex, +} from "../use_sol_signature_base"; + +export interface UseAllTxSigModalArgs { + modalId: string; + data: MakeSolAllTxSignData; + getIsAborted: () => boolean; +} + +export function useAllTxSigModal(args: UseAllTxSigModalArgs) { + const { modalId, data, getIsAborted } = args; + const hostOrigin = data.payload.origin; + + const base = useSolSignatureBase({ modalId, hostOrigin, getIsAborted }); + const [signingProgress, setSigningProgress] = useState(0); + + const txCount = data.payload.data.serialized_transactions.length; + + async function onApprove() { + if (getIsAborted()) return; + + const ctx = base.prepareSigningContext(); + if (!ctx) return; + + base.setIsLoading(true); + setSigningProgress(0); + + try { + const signatures: string[] = []; + const messagesToSign = data.payload.data.messages_to_sign; + + for (let i = 0; i < messagesToSign.length; i++) { + if (getIsAborted()) return; + + const message = base64ToUint8Array(messagesToSign[i]); + const result = await signMessageToHex(message, ctx); + + if (!result.success) { + base.emitError(result.error); + return; + } + + signatures.push(result.signature); + setSigningProgress(i + 1); + } + + base.closeWithSignatures(signatures); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + base.emitUnknownError(errorMessage); + } finally { + base.setIsLoading(false); + } + } + + return { + onReject: base.onReject, + onApprove, + isLoading: base.isLoading, + isApproveEnabled: base.isApproveEnabled, + isDemo: base.isDemo, + theme: base.theme, + data, + txCount, + signingProgress, + }; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/common/signature_content.module.scss b/embed/oko_attached/src/components/modal_variants/sol/common/signature_content.module.scss new file mode 100644 index 000000000..b32755231 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/common/signature_content.module.scss @@ -0,0 +1,62 @@ +.signatureContent { + display: flex; + flex-direction: column; + padding: 0 16px; +} + +.chainInfo { + display: flex; + align-items: center; +} + +.chainLogo { + width: 32px; + height: 32px; + border-radius: 16px; +} + +.chainName { + font-size: 14px; + font-weight: 600; + color: var(--fg-secondary); +} + +.signTypeTitle { + font-size: 20px; + font-weight: 600; + color: var(--fg-primary); +} + +.metadataWrapper { + display: flex; + flex-direction: column; +} + +.originRow { + display: flex; + align-items: center; +} + +.originFavicon { + width: 16px; + height: 16px; + margin-right: 8px; +} + +.signInfoColumn { + display: flex; + flex-direction: column; +} + +.chainInfoRow { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.chainNameGroup { + display: flex; + align-items: center; + gap: 4px; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/common/summary.module.scss b/embed/oko_attached/src/components/modal_variants/sol/common/summary.module.scss new file mode 100644 index 000000000..dc13c2a7a --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/common/summary.module.scss @@ -0,0 +1,34 @@ +.summaryContainer { + display: flex; + flex-direction: column; + align-items: center; + align-self: stretch; + gap: 8px; +} + +.summaryHeader { + display: flex; + align-items: center; + align-self: stretch; + justify-content: space-between; +} + +.summaryHeaderRight { + display: flex; + align-items: center; + justify-content: center; + height: 18px; + cursor: pointer; +} + +.summaryHeaderRightIcon { + width: 16px; + height: 16px; + aspect-ratio: 1/1; +} + +.divider { + width: 100%; + height: 1px; + background: var(--border-primary); +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/make_signature_sol_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/make_signature_sol_modal.tsx new file mode 100644 index 000000000..1cffa3d9f --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/make_signature_sol_modal.tsx @@ -0,0 +1,48 @@ +import type { FC } from "react"; +import type { MakeSolanaSigData } from "@oko-wallet/oko-sdk-core"; + +import { MakeTxSigModal } from "./tx_sig/make_tx_sig_modal"; +import { MakeAllTxSigModal } from "./all_tx_sig/make_all_tx_sig_modal"; +import { MakeMessageSigModal } from "./message_sig/make_message_sig_modal"; + +export interface MakeSignatureSolModalProps { + getIsAborted: () => boolean; + modalId: string; + data: MakeSolanaSigData; +} + +export const MakeSignatureSolModal: FC = ({ + getIsAborted, + data, + modalId, +}) => { + switch (data.sign_type) { + case "tx": { + return ( + + ); + } + case "all_tx": { + return ( + + ); + } + case "message": { + return ( + + ); + } + } +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/message_sig/make_message_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/message_sig/make_message_sig_modal.tsx new file mode 100644 index 000000000..a3b1eab16 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/message_sig/make_message_sig_modal.tsx @@ -0,0 +1,74 @@ +import type { FC } from "react"; +import type { MakeSolMessageSignData } from "@oko-wallet/oko-sdk-core"; +import { XCloseIcon } from "@oko-wallet/oko-common-ui/icons/x_close"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Button } from "@oko-wallet/oko-common-ui/button"; + +import styles from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_signature_modal.module.scss"; +import { CommonModal } from "@oko-wallet-attached/components/modal_variants/common/common_modal"; +import { DemoView } from "@oko-wallet-attached/components/modal_variants/common/make_signature/demo_view"; +import { SignWithOkoBox } from "@oko-wallet-attached/components/sign_with_oko_box/sign_with_oko_box"; +import { useMessageSigModal } from "./use_message_sig_modal"; +import { SolanaMessageSignatureContent } from "./sol_message_signature_content"; + +export interface MakeMessageSigModalProps { + getIsAborted: () => boolean; + modalId: string; + data: MakeSolMessageSignData; +} + +export const MakeMessageSigModal: FC = ({ + getIsAborted, + data, + modalId, +}) => { + const { onReject, onApprove, isLoading, isApproveEnabled, isDemo, theme } = + useMessageSigModal({ + getIsAborted, + data, + modalId, + }); + + return ( +
+ +
+ +
+ +
+ +
+ + + +
+ + +
+ + + +
+ + {isDemo && } +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_signature_content.tsx b/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_signature_content.tsx new file mode 100644 index 000000000..92599fa2e --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_signature_content.tsx @@ -0,0 +1,91 @@ +import type { FC } from "react"; +import type { SolanaMessageSignPayload } from "@oko-wallet/oko-sdk-core"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; + +import styles from "../common/signature_content.module.scss"; +import { Avatar } from "@oko-wallet-attached/components/avatar/avatar"; +import { SignerAddressOrEmail } from "@oko-wallet-attached/components/modal_variants/common/metadata_content/signer_address_or_email/signer_address_or_email"; +import { SolanaMessageSummary } from "./sol_message_summary"; +import { SOLANA_LOGO_URL } from "@oko-wallet-attached/constants/urls"; + +interface SolanaMessageSignatureContentProps { + payload: SolanaMessageSignPayload; +} + +function getFaviconUrl(origin: string): string { + if (!origin) return ""; + try { + const parsed = new URL(origin); + return `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + parsed.origin, + )}`; + } catch { + return ""; + } +} + +export const SolanaMessageSignatureContent: FC< + SolanaMessageSignatureContentProps +> = ({ payload }) => { + const { origin, signer } = payload; + const faviconUrl = getFaviconUrl(origin); + + return ( +
+
+ Solana + + Solana +
+ + + +
Sign Message
+ + + +
+
+ {faviconUrl && faviconUrl.length > 0 && ( + favicon + )} + + {origin.replace(/^https?:\/\//, "")} + +
+ + + +
+
+ + requested your + +
+ + + Solana signature + +
+
+ + +
+
+ + + + +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_summary.tsx b/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_summary.tsx new file mode 100644 index 000000000..d3fddf876 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/message_sig/sol_message_summary.tsx @@ -0,0 +1,97 @@ +import { type FC, type ReactNode, useState, useMemo } from "react"; +import type { SolanaMessageSignPayload } from "@oko-wallet/oko-sdk-core"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { ChevronRightIcon } from "@oko-wallet/oko-common-ui/icons/chevron_right"; + +import styles from "../common/summary.module.scss"; +import { MakeSignatureRawCodeBlock } from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_sig_modal_code_block"; +import { MakeSignatureRawCodeBlockContainer } from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_sig_modal_code_block_container"; +import { TxContainer } from "@oko-wallet-attached/components/modal_variants/eth/tx_sig/actions/common/tx_container"; +import { TxRow } from "@oko-wallet-attached/components/modal_variants/common/tx_row"; + +export interface SolanaMessageSummaryProps { + payload: SolanaMessageSignPayload; +} + +export const SolanaMessageSummary: FC = ({ + payload, +}) => { + const [isRawView, setIsRawView] = useState(false); + + const { rawData, smartViewContent } = useMemo(() => { + const msgData = payload.data; + let decoded = ""; + + try { + const hex = msgData.message.startsWith("0x") + ? msgData.message.slice(2) + : msgData.message; + const byteArray = new Uint8Array(hex.length / 2); + for (let i = 0; i < byteArray.length; i++) { + byteArray[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + decoded = new TextDecoder().decode(byteArray); + } catch { + decoded = msgData.message; + } + + const content: ReactNode = ( + + + + {decoded.length > 200 ? decoded.slice(0, 200) + "..." : decoded} + + + + ); + + return { + rawData: JSON.stringify({ message: msgData.message }, null, 2), + smartViewContent: content, + }; + }, [payload.data]); + + function handleToggleView() { + setIsRawView((prev) => !prev); + } + + let content: ReactNode | null = null; + + if (isRawView) { + content = ( + + + + ); + } else { + content = smartViewContent; + } + + return ( +
+
+ + Message + +
+ + {isRawView ? "Smart View" : "Raw View"} + + +
+
+ {content} +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/message_sig/use_message_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/message_sig/use_message_sig_modal.tsx new file mode 100644 index 000000000..2f90161fd --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/message_sig/use_message_sig_modal.tsx @@ -0,0 +1,56 @@ +import type { MakeSolMessageSignData } from "@oko-wallet/oko-sdk-core"; +import { hexToUint8Array } from "@oko-wallet-attached/crypto/keygen_ed25519"; +import { + useSolSignatureBase, + signMessageToHex, +} from "../use_sol_signature_base"; + +export interface UseMessageSigModalArgs { + modalId: string; + data: MakeSolMessageSignData; + getIsAborted: () => boolean; +} + +export function useMessageSigModal(args: UseMessageSigModalArgs) { + const { modalId, data, getIsAborted } = args; + const hostOrigin = data.payload.origin; + + const base = useSolSignatureBase({ modalId, hostOrigin, getIsAborted }); + + async function onApprove() { + if (getIsAborted()) return; + + const ctx = base.prepareSigningContext(); + if (!ctx) return; + + base.setIsLoading(true); + + try { + const message = hexToUint8Array(data.payload.data.message); + const result = await signMessageToHex(message, ctx); + + if (!result.success) { + base.emitError(result.error); + return; + } + + base.closeWithSignature(result.signature); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + base.emitUnknownError(errorMessage); + } finally { + base.setIsLoading(false); + } + } + + return { + onReject: base.onReject, + onApprove, + isLoading: base.isLoading, + isApproveEnabled: base.isApproveEnabled, + isDemo: base.isDemo, + theme: base.theme, + data, + }; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/make_tx_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/make_tx_sig_modal.tsx new file mode 100644 index 000000000..e9e5ff260 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/make_tx_sig_modal.tsx @@ -0,0 +1,117 @@ +import type { FC } from "react"; +import type { MakeSolTxSignData } from "@oko-wallet/oko-sdk-core"; +import { XCloseIcon } from "@oko-wallet/oko-common-ui/icons/x_close"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Button } from "@oko-wallet/oko-common-ui/button"; + +import styles from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_signature_modal.module.scss"; +import { CommonModal } from "@oko-wallet-attached/components/modal_variants/common/common_modal"; +import { DemoView } from "@oko-wallet-attached/components/modal_variants/common/make_signature/demo_view"; +import { SignWithOkoBox } from "@oko-wallet-attached/components/sign_with_oko_box/sign_with_oko_box"; +import { trackTxButtonEvent } from "@oko-wallet-attached/analytics/events"; +import { useTxSigModal } from "./use_tx_sig_modal"; +import { SolanaTxSignatureContent } from "./sol_tx_signature_content"; +import { SolanaTxFee } from "./sol_tx_fee"; +import { useParseTx } from "./use_parse_tx"; + +export interface MakeTxSigModalProps { + getIsAborted: () => boolean; + modalId: string; + data: MakeSolTxSignData; +} + +export const MakeTxSigModal: FC = ({ + getIsAborted, + data, + modalId, +}) => { + const { onReject, onApprove, isLoading, isApproveEnabled, isDemo, theme } = + useTxSigModal({ + getIsAborted, + data, + modalId, + }); + + const { + parsedTx, + parseError, + isLoading: isParsing, + } = useParseTx(data.payload.data.serialized_transaction); + + function handleRejectClick() { + trackTxButtonEvent({ + eventType: "reject", + hostOrigin: data.payload.origin, + chainType: "solana", + instructions: parsedTx?.instructions ?? null, + }); + + onReject(); + } + + function handleApproveClick() { + trackTxButtonEvent({ + eventType: "approve", + hostOrigin: data.payload.origin, + chainType: "solana", + instructions: parsedTx?.instructions ?? null, + }); + + onApprove(); + } + + return ( +
+ +
+ +
+ +
+ +
+ + + + + + + +
+ + +
+ + + +
+ + {isDemo && } +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.module.scss b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.module.scss new file mode 100644 index 000000000..4d9c93982 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.module.scss @@ -0,0 +1,40 @@ +.container { + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + overflow-y: auto; + border-radius: 12px; + background: var(--bg-secondary); + width: 100%; + gap: 16px; +} + +.tokenInfo { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.tokenAmount { + text-overflow: ellipsis; + word-break: break-all; +} + +.address { + word-break: break-all; +} + +.instructionsContainer { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.instructionDivider { + width: 100%; + height: 1px; + background: var(--border-primary); +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.tsx new file mode 100644 index 000000000..a73ddde40 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/instructions.tsx @@ -0,0 +1,111 @@ +import type { FC, ReactNode } from "react"; +import type { ParsedInstruction } from "@oko-wallet-attached/tx-parsers/sol"; +import { Skeleton } from "@oko-wallet/oko-common-ui/skeleton"; + +import styles from "./instructions.module.scss"; +import { SolTransferPretty } from "./transfer/transfer"; +import { TokenTransferPretty } from "./transfer/token_transfer"; +import { UnknownInstruction } from "./unknown/unknown"; + +function renderInstruction( + instruction: ParsedInstruction, + index: number, +): ReactNode { + const { programId, instructionName, data, accounts } = instruction; + + // System Program - SOL Transfer + if (programId === "11111111111111111111111111111111") { + if (instructionName === "transfer") { + const lamports = data.lamports as bigint | number | undefined; + const from = accounts[0]?.pubkey; + const to = accounts[1]?.pubkey; + + if (lamports !== undefined) { + return ( + + ); + } + } + } + + // Token Program - Token Transfer + if ( + programId === "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" || + programId === "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + ) { + // transferChecked: source, mint, destination, owner + if (instructionName === "transferChecked") { + const amount = data.amount as bigint | number | undefined; + const decimals = data.decimals as number | undefined; + const from = accounts[0]?.pubkey; + const mint = accounts[1]?.pubkey; + const to = accounts[2]?.pubkey; + + if (amount !== undefined) { + return ( + + ); + } + } + + // transfer: source, destination, owner (no mint in accounts) + if (instructionName === "transfer") { + const amount = data.amount as bigint | number | undefined; + const from = accounts[0]?.pubkey; + const to = accounts[1]?.pubkey; + + if (amount !== undefined) { + return ( + + ); + } + } + + return ; + } + + // Default: Unknown instruction + return ; +} + +export interface InstructionsProps { + instructions: ParsedInstruction[]; + isLoading?: boolean; +} + +export const Instructions: FC = ({ + instructions, + isLoading, +}) => { + if (isLoading) { + return ; + } + + return ( +
+ {instructions.flatMap((ix, index) => [ + index > 0 && ( +
+ ), + renderInstruction(ix, index), + ])} +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/token_transfer.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/token_transfer.tsx new file mode 100644 index 000000000..5e73c5f98 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/token_transfer.tsx @@ -0,0 +1,128 @@ +import type { FC } from "react"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { Skeleton } from "@oko-wallet/oko-common-ui/skeleton"; + +import { Avatar } from "@oko-wallet-attached/components/avatar/avatar"; +import { TxRow } from "@oko-wallet-attached/components/modal_variants/common/tx_row"; +import { useGetSolanaTokenMetadata } from "@oko-wallet-attached/web3/solana/queries"; +import styles from "../instructions.module.scss"; + +function shortenAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function formatTokenAmount(amount: bigint | number, decimals: number): string { + if (decimals === 0) { + return amount.toLocaleString(); + } + + // Use scientific notation to avoid floating-point precision issues + const formatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }); + + // Convert to scientific notation string: "123456E-6" for amount=123456, decimals=6 + return formatter.format(`${amount}E-${decimals}` as unknown as number); +} + +export interface TokenTransferPrettyProps { + amount: bigint | number; + decimals?: number; + mint?: string; + from?: string; + to?: string; +} + +export const TokenTransferPretty: FC = ({ + amount, + decimals: providedDecimals, + mint, + from, + to, +}) => { + const { data: tokenMetadata, isLoading } = useGetSolanaTokenMetadata({ + mintAddress: mint, + }); + + const decimals = tokenMetadata?.decimals ?? providedDecimals ?? 0; + const symbol = tokenMetadata?.symbol; + const name = tokenMetadata?.name; + const icon = tokenMetadata?.icon; + const hasMetadata = !!symbol; + + const formattedAmount = formatTokenAmount(amount, decimals); + + return ( +
+ +
+ {isLoading ? ( + + ) : hasMetadata ? ( + + ) : null} + + {isLoading ? ( + + ) : hasMetadata ? ( + `${formattedAmount} ${symbol}` + ) : mint ? ( + `${formattedAmount} (${shortenAddress(mint)})` + ) : ( + formattedAmount + )} + +
+
+ {hasMetadata && name && ( + + + {name} + + + )} + {from && ( + + + {shortenAddress(from)} + + + )} + {to && ( + + + {shortenAddress(to)} + + + )} +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/transfer.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/transfer.tsx new file mode 100644 index 000000000..6e933914f --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/transfer/transfer.tsx @@ -0,0 +1,81 @@ +import type { FC } from "react"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; + +import { Avatar } from "@oko-wallet-attached/components/avatar/avatar"; +import styles from "../instructions.module.scss"; +import { TxRow } from "@oko-wallet-attached/components/modal_variants/common/tx_row"; +import { SOLANA_LOGO_URL } from "@oko-wallet-attached/constants/urls"; + +function shortenAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function formatLamports(lamports: bigint | number): string { + // Use scientific notation to avoid floating-point precision issues + // SOL has 9 decimals + const formatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 9, + }); + return `${formatter.format(`${lamports}E-9` as unknown as number)} SOL`; +} + +export interface SolTransferPrettyProps { + lamports: bigint | number; + from?: string; + to?: string; +} + +export const SolTransferPretty: FC = ({ + lamports, + from, + to, +}) => { + return ( +
+ +
+ + + {formatLamports(lamports)} + +
+
+ {from && ( + + + {shortenAddress(from)} + + + )} + {to && ( + + + {shortenAddress(to)} + + + )} +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/unknown/unknown.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/unknown/unknown.tsx new file mode 100644 index 000000000..3466ed581 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/msg/unknown/unknown.tsx @@ -0,0 +1,43 @@ +import type { FC } from "react"; +import type { ParsedInstruction } from "@oko-wallet-attached/tx-parsers/sol"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; + +import styles from "../instructions.module.scss"; +import { TxRow } from "@oko-wallet-attached/components/modal_variants/common/tx_row"; + +export interface UnknownInstructionProps { + instruction: ParsedInstruction; +} + +export const UnknownInstruction: FC = ({ + instruction, +}) => { + const { programName, instructionName, data } = instruction; + + return ( +
+ + + {instructionName} + + + {Object.keys(data).length > 0 && data.raw === undefined && ( + + + {JSON.stringify(data, null, 2)} + + + )} + + + {programName} + + +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.module.scss b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.module.scss new file mode 100644 index 000000000..4e41e3e80 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.module.scss @@ -0,0 +1,6 @@ +.feeContainer { + display: flex; + align-items: center; + gap: 4px; + justify-content: center; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.tsx new file mode 100644 index 000000000..e99bf8dac --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_fee.tsx @@ -0,0 +1,41 @@ +import type { FC } from "react"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; + +import styles from "./sol_tx_fee.module.scss"; +import { useCalculateFee } from "@oko-wallet-attached/web3/solana/use_calculate_fee"; + +const LAMPORTS_PER_SOL = 1_000_000_000; + +function formatFee(lamports: number): string { + const sol = lamports / LAMPORTS_PER_SOL; + if (sol < 0.000001) { + return `${lamports} lamports`; + } + return `${sol.toFixed(6)} SOL`; +} + +export interface SolanaTxFeeProps { + serializedTransaction: string; + isVersioned: boolean; +} + +export const SolanaTxFee: FC = ({ + serializedTransaction, + isVersioned, +}) => { + const { fee } = useCalculateFee({ + serializedTransaction, + isVersioned, + }); + + return ( +
+ + Fee + + + {fee !== null ? formatFee(fee) : "-"} + +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_signature_content.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_signature_content.tsx new file mode 100644 index 000000000..053b36ae3 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_signature_content.tsx @@ -0,0 +1,103 @@ +import type { FC } from "react"; +import type { SolanaTxSignPayload } from "@oko-wallet/oko-sdk-core"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import type { ParsedTransaction } from "@oko-wallet-attached/tx-parsers/sol"; + +import styles from "../common/signature_content.module.scss"; +import { Avatar } from "@oko-wallet-attached/components/avatar/avatar"; +import { SignerAddressOrEmail } from "@oko-wallet-attached/components/modal_variants/common/metadata_content/signer_address_or_email/signer_address_or_email"; +import { SolanaTxSummary } from "./sol_tx_summary"; +import { SOLANA_LOGO_URL } from "@oko-wallet-attached/constants/urls"; + +interface SolanaTxSignatureContentProps { + payload: SolanaTxSignPayload; + parsedTx: ParsedTransaction | null; + parseError: string | null; + isLoading: boolean; +} + +function getFaviconUrl(origin: string): string { + if (!origin) return ""; + try { + const parsed = new URL(origin); + return `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + parsed.origin, + )}`; + } catch { + return ""; + } +} + +export const SolanaTxSignatureContent: FC = ({ + payload, + parsedTx, + parseError, + isLoading, +}) => { + const { origin, signer } = payload; + const faviconUrl = getFaviconUrl(origin); + + return ( +
+
+ Solana + + Solana +
+ + + +
Sign Solana Transaction
+ + + +
+
+ {faviconUrl && faviconUrl.length > 0 && ( + favicon + )} + + {origin.replace(/^https?:\/\//, "")} + +
+ + + +
+
+ + requested your + +
+ + + Solana signature + +
+
+ + +
+
+ + + + +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_summary.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_summary.tsx new file mode 100644 index 000000000..04dfc7539 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/sol_tx_summary.tsx @@ -0,0 +1,107 @@ +import { type FC, type ReactNode, useState, useMemo } from "react"; +import type { SolanaTxSignPayload } from "@oko-wallet/oko-sdk-core"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { ChevronRightIcon } from "@oko-wallet/oko-common-ui/icons/chevron_right"; +import type { ParsedTransaction } from "@oko-wallet-attached/tx-parsers/sol"; + +import styles from "../common/summary.module.scss"; +import { MakeSignatureRawCodeBlock } from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_sig_modal_code_block"; +import { MakeSignatureRawCodeBlockContainer } from "@oko-wallet-attached/components/modal_variants/common/make_signature/make_sig_modal_code_block_container"; +import { TxContainer } from "@oko-wallet-attached/components/modal_variants/eth/tx_sig/actions/common/tx_container"; +import { TxRow } from "@oko-wallet-attached/components/modal_variants/common/tx_row"; +import { Instructions } from "./msg/instructions"; + +export interface SolanaTxSummaryProps { + payload: SolanaTxSignPayload; + parsedTx: ParsedTransaction | null; + parseError: string | null; + isLoading: boolean; +} + +export const SolanaTxSummary: FC = ({ + payload, + parsedTx, + parseError, + isLoading, +}) => { + const [isRawView, setIsRawView] = useState(false); + + const txData = payload.data; + + const { rawData, smartViewContent } = useMemo(() => { + let content: ReactNode; + + if (parseError) { + content = ( + + + + {parseError} + + + + ); + } else if (isLoading || !parsedTx) { + content = ( + + + + Parsing... + + + + ); + } else { + content = ; + } + + return { + rawData: JSON.stringify( + { + serialized_transaction: txData.serialized_transaction, + message_to_sign: txData.message_to_sign, + is_versioned: txData.is_versioned, + }, + null, + 2, + ), + smartViewContent: content, + }; + }, [txData, parsedTx, parseError, isLoading]); + + function handleToggleView() { + setIsRawView((prev) => !prev); + } + + let content: ReactNode | null = null; + + if (isRawView) { + content = ( + + + + ); + } else { + content = smartViewContent; + } + + return ( +
+
+ + Transaction + +
+ + {isRawView ? "Smart View" : "Raw View"} + + +
+
+ {content} +
+ ); +}; diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_parse_tx.ts b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_parse_tx.ts new file mode 100644 index 000000000..ea57a7093 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_parse_tx.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from "react"; +import { + deserializeTransaction, + parseTransaction, + type ParsedTransaction, +} from "@oko-wallet-attached/tx-parsers/sol"; + +import { base64ToUint8Array } from "@oko-wallet-attached/utils/base64"; + +export interface UseParseTxResult { + parsedTx: ParsedTransaction | null; + parseError: string | null; + isLoading: boolean; +} + +export function useParseTx(serializedTransaction: string): UseParseTxResult { + const [parsedTx, setParsedTx] = useState(null); + const [parseError, setParseError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function parseTx() { + setIsLoading(true); + try { + const txBytes = base64ToUint8Array(serializedTransaction); + const deserializeResult = deserializeTransaction(txBytes); + + if (!deserializeResult.success) { + setParseError(deserializeResult.error); + setIsLoading(false); + return; + } + + const parseResult = await parseTransaction(deserializeResult.data); + + if (!parseResult.success) { + setParseError(parseResult.error); + setIsLoading(false); + return; + } + + setParsedTx(parseResult.data); + setParseError(null); + } catch (err) { + setParseError(String(err)); + } finally { + setIsLoading(false); + } + } + + parseTx(); + }, [serializedTransaction]); + + return { parsedTx, parseError, isLoading }; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_tx_sig_modal.tsx b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_tx_sig_modal.tsx new file mode 100644 index 000000000..2203f7c79 --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/tx_sig/use_tx_sig_modal.tsx @@ -0,0 +1,56 @@ +import type { MakeSolTxSignData } from "@oko-wallet/oko-sdk-core"; +import { base64ToUint8Array } from "@oko-wallet-attached/utils/base64"; +import { + useSolSignatureBase, + signMessageToHex, +} from "../use_sol_signature_base"; + +export interface UseTxSigModalArgs { + modalId: string; + data: MakeSolTxSignData; + getIsAborted: () => boolean; +} + +export function useTxSigModal(args: UseTxSigModalArgs) { + const { modalId, data, getIsAborted } = args; + const hostOrigin = data.payload.origin; + + const base = useSolSignatureBase({ modalId, hostOrigin, getIsAborted }); + + async function onApprove() { + if (getIsAborted()) return; + + const ctx = base.prepareSigningContext(); + if (!ctx) return; + + base.setIsLoading(true); + + try { + const message = base64ToUint8Array(data.payload.data.message_to_sign); + const result = await signMessageToHex(message, ctx); + + if (!result.success) { + base.emitError(result.error); + return; + } + + base.closeWithSignature(result.signature); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + base.emitUnknownError(errorMessage); + } finally { + base.setIsLoading(false); + } + } + + return { + onReject: base.onReject, + onApprove, + isLoading: base.isLoading, + isApproveEnabled: base.isApproveEnabled, + isDemo: base.isDemo, + theme: base.theme, + data, + }; +} diff --git a/embed/oko_attached/src/components/modal_variants/sol/use_sol_signature_base.tsx b/embed/oko_attached/src/components/modal_variants/sol/use_sol_signature_base.tsx new file mode 100644 index 000000000..5d2e9671a --- /dev/null +++ b/embed/oko_attached/src/components/modal_variants/sol/use_sol_signature_base.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import type { + MakeSolSigError, + OpenModalAckPayload, +} from "@oko-wallet/oko-sdk-core"; + +import { useAppState } from "@oko-wallet-attached/store/app"; +import { useMemoryState } from "@oko-wallet-attached/store/memory"; +import { DEMO_WEB_ORIGIN } from "@oko-wallet-attached/requests/endpoints"; +import { + makeSignOutputEd25519, + type KeyPackageEd25519, +} from "@oko-wallet-attached/crypto/sign_ed25519"; +import { teddsaKeygenFromHex } from "@oko-wallet-attached/crypto/keygen_ed25519"; + +export interface UseSolSignatureBaseArgs { + modalId: string; + hostOrigin: string; + getIsAborted: () => boolean; +} + +export interface SigningContext { + keyPackage: KeyPackageEd25519; + apiKey: string; + authToken: string; + getIsAborted: () => boolean; +} + +export type SigningResult = + | { success: true; signature: string } + | { success: true; signatures: string[] } + | { success: false }; + +/** + * Sign a single message and return hex-encoded signature + */ +export async function signMessageToHex( + message: Uint8Array, + ctx: SigningContext, +): Promise<{ success: true; signature: string } | { success: false; error: MakeSolSigError }> { + const signatureRes = await makeSignOutputEd25519( + message, + ctx.keyPackage, + ctx.apiKey, + ctx.authToken, + ctx.getIsAborted, + ); + + if (!signatureRes.success) { + return { success: false, error: signatureRes.err }; + } + + const signatureHex = Array.from(signatureRes.data) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return { success: true, signature: signatureHex }; +} + +export function useSolSignatureBase(args: UseSolSignatureBaseArgs) { + const { modalId, hostOrigin, getIsAborted } = args; + const { closeModal, setError } = useMemoryState(); + + const theme = useAppState().getTheme(hostOrigin); + const apiKey = useAppState().getApiKey(hostOrigin); + const authToken = useAppState().getAuthToken(hostOrigin); + const keyPackageHex = useAppState().getKeyPackageEd25519(hostOrigin); + + const [isLoading, setIsLoading] = useState(false); + + const isDemo = !!hostOrigin && hostOrigin === DEMO_WEB_ORIGIN; + const isApproveEnabled = !!keyPackageHex && !!apiKey && !!authToken; + + function onReject() { + const ack: OpenModalAckPayload = { + modal_type: "sol/make_signature", + modal_id: modalId, + type: "reject", + }; + closeModal(ack); + } + + function emitError(error: MakeSolSigError) { + setError({ + modal_type: "sol/make_signature", + modal_id: modalId, + type: "error", + error, + }); + } + + function emitUnknownError(message: string) { + emitError({ type: "unknown_error", error: message }); + } + + function closeWithSignature(signature: string) { + const ack: OpenModalAckPayload = { + modal_type: "sol/make_signature", + modal_id: modalId, + type: "approve", + data: { + chain_type: "sol", + sig_result: { type: "signature", signature }, + }, + }; + closeModal(ack); + } + + function closeWithSignatures(signatures: string[]) { + const ack: OpenModalAckPayload = { + modal_type: "sol/make_signature", + modal_id: modalId, + type: "approve", + data: { + chain_type: "sol", + sig_result: { type: "signatures", signatures }, + }, + }; + closeModal(ack); + } + + /** + * Prepare signing context. Returns null if validation fails. + */ + function prepareSigningContext(): SigningContext | null { + if (!keyPackageHex || !apiKey || !authToken) { + emitUnknownError("Missing key package, API key, or auth token"); + return null; + } + + const keyPackageRes = teddsaKeygenFromHex(keyPackageHex); + if (!keyPackageRes.success) { + emitUnknownError(keyPackageRes.err); + return null; + } + + return { + keyPackage: { + keyPackage: keyPackageRes.data.key_package, + publicKeyPackage: keyPackageRes.data.public_key_package, + identifier: keyPackageRes.data.identifier, + }, + apiKey, + authToken, + getIsAborted, + }; + } + + return { + // State + isLoading, + setIsLoading, + isApproveEnabled, + isDemo, + theme, + + // Actions + onReject, + emitError, + emitUnknownError, + closeWithSignature, + closeWithSignatures, + + // Signing + prepareSigningContext, + getIsAborted, + }; +} diff --git a/embed/oko_attached/src/constants/urls.ts b/embed/oko_attached/src/constants/urls.ts new file mode 100644 index 000000000..fe151f494 --- /dev/null +++ b/embed/oko_attached/src/constants/urls.ts @@ -0,0 +1,4 @@ +export const S3_BUCKET_URL = + "https://oko-wallet.s3.ap-northeast-2.amazonaws.com/icons"; + +export const SOLANA_LOGO_URL = `${S3_BUCKET_URL}/solana.png`; diff --git a/embed/oko_attached/src/crypto/hash.ts b/embed/oko_attached/src/crypto/hash.ts index b45434c39..60ac3086c 100644 --- a/embed/oko_attached/src/crypto/hash.ts +++ b/embed/oko_attached/src/crypto/hash.ts @@ -2,6 +2,10 @@ import { Bytes, type Bytes32 } from "@oko-wallet/bytes"; import { sha256 } from "@oko-wallet/crypto-js"; import type { Result } from "@oko-wallet/stdlib-js"; +/** + * Hash keyshare node names to get identifiers for secp256k1 SSS. + * The hash is reduced to fit within the secp256k1 scalar field order. + */ export async function hashKeyshareNodeNames( keyshareNodeNames: string[], ): Promise> { @@ -35,3 +39,43 @@ export async function hashKeyshareNodeNames( data: hashes, }; } + +/** + * Hash keyshare node names to get identifiers for Ed25519 SSS. + * The hash is reduced to fit within the Ed25519 scalar field order (~2^252). + * Ed25519 uses little-endian byte order, so we reduce the most significant byte (byte 31). + */ +export async function hashKeyshareNodeNamesEd25519( + keyshareNodeNames: string[], +): Promise> { + const hashes = []; + for (const name of keyshareNodeNames) { + const hashResult = sha256(name); + if (hashResult.success === false) { + return { + success: false, + err: hashResult.err, + }; + } + const hash = hashResult.data; + const hashU8Arr = new Uint8Array(hash.toUint8Array()); + // Ed25519 scalar order is ~2^252 (specifically: 2^252 + 27742317777372353535851937790883648493) + // In little-endian, byte 31 is the most significant byte. + // Set byte 31 to 0x0F or less to ensure value is < 2^252 + // This gives us ~248 bits of entropy which is still cryptographically secure. + hashU8Arr[31] = hashU8Arr[31] & 0x0f; + const bytesRes = Bytes.fromUint8Array(hashU8Arr, 32); + if (bytesRes.success === false) { + return { + success: false, + err: bytesRes.err, + }; + } + hashes.push(bytesRes.data); + } + + return { + success: true, + data: hashes, + }; +} diff --git a/embed/oko_attached/src/crypto/keygen_ed25519.ts b/embed/oko_attached/src/crypto/keygen_ed25519.ts new file mode 100644 index 000000000..cc09eadbd --- /dev/null +++ b/embed/oko_attached/src/crypto/keygen_ed25519.ts @@ -0,0 +1,68 @@ +import type { TeddsaKeygenOutputBytes } from "@oko-wallet/teddsa-hooks"; +import type { Result } from "@oko-wallet/stdlib-js"; +import { Bytes, type Bytes32 } from "@oko-wallet/bytes"; + +export interface KeyPackageEd25519Hex { + keyPackage: string; + publicKeyPackage: string; + identifier: string; + publicKey: string; +} + +export function hexToUint8Array(hex: string): Uint8Array { + const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex; + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16); + } + return bytes; +} + +export function uint8ArrayToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function teddsaKeygenToHex( + keygen: TeddsaKeygenOutputBytes, +): KeyPackageEd25519Hex { + return { + keyPackage: uint8ArrayToHex(keygen.key_package), + publicKeyPackage: uint8ArrayToHex(keygen.public_key_package), + identifier: uint8ArrayToHex(keygen.identifier), + publicKey: keygen.public_key.toHex(), + }; +} + +export function teddsaKeygenFromHex( + data: KeyPackageEd25519Hex, +): Result { + try { + const publicKeyRes = Bytes.fromHexString(data.publicKey, 32); + if (!publicKeyRes.success) { + return { success: false, err: publicKeyRes.err }; + } + + return { + success: true, + data: { + key_package: hexToUint8Array(data.keyPackage), + public_key_package: hexToUint8Array(data.publicKeyPackage), + identifier: hexToUint8Array(data.identifier), + public_key: publicKeyRes.data, + }, + }; + } catch (error) { + return { + success: false, + err: error instanceof Error ? error.message : String(error), + }; + } +} + +export function getPublicKeyFromKeyPackage( + keyPackageHex: KeyPackageEd25519Hex, +): Result { + return Bytes.fromHexString(keyPackageHex.publicKey, 32); +} diff --git a/embed/oko_attached/src/crypto/sign_ed25519.ts b/embed/oko_attached/src/crypto/sign_ed25519.ts new file mode 100644 index 000000000..f0bf9c358 --- /dev/null +++ b/embed/oko_attached/src/crypto/sign_ed25519.ts @@ -0,0 +1,160 @@ +import { + teddsaSignRound1, + teddsaSignRound2, + teddsaAggregate, +} from "@oko-wallet/teddsa-hooks"; +import type { + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "@oko-wallet/teddsa-interface"; +import type { Result } from "@oko-wallet/stdlib-js"; +import type { MakeSignOutputError } from "@oko-wallet/oko-sdk-core"; +import { + reqPresignEd25519, + reqSignEd25519, +} from "@oko-wallet/teddsa-api-lib"; + +import { TSS_V1_ENDPOINT } from "@oko-wallet-attached/requests/oko_api"; + +export interface KeyPackageEd25519 { + keyPackage: Uint8Array; + publicKeyPackage: Uint8Array; + identifier: Uint8Array; +} + +/** + * Ed25519 signing using presign flow. + * + * Flow: + * 1. Server presign: Generate server nonces/commitments (requires apiKey) + * 2. Client round1: Generate client nonces/commitments + * 3. Server sign: Generate server signature share using presign (no apiKey needed) + * 4. Client round2: Generate client signature share + * 5. Aggregate: Combine signature shares into final signature + */ +export async function makeSignOutputEd25519( + message: Uint8Array, + keyPackage: KeyPackageEd25519, + apiKey: string, + authToken: string, + getIsAborted: () => boolean, +): Promise> { + try { + if (getIsAborted()) { + return { success: false, err: { type: "aborted" } }; + } + + // 1. Server presign: Get server commitments (requires apiKey for session creation) + const presignRes = await reqPresignEd25519(TSS_V1_ENDPOINT, {}, apiKey, authToken); + + if (!presignRes.success) { + return { + success: false, + err: { type: "sign_fail", error: { type: "error", msg: presignRes.msg } }, + }; + } + + const { session_id: sessionId, commitments_0: serverCommitment } = presignRes.data; + + if (getIsAborted()) { + return { success: false, err: { type: "aborted" } }; + } + + // 2. Client round1: Generate client nonces and commitments + const round1Result = teddsaSignRound1(keyPackage.keyPackage); + if (!round1Result.success) { + return { + success: false, + err: { type: "sign_fail", error: { type: "error", msg: round1Result.err } }, + }; + } + + const clientCommitment: TeddsaCommitmentEntry = { + identifier: round1Result.data.identifier, + commitments: round1Result.data.commitments, + }; + + const allCommitments: TeddsaCommitmentEntry[] = [ + clientCommitment, + serverCommitment, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + if (getIsAborted()) { + return { success: false, err: { type: "aborted" } }; + } + + // 3. Server sign: Get server signature share using presign session (no apiKey needed) + const serverSignRes = await reqSignEd25519( + TSS_V1_ENDPOINT, + { + session_id: sessionId, + msg: [...message], + commitments_1: clientCommitment, + }, + authToken, + ); + + if (!serverSignRes.success) { + return { + success: false, + err: { type: "sign_fail", error: { type: "error", msg: serverSignRes.msg } }, + }; + } + + const serverSignatureShare: TeddsaSignatureShareEntry = serverSignRes.data.signature_share_0; + + if (getIsAborted()) { + return { success: false, err: { type: "aborted" } }; + } + + // 4. Client round2: Generate client signature share + const round2Result = teddsaSignRound2( + message, + keyPackage.keyPackage, + new Uint8Array(round1Result.data.nonces), + allCommitments, + ); + + if (!round2Result.success) { + return { + success: false, + err: { type: "sign_fail", error: { type: "error", msg: round2Result.err } }, + }; + } + + const clientSignatureShare: TeddsaSignatureShareEntry = { + identifier: round2Result.data.identifier, + signature_share: round2Result.data.signature_share, + }; + + const allSignatureShares: TeddsaSignatureShareEntry[] = [ + clientSignatureShare, + serverSignatureShare, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // 5. Aggregate: Combine signature shares into final signature + const aggregateResult = teddsaAggregate( + message, + allCommitments, + allSignatureShares, + keyPackage.publicKeyPackage, + ); + + if (!aggregateResult.success) { + return { + success: false, + err: { type: "sign_fail", error: { type: "error", msg: aggregateResult.err } }, + }; + } + + return { success: true, data: aggregateResult.data }; + } catch (error) { + return { + success: false, + err: { + type: "sign_fail", + error: { type: "error", msg: error instanceof Error ? error.message : String(error) }, + }, + }; + } +} diff --git a/embed/oko_attached/src/crypto/sss_ed25519.ts b/embed/oko_attached/src/crypto/sss_ed25519.ts new file mode 100644 index 000000000..6a31c3006 --- /dev/null +++ b/embed/oko_attached/src/crypto/sss_ed25519.ts @@ -0,0 +1,239 @@ +import type { KeyShareNodeMetaWithNodeStatusInfo } from "@oko-wallet/oko-types/tss"; +import { wasmModule } from "@oko-wallet/frost-ed25519-keplr-wasm"; +import { Bytes, type Bytes32 } from "@oko-wallet/bytes"; +import type { + PointNumArr, + UserKeySharePointByNode, +} from "@oko-wallet/oko-types/user_key_share"; +import type { TeddsaKeygenOutputBytes } from "@oko-wallet/teddsa-hooks"; +import type { Result } from "@oko-wallet/stdlib-js"; + +import { hashKeyshareNodeNamesEd25519 } from "./hash"; + +/** + * Data structure for Ed25519 key share backup on KS nodes. + * Contains the SSS split of signing_share along with public info needed for reconstruction. + */ +export interface Ed25519KeyShareBackup { + /** SSS split point (x: identifier, y: share) */ + share: { + x: Bytes32; + y: Bytes32; + }; + /** Serialized PublicKeyPackage (needed for reconstruction) */ + publicKeyPackage: string; // hex string + /** Participant identifier (needed for reconstruction) */ + identifier: string; // hex string + /** Ed25519 public key (for verification) */ + publicKey: string; // hex string +} + +/** + * Split Ed25519 key package for backup on Key Share Nodes. + * + * Extracts the signing_share from the key_package and splits it using SSS. + * Also includes the public information needed to reconstruct the key_package. + */ +export async function splitUserKeySharesEd25519( + keygen_1: TeddsaKeygenOutputBytes, + keyshareNodeMeta: KeyShareNodeMetaWithNodeStatusInfo, +): Promise> { + try { + const keyPackageBytes = [...keygen_1.key_package]; + + // Extract signing_share from key_package (32-byte scalar) + const signingShareArr: number[] = + wasmModule.extract_signing_share(keyPackageBytes); + + // Hash KS node names to get identifiers for SSS (Ed25519-compatible) + const keyshareNodeHashesRes = await hashKeyshareNodeNamesEd25519( + keyshareNodeMeta.nodes.map((meta) => meta.name), + ); + if (keyshareNodeHashesRes.success === false) { + return { + success: false, + err: keyshareNodeHashesRes.err, + }; + } + const keyshareNodeHashes = keyshareNodeHashesRes.data.map((bytes) => { + return [...bytes.toUint8Array()]; + }); + + // Split signing_share using SSS + const splitPoints: PointNumArr[] = wasmModule.sss_split( + signingShareArr, + keyshareNodeHashes, + keyshareNodeMeta.threshold, + ); + + // Convert to UserKeySharePointByNode format + const shares: UserKeySharePointByNode[] = splitPoints.map( + (point: PointNumArr, index: number) => { + const xBytesRes = Bytes.fromUint8Array( + Uint8Array.from([...point.x]), + 32, + ); + if (xBytesRes.success === false) { + throw new Error(xBytesRes.err); + } + const yBytesRes = Bytes.fromUint8Array( + Uint8Array.from([...point.y]), + 32, + ); + if (yBytesRes.success === false) { + throw new Error(yBytesRes.err); + } + return { + node: { + name: keyshareNodeMeta.nodes[index].name, + endpoint: keyshareNodeMeta.nodes[index].endpoint, + }, + share: { + x: xBytesRes.data, + y: yBytesRes.data, + }, + }; + }, + ); + + return { + success: true, + data: shares, + }; + } catch (error: any) { + return { + success: false, + err: `splitUserKeySharesEd25519 failed: ${String(error)}`, + }; + } +} + +/** + * Combine Ed25519 key shares to recover the signing_share. + */ +export async function combineUserSharesEd25519( + userKeySharePoints: UserKeySharePointByNode[], + threshold: number, +): Promise> { + try { + if (threshold < 2) { + return { + success: false, + err: "Threshold must be at least 2", + }; + } + + if (userKeySharePoints.length < threshold) { + return { + success: false, + err: "Number of user key shares is less than threshold", + }; + } + + const points: PointNumArr[] = userKeySharePoints.map( + (userKeySharePoint) => ({ + x: [...userKeySharePoint.share.x.toUint8Array()], + y: [...userKeySharePoint.share.y.toUint8Array()], + }), + ); + + // Combine shares to recover signing_share + const combinedSigningShare: number[] = wasmModule.sss_combine( + points, + threshold, + ); + + return { + success: true, + data: Uint8Array.from(combinedSigningShare), + }; + } catch (e) { + return { + success: false, + err: `combineUserSharesEd25519 failed: ${String(e)}`, + }; + } +} + +/** + * Reconstruct a full KeyPackage from a recovered signing_share and public info. + */ +export async function reconstructKeyPackageEd25519( + signingShare: Uint8Array, + publicKeyPackage: Uint8Array, + identifier: Uint8Array, +): Promise> { + try { + const keyPackageArr: number[] = wasmModule.reconstruct_key_package( + [...signingShare], + [...publicKeyPackage], + [...identifier], + ); + + return { + success: true, + data: Uint8Array.from(keyPackageArr), + }; + } catch (e) { + return { + success: false, + err: `reconstructKeyPackageEd25519 failed: ${String(e)}`, + }; + } +} + +/** + * Full recovery of Ed25519 keygen output from KS node shares. + * + * Given the SSS shares from KS nodes plus the stored public info, + * reconstructs the complete TeddsaKeygenOutputBytes. + */ +export async function recoverEd25519Keygen( + userKeySharePoints: UserKeySharePointByNode[], + threshold: number, + publicKeyPackage: Uint8Array, + identifier: Uint8Array, + publicKey: Bytes32, +): Promise> { + try { + // Combine shares to recover signing_share + const combineRes = await combineUserSharesEd25519( + userKeySharePoints, + threshold, + ); + if (!combineRes.success) { + return { + success: false, + err: combineRes.err, + }; + } + + // Reconstruct key_package from signing_share + public info + const reconstructRes = await reconstructKeyPackageEd25519( + combineRes.data, + publicKeyPackage, + identifier, + ); + if (!reconstructRes.success) { + return { + success: false, + err: reconstructRes.err, + }; + } + + return { + success: true, + data: { + key_package: reconstructRes.data, + public_key_package: publicKeyPackage, + identifier: identifier, + public_key: publicKey, + }, + }; + } catch (e) { + return { + success: false, + err: `recoverEd25519Keygen failed: ${String(e)}`, + }; + } +} diff --git a/embed/oko_attached/src/requests/ks_node.ts b/embed/oko_attached/src/requests/ks_node.ts index 0a3d7a86d..0141f5856 100644 --- a/embed/oko_attached/src/requests/ks_node.ts +++ b/embed/oko_attached/src/requests/ks_node.ts @@ -1,4 +1,5 @@ import { type GetKeyShareResponse } from "@oko-wallet/ksn-interface/key_share"; +import type { CurveType } from "@oko-wallet/ksn-interface/curve_type"; import type { NodeStatusInfo } from "@oko-wallet/oko-types/tss"; import type { AuthType } from "@oko-wallet/oko-types/auth"; import type { @@ -9,7 +10,7 @@ import type { Result } from "@oko-wallet/stdlib-js"; import type { KSNodeApiResponse } from "@oko-wallet/ksn-interface/response"; import type { RequestSplitSharesError } from "../types/ks_node_request"; -import type { Bytes33 } from "@oko-wallet/bytes"; +import type { Bytes32, Bytes33 } from "@oko-wallet/bytes"; import { decodeKeyShareStringToPoint256, encodePoint256ToKeyShareString, @@ -280,3 +281,230 @@ message(${response.statusText}) in ${ksNodeEndpoint}`, }; } } + +/** + * Send Ed25519 key share to a single KS node. + */ +export async function doSendUserKeySharesEd25519( + ksNodeEndpoint: string, + idToken: string, + publicKey: Bytes32, + serverShare: Point256, + authType: AuthType, +): Promise> { + try { + const serverShareString = encodePoint256ToKeyShareString(serverShare); + const response = await fetch(`${ksNodeEndpoint}/keyshare/v1/register`, { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + auth_type: authType, + curve_type: "ed25519" as CurveType, + public_key: publicKey.toHex(), + share: serverShareString, + }), + }); + + if (!response.ok) { + try { + const data = (await response.json()) as KSNodeApiResponse; + if (!data.success && data.code === "DUPLICATE_PUBLIC_KEY") { + return { success: true, data: void 0 }; + } + } catch (_) {} + + return { + success: false, + err: `Failed to send Ed25519 key share: status(${response.status}) \ +message(${response.statusText}) in ${ksNodeEndpoint}`, + }; + } + + const data = (await response.json()) as { success: boolean }; + if (data.success === false) { + return { + success: false, + err: `Failed to register Ed25519 key share in ${ksNodeEndpoint}`, + }; + } + return { + success: true, + data: void 0, + }; + } catch (e) { + return { + success: false, + err: `Failed to send Ed25519 key share in ${ksNodeEndpoint}: ${String(e)}`, + }; + } +} + +/** + * Request Ed25519 key shares from KS nodes for recovery. + */ +export async function requestSplitSharesEd25519( + publicKey: Bytes32, + idToken: string, + allNodes: NodeStatusInfo[], + threshold: number, + authType: AuthType = "google", +): Promise> { + const shuffledNodes = [...allNodes]; + for (let i = shuffledNodes.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledNodes[i], shuffledNodes[j]] = [shuffledNodes[j], shuffledNodes[i]]; + } + + const succeeded: UserKeySharePointByNode[] = []; + let nodesToTry = shuffledNodes.slice(0, threshold); + let backupNodes = shuffledNodes.slice(threshold); + + while (succeeded.length < threshold && nodesToTry.length > 0) { + const results = await Promise.allSettled( + nodesToTry.map((node) => + requestSplitShareEd25519(publicKey, idToken, node, authType), + ), + ); + + const failedNodes: NodeStatusInfo[] = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const node = nodesToTry[i]; + + if (result.status === "fulfilled" && result.value.success) { + succeeded.push(result.value.data); + } else { + const errorCode = + result.status === "fulfilled" && !result.value.success + ? result.value.err + : null; + + if (errorCode === "WALLET_NOT_FOUND") { + return { + success: false, + err: { + code: "WALLET_NOT_FOUND", + affectedNode: { name: node.name, endpoint: node.endpoint }, + }, + }; + } + + failedNodes.push(node); + } + } + + if (succeeded.length >= threshold) { + return { + success: true, + data: succeeded.slice(0, threshold), + }; + } + + nodesToTry = []; + for (let i = 0; i < failedNodes.length && backupNodes.length > 0; i++) { + nodesToTry.push(backupNodes.shift()!); + } + } + + return { + success: false, + err: { + code: "INSUFFICIENT_SHARES", + got: succeeded.length, + need: threshold, + }, + }; +} + +/** + * Request Ed25519 key share from a single KS node. + */ +export async function requestSplitShareEd25519( + publicKey: Bytes32, + idToken: string, + node: NodeStatusInfo, + authType: AuthType, + maxRetries: number = 2, +): Promise> { + let attempt = 0; + while (attempt < maxRetries) { + try { + const response = await fetch(`${node.endpoint}/keyshare/v1/`, { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + auth_type: authType, + curve_type: "ed25519" as CurveType, + public_key: publicKey.toHex(), + }), + }); + + if (!response.ok) { + let parsedCode: string | null = null; + try { + const data = + (await response.json()) as KSNodeApiResponse; + if (data.success === false) { + parsedCode = data.code || null; + const isNotFound = + data.code === "USER_NOT_FOUND" || + data.code === "WALLET_NOT_FOUND"; + if (isNotFound) { + return { success: false, err: "WALLET_NOT_FOUND" }; + } + } + } catch (_) {} + + if (attempt < maxRetries - 1) { + attempt = attempt + 1; + continue; + } + return { success: false, err: parsedCode ?? `HTTP_${response.status}` }; + } + + const data = + (await response.json()) as KSNodeApiResponse; + + if (data.success === false) { + return { success: false, err: data.code || "UNKNOWN_ERROR" }; + } + + const sharePointRes = decodeKeyShareStringToPoint256(data.data.share); + if (sharePointRes.success === false) { + return { success: false, err: sharePointRes.err }; + } + + return { + success: true, + data: { + node: { + name: node.name, + endpoint: node.endpoint, + }, + share: sharePointRes.data, + }, + }; + } catch (e) { + if (attempt < maxRetries - 1) { + attempt = attempt + 1; + continue; + } + return { + success: false, + err: `Failed to request Ed25519 split share: ${String(e)}`, + }; + } + } + + return { + success: false, + err: "Failed to request Ed25519 split share: max retries exceeded", + }; +} diff --git a/embed/oko_attached/src/store/app.ts b/embed/oko_attached/src/store/app.ts index d8519557c..9ded68879 100644 --- a/embed/oko_attached/src/store/app.ts +++ b/embed/oko_attached/src/store/app.ts @@ -13,10 +13,18 @@ interface WalletState { name: string | null; } +interface KeyPackageEd25519State { + keyPackage: string; + publicKeyPackage: string; + identifier: string; + publicKey: string; +} + interface PerOriginState { theme: Theme | null; apiKey: string | null; keyshare_1: string | null; + keyPackageEd25519: KeyPackageEd25519State | null; nonce: string | null; codeVerifier: string | null; authToken: string | null; @@ -43,6 +51,12 @@ interface AppActions { getKeyshare_1: (hostOrigin: string) => string | null; setKeyshare_1: (hostOrigin: string, keyshare_1: string | null) => void; + getKeyPackageEd25519: (hostOrigin: string) => KeyPackageEd25519State | null; + setKeyPackageEd25519: ( + hostOrigin: string, + keyPackage: KeyPackageEd25519State | null, + ) => void; + getApiKey: (hostOrigin: string) => string | null; setApiKey: (hostOrigin: string, apiKey: string | null) => void; @@ -103,6 +117,20 @@ export const useAppState = create( }, }); }, + setKeyPackageEd25519: ( + hostOrigin: string, + keyPackageEd25519: KeyPackageEd25519State | null, + ) => { + set({ + perOrigin: { + ...get().perOrigin, + [hostOrigin]: { + ...get().perOrigin[hostOrigin], + keyPackageEd25519, + }, + }, + }); + }, setAuthToken: (hostOrigin: string, authToken: string | null) => { set({ perOrigin: { @@ -123,6 +151,7 @@ export const useAppState = create( theme: null, apiKey: null, keyshare_1: null, + keyPackageEd25519: null, nonce: null, codeVerifier: null, authToken: null, @@ -167,6 +196,9 @@ export const useAppState = create( getKeyshare_1: (hostOrigin: string) => { return get().perOrigin[hostOrigin]?.keyshare_1; }, + getKeyPackageEd25519: (hostOrigin: string) => { + return get().perOrigin[hostOrigin]?.keyPackageEd25519; + }, getApiKey: (hostOrigin: string) => { return get().perOrigin[hostOrigin]?.apiKey; }, diff --git a/embed/oko_attached/src/tx-parsers/sol/index.ts b/embed/oko_attached/src/tx-parsers/sol/index.ts new file mode 100644 index 000000000..1f24da61a --- /dev/null +++ b/embed/oko_attached/src/tx-parsers/sol/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./parser"; +export * from "./instruction"; diff --git a/embed/oko_attached/src/tx-parsers/sol/instruction.ts b/embed/oko_attached/src/tx-parsers/sol/instruction.ts new file mode 100644 index 000000000..58be6d9a0 --- /dev/null +++ b/embed/oko_attached/src/tx-parsers/sol/instruction.ts @@ -0,0 +1,120 @@ +import { + SolanaFMParser, + ParserType, + type InstructionParserInterface, +} from "@solanafm/explorer-kit"; +import { getProgramIdl } from "@solanafm/explorer-kit-idls"; +import type { ParsedInstruction, ParsedAccount, ParseResult } from "./types"; + +const parserCache = new Map(); + +const KNOWN_PROGRAMS: Record = { + "11111111111111111111111111111111": "System Program", + TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA: "Token Program", + TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb: "Token-2022", + ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL: "Associated Token Program", + JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4: "Jupiter v6", + whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc: "Orca Whirlpool", + "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin": "Serum DEX v3", +}; + +async function getParserForProgram( + programId: string, +): Promise { + if (parserCache.has(programId)) { + return parserCache.get(programId) ?? null; + } + + try { + const idl = await getProgramIdl(programId); + if (!idl) { + parserCache.set(programId, null); + return null; + } + + const parser = new SolanaFMParser(idl, programId); + const instructionParser = parser.createParser(ParserType.INSTRUCTION); + + if ( + instructionParser && + "parseInstructions" in instructionParser + ) { + parserCache.set(programId, instructionParser as InstructionParserInterface); + return instructionParser as InstructionParserInterface; + } + } catch { + parserCache.set(programId, null); + } + + return null; +} + +export async function parseInstruction( + programId: string, + instructionData: string, + accounts: { pubkey: string; isSigner: boolean; isWritable: boolean }[], +): Promise> { + const programName = KNOWN_PROGRAMS[programId] ?? "Unknown Program"; + + const parser = await getParserForProgram(programId); + + if (!parser) { + return { + success: true, + data: { + programId, + programName, + instructionName: "Unknown", + data: { raw: instructionData }, + accounts: accounts.map((acc) => ({ + pubkey: acc.pubkey, + isSigner: acc.isSigner, + isWritable: acc.isWritable, + })), + }, + }; + } + + try { + const parsed = parser.parseInstructions(instructionData); + + if (!parsed) { + return { + success: true, + data: { + programId, + programName, + instructionName: "Unknown", + data: { raw: instructionData }, + accounts: accounts.map((acc) => ({ + pubkey: acc.pubkey, + isSigner: acc.isSigner, + isWritable: acc.isWritable, + })), + }, + }; + } + + const parsedAccounts: ParsedAccount[] = accounts.map((acc) => ({ + pubkey: acc.pubkey, + isSigner: acc.isSigner, + isWritable: acc.isWritable, + })); + + return { + success: true, + data: { + programId, + programName, + instructionName: parsed.name ?? "Unknown", + data: parsed.data ?? {}, + accounts: parsedAccounts, + }, + }; + } catch (error) { + return { + success: false, + error: `Failed to parse instruction: ${error}`, + }; + } +} diff --git a/embed/oko_attached/src/tx-parsers/sol/parser.ts b/embed/oko_attached/src/tx-parsers/sol/parser.ts new file mode 100644 index 000000000..debfd67dd --- /dev/null +++ b/embed/oko_attached/src/tx-parsers/sol/parser.ts @@ -0,0 +1,115 @@ +import { + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; +import { parseInstruction } from "./instruction"; +import type { + ParsedTransaction, + ParsedInstruction, + ParseResult, +} from "./types"; +import bs58 from "bs58"; + +export async function parseTransaction( + transaction: Transaction | VersionedTransaction, +): Promise> { + try { + if (transaction instanceof Transaction) { + return await parseLegacyTransaction(transaction); + } else { + return await parseVersionedTransaction(transaction); + } + } catch (error) { + return { + success: false, + error: `Failed to parse transaction: ${error}`, + }; + } +} + +async function parseLegacyTransaction( + transaction: Transaction, +): Promise> { + const instructions: ParsedInstruction[] = []; + + for (const ix of transaction.instructions) { + const accounts = ix.keys.map((key) => ({ + pubkey: key.pubkey.toBase58(), + isSigner: key.isSigner, + isWritable: key.isWritable, + })); + + const result = await parseInstruction( + ix.programId.toBase58(), + bs58.encode(ix.data), + accounts, + ); + + if (result.success) { + instructions.push(result.data); + } + } + + return { + success: true, + data: { + instructions, + feePayer: transaction.feePayer?.toBase58() ?? null, + recentBlockhash: transaction.recentBlockhash ?? null, + }, + }; +} + +async function parseVersionedTransaction( + transaction: VersionedTransaction, +): Promise> { + const message = transaction.message; + const instructions: ParsedInstruction[] = []; + const accountKeys = message.staticAccountKeys; + + for (const ix of message.compiledInstructions) { + const programId = accountKeys[ix.programIdIndex].toBase58(); + + const accounts = ix.accountKeyIndexes.map((idx) => ({ + pubkey: accountKeys[idx].toBase58(), + isSigner: message.isAccountSigner(idx), + isWritable: message.isAccountWritable(idx), + })); + + const result = await parseInstruction(programId, bs58.encode(ix.data), accounts); + + if (result.success) { + instructions.push(result.data); + } + } + + return { + success: true, + data: { + instructions, + feePayer: accountKeys[0]?.toBase58() ?? null, + recentBlockhash: message.recentBlockhash ?? null, + }, + }; +} + +export function deserializeTransaction( + serialized: Uint8Array | Buffer, +): ParseResult { + try { + const buffer = Buffer.from(serialized); + + try { + const versionedTx = VersionedTransaction.deserialize(buffer); + return { success: true, data: versionedTx }; + } catch { + const legacyTx = Transaction.from(buffer); + return { success: true, data: legacyTx }; + } + } catch (error) { + return { + success: false, + error: `Failed to deserialize transaction: ${error}`, + }; + } +} diff --git a/embed/oko_attached/src/tx-parsers/sol/types.ts b/embed/oko_attached/src/tx-parsers/sol/types.ts new file mode 100644 index 000000000..57bb9e6c5 --- /dev/null +++ b/embed/oko_attached/src/tx-parsers/sol/types.ts @@ -0,0 +1,24 @@ +export interface ParsedInstruction { + programId: string; + programName: string; + instructionName: string; + data: Record; + accounts: ParsedAccount[]; +} + +export interface ParsedAccount { + pubkey: string; + isSigner: boolean; + isWritable: boolean; + name?: string; +} + +export interface ParsedTransaction { + instructions: ParsedInstruction[]; + feePayer: string | null; + recentBlockhash: string | null; +} + +export type ParseResult = + | { success: true; data: T } + | { success: false; error: string }; diff --git a/embed/oko_attached/src/utils/base64.ts b/embed/oko_attached/src/utils/base64.ts new file mode 100644 index 000000000..725fa4d90 --- /dev/null +++ b/embed/oko_attached/src/utils/base64.ts @@ -0,0 +1,8 @@ +export function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} diff --git a/embed/oko_attached/src/wasm/index.ts b/embed/oko_attached/src/wasm/index.ts index c5aa465d7..17d3beed0 100644 --- a/embed/oko_attached/src/wasm/index.ts +++ b/embed/oko_attached/src/wasm/index.ts @@ -1,11 +1,23 @@ -import * as wasmModule from "@oko-wallet/cait-sith-keplr-wasm/pkg/cait_sith_keplr_wasm"; -import { initWasm } from "@oko-wallet/cait-sith-keplr-wasm"; +import * as caitSithWasmModule from "@oko-wallet/cait-sith-keplr-wasm/pkg/cait_sith_keplr_wasm"; +import { initWasm as initCaitSithWasm } from "@oko-wallet/cait-sith-keplr-wasm"; +import * as frostWasmModule from "@oko-wallet/frost-ed25519-keplr-wasm/pkg/frost_ed25519_keplr_wasm"; +import { initWasm as initFrostWasm } from "@oko-wallet/frost-ed25519-keplr-wasm"; export async function initKeplrWasm() { try { - await initWasm(wasmModule, "/pkg/cait_sith_keplr_wasm_bg.wasm"); - console.log("[attached] WASM initialized"); + await initCaitSithWasm( + caitSithWasmModule, + "/pkg/cait_sith_keplr_wasm_bg.wasm", + ); + console.log("[attached] cait-sith WASM initialized"); } catch (err) { - console.error("[attached] Error initializing WASM, err: %s", err); + console.error("[attached] Error initializing cait-sith WASM, err: %s", err); + } + + try { + await initFrostWasm(frostWasmModule, "/pkg/frost_ed25519_keplr_wasm_bg.wasm"); + console.log("[attached] frost-ed25519 WASM initialized"); + } catch (err) { + console.error("[attached] Error initializing frost-ed25519 WASM, err: %s", err); } } diff --git a/embed/oko_attached/src/web3/solana/queries/index.ts b/embed/oko_attached/src/web3/solana/queries/index.ts new file mode 100644 index 000000000..09a0f0dc0 --- /dev/null +++ b/embed/oko_attached/src/web3/solana/queries/index.ts @@ -0,0 +1 @@ +export * from "./use_get_token_metadata"; diff --git a/embed/oko_attached/src/web3/solana/queries/use_get_token_metadata.ts b/embed/oko_attached/src/web3/solana/queries/use_get_token_metadata.ts new file mode 100644 index 000000000..a42755806 --- /dev/null +++ b/embed/oko_attached/src/web3/solana/queries/use_get_token_metadata.ts @@ -0,0 +1,91 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +const JUPITER_TOKEN_API_V2 = "https://api.jup.ag/tokens/v2"; + +export interface JupiterToken { + id: string; + name: string; + symbol: string; + decimals: number; + icon: string | null; + tags: string[]; + isVerified: boolean; +} + +export type SolanaTokenMetadataResult = { + name?: string; + symbol?: string; + decimals?: number; + icon?: string; +}; + +export interface UseGetSolanaTokenMetadataProps { + mintAddress?: string; + options?: Partial>; +} + +async function fetchTokenMetadata( + mintAddress: string, +): Promise { + const url = `${JUPITER_TOKEN_API_V2}/search?query=${encodeURIComponent(mintAddress)}`; + + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error( + `Jupiter API error: ${response.status} ${response.statusText}`, + ); + } + + const tokens: JupiterToken[] = await response.json(); + + const token = tokens.find( + (t) => t.id.toLowerCase() === mintAddress.toLowerCase(), + ); + + return token ?? null; +} + +export function useGetSolanaTokenMetadata({ + mintAddress, + options, +}: UseGetSolanaTokenMetadataProps) { + return useQuery({ + queryKey: ["solana-token-metadata", mintAddress], + queryFn: async (): Promise => { + const defaultResult = { + name: undefined, + symbol: undefined, + decimals: undefined, + icon: undefined, + }; + + if (!mintAddress) { + return defaultResult; + } + + try { + const token = await fetchTokenMetadata(mintAddress); + if (!token) { + return defaultResult; + } + return { + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + icon: token.icon ?? undefined, + }; + } catch { + return defaultResult; + } + }, + ...options, + enabled: !!mintAddress && options?.enabled !== false, + }); +} diff --git a/embed/oko_attached/src/web3/solana/use_calculate_fee.ts b/embed/oko_attached/src/web3/solana/use_calculate_fee.ts new file mode 100644 index 000000000..659550ce6 --- /dev/null +++ b/embed/oko_attached/src/web3/solana/use_calculate_fee.ts @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { + VersionedTransaction, + Transaction, + ComputeBudgetProgram, +} from "@solana/web3.js"; + +import { base64ToUint8Array } from "@oko-wallet-attached/utils/base64"; + +const LAMPORTS_PER_SIGNATURE = 5000; +const DEFAULT_COMPUTE_UNITS = 200_000; +const COMPUTE_BUDGET_PROGRAM_ID = ComputeBudgetProgram.programId.toBase58(); + +interface PriorityFeeInfo { + computeUnits: number; + microLamportsPerUnit: number; +} + +function parsePriorityFee( + instructions: { programId: string; data: Uint8Array }[], +): PriorityFeeInfo { + let computeUnits = DEFAULT_COMPUTE_UNITS; + let microLamportsPerUnit = 0; + + for (const ix of instructions) { + if (ix.programId !== COMPUTE_BUDGET_PROGRAM_ID) continue; + + const data = ix.data; + if (data.length === 0) continue; + + const instructionType = data[0]; + + // SetComputeUnitLimit = 2 + if (instructionType === 2 && data.length >= 5) { + computeUnits = new DataView( + data.buffer, + data.byteOffset + 1, + 4, + ).getUint32(0, true); + } + + // SetComputeUnitPrice = 3 + if (instructionType === 3 && data.length >= 9) { + microLamportsPerUnit = Number( + new DataView(data.buffer, data.byteOffset + 1, 8).getBigUint64(0, true), + ); + } + } + + return { computeUnits, microLamportsPerUnit }; +} + +export interface UseCalculateFeeProps { + serializedTransaction: string; + isVersioned: boolean; +} + +export interface UseCalculateFeeResult { + fee: number | null; +} + +export function useCalculateFee({ + serializedTransaction, + isVersioned, +}: UseCalculateFeeProps): UseCalculateFeeResult { + const fee = useMemo(() => { + if (!serializedTransaction) { + return null; + } + + try { + const txBytes = base64ToUint8Array(serializedTransaction); + + let numSignatures: number; + let priorityFeeInfo: PriorityFeeInfo; + + if (isVersioned) { + const transaction = VersionedTransaction.deserialize(txBytes); + const message = transaction.message; + + numSignatures = message.header.numRequiredSignatures; + + const accountKeys = message.staticAccountKeys.map((k) => k.toBase58()); + const instructions = message.compiledInstructions.map((ix) => ({ + programId: accountKeys[ix.programIdIndex], + data: ix.data, + })); + priorityFeeInfo = parsePriorityFee(instructions); + } else { + const transaction = Transaction.from(txBytes); + const message = transaction.compileMessage(); + + numSignatures = message.header.numRequiredSignatures; + + const instructions = transaction.instructions.map((ix) => ({ + programId: ix.programId.toBase58(), + data: ix.data, + })); + priorityFeeInfo = parsePriorityFee(instructions); + } + + // Base fee: 5000 lamports per signature + const baseFee = numSignatures * LAMPORTS_PER_SIGNATURE; + + // Priority fee: microLamports × computeUnits / 1_000_000 + const priorityFee = Math.floor( + (priorityFeeInfo.microLamportsPerUnit * priorityFeeInfo.computeUnits) / + 1_000_000, + ); + + return baseFee + priorityFee; + } catch { + return null; + } + }, [serializedTransaction, isVersioned]); + + return { fee }; +} diff --git a/embed/oko_attached/src/window_msgs/get_public_key_ed25519.ts b/embed/oko_attached/src/window_msgs/get_public_key_ed25519.ts new file mode 100644 index 000000000..7029d1a3c --- /dev/null +++ b/embed/oko_attached/src/window_msgs/get_public_key_ed25519.ts @@ -0,0 +1,31 @@ +import type { OkoWalletMsgGetPublicKeyEd25519Ack } from "@oko-wallet/oko-sdk-core"; + +import { OKO_SDK_TARGET } from "./target"; +import { useAppState } from "@oko-wallet-attached/store/app"; +import type { MsgEventContext } from "./types"; + +export async function handleGetPublicKeyEd25519(ctx: MsgEventContext) { + const { port, hostOrigin } = ctx; + const keyPackageEd25519 = useAppState.getState().getKeyPackageEd25519(hostOrigin); + + let payload: OkoWalletMsgGetPublicKeyEd25519Ack["payload"]; + if (keyPackageEd25519?.publicKey) { + payload = { + success: true, + data: keyPackageEd25519.publicKey, + }; + } else { + payload = { + success: false, + err: "No Ed25519 public key found", + }; + } + + const ack: OkoWalletMsgGetPublicKeyEd25519Ack = { + target: OKO_SDK_TARGET, + msg_type: "get_public_key_ed25519_ack", + payload, + }; + + port.postMessage(ack); +} diff --git a/embed/oko_attached/src/window_msgs/index.ts b/embed/oko_attached/src/window_msgs/index.ts index 440c2a161..870ffdad3 100644 --- a/embed/oko_attached/src/window_msgs/index.ts +++ b/embed/oko_attached/src/window_msgs/index.ts @@ -2,6 +2,7 @@ import type { OkoWalletMsg } from "@oko-wallet/oko-sdk-core"; import type { MsgEventContext } from "./types"; import { handleGetPublicKey } from "./get_public_key"; +import { handleGetPublicKeyEd25519 } from "./get_public_key_ed25519"; import { handleSetOAuthNonce } from "./set_oauth_nonce"; import { handleSetCodeVerifier } from "./set_code_verifier"; import { handleOpenModal } from "./open_modal"; @@ -58,6 +59,11 @@ export function makeMsgHandler() { break; } + case "get_public_key_ed25519": { + await handleGetPublicKeyEd25519(ctx); + break; + } + case "get_email": { await handleGetEmail(ctx); break; diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/index.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/index.ts index a50ca087b..0216599c3 100644 --- a/embed/oko_attached/src/window_msgs/oauth_info_pass/index.ts +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/index.ts @@ -107,6 +107,7 @@ export async function handleOAuthInfoPass( idToken, userExists, authType, + apiKey, ); if (!handleUserSignInRes.success) { await bail(message, handleUserSignInRes.err); @@ -124,6 +125,11 @@ export async function handleOAuthInfoPass( name: signInResult.name, }); + // Store Ed25519 key package if available (for Solana support) + if (signInResult.keyPackageEd25519) { + appState.setKeyPackageEd25519(hostOrigin, signInResult.keyPackageEd25519); + } + hasSignedIn = true; isNewUser = signInResult.isNewUser; @@ -166,6 +172,7 @@ export async function handleUserSignIn( idToken: string, userExists: CheckEmailResponse, authType: AuthType, + apiKey: string, ): Promise> { const meta = userExists.keyshare_node_meta; @@ -176,6 +183,7 @@ export async function handleUserSignIn( idToken, meta, authType, + apiKey, referralInfo, ); if (!signInRes.success) { @@ -204,7 +212,7 @@ export async function handleUserSignIn( } // sign in flow else { - const signInRes = await handleExistingUser(idToken, meta, authType); + const signInRes = await handleExistingUser(idToken, meta, authType, apiKey); if (!signInRes.success) { throw signInRes.err; } diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/user.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/user.ts index 0624d8f37..ae1eb6edd 100644 --- a/embed/oko_attached/src/window_msgs/oauth_info_pass/user.ts +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/user.ts @@ -27,17 +27,102 @@ import { reshareUserKeyShares } from "@oko-wallet-attached/crypto/reshare"; import { doSendUserKeyShares, requestSplitShares, + requestSplitSharesEd25519, } from "@oko-wallet-attached/requests/ks_node"; import { runKeygen } from "@oko-wallet/cait-sith-keplr-hooks"; +import { runTeddsaKeygen } from "@oko-wallet/teddsa-hooks"; import { reqKeygen } from "@oko-wallet/api-lib"; import { Bytes } from "@oko-wallet/bytes"; import type { ReferralInfo } from "@oko-wallet-attached/store/memory/types"; +import { + teddsaKeygenToHex, + type KeyPackageEd25519Hex, +} from "@oko-wallet-attached/crypto/keygen_ed25519"; +import { recoverEd25519Keygen } from "@oko-wallet-attached/crypto/sss_ed25519"; + +async function recoverEd25519KeyPackage( + idToken: string, + keyshareNodeMeta: KeyShareNodeMetaWithNodeStatusInfo, + authType: AuthType, +): Promise { + const publicInfoRes = await fetch( + `${TSS_V1_ENDPOINT}/wallet_ed25519/public_info`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({}), + }, + ); + + if (!publicInfoRes.ok) { + console.warn( + "[attached] Ed25519 public info HTTP error:", + publicInfoRes.status, + ); + return null; + } + + const publicInfoData = await publicInfoRes.json(); + if (!publicInfoData.success) { + console.warn( + "[attached] Ed25519 public info request failed:", + publicInfoData.msg, + ); + return null; + } + + const { + public_key: publicKeyHex, + public_key_package, + identifier, + } = publicInfoData.data; + + const publicKeyBytesRes = Bytes.fromHexString(publicKeyHex, 32); + if (!publicKeyBytesRes.success) { + console.warn( + "[attached] Invalid Ed25519 public key:", + publicKeyBytesRes.err, + ); + return null; + } + + const sharesRes = await requestSplitSharesEd25519( + publicKeyBytesRes.data, + idToken, + keyshareNodeMeta.nodes, + keyshareNodeMeta.threshold, + authType, + ); + if (!sharesRes.success) { + console.warn("[attached] Ed25519 shares request failed:", sharesRes.err); + return null; + } + + const recoveryRes = await recoverEd25519Keygen( + sharesRes.data, + keyshareNodeMeta.threshold, + Uint8Array.from(public_key_package), + Uint8Array.from(identifier), + publicKeyBytesRes.data, + ); + if (!recoveryRes.success) { + console.warn("[attached] Ed25519 key recovery failed:", recoveryRes.err); + return null; + } + + console.log("[attached] Ed25519 key recovered successfully"); + return teddsaKeygenToHex(recoveryRes.data); +} export async function handleExistingUser( idToken: string, keyshareNodeMeta: KeyShareNodeMetaWithNodeStatusInfo, authType: AuthType, + apiKey: string, ): Promise> { // 1. sign in to api server const signInRes = await makeAuthorizedOkoApiRequest( @@ -141,6 +226,7 @@ export async function handleExistingUser( isNewUser: false, email: signInResp.user.email ?? null, name: signInResp.user.name ?? null, + keyPackageEd25519: null, }, }; } @@ -173,16 +259,313 @@ user pk: ${signInResp.user.public_key}`, }; } + // 4. Ed25519 keygen for Solana support (if user doesn't have Ed25519 wallet yet) + // This is non-blocking - failure doesn't fail the entire sign-in + let keyPackageEd25519: KeyPackageEd25519Hex | null = null; + // Track updated JWT token (with wallet_id_ed25519) from Ed25519 keygen + let updatedJwtToken: string | null = null; + try { + const ed25519KeygenRes = await runTeddsaKeygen(); + if (ed25519KeygenRes.success) { + const { keygen_1: ed25519Keygen1, keygen_2: ed25519Keygen2 } = + ed25519KeygenRes.data; + + // Send Ed25519 keygen_2 to server + const serverRes = await fetch(`${TSS_V1_ENDPOINT}/keygen_ed25519`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({ + keygen_2: { + key_package: [...ed25519Keygen2.key_package], + public_key_package: [...ed25519Keygen2.public_key_package], + identifier: [...ed25519Keygen2.identifier], + public_key: [...ed25519Keygen2.public_key.toUint8Array()], + }, + }), + }); + + if (serverRes.ok) { + const serverData = await serverRes.json(); + if (serverData.success) { + keyPackageEd25519 = teddsaKeygenToHex(ed25519Keygen1); + // Capture new JWT that includes wallet_id_ed25519 + updatedJwtToken = serverData.data.token; + console.log("[attached] Ed25519 keygen successful for existing user"); + + // SSS split Ed25519 key_package and send to KS nodes + try { + const { splitUserKeySharesEd25519 } = await import( + "@oko-wallet-attached/crypto/sss_ed25519" + ); + const { doSendUserKeySharesEd25519 } = await import( + "@oko-wallet-attached/requests/ks_node" + ); + + const splitEd25519Res = await splitUserKeySharesEd25519( + ed25519Keygen1, + keyshareNodeMeta, + ); + + if (splitEd25519Res.success) { + const ed25519KeyShares = splitEd25519Res.data; + + // Send Ed25519 shares to all KS nodes + const sendEd25519Results = await Promise.all( + ed25519KeyShares.map((keyShareByNode) => + doSendUserKeySharesEd25519( + keyShareByNode.node.endpoint, + idToken, + ed25519Keygen1.public_key, + keyShareByNode.share, + authType, + ), + ), + ); + + const ed25519SendErrors = sendEd25519Results.filter( + (result) => result.success === false, + ); + if (ed25519SendErrors.length > 0) { + console.warn( + "[attached] Some Ed25519 key shares failed to send:", + ed25519SendErrors.map((e) => e.err).join(", "), + ); + } else { + console.log( + "[attached] Ed25519 key shares sent to KS nodes successfully", + ); + } + } else { + console.warn( + "[attached] Ed25519 SSS split failed:", + splitEd25519Res.err, + ); + } + } catch (sssErr) { + console.warn("[attached] Ed25519 SSS error:", sssErr); + } + } else if (serverData.code === "WALLET_ALREADY_EXISTS") { + // User already has Ed25519 wallet, recover from KS nodes + console.log( + "[attached] Ed25519 wallet already exists, recovering from KS nodes", + ); + try { + // 1. Get Ed25519 public info from server + const publicInfoRes = await fetch( + `${TSS_V1_ENDPOINT}/wallet_ed25519/public_info`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({}), + }, + ); + + if (publicInfoRes.ok) { + const publicInfoData = await publicInfoRes.json(); + if (publicInfoData.success) { + const { + public_key: publicKeyHex, + public_key_package, + identifier, + } = publicInfoData.data; + + // 2. Request Ed25519 shares from KS nodes + const publicKeyBytesRes = Bytes.fromHexString(publicKeyHex, 32); + if (publicKeyBytesRes.success) { + const sharesRes = await requestSplitSharesEd25519( + publicKeyBytesRes.data, + idToken, + keyshareNodeMeta.nodes, + keyshareNodeMeta.threshold, + authType, + ); + + if (sharesRes.success) { + // 3. Recover Ed25519 keygen output + const recoveryRes = await recoverEd25519Keygen( + sharesRes.data, + keyshareNodeMeta.threshold, + Uint8Array.from(public_key_package), + Uint8Array.from(identifier), + publicKeyBytesRes.data, + ); + + if (recoveryRes.success) { + keyPackageEd25519 = teddsaKeygenToHex(recoveryRes.data); + console.log( + "[attached] Ed25519 key recovered successfully", + ); + } else { + console.warn( + "[attached] Ed25519 key recovery failed:", + recoveryRes.err, + ); + } + } else { + console.warn( + "[attached] Ed25519 shares request failed:", + sharesRes.err, + ); + } + } else { + console.warn( + "[attached] Invalid Ed25519 public key:", + publicKeyBytesRes.err, + ); + } + } else { + console.warn( + "[attached] Ed25519 public info request failed:", + publicInfoData.msg, + ); + } + } else { + console.warn( + "[attached] Ed25519 public info HTTP error:", + publicInfoRes.status, + ); + } + } catch (recoveryErr) { + console.warn("[attached] Ed25519 recovery error:", recoveryErr); + } + } else { + console.warn( + "[attached] Ed25519 server keygen failed:", + serverData.msg, + ); + } + } else { + // Non-2xx response - check if it's WALLET_ALREADY_EXISTS (409) + try { + const errorData = await serverRes.json(); + if (errorData.code === "WALLET_ALREADY_EXISTS") { + // User already has Ed25519 wallet, recover from KS nodes + console.log( + "[attached] Ed25519 wallet already exists (409), recovering from KS nodes", + ); + try { + // 1. Get Ed25519 public info from server + const publicInfoRes = await fetch( + `${TSS_V1_ENDPOINT}/wallet_ed25519/public_info`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({}), + }, + ); + + if (publicInfoRes.ok) { + const publicInfoData = await publicInfoRes.json(); + if (publicInfoData.success) { + const { + public_key: publicKeyHex, + public_key_package, + identifier, + } = publicInfoData.data; + + // 2. Request Ed25519 shares from KS nodes + const publicKeyBytesRes = Bytes.fromHexString(publicKeyHex, 32); + if (publicKeyBytesRes.success) { + const sharesRes = await requestSplitSharesEd25519( + publicKeyBytesRes.data, + idToken, + keyshareNodeMeta.nodes, + keyshareNodeMeta.threshold, + authType, + ); + + if (sharesRes.success) { + // 3. Recover Ed25519 keygen output + const recoveryRes = await recoverEd25519Keygen( + sharesRes.data, + keyshareNodeMeta.threshold, + Uint8Array.from(public_key_package), + Uint8Array.from(identifier), + publicKeyBytesRes.data, + ); + + if (recoveryRes.success) { + keyPackageEd25519 = teddsaKeygenToHex(recoveryRes.data); + console.log( + "[attached] Ed25519 key recovered successfully", + ); + } else { + console.warn( + "[attached] Ed25519 key recovery failed:", + recoveryRes.err, + ); + } + } else { + console.warn( + "[attached] Ed25519 shares request failed:", + sharesRes.err, + ); + } + } else { + console.warn( + "[attached] Invalid Ed25519 public key:", + publicKeyBytesRes.err, + ); + } + } else { + console.warn( + "[attached] Ed25519 public info request failed:", + publicInfoData.msg, + ); + } + } else { + console.warn( + "[attached] Ed25519 public info HTTP error:", + publicInfoRes.status, + ); + } + } catch (recoveryErr) { + console.warn("[attached] Ed25519 recovery error:", recoveryErr); + } + } else { + console.warn( + "[attached] Ed25519 server keygen request failed:", + serverRes.status, + errorData, + ); + } + } catch { + console.warn( + "[attached] Ed25519 server keygen request failed (non-JSON):", + serverRes.status, + ); + } + } + } else { + console.warn("[attached] Ed25519 keygen failed:", ed25519KeygenRes.err); + } + } catch (err) { + // Log but don't fail sign-in if Ed25519 keygen fails + console.warn("[attached] Ed25519 keygen error:", err); + } + return { success: true, data: { publicKey: signInResp.user.public_key, walletId: signInResp.user.wallet_id, - jwtToken: signInResp.token, + // Use updated JWT (with wallet_id_ed25519) if Ed25519 keygen succeeded + jwtToken: updatedJwtToken ?? signInResp.token, keyshare_1: keyshare_1_res.data, isNewUser: false, email: signInResp.user.email ?? null, name: signInResp.user.name ?? null, + keyPackageEd25519, }, }; } @@ -191,6 +574,7 @@ export async function handleNewUser( idToken: string, keyshareNodeMeta: KeyShareNodeMetaWithNodeStatusInfo, authType: AuthType, + apiKey: string, referralInfo?: ReferralInfo | null, ): Promise> { // TODO: @jinwoo, (wip) importing secret key @@ -273,16 +657,129 @@ export async function handleNewUser( } } + // Ed25519 keygen (for Solana support) + // This is non-blocking - failure doesn't fail the entire sign-in + let keyPackageEd25519: KeyPackageEd25519Hex | null = null; + // Track updated JWT token (with wallet_id_ed25519) from Ed25519 keygen + let updatedJwtToken: string | null = null; + try { + const ed25519KeygenRes = await runTeddsaKeygen(); + if (ed25519KeygenRes.success) { + const { keygen_1: ed25519Keygen1, keygen_2: ed25519Keygen2 } = + ed25519KeygenRes.data; + + // Send Ed25519 keygen_2 to server + const serverRes = await fetch(`${TSS_V1_ENDPOINT}/keygen_ed25519`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({ + keygen_2: { + key_package: [...ed25519Keygen2.key_package], + public_key_package: [...ed25519Keygen2.public_key_package], + identifier: [...ed25519Keygen2.identifier], + public_key: [...ed25519Keygen2.public_key.toUint8Array()], + }, + }), + }); + + if (serverRes.ok) { + const serverData = await serverRes.json(); + if (serverData.success) { + keyPackageEd25519 = teddsaKeygenToHex(ed25519Keygen1); + // Capture new JWT that includes wallet_id_ed25519 + updatedJwtToken = serverData.data.token; + console.log("[attached] Ed25519 keygen successful"); + + // SSS split Ed25519 key_package and send to KS nodes + try { + const { splitUserKeySharesEd25519 } = await import( + "@oko-wallet-attached/crypto/sss_ed25519" + ); + const { doSendUserKeySharesEd25519 } = await import( + "@oko-wallet-attached/requests/ks_node" + ); + + const splitEd25519Res = await splitUserKeySharesEd25519( + ed25519Keygen1, + keyshareNodeMeta, + ); + + if (splitEd25519Res.success) { + const ed25519KeyShares = splitEd25519Res.data; + + // Send Ed25519 shares to all KS nodes + const sendEd25519Results = await Promise.all( + ed25519KeyShares.map((keyShareByNode) => + doSendUserKeySharesEd25519( + keyShareByNode.node.endpoint, + idToken, + ed25519Keygen1.public_key, + keyShareByNode.share, + authType, + ), + ), + ); + + const ed25519SendErrors = sendEd25519Results.filter( + (result) => result.success === false, + ); + if (ed25519SendErrors.length > 0) { + console.warn( + "[attached] Some Ed25519 key shares failed to send:", + ed25519SendErrors.map((e) => e.err).join(", "), + ); + } else { + console.log( + "[attached] Ed25519 key shares sent to KS nodes successfully", + ); + } + } else { + console.warn( + "[attached] Ed25519 SSS split failed:", + splitEd25519Res.err, + ); + } + } catch (sssErr) { + console.warn("[attached] Ed25519 SSS error:", sssErr); + } + } else { + console.warn( + "[attached] Ed25519 server keygen failed:", + serverData.msg, + ); + } + } else { + const errorBody = await serverRes.text(); + console.warn( + "[attached] Ed25519 server keygen request failed:", + serverRes.status, + errorBody, + ); + } + } else { + console.warn("[attached] Ed25519 keygen failed:", ed25519KeygenRes.err); + } + } catch (err) { + // Log but don't fail sign-in if Ed25519 keygen fails + console.warn("[attached] Ed25519 keygen error:", err); + } + return { success: true, data: { publicKey: reqKeygenRes.data.user.public_key, walletId: reqKeygenRes.data.user.wallet_id, - jwtToken: reqKeygenRes.data.token, + // Use updated JWT (with wallet_id_ed25519) if Ed25519 keygen succeeded + jwtToken: updatedJwtToken ?? reqKeygenRes.data.token, keyshare_1: keygen_1.tss_private_share.toHex(), isNewUser: true, email: reqKeygenRes.data.user.email ?? null, name: reqKeygenRes.data.user.name ?? null, + keyPackageEd25519, }, }; } @@ -380,6 +877,12 @@ export async function handleReshare( }; } + const keyPackageEd25519 = await recoverEd25519KeyPackage( + idToken, + keyshareNodeMeta, + authType, + ); + return { success: true, data: { @@ -390,6 +893,7 @@ export async function handleReshare( isNewUser: false, email: signInResp.user.email ?? null, name: signInResp.user.name ?? null, + keyPackageEd25519, }, }; } diff --git a/sandbox/sandbox_sol/next-env.d.ts b/sandbox/sandbox_sol/next-env.d.ts index 830fb594c..c4b7818fb 100644 --- a/sandbox/sandbox_sol/next-env.d.ts +++ b/sandbox/sandbox_sol/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/sandbox/sandbox_sol/next.config.ts b/sandbox/sandbox_sol/next.config.ts index 0d9807765..55c198504 100644 --- a/sandbox/sandbox_sol/next.config.ts +++ b/sandbox/sandbox_sol/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, + turbopack: {}, webpack: (config) => { config.resolve.fallback = { ...config.resolve.fallback, diff --git a/sandbox/sandbox_sol/package.json b/sandbox/sandbox_sol/package.json index 21c059446..94efcf1f8 100644 --- a/sandbox/sandbox_sol/package.json +++ b/sandbox/sandbox_sol/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@oko-wallet/oko-sdk-sol": "workspace:*", + "@solana/wallet-adapter-base": "^0.9.23", + "@solana/wallet-adapter-react": "^0.15.35", + "@solana/wallet-adapter-react-ui": "^0.9.35", "@solana/web3.js": "^1.98.0", "bs58": "^6.0.0", "next": "^16.1.1", @@ -19,9 +22,11 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.1.3", "typescript": "^5.8.3" } } diff --git a/sandbox/sandbox_sol/postcss.config.mjs b/sandbox/sandbox_sol/postcss.config.mjs new file mode 100644 index 000000000..c7bcb4b1e --- /dev/null +++ b/sandbox/sandbox_sol/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/sandbox/sandbox_sol/public/icon.png b/sandbox/sandbox_sol/public/icon.png new file mode 100644 index 000000000..e52eddf95 Binary files /dev/null and b/sandbox/sandbox_sol/public/icon.png differ diff --git a/sandbox/sandbox_sol/public/logo.png b/sandbox/sandbox_sol/public/logo.png new file mode 100644 index 000000000..0db0fc81f Binary files /dev/null and b/sandbox/sandbox_sol/public/logo.png differ diff --git a/sandbox/sandbox_sol/src/app/globals.css b/sandbox/sandbox_sol/src/app/globals.css index eb17b04b7..e5c0d342f 100644 --- a/sandbox/sandbox_sol/src/app/globals.css +++ b/sandbox/sandbox_sol/src/app/globals.css @@ -1,38 +1,31 @@ -:root { - --bg-primary: #0a0a0a; - --bg-secondary: #141414; - --bg-tertiary: #1a1a1a; - --text-primary: #ffffff; - --text-secondary: #a0a0a0; - --border: #2a2a2a; - --accent: #9945ff; - --accent-hover: #7c35d9; - --success: #14f195; - --error: #ff4444; -} +@import "tailwindcss"; -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; +:root { + --background: #0a0a0a; + --foreground: #ededed; } -body { - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + /* UI tokens */ + --color-widget: #24262a; + --color-widget-border: #2f3136; + --color-widget-field: #1f2125; + --color-widget-border-hover: #3a3f45; + /* Solana brand colors */ + --color-solana-purple: #9945ff; + --color-solana-green: #14f195; } -a { - color: inherit; - text-decoration: none; +@layer base { + body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } } diff --git a/sandbox/sandbox_sol/src/app/layout.tsx b/sandbox/sandbox_sol/src/app/layout.tsx index de24b58b1..199ec2c67 100644 --- a/sandbox/sandbox_sol/src/app/layout.tsx +++ b/sandbox/sandbox_sol/src/app/layout.tsx @@ -1,9 +1,23 @@ import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + export const metadata: Metadata = { title: "Sandbox SOL - Oko Wallet", description: "Solana SDK testing sandbox for Oko Wallet", + icons: { + icon: "/icon.png", + }, }; export default function RootLayout({ @@ -13,7 +27,11 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + ); } diff --git a/sandbox/sandbox_sol/src/app/page.module.css b/sandbox/sandbox_sol/src/app/page.module.css deleted file mode 100644 index 4af6fbe32..000000000 --- a/sandbox/sandbox_sol/src/app/page.module.css +++ /dev/null @@ -1,41 +0,0 @@ -.main { - min-height: 100vh; - padding: 2rem; -} - -.container { - max-width: 800px; - margin: 0 auto; -} - -.header { - text-align: center; - margin-bottom: 2rem; -} - -.title { - font-size: 2.5rem; - font-weight: 700; - background: linear-gradient(90deg, #9945ff, #14f195); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 0.5rem; -} - -.subtitle { - color: var(--text-secondary); - font-size: 1.125rem; -} - -.content { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.widgets { - display: flex; - flex-direction: column; - gap: 1.5rem; -} diff --git a/sandbox/sandbox_sol/src/app/page.tsx b/sandbox/sandbox_sol/src/app/page.tsx index b2326c025..06b4ed41c 100644 --- a/sandbox/sandbox_sol/src/app/page.tsx +++ b/sandbox/sandbox_sol/src/app/page.tsx @@ -1,10 +1,8 @@ "use client"; import { useOkoSol } from "@/hooks/use_oko_sol"; -import { WalletStatus } from "@/components/wallet_status"; -import { SignMessageWidget } from "@/components/sign_message_widget"; -import { SignTransactionWidget } from "@/components/sign_transaction_widget"; -import styles from "./page.module.css"; +import LoginView from "@/components/LoginView"; +import ConnectedView from "@/components/ConnectedView"; export default function Home() { const { @@ -12,38 +10,21 @@ export default function Home() { isInitializing, isConnected, publicKey, - error, connect, disconnect, } = useOkoSol(); return ( -
-
-
-

Sandbox SOL

-

Oko Wallet Solana SDK Testing

-
- -
- - - {isConnected && ( -
- - -
- )} -
-
+
+ {!isConnected ? ( + + ) : ( + + )}
); } diff --git a/sandbox/sandbox_sol/src/app/wallet-adapter/page.tsx b/sandbox/sandbox_sol/src/app/wallet-adapter/page.tsx new file mode 100644 index 000000000..f56970076 --- /dev/null +++ b/sandbox/sandbox_sol/src/app/wallet-adapter/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect } from "react"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { + WalletMultiButton, + WalletDisconnectButton, +} from "@solana/wallet-adapter-react-ui"; +import { WalletAdapterProvider } from "@/components/WalletAdapterProvider"; +import { useOkoSol } from "@/hooks/use_oko_sol"; + +function WalletAdapterContent() { + const { isInitialized, isInitializing } = useOkoSol(); + const { publicKey, connected, wallet, wallets } = useWallet(); + + useEffect(() => { + console.log("[wallet-adapter] Available wallets:", wallets); + }, [wallets]); + + if (isInitializing) { + return ( +
+

Initializing Oko SDK...

+
+ ); + } + + if (!isInitialized) { + return ( +
+

Failed to initialize SDK

+
+ ); + } + + return ( +
+
+

Wallet Adapter Test

+

+ Testing wallet-standard integration with @solana/wallet-adapter +

+
+ +
+ + {connected && } +
+ +
+

Status

+ +
+
+ Connected: + + {connected ? "Yes" : "No"} + +
+ +
+ Wallet: + {wallet?.adapter.name ?? "None"} +
+ +
+ Public Key: + + {publicKey?.toBase58().slice(0, 20)}... + +
+
+
+ +
+

Discovered Wallets

+
+ {wallets.length === 0 ? ( +

No wallets discovered

+ ) : ( + wallets.map((w) => ( +
+ {w.adapter.icon && ( + {w.adapter.name} + )} + {w.adapter.name} + ({w.readyState}) +
+ )) + )} +
+
+ + +
+ ); +} + +export default function WalletAdapterPage() { + return ( + +
+
+ +
+
+
+ ); +} diff --git a/sandbox/sandbox_sol/src/components/AccountInfo.tsx b/sandbox/sandbox_sol/src/components/AccountInfo.tsx new file mode 100644 index 000000000..36fde5564 --- /dev/null +++ b/sandbox/sandbox_sol/src/components/AccountInfo.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import Button from "./Button"; +import { DEVNET_CONNECTION } from "@/lib/connection"; + +interface AccountInfoProps { + publicKey: string; + onDisconnect: () => void; + className?: string; +} + +export default function AccountInfo({ + publicKey, + onDisconnect, + className, +}: AccountInfoProps) { + const [balance, setBalance] = useState(null); + const [isLoadingBalance, setIsLoadingBalance] = useState(false); + + const fetchBalance = useCallback(async () => { + if (!publicKey) { + setBalance(null); + return; + } + + setIsLoadingBalance(true); + try { + const pubkey = new PublicKey(publicKey); + const lamports = await DEVNET_CONNECTION.getBalance(pubkey); + setBalance(lamports / LAMPORTS_PER_SOL); + } catch (err) { + console.error("[sandbox_sol] Failed to fetch balance:", err); + setBalance(null); + } finally { + setIsLoadingBalance(false); + } + }, [publicKey]); + + useEffect(() => { + fetchBalance(); + }, [fetchBalance]); + + function formatAddress(address: string) { + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } + + const balanceDisplay = isLoadingBalance + ? "..." + : balance !== null + ? `${balance.toFixed(4)} SOL` + : "—"; + + return ( +
+
+

Account

+ +
+ +
+ + +
+
+ ); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +function CopyableAddress({ + value, + format, +}: { + value?: string; + format?: (value: string) => string; +}) { + const [copied, setCopied] = useState(false); + + return ( +
+ Wallet Address + +
+ ); +} + +function AvailableBalance({ + value, + onRefresh, + isLoading, +}: { + value?: string; + onRefresh: () => void; + isLoading: boolean; +}) { + return ( +
+
+ Available Balance + +
+
+
+ {value ?? "..."} +
+ + Get Solana devnet SOL + + + + + +
+
+ ); +} diff --git a/sandbox/sandbox_sol/src/components/Button.tsx b/sandbox/sandbox_sol/src/components/Button.tsx new file mode 100644 index 000000000..d169ccaca --- /dev/null +++ b/sandbox/sandbox_sol/src/components/Button.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; + +type ButtonVariant = "primary" | "ghost"; +type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + loading?: boolean; +} + +const base = + "inline-flex items-center justify-center gap-2 font-semibold rounded-2xl transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-700/60 disabled:opacity-60 disabled:cursor-not-allowed disabled:shadow-none"; + +const variants: Record = { + primary: "bg-white text-black hover:bg-gray-100 active:scale-[0.99]", + ghost: + "bg-transparent text-gray-200 border border-gray-700/60 hover:bg-gray-800/50", +}; + +const sizes: Record = { + sm: "h-9 px-4 text-sm", + md: "h-12 px-6 text-base", + lg: "h-14 px-8 text-lg", +}; + +export default function Button({ + variant = "primary", + size = "md", + fullWidth, + loading = false, + className = "", + ...props +}: ButtonProps) { + const widthClass = fullWidth ? "w-full" : ""; + return ( + + ); +} diff --git a/sandbox/sandbox_sol/src/components/ConnectedView.tsx b/sandbox/sandbox_sol/src/components/ConnectedView.tsx new file mode 100644 index 000000000..be4b595d1 --- /dev/null +++ b/sandbox/sandbox_sol/src/components/ConnectedView.tsx @@ -0,0 +1,35 @@ +"use client"; + +import AccountInfo from "./AccountInfo"; +import { SignMessageWidget } from "./sign_message_widget"; +import { SignTransactionWidget } from "./sign_transaction_widget"; +import { SplTokenTransferWidget } from "./spl_token_transfer_widget"; +import { TestTransactionsWidget } from "./test_transactions_widget"; +import { SiwsWidget } from "./siws_widget"; + +interface ConnectedViewProps { + publicKey: string; + onDisconnect: () => void; +} + +export default function ConnectedView({ + publicKey, + onDisconnect, +}: ConnectedViewProps) { + return ( +
+
+
+ +
+
+ + + + + +
+
+
+ ); +} diff --git a/sandbox/sandbox_sol/src/components/LoginView.tsx b/sandbox/sandbox_sol/src/components/LoginView.tsx new file mode 100644 index 000000000..713b227e6 --- /dev/null +++ b/sandbox/sandbox_sol/src/components/LoginView.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; +import Button from "./Button"; + +interface LoginViewProps { + isInitialized: boolean; + isInitializing: boolean; + onConnect: () => void; +} + +export default function LoginView({ + isInitialized, + isInitializing, + onConnect, +}: LoginViewProps) { + return ( +
+
+
+ Oko +
+
+

Welcome to Oko

+

+ Sign in to get started with Solana +

+
+
+ + + + Test Wallet Adapter Integration + +
+ ); +} diff --git a/sandbox/sandbox_sol/src/components/WalletAdapterProvider.tsx b/sandbox/sandbox_sol/src/components/WalletAdapterProvider.tsx new file mode 100644 index 000000000..58b44f657 --- /dev/null +++ b/sandbox/sandbox_sol/src/components/WalletAdapterProvider.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useMemo } from "react"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; +import { clusterApiUrl } from "@solana/web3.js"; + +import "@solana/wallet-adapter-react-ui/styles.css"; + +interface WalletAdapterProviderProps { + children: React.ReactNode; +} + +export function WalletAdapterProvider({ + children, +}: WalletAdapterProviderProps) { + const endpoint = useMemo(() => clusterApiUrl("devnet"), []); + + // No wallets array needed - wallet-standard wallets are auto-discovered + const wallets = useMemo(() => [], []); + + return ( + + + {children} + + + ); +} diff --git a/sandbox/sandbox_sol/src/components/sign_message_widget.tsx b/sandbox/sandbox_sol/src/components/sign_message_widget.tsx index a3df63108..76ebfc0d2 100644 --- a/sandbox/sandbox_sol/src/components/sign_message_widget.tsx +++ b/sandbox/sandbox_sol/src/components/sign_message_widget.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useSdkStore } from "@/store/sdk"; import bs58 from "bs58"; -import styles from "./widget.module.css"; +import Button from "./Button"; export function SignMessageWidget() { const { okoSolWallet } = useSdkStore(); @@ -36,16 +36,20 @@ export function SignMessageWidget() { }; return ( -
-

Sign Message

-

+

+

+ Sign Message +

+

Sign an arbitrary message with your Solana wallet

-
- +
+