467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
import React, {useEffect, useState} from 'react'
|
|
import {useDelegatedActions, usePrivy, useWallets, WalletWithMetadata} from '@privy-io/react-auth'
|
|
import {Account} from '../../../generated/ManagingApi'
|
|
import canonicalize from 'canonicalize' // Support JSON canonicalization
|
|
import elliptic from 'elliptic' // Elliptic curve cryptography for browsers
|
|
import * as base64 from 'base64-js' // Base64 encoding/decoding
|
|
import Modal from '../../../components/mollecules/Modal/Modal'
|
|
|
|
// Replace with your app configuration - these should come from environment variables in a real app
|
|
const PRIVY_APP_ID = 'insert-your-app-id'
|
|
const PRIVY_APP_SECRET = 'insert-your-app-secret' // Don't hardcode in production!
|
|
const PRIVY_AUTHORIZATION_KEY = 'wallet-auth:insert-your-private-key-here' // Don't hardcode in production!
|
|
|
|
// Initialize elliptic curve - P-256 is the curve used by Privy (NIST P-256)
|
|
const EC = new elliptic.ec('p256')
|
|
|
|
interface PrivyDelegationModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
account: Account
|
|
}
|
|
|
|
// Use Web Crypto API to hash the message with SHA-256
|
|
async function sha256Hash(message: Uint8Array): Promise<Uint8Array> {
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', message)
|
|
return new Uint8Array(hashBuffer)
|
|
}
|
|
|
|
async function generateAuthorizationSignature({url, body}: {url: string; body: object}) {
|
|
try {
|
|
// First, canonicalize the request body - this is crucial for consistent signatures
|
|
const canonicalizedBody = JSON.parse(canonicalize(body) as string);
|
|
|
|
// Create the payload object that follows Privy's specification
|
|
const payload = {
|
|
version: 1,
|
|
method: 'POST',
|
|
url,
|
|
body: canonicalizedBody, // Use the canonicalized body here
|
|
headers: {
|
|
'privy-app-id': PRIVY_APP_ID
|
|
}
|
|
};
|
|
|
|
// JSON-canonicalize the payload to ensure deterministic serialization
|
|
const serializedPayload = canonicalize(payload) as string;
|
|
|
|
console.log('Canonicalized payload:', serializedPayload);
|
|
|
|
// Extract private key from the authorization key by removing the prefix
|
|
const privateKeyBase64 = PRIVY_AUTHORIZATION_KEY.replace('wallet-auth:', '');
|
|
|
|
// Decode the base64 private key to get the raw bytes
|
|
const privateKeyBytes = base64.toByteArray(privateKeyBase64);
|
|
|
|
// Import the private key into elliptic
|
|
const privateKey = EC.keyFromPrivate(privateKeyBytes);
|
|
|
|
// Convert the serialized payload to UTF-8 bytes
|
|
const messageBytes = new TextEncoder().encode(serializedPayload);
|
|
|
|
// Hash the message using SHA-256 with Web Crypto API
|
|
const messageHash = await sha256Hash(messageBytes);
|
|
|
|
// Sign the hash with the private key - use Uint8Array instead of Buffer
|
|
// Using the array directly instead of creating a Buffer
|
|
const signature = privateKey.sign(Array.from(messageHash));
|
|
|
|
// Convert signature to the DER format and then to base64
|
|
const signatureDer = signature.toDER();
|
|
const signatureBase64 = base64.fromByteArray(new Uint8Array(signatureDer));
|
|
|
|
return {
|
|
payload: serializedPayload,
|
|
signature: signatureBase64,
|
|
body: canonicalizedBody, // Return the canonicalized body
|
|
canonicalizedBodyString: canonicalize(canonicalizedBody) as string // Return the string form too
|
|
};
|
|
} catch (err) {
|
|
console.error('Error generating signature:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Function to send the actual request with the generated signature
|
|
async function sendPrivyRequest(url: string, body: any, signature: string): Promise<any> {
|
|
try {
|
|
// Create basic auth token for Privy API
|
|
const authToken = btoa(`${PRIVY_APP_ID}:${PRIVY_APP_SECRET}`);
|
|
|
|
// Set up the request headers
|
|
const headers = {
|
|
'Authorization': `Basic ${authToken}`,
|
|
'privy-app-id': PRIVY_APP_ID,
|
|
'privy-authorization-signature': signature,
|
|
'Content-Type': 'application/json'
|
|
};
|
|
|
|
console.log('Sending request with headers:', headers);
|
|
console.log('Body:', body);
|
|
|
|
// Make the API call
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: body // Use the exact same canonicalized body string
|
|
});
|
|
|
|
// Get the response body
|
|
const responseText = await response.text();
|
|
|
|
// Try to parse as JSON if possible
|
|
try {
|
|
const responseJson = JSON.parse(responseText);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API error (${response.status}): ${JSON.stringify(responseJson)}`);
|
|
}
|
|
|
|
return responseJson;
|
|
} catch (parseError) {
|
|
// If not valid JSON, return as text
|
|
if (!response.ok) {
|
|
throw new Error(`API error (${response.status}): ${responseText}`);
|
|
}
|
|
|
|
return { text: responseText };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending Privy request:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const PrivyDelegationModal: React.FC<PrivyDelegationModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
account,
|
|
}) => {
|
|
const [isDelegated, setIsDelegated] = useState<boolean>(false)
|
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [signatureResult, setSignatureResult] = useState<{
|
|
payload: string,
|
|
signature: string,
|
|
body?: any,
|
|
canonicalizedBodyString?: string
|
|
} | null>(null)
|
|
const [isSigningMessage, setIsSigningMessage] = useState<boolean>(false)
|
|
const [isSendingRequest, setIsSendingRequest] = useState<boolean>(false)
|
|
const [messageToSign, setMessageToSign] = useState<string>('Hello, Ethereum')
|
|
const [apiResponse, setApiResponse] = useState<any>(null)
|
|
|
|
const { delegateWallet, revokeWallets } = useDelegatedActions()
|
|
const { user } = usePrivy()
|
|
const { ready, wallets } = useWallets()
|
|
|
|
// Find the embedded wallet to delegate from the array of the user's wallets
|
|
const walletToDelegate = wallets.find(
|
|
(wallet) => wallet.walletClientType === 'privy' && wallet.address === account.key
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (user && ready) {
|
|
checkDelegationStatus()
|
|
}
|
|
}, [user, ready])
|
|
|
|
const checkDelegationStatus = () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
// Check if the wallet is already delegated by inspecting the user's linked accounts
|
|
const delegatedWallet = user?.linkedAccounts.find(
|
|
(linkedAccount): linkedAccount is WalletWithMetadata =>
|
|
linkedAccount.type === 'wallet' &&
|
|
linkedAccount.delegated &&
|
|
linkedAccount.address.toLowerCase() === account.key?.toLowerCase()
|
|
)
|
|
|
|
setIsDelegated(!!delegatedWallet)
|
|
} catch (err: any) {
|
|
console.error('Error checking delegation status:', err)
|
|
setError(`Failed to check delegation status: ${err.message}`)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleRequestDelegation = async () => {
|
|
try {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
if (!walletToDelegate) {
|
|
throw new Error('No embedded wallet found to delegate')
|
|
}
|
|
|
|
// Fixes the linter error by using the correct parameter type
|
|
await delegateWallet({
|
|
address: walletToDelegate.address,
|
|
chainType: 'ethereum'
|
|
})
|
|
|
|
setIsDelegated(true)
|
|
} catch (err: any) {
|
|
console.error('Error requesting delegation:', err)
|
|
setError(`Failed to request delegation: ${err.message}`)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleRevokeDelegation = async () => {
|
|
try {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
if (!walletToDelegate) {
|
|
throw new Error('No embedded wallet found to revoke delegation')
|
|
}
|
|
|
|
// Fix for linter error - revokeWallets doesn't take parameters
|
|
await revokeWallets()
|
|
|
|
setIsDelegated(false)
|
|
} catch (err: any) {
|
|
console.error('Error revoking delegation:', err)
|
|
setError(`Failed to revoke delegation: ${err.message}`)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSignMessage = async () => {
|
|
try {
|
|
setIsSigningMessage(true)
|
|
setSignatureResult(null)
|
|
setApiResponse(null)
|
|
setError(null)
|
|
|
|
if (!walletToDelegate) {
|
|
throw new Error('No embedded wallet found to sign with')
|
|
}
|
|
|
|
// Prepare the request body
|
|
const body = {
|
|
address: walletToDelegate.address,
|
|
chain_type: 'ethereum',
|
|
method: 'personal_sign',
|
|
params: {
|
|
message: messageToSign,
|
|
encoding: 'utf-8'
|
|
}
|
|
}
|
|
|
|
// Generate the signature using browser crypto
|
|
const result = await generateAuthorizationSignature({
|
|
url: 'https://auth.privy.io/api/v1/wallets/rpc',
|
|
body
|
|
})
|
|
|
|
setSignatureResult(result)
|
|
|
|
// For debugging in the console
|
|
console.log("Generated Authorization Signature:", result.signature);
|
|
console.log("Payload:", result.payload);
|
|
console.log("Body:", result.body);
|
|
console.log("Canonicalized Body String:", result.canonicalizedBodyString);
|
|
|
|
// Create a curl command that could be used to make the request
|
|
// Note: We're using the canonicalized body from the result to ensure it matches what was signed
|
|
const curlCommand = `curl --request POST https://auth.privy.io/api/v1/wallets/rpc \\
|
|
-u "${PRIVY_APP_ID}:YOUR_APP_SECRET" \\
|
|
-H "privy-app-id: ${PRIVY_APP_ID}" \\
|
|
-H "privy-authorization-signature: ${result.signature}" \\
|
|
-H 'Content-Type: application/json' \\
|
|
-d '${result.canonicalizedBodyString}'`;
|
|
|
|
console.log("Curl command for testing:");
|
|
console.log(curlCommand);
|
|
|
|
} catch (err: any) {
|
|
console.error('Error signing message:', err)
|
|
setError(`Failed to sign message: ${err.message}`)
|
|
} finally {
|
|
setIsSigningMessage(false)
|
|
}
|
|
}
|
|
|
|
const handleSendRequest = async () => {
|
|
if (!signatureResult?.canonicalizedBodyString || !signatureResult?.signature) {
|
|
setError('No signature available. Please generate a signature first.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSendingRequest(true);
|
|
setApiResponse(null);
|
|
setError(null);
|
|
|
|
// Send the actual request using the same canonicalized body that was used for signature
|
|
const response = await sendPrivyRequest(
|
|
'https://auth.privy.io/api/v1/wallets/rpc',
|
|
signatureResult.canonicalizedBodyString,
|
|
signatureResult.signature
|
|
);
|
|
|
|
setApiResponse(response);
|
|
console.log('API Response:', response);
|
|
} catch (err: any) {
|
|
console.error('Error sending request:', err);
|
|
setError(`Failed to send request: ${err.message}`);
|
|
} finally {
|
|
setIsSendingRequest(false);
|
|
}
|
|
};
|
|
|
|
// Modal content to be passed to the Modal component
|
|
const modalContent = (
|
|
<>
|
|
{isLoading ? (
|
|
<div className="text-center py-4">
|
|
<span className="loading loading-spinner loading-md"></span>
|
|
<p>Checking delegation status...</p>
|
|
</div>
|
|
) : error ? (
|
|
<div className="alert alert-error mb-4">
|
|
<p>{error}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="mb-4">
|
|
<p className="mb-2">
|
|
<strong>Wallet Address:</strong> {account.key}
|
|
</p>
|
|
<p className="mb-2">
|
|
<strong>Delegation Status:</strong>{' '}
|
|
<span className={isDelegated ? 'text-success' : 'text-error'}>
|
|
{isDelegated ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-center mb-4">
|
|
{isDelegated ? (
|
|
<button
|
|
onClick={handleRevokeDelegation}
|
|
className="btn btn-error"
|
|
disabled={isLoading}
|
|
>
|
|
Revoke Delegation
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleRequestDelegation}
|
|
className="btn btn-primary"
|
|
disabled={isLoading}
|
|
>
|
|
Enable Delegation
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="divider">Message Signing</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="form-control w-full">
|
|
<div className="label">
|
|
<span className="label-text">Message to sign</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={messageToSign}
|
|
onChange={(e) => setMessageToSign(e.target.value)}
|
|
className="input input-bordered w-full"
|
|
disabled={isSigningMessage}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 mb-4">
|
|
<button
|
|
onClick={handleSignMessage}
|
|
className="btn btn-secondary"
|
|
disabled={isSigningMessage || !messageToSign}
|
|
>
|
|
{isSigningMessage ? (
|
|
<>
|
|
<span className="loading loading-spinner loading-sm"></span>
|
|
Generating Signature...
|
|
</>
|
|
) : (
|
|
'Generate Signature'
|
|
)}
|
|
</button>
|
|
|
|
{signatureResult && (
|
|
<button
|
|
onClick={handleSendRequest}
|
|
className="btn btn-primary"
|
|
disabled={isSendingRequest || !signatureResult}
|
|
>
|
|
{isSendingRequest ? (
|
|
<>
|
|
<span className="loading loading-spinner loading-sm"></span>
|
|
Sending Request...
|
|
</>
|
|
) : (
|
|
'Send Request Using Signature'
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{signatureResult && (
|
|
<div className="mb-4">
|
|
<div className="label">
|
|
<span className="label-text font-bold">Generated Signature:</span>
|
|
</div>
|
|
<div className="bg-gray-100 p-2 rounded overflow-x-auto">
|
|
<pre className="text-xs break-all whitespace-pre-wrap">{signatureResult.signature}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{apiResponse && (
|
|
<div className="mb-4">
|
|
<div className="label">
|
|
<span className="label-text font-bold">API Response:</span>
|
|
</div>
|
|
<div className="bg-gray-100 p-2 rounded overflow-x-auto">
|
|
<pre className="text-xs break-all whitespace-pre-wrap">
|
|
{JSON.stringify(apiResponse, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4">
|
|
<div className="alert alert-info">
|
|
<p className="text-sm">
|
|
<strong>Note:</strong> This component ensures that the exact same canonicalized payload is used
|
|
for both signature generation and request sending, maintaining signature integrity.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
return (
|
|
<Modal
|
|
showModal={isOpen}
|
|
onClose={onClose}
|
|
titleHeader="Wallet Delegation"
|
|
>
|
|
{modalContent}
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default PrivyDelegationModal
|
|
|