diff --git a/apps/mcp/lib/privy/delegated-signer.ts b/apps/mcp/lib/privy/delegated-signer.ts index 3b8e47e..7f8ed58 100644 --- a/apps/mcp/lib/privy/delegated-signer.ts +++ b/apps/mcp/lib/privy/delegated-signer.ts @@ -10,31 +10,34 @@ export type SignResult = | { ok: false; reason: "delegation_revoked" | "upstream_error" }; /** - * Ask Privy to sign a Solana transaction on behalf of a user who has - * delegated their wallet to our server. Returns a partially-signed - * transaction (only the user's signature slot filled) — the caller - * still needs to get a fee-payer signature via the gas station. + * Ask Privy to sign a Solana transaction on behalf of a user whose embedded + * wallet has our server's signer attached via `addSigners` in the frontend. + * Returns a partially-signed transaction (only the user's signature slot + * filled) — the caller still needs to get a fee-payer signature via the + * gas station. * - * Privy's signTransaction API takes the internal wallet `id`, not the - * on-chain pubkey. We resolve the address → id via getWalletByAddress - * before signing. + * The request is authorized by passing the server's PKCS8 private key in + * `authorization_context.authorization_private_keys`. Privy verifies it + * against the public key registered under the key quorum the user + * authorized in `/app/credentials`. */ export async function signSolanaTransaction( client: PrivyClient, input: SignInput ): Promise { + const authorizationPrivateKey = process.env.PRIVY_SIGNER_PRIVATE_KEY; + if (!authorizationPrivateKey) { + return { ok: false, reason: "upstream_error" }; + } + let walletId: string; try { - // Look up the Privy internal wallet ID by on-chain address. - // Privy's signTransaction API takes the internal id, not the pubkey. const wallet = await (client.wallets as any).getWalletByAddress({ address: input.walletAddress, }); walletId = wallet.id; } catch (err: any) { if (err?.status === 404) { - // Either the user never delegated, or revoked off-band via Privy dashboard. - // From our perspective the signing capability is gone — treat as revoked. return { ok: false, reason: "delegation_revoked" }; } return { ok: false, reason: "upstream_error" }; @@ -46,9 +49,11 @@ export async function signSolanaTransaction( .solana() .signTransaction(walletId, { transaction: input.unsignedTx, + authorization_context: { + authorization_private_keys: [authorizationPrivateKey], + }, }); - // Privy returns base64 in snake_case. Decode to bytes for callers. const signedTx = Buffer.from(response.signed_transaction, "base64"); return { ok: true, signedTx: new Uint8Array(signedTx) }; } catch (err: any) { diff --git a/apps/mcp/tests/privy/delegated-signer.test.ts b/apps/mcp/tests/privy/delegated-signer.test.ts index 0fff99a..c218e52 100644 --- a/apps/mcp/tests/privy/delegated-signer.test.ts +++ b/apps/mcp/tests/privy/delegated-signer.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { signSolanaTransaction } from "@/lib/privy/delegated-signer"; +beforeEach(() => { + // signSolanaTransaction reads PRIVY_SIGNER_PRIVATE_KEY to attach an + // authorization_context to the SDK call. The mocked Privy client doesn't + // validate the key, so any non-empty string works for these tests. + process.env.PRIVY_SIGNER_PRIVATE_KEY = "test-private-key"; +}); + function makeFakeClient(opts: { getByAddressImpl?: (...args: any[]) => any; signImpl?: (...args: any[]) => any; diff --git a/frontend/app/app/credentials/AgentDelegationCard.tsx b/frontend/app/app/credentials/AgentDelegationCard.tsx index 986662a..006f47f 100644 --- a/frontend/app/app/credentials/AgentDelegationCard.tsx +++ b/frontend/app/app/credentials/AgentDelegationCard.tsx @@ -8,21 +8,22 @@ * delegation API. * * Hook notes (v3.22.x): - * - `useHeadlessDelegatedActions` — exists; provides `delegateWallet` and - * `revokeWallets` without any modal UI. - * - `useSolanaWallets` — does NOT exist in this version. We derive the - * Solana wallet from `usePrivy().user.linkedAccounts`, filtering for - * `type === 'wallet' && chainType === 'solana'`. - * - `useWallets` from `@privy-io/react-auth` returns Ethereum-only - * `ConnectedWallet[]` and does not include Solana wallets. + * - `useSigners().addSigners(...)` — the TEE-compatible API. Both + * `useDelegatedActions` and `useHeadlessDelegatedActions` are + * on-device-only and throw / hang on TEE apps. `addSigners` attaches + * a server-side key quorum (registered in the Privy dashboard) so the + * backend can sign with the user's wallet without further prompts. + * - We get the Solana wallet from `useWallets()` in + * `@privy-io/react-auth/solana`, NOT from `user.linkedAccounts`. The + * latter has the address but no initialized wallet proxy, so passing + * that address to `addSigners` throws "Wallet proxy not initialized". */ import { useCallback, useEffect, useState } from "react"; -import { - usePrivy, - useHeadlessDelegatedActions, - type LinkedAccountWithMetadata, -} from "@privy-io/react-auth"; +import { usePrivy, useSigners } from "@privy-io/react-auth"; +import { useWallets as useSolanaWallets } from "@privy-io/react-auth/solana"; + +const SIGNER_ID = process.env.NEXT_PUBLIC_PRIVY_SIGNER_ID; import { Button } from "@/components/ui/button"; @@ -41,13 +42,6 @@ interface GetDelegationResponse { delegation: DelegationRecord | null; } -/** Narrow a LinkedAccountWithMetadata to a Solana wallet entry. */ -function isSolanaWalletAccount( - account: LinkedAccountWithMetadata, -): account is Extract { - return account.type === "wallet" && account.chainType === "solana"; -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -77,17 +71,14 @@ function shortPubkey(pubkey: string): string { export function AgentDelegationCard() { const { user, getAccessToken } = usePrivy(); - const { delegateWallet, revokeWallets } = useHeadlessDelegatedActions(); + const { addSigners, removeSigners } = useSigners(); + const { wallets: solanaWallets, ready: walletsReady } = useSolanaWallets(); const [delegation, setDelegation] = useState(null); const [loadingState, setLoadingState] = useState<"idle" | "fetching" | "mutating">("fetching"); const [error, setError] = useState(null); - // Derive the first Solana wallet from the user's linked accounts. - // `useWallets()` from @privy-io/react-auth only surfaces Ethereum wallets; - // Solana wallets must be found via `user.linkedAccounts`. - const solanaWallet = - user?.linkedAccounts.find(isSolanaWalletAccount) ?? null; + const solanaWallet = walletsReady && solanaWallets.length > 0 ? solanaWallets[0] : null; // --------------------------------------------------------------------------- // Fetch current delegation from DB @@ -125,13 +116,25 @@ export function AgentDelegationCard() { async function onAuthorize() { if (!solanaWallet) return; + if (!SIGNER_ID) { + setError("Missing NEXT_PUBLIC_PRIVY_SIGNER_ID env var."); + return; + } setError(null); setLoadingState("mutating"); try { - await delegateWallet({ - address: solanaWallet.address, - chainType: "solana", - }); + try { + await addSigners({ + address: solanaWallet.address, + signers: [{ signerId: SIGNER_ID, policyIds: [] }], + }); + } catch (signerErr) { + // Idempotency: if the signer is already attached in Privy (e.g. a + // previous attempt added it but the backend POST below failed and + // left the DB row missing), treat it as success and proceed. + const msg = signerErr instanceof Error ? signerErr.message : ""; + if (!/duplicate signer/i.test(msg)) throw signerErr; + } const token = await getAccessToken(); if (!token) throw new Error("No access token"); const r = await fetch("/api/agent-delegation", { @@ -163,10 +166,11 @@ export function AgentDelegationCard() { // --------------------------------------------------------------------------- async function onRevoke() { + if (!solanaWallet) return; setError(null); setLoadingState("mutating"); try { - await revokeWallets(); + await removeSigners({ address: solanaWallet.address }); const token = await getAccessToken(); if (!token) throw new Error("No access token"); const r = await fetch("/api/agent-delegation", { diff --git a/frontend/app/app/credentials/ApiKeysSection.tsx b/frontend/app/app/credentials/ApiKeysSection.tsx index 741a6b7..ac6b0da 100644 --- a/frontend/app/app/credentials/ApiKeysSection.tsx +++ b/frontend/app/app/credentials/ApiKeysSection.tsx @@ -100,7 +100,12 @@ export function ApiKeysSection() { } finally { setLoading(false); } - }, [privy]); + // The Privy context object's identity changes on every render. Depending on + // it caused refresh → useEffect → setState → render → new refresh → loop. + // Capturing privy via closure on mount is fine here — token refresh + // happens server-side and one initial load is sufficient. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { void refresh(); diff --git a/frontend/app/app/credentials/ConnectedAppsSection.tsx b/frontend/app/app/credentials/ConnectedAppsSection.tsx index 17f876f..7987df6 100644 --- a/frontend/app/app/credentials/ConnectedAppsSection.tsx +++ b/frontend/app/app/credentials/ConnectedAppsSection.tsx @@ -91,7 +91,10 @@ export function ConnectedAppsSection() { } finally { setLoading(false); } - }, [privy]); + // See ApiKeysSection — same fix: `privy` identity changes on every render + // and caused an infinite render loop. Capture via closure on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { void refresh();