using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using Managing.Infrastructure.Evm.Abstractions; using Managing.Infrastructure.Evm.Models; using Managing.Infrastructure.Evm.Models.Privy; using Org.Webpki.JsonCanonicalizer; public class PrivyService : IPrivyService { private readonly HttpClient _privyClient; private readonly string _appId; private readonly string _appSecret; private readonly string _authorizationKey; public PrivyService(IPrivySettings settings) { _privyClient = new HttpClient(); _appId = settings.AppId; _appSecret = settings.AppSecret; _authorizationKey = settings.AuthorizationKey; ConfigureHttpClient(); } private void ConfigureHttpClient() { _privyClient.BaseAddress = new Uri("https://auth.privy.io/"); var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_appId}:{_appSecret}")); _privyClient.DefaultRequestHeaders.Add("privy-app-id", _appId); _privyClient.DefaultRequestHeaders.Add("Authorization", $"Basic {authToken}"); _privyClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } /// /// Generates an authorization signature for a request to the Privy API /// /// The full URL for the request /// The request body /// The HTTP method to use for the request (defaults to POST) /// The generated signature public string GenerateAuthorizationSignature(string url, object body, string httpMethod = "POST") { try { // Ensure we have a full, absolute URL for signature calculation string fullUrl; if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) { // Already a full URL fullUrl = url; } else { // It's a relative path, so construct the full URL using the base address string relativePath = url.StartsWith("/") ? url.Substring(1) : url; fullUrl = new Uri(_privyClient.BaseAddress, relativePath).ToString(); } Console.WriteLine($"Full URL for signature: {fullUrl}"); // Create a new dictionary for headers to ensure consistent ordering var headers = new Dictionary { { "privy-app-id", _appId } }; // Create the properly structured payload object according to Privy's specification var signaturePayload = new Dictionary { ["version"] = 1, ["method"] = httpMethod, ["url"] = fullUrl, // Use the FULL URL for signature calculation as per Privy docs ["body"] = body, ["headers"] = headers }; // Serialize to JSON with consistent settings // Note: We're not forcing camelCase conversion, preserving original property casing var options = new JsonSerializerOptions { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, PropertyNamingPolicy = null // Preserve original property casing }; string serializedPayload = JsonSerializer.Serialize(signaturePayload, options); Console.WriteLine($"Request payload for signature: {serializedPayload}"); // Use the JSON Canonicalizer to ensure consistent JSON formatting JsonCanonicalizer jsonCanonicalizer = new JsonCanonicalizer(serializedPayload); byte[] canonicalizedBytes = jsonCanonicalizer.GetEncodedUTF8(); string canonicalizedString = jsonCanonicalizer.GetEncodedString(); Console.WriteLine($"Request jsonCanonicalizer payload for signature: {canonicalizedString}"); // Remove the 'wallet-auth:' prefix from the authorization key string privateKeyAsString = _authorizationKey.Replace("wallet-auth:", ""); // Convert the private key to PEM format string privateKeyAsPem = $"-----BEGIN PRIVATE KEY-----\n{privateKeyAsString}\n-----END PRIVATE KEY-----"; // Create a private key object explicitly using ECDSA P-256 curve using var privateKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); privateKey.ImportFromPem(privateKeyAsPem); // Sign the canonicalized payload buffer with the private key using SHA-256 // CngAlgorithm.ECDsaP256 is implicitly used through the curve specification above byte[] signatureBuffer = privateKey.SignData(canonicalizedBytes, HashAlgorithmName.SHA256); // Convert the signature to a base64 string string signature = Convert.ToBase64String(signatureBuffer); Console.WriteLine($"Generated signature: {signature}"); return signature; } catch (Exception ex) { Console.WriteLine($"Error generating signature: {ex.Message}"); Console.WriteLine($"Stack trace: {ex.StackTrace}"); if (ex.InnerException != null) { Console.WriteLine($"Inner exception: {ex.InnerException.Message}"); } throw new Exception($"Failed to generate authorization signature: {ex.Message}", ex); } } /// /// Generates an authorization signature for delegated actions and sends the HTTP request with the same payload /// /// The full URL for the request /// The request body /// The HTTP method to use for the request (defaults to POST) /// The HTTP response from the request private async Task GenerateAuthorizationSignatureAndSendRequestAsync(string url, object body, HttpMethod httpMethod = null) { httpMethod ??= HttpMethod.Post; try { // Ensure we have a full, absolute URL for the request string fullUrl; string requestPath; if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) { // Already a full URL fullUrl = url; // For the HTTP request, we need just the path if it matches our base address if (uri.Host == new Uri(_privyClient.BaseAddress.ToString()).Host) { requestPath = uri.PathAndQuery; } else { // Using a different host than the base address throw new InvalidOperationException($"URL host {uri.Host} doesn't match base address host {_privyClient.BaseAddress.Host}"); } } else { // It's a relative path, so construct the full URL using the base address string relativePath = url.StartsWith("/") ? url.Substring(1) : url; fullUrl = new Uri(_privyClient.BaseAddress, relativePath).ToString(); requestPath = url.StartsWith("/") ? url : $"/{url}"; } Console.WriteLine($"Full URL for signature: {fullUrl}"); Console.WriteLine($"Request path for HTTP request: {requestPath}"); // Generate the authorization signature string signature = GenerateAuthorizationSignature(fullUrl, body, httpMethod.Method); // Prepare the JSON serialization options var options = new JsonSerializerOptions { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, PropertyNamingPolicy = null // Preserve original property casing }; // Create the HTTP request var request = new HttpRequestMessage(httpMethod, requestPath); // Use the same serialization options to ensure the request body is identical to what we signed var json = JsonSerializer.Serialize(body, options); // Create StringContent with explicit Content-Type header var content = new StringContent(json, Encoding.UTF8); content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); request.Content = content; // Add the headers in the same order we used for signing request.Headers.Add("privy-app-id", _appId); request.Headers.Add("privy-authorization-signature", signature); // Log all request headers and content for debugging Console.WriteLine($"Sending request to {fullUrl}"); Console.WriteLine($"With signature: {signature}"); Console.WriteLine($"Request content: {json}"); Console.WriteLine("Request headers:"); foreach (var header in request.Headers) { Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); } if (request.Content != null && request.Content.Headers != null) { Console.WriteLine("Content headers:"); foreach (var header in request.Content.Headers) { Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); } } // Send the request and return the response var response = await _privyClient.SendAsync(request); // Log response information Console.WriteLine($"Response status: {response.StatusCode}"); string responseContent = await response.Content.ReadAsStringAsync(); Console.WriteLine($"Response content: {responseContent}"); return response; } catch (Exception ex) { Console.WriteLine($"Error sending request: {ex.Message}"); Console.WriteLine($"Stack trace: {ex.StackTrace}"); if (ex.InnerException != null) { Console.WriteLine($"Inner exception: {ex.InnerException.Message}"); } throw new Exception($"Failed to send request: {ex.Message}", ex); } } /// /// Adds the authorization signature header to the request /// /// /// This method is kept for backward compatibility. /// Prefer using GenerateAuthorizationSignatureAndSendRequestAsync which both generates /// the signature and sends the request with the same payload. /// /// The HTTP request message /// The full URL for the request /// The request body private void AddAuthorizationSignatureHeader(HttpRequestMessage request, string url, object body) { if (!string.IsNullOrEmpty(_authorizationKey)) { string signature = GenerateAuthorizationSignature(url, body); request.Headers.Add("privy-authorization-signature", signature); } } public async Task CreateWalletAsync(string chainType = "ethereum") { try { var requestBody = new { chain_type = chainType }; var url = "https://api.privy.io/v1/wallets"; // Use the new method that both generates the signature and sends the request var response = await GenerateAuthorizationSignatureAndSendRequestAsync(url, requestBody); var result = new PrivyWallet(); if (response.IsSuccessStatusCode) { result = await response.Content.ReadFromJsonAsync(); } else { throw new Exception(await response.Content.ReadAsStringAsync()); } return result; } catch (Exception ex) { throw new Exception(ex.Message); } } public async Task SendTransactionAsync(string walletId, string recipientAddress, long value, string caip2 = "eip155:84532") { var requestBody = new { method = "eth_sendTransaction", caip2, @params = new { transaction = new { to = recipientAddress, value } } }; var url = $"https://api.privy.io/v1/wallets/{walletId}/rpc"; // Use the new method that both generates the signature and sends the request return await GenerateAuthorizationSignatureAndSendRequestAsync(url, requestBody); } public class PrivyRequest { [JsonPropertyName("method")] public string Method { get; set; } [JsonPropertyName("chain_type")] public string ChainType { get; set; } [JsonPropertyName("address")] public string Address { get; set; } [JsonPropertyName("params")] public PrivyParamsRequest Params { get; set; } } public class PrivyParamsRequest { [JsonPropertyName("message")] public string Message { get; set; } [JsonPropertyName("encoding")] public string Encoding { get; set; } } /// /// Signs a message using the embedded wallet /// /// The address of the embedded wallet /// The message to sign /// The signing method to use (e.g., "personal_sign", "eth_sign") /// The signature response public async Task SignMessageAsync(string embeddedWallet, string message, string method = "personal_sign") { try { // Construct the request body using the exact format from Privy documentation var requestBody = new { address = embeddedWallet, chain_type = "ethereum", method = method, @params = new { message = message, encoding = "utf-8" } }; // The full URL for the Privy RPC endpoint exactly as specified in docs var url = "https://auth.privy.io/v1/wallets/rpc"; // Use the new method that both generates the signature and sends the request var response = await GenerateAuthorizationSignatureAndSendRequestAsync(url, requestBody); // Check for successful response string responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new Exception($"Failed to sign message: {response.StatusCode} - {responseContent}"); } // Parse the response to get the signature var responseObject = JsonSerializer.Deserialize(responseContent); // Extract the signature from the response if (responseObject.TryGetProperty("data", out var dataElement)) { string signatureResult = dataElement.GetString() ?? string.Empty; Console.WriteLine($"Extracted signature: {signatureResult}"); return signatureResult; } throw new Exception($"Invalid signature response format: {responseContent}"); } catch (Exception ex) { Console.WriteLine($"SignMessageAsync error: {ex}"); throw new Exception($"Error signing message: {ex.Message}", ex); } } /// /// Signs typed data (EIP-712) using the embedded wallet /// /// The ID of the wallet to use for signing /// The typed data to sign (must be a valid JSON string conforming to EIP-712) /// The CAIP-2 chain identifier /// The signature public async Task SignTypedDataAsync(string walletId, string typedData, string caip2 = "eip155:84532") { try { // Parse the typed data to ensure it's valid JSON var typedDataJson = JsonSerializer.Deserialize(typedData); // Construct the request body according to the Privy documentation var requestBody = new { method = "eth_signTypedData_v4", caip2, @params = new[] { walletId, typedData } }; // Construct the full URL for the request var url = $"https://api.privy.io/v1/wallets/{walletId}/rpc"; // Use the new method that both generates the signature and sends the request var response = await GenerateAuthorizationSignatureAndSendRequestAsync(url, requestBody); // Handle the response if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); throw new Exception($"Failed to sign typed data: {errorContent}"); } // Parse the response to get the signature var responseContent = await response.Content.ReadAsStringAsync(); var responseObject = JsonSerializer.Deserialize(responseContent); // Extract the signature from the response if (responseObject.TryGetProperty("data", out var dataElement)) { return dataElement.GetString() ?? string.Empty; } throw new Exception($"Invalid signature response format: {responseContent}"); } catch (JsonException ex) { throw new Exception($"Invalid typed data JSON format: {ex.Message}", ex); } catch (Exception ex) { throw new Exception($"Error signing typed data: {ex.Message}", ex); } } /// /// Gets information about a user, including their linked wallet accounts and delegation status /// /// The Privy DID of the user (format: did:privy:XXXXX) /// User information including wallets and delegation status public async Task GetUserWalletsAsync(string userDid) { if (string.IsNullOrEmpty(userDid)) { throw new ArgumentException("User DID cannot be null or empty", nameof(userDid)); } if (!userDid.StartsWith("did:privy:")) { throw new ArgumentException("User DID must start with 'did:privy:'", nameof(userDid)); } try { // Construct the URL for getting user information var url = $"/api/v1/users/{userDid}"; // Create the HTTP request var request = new HttpRequestMessage(HttpMethod.Get, url); // Send the request var response = await _privyClient.SendAsync(request); // Check for success if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); throw new Exception($"Failed to get user wallets: {response.StatusCode} - {errorContent}"); } // Parse the response var responseContent = await response.Content.ReadAsStringAsync(); Console.WriteLine($"User API Response: {responseContent}"); var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; // Parse the response manually to handle potentially unexpected formats using var document = JsonDocument.Parse(responseContent); var root = document.RootElement; // Create the user info object var userInfo = new PrivyUserInfo(); // Extract the DID if (root.TryGetProperty("did", out var didElement)) { userInfo.Did = didElement.GetString(); } // Extract timestamps if they exist if (root.TryGetProperty("created_at", out var createdElement) && createdElement.TryGetInt64(out var createdTimestamp)) { userInfo.CreatedAtTimestamp = createdTimestamp; } if (root.TryGetProperty("updated_at", out var updatedElement) && updatedElement.TryGetInt64(out var updatedTimestamp)) { userInfo.UpdatedAtTimestamp = updatedTimestamp; } // Extract linked accounts if (root.TryGetProperty("linked_accounts", out var accountsArray) && accountsArray.ValueKind == JsonValueKind.Array) { foreach (var accountElement in accountsArray.EnumerateArray()) { try { var account = JsonSerializer.Deserialize(accountElement.GetRawText(), options); if (account != null) { userInfo.LinkedAccounts.Add(account); } } catch (Exception ex) { Console.WriteLine($"Error deserializing account: {ex.Message}"); // Continue with the next account if one fails } } } return userInfo; } catch (Exception ex) { throw new Exception($"Error retrieving user wallets: {ex.Message}", ex); } } }