Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions apps/mcp/lib/privy/delegated-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SignResult> {
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" };
Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion apps/mcp/tests/privy/delegated-signer.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
64 changes: 34 additions & 30 deletions frontend/app/app/credentials/AgentDelegationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -41,13 +42,6 @@ interface GetDelegationResponse {
delegation: DelegationRecord | null;
}

/** Narrow a LinkedAccountWithMetadata to a Solana wallet entry. */
function isSolanaWalletAccount(
account: LinkedAccountWithMetadata,
): account is Extract<LinkedAccountWithMetadata, { type: "wallet" }> {
return account.type === "wallet" && account.chainType === "solana";
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<DelegationRecord | null>(null);
const [loadingState, setLoadingState] = useState<"idle" | "fetching" | "mutating">("fetching");
const [error, setError] = useState<string | null>(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
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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", {
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/app/credentials/ApiKeysSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion frontend/app/app/credentials/ConnectedAppsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading