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);
}
}
}