Privy
Privy is a simple, powerful library to connect users to your web3 product. It allows users to sign up with familiar Web2 methods (email, social logins) or connect their existing crypto wallets, providing a seamless onboarding experience.
By integrating Privy with Notus API, developers can:
Offer flexible login options (email, social, wallet) for easy user onboarding.
Abstract away blockchain complexities, associating a smart wallet (EIP-7702) with each user seamlessly.
Enable gasless transactions and other Account Abstraction benefits via Notus API, signed using Privy's embedded wallets.
Securely manage user sessions and interactions, with Privy handling wallet creation/management and Notus API powering onchain actions.
This combination allows you to build user-friendly dApps that appeal to both Web2 and Web3 audiences.
This guide demonstrates how to use Privy with the Notus API in a Next.js App Router project. You'll learn how to authenticate a user, retrieve their Externally Owned Account (EOA), use that EOA to interact with Notus API via backend API Route Handlers to register a smart wallet, request a swap quote, and execute a UserOperation.
Eager to get started? Check out our Privy + Notus API Example on GitHub for a complete implementation using Next.js App Router.
How to Use Privy with Notus API
Let's walk through the full integration step by step, emphasizing that Notus API calls are made from your backend (Next.js API Route Handlers) for security and best practices.
1. Start a Next.js Project
First, create a new Next.js project if you haven't already (ensure you are using the App Router, which is the default for recent versions of Next.js):
npx create-next-app@latest my-privy-notus-app
cd my-privy-notus-app2. Install Privy SDK
Install the Privy React SDK and viem (used to sign the user operation hash as raw bytes):
npm install @privy-io/react-auth viem3. Frontend: Create a Login Button
Create a simple client component for logging in and out with Privy. This will be used on your main page.
"use client";
import { usePrivy } from '@privy-io/react-auth';
export default function PrivyLoginButton() {
const { ready, authenticated, login, logout } = usePrivy();
// Wait for Privy to be ready before rendering
if (!ready) {
return null;
}
return (
<div>
{authenticated ? (
<button onClick={logout}>
Log Out
</button>
) : (
<button onClick={login}>
Log In with Privy
</button>
)}
</div>
);
}4. Server-Side Authentication and Smart Wallet Management
Create a server-side authentication function that handles both Privy authentication and smart wallet management. This function will be used in your server components to get the authenticated user and their smart wallet address.
import { cookies } from "next/headers";
import { privy } from "./privy";
import { notusAPI } from "./notusAPI";
// EIP-7702 factory address
const FACTORY_ADDRESS = "0x2577507b78c2008ff367261cb6285d44ba5ef2e9";
// Server-side authentication function that handles Privy auth and smart wallet setup
export async function auth() {
try {
// Get Privy token from cookies
const { get } = await cookies();
const token = get("privy-token");
if (!token) {
return null;
}
// Verify the auth token
const authToken = await privy.verifyAuthToken(token.value);
if (!authToken) {
return null;
}
// Get user from Privy using the verified user ID
let user = await privy.getUserById(authToken.userId);
// Create wallet if user doesn't have one
if (!user.wallet?.address) {
user = await privy.createWallets({
userId: user.id,
createEthereumWallet: true,
});
}
// Check if smart wallet exists
const { wallet } = await notusAPI
.get("wallets/address", {
searchParams: {
externallyOwnedAccount: user.wallet?.address as string,
factory: FACTORY_ADDRESS,
},
})
.json<{
wallet: { accountAbstraction: string; registeredAt: string | null };
}>();
// Register smart wallet if it doesn't exist
if (!wallet.registeredAt) {
await notusAPI.post("wallets/register", {
json: {
externallyOwnedAccount: user.wallet?.address as string,
factory: FACTORY_ADDRESS,
eip7702: true,
},
});
}
// Return user with smart wallet address
return {
...user,
accountAbstractionAddress: wallet.accountAbstraction,
};
} catch (error) {
console.error(error);
return null;
}
}Then, use this auth() function in your server components to get the authenticated user and their smart wallet address:
import { auth } from "./auth";
import { redirect } from "next/navigation";
export default async function Home() {
const user = await auth();
if (!user) {
redirect("/login");
}
return (
<main>
<h1>Welcome, {user.email}</h1>
<p>Your Smart Wallet: {user.accountAbstractionAddress}</p>
{/* Add your app content here */}
</main>
);
}The auth() function handles:
- Retrieving the Privy token from cookies
- Verifying the auth token using
verifyAuthToken() - Getting the user from Privy using
getUserById() - Creating a wallet if the user doesn't have one
- Checking if a smart wallet exists for the user's EOA
- Registering a new smart wallet if needed
- Returning the user with their smart wallet address
This approach is more secure as it keeps all authentication and wallet management logic on the server side.
Note: With EIP-7702, the user's EOA itself becomes the smart wallet by setting a contract as its own code. The wallet is activated with the user's first onchain transaction (e.g., swap or transfer) via a UserOperation.
5. Server-Side Transfer Quote Generation
Create a server action to handle transfer quote generation. This keeps the Notus API key secure on the server side.
"use server";
import { notusAPI } from "../notusAPI";
type TransferQuote = {
userOperationHash: `0x${string}` | null;
walletAddress: string;
toAddress: string;
token: string;
amountToSend: string;
amountToBeReceived: string;
expiresAt: number;
};
export async function getTransferQuote({
smartWalletAddress,
toAddress,
token,
amount,
chainId = 137, // Example: Polygon
}: {
smartWalletAddress: string;
toAddress: string;
token: string;
amount: string;
chainId?: number;
}) {
try {
// The transfer quote is wrapped in a `transfer` object
const { transfer } = await notusAPI
.post("crypto/transfer", {
json: {
amount: String(amount),
chainId: Number(chainId),
gasFeePaymentMethod: "ADD_TO_AMOUNT",
payGasFeeToken: token,
token,
walletAddress: smartWalletAddress,
toAddress,
transactionFeePercent: 0,
},
})
.json<{ transfer: TransferQuote }>();
return { success: true, data: transfer };
} catch (error: any) {
console.error("Error getting transfer quote:", error);
return { success: false, error: error.message || "Failed to get transfer quote" };
}
}To transfer native tokens (e.g., MATIC on Polygon), use 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee as the token address.
Note: The payGasFeeToken field should contain the address of an ERC-20 token held in the smart wallet. This token will be used to pay both the partner’s transactionFeePercent and the gas fees for the UserOperation. In most cases, payGasFeeToken is the same as token, typically the token the user already holds in their wallet.
6. Server-Side User Operation Execution
Create a server action to handle the execution of user operations. This is where the magic happens - after getting a quote and signing it with Privy, we send the signature to Notus API to execute the transaction.
"use server";
import { notusAPI } from "../notusAPI";
export async function executeUserOperation({
userOperationHash,
signature,
}: {
userOperationHash: `0x${string}`;
signature: `0x${string}`;
}) {
try {
// Use the server-side notusAPI client so the API key stays on the server
const data = await notusAPI
.post("crypto/execute-user-op", {
json: {
userOperationHash,
signature,
},
})
.json<{ userOperationHash: string; estimatedExecutionTime: string }>();
return { success: true, data };
} catch (error: any) {
console.error("Error executing user operation:", error);
return { success: false, error: error.message || "Failed to execute user operation" };
}
}How User Operation Execution Works:
- The
userOperationHashfrom the transfer/swap quote is signed as raw bytes using the Privy wallet (viatoViemAccount), not as a text string - The signature is sent to Notus API's
/execute-user-opendpoint - Notus API verifies the signature matches the EOA that created the quote
- If valid, Notus API creates and submits a UserOperation - a special transaction format for smart contract wallets
- The transaction is executed through Account Abstraction (ERC-4337)
The userOperationHash is a 32-byte hash and must be signed as raw bytes. Signing the hex string as text (for example, passing it directly to Privy's useSignMessage, which only accepts a UTF-8 string) produces a different signature that Notus API cannot verify, and the operation fails with an invalid signature error.
7. Frontend: Transfer Execution Component
Create a client component that handles the transfer execution process, using the server action for quote generation and Privy for message signing.
"use client";
import { toViemAccount, useWallets } from '@privy-io/react-auth';
import { useState } from 'react';
import type { Hex } from 'viem';
import { getTransferQuote } from '../actions/transfer';
import { executeUserOperation } from '../actions/execute';
export default function TransferExecutor({ eoa, smartWalletAddress }: { eoa: string; smartWalletAddress: string; }) {
const { wallets } = useWallets();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userOpHash, setUserOpHash] = useState<string | null>(null);
const handleTransfer = async () => {
setIsLoading(true);
setError(null);
try {
// 1. Find the Privy wallet that owns this EOA
const connectedWallet = wallets.find(
(wallet) => wallet.address.toLowerCase() === eoa.toLowerCase(),
);
if (!connectedWallet) {
throw new Error("Reconnect the Privy wallet that owns this account.");
}
// 2. Get the transfer quote from the server action
const quote = await getTransferQuote({
smartWalletAddress,
toAddress: "0x123...",
token: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // USDC
amount: "1",
chainId: 137,
});
if (!quote.success) throw new Error(quote.error);
const userOperationHash = quote.data.userOperationHash;
if (!userOperationHash) throw new Error("Quote has no user operation hash.");
// 3. Sign the RAW bytes of the userOperationHash (not the hex string as text)
const account = await toViemAccount({ wallet: connectedWallet });
const signature = await account.signMessage({
message: { raw: userOperationHash as Hex },
});
// 4. Execute the signed user operation via the server action
const result = await executeUserOperation({
userOperationHash,
signature,
});
if (!result.success) throw new Error(result.error);
setUserOpHash(result.data.userOperationHash);
} catch (error) {
setError(error instanceof Error ? error.message : "Transfer failed");
} finally {
setIsLoading(false);
}
};
return (
<div>
<button onClick={handleTransfer} disabled={isLoading || !eoa}>
{isLoading ? 'Processing...' : 'Transfer 1 USDC'}
</button>
{userOpHash && <p>UserOp Hash: {userOpHash}</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}Why toViemAccount instead of useSignMessage?
The userOperationHash must be signed as raw bytes. Privy's useSignMessage only accepts a plain string and signs it as UTF-8 text, producing a signature Notus API cannot verify. Wrapping the connected Privy wallet with toViemAccount gives you viem's signMessage, where { message: { raw } } signs the exact 32 bytes the API expects. Use useWallets() to locate the wallet that owns the user's EOA before signing.
That's it! You've successfully integrated Privy authentication with Notus for gasless transactions. You can now build powerful web3 applications with a seamless user experience.
If you have any questions, make sure to check out Privy's documentation and join their Discord community for support.