From 1afe66dfcec00b2bc5cb734f6d3ce9fd05254039 Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Sat, 30 Mar 2024 04:04:16 +0300
Subject: [PATCH] embedded wallet
---
Directory.Packages.props | 3 +
Thirdweb.Console/Program.cs | 31 +-
.../Thirdweb.Contracts/ThirdwebContract.cs | 2 +-
Thirdweb/Thirdweb.Wallets/IWallet.cs | 17 +-
.../Thirdweb.Wallets.Embedded/Embedded.cs | 209 +++++++
.../EmbeddedWallet.Authentication/AWS.cs | 110 ++++
.../Server.Types.cs | 293 +++++++++
.../EmbeddedWallet.Authentication/Server.cs | 571 ++++++++++++++++++
.../EmbeddedWallet.Cryptography.cs | 136 +++++
.../EmbeddedWallet.Encryption/IvGenerator.cs | 73 +++
.../EmbeddedWallet.Encryption/Secrets.cs | 476 +++++++++++++++
.../VerificationException.cs | 20 +
.../EmbeddedWallet.Models/User.cs | 16 +
.../EmbeddedWallet.Models/UserStatus.cs | 10 +
.../LocalStorage.Types.cs | 72 +++
.../EmbeddedWallet.Storage/LocalStorage.cs | 108 ++++
.../EmbeddedWallet.AuthEndpoint.cs | 13 +
.../EmbeddedWallet/EmbeddedWallet.EmailOTP.cs | 62 ++
.../EmbeddedWallet/EmbeddedWallet.JWT.cs | 13 +
.../EmbeddedWallet/EmbeddedWallet.Misc.cs | 223 +++++++
.../EmbeddedWallet/EmbeddedWallet.OAuth.cs | 27 +
.../EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs | 41 ++
.../EmbeddedWallet/EmbeddedWallet.cs | 25 +
.../EmbeddedWallet.cs | 7 -
.../PrivateKey.cs} | 23 +-
Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs | 39 +-
.../ThirdwebAccountOptions.cs | 4 +-
Thirdweb/Thirdweb.csproj | 3 +
28 files changed, 2592 insertions(+), 35 deletions(-)
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs
create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs
delete mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs
rename Thirdweb/Thirdweb.Wallets/{Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs => Thirdweb.Wallets.PrivateKey/PrivateKey.cs} (90%)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5759a8f..049fdeb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,5 +16,8 @@
+
+
+
\ No newline at end of file
diff --git a/Thirdweb.Console/Program.cs b/Thirdweb.Console/Program.cs
index d937303..ca8b262 100644
--- a/Thirdweb.Console/Program.cs
+++ b/Thirdweb.Console/Program.cs
@@ -1,6 +1,5 @@
using System.Numerics;
using dotenv.net;
-using Nethereum.Hex.HexTypes;
using Newtonsoft.Json;
using Thirdweb;
@@ -27,8 +26,36 @@
var readResult = await ThirdwebContract.ReadContract(contract, "name");
Console.WriteLine($"Contract read result: {readResult}");
-var accountOptions = new ThirdwebAccountOptions(client: client, type: WalletType.PrivateKey, privateKey: privateKey);
+var accountOptions = new ThirdwebAccountOptions(client: client, type: WalletType.Embedded, email: "firekeeper@thirdweb.com");
var account = new ThirdwebAccount(accountOptions);
+await account.Initialize();
+var isConnected = account.IsConnected();
+await account.Disconnect(); // Force email
+
+if (!account.IsConnected())
+{
+ if (account.Wallet is not Embedded embedded)
+ {
+ throw new Exception("Account is not connected and is not an embedded wallet.");
+ }
+ await embedded.SendOTP();
+ var otp = Console.ReadLine();
+ (var address, var canRetry) = await embedded.SubmitOTP(otp);
+ if (canRetry)
+ {
+ Console.WriteLine("OTP failed. Please try again.");
+ var otp2 = Console.ReadLine();
+ (address, _) = await embedded.SubmitOTP(otp2);
+ }
+ if (string.IsNullOrEmpty(address))
+ {
+ throw new Exception("Unknown Error, Null Address");
+ }
+ else
+ {
+ Console.WriteLine($"Successfully Logged In! Address: {address}");
+ }
+}
Console.WriteLine($"Wallet address: {account.GetAddress()}");
var message = "Hello, Thirdweb!";
diff --git a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs
index aaa55a0..de4963b 100644
--- a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs
+++ b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs
@@ -64,7 +64,7 @@ public static async Task WriteContract(ThirdwebAccount account, Thirdweb
transaction.Nonce = new HexBigInteger(await rpc.SendRequestAsync("eth_getTransactionCount", account.GetAddress(), "latest"));
string hash;
- if (account.Options.Type == WalletType.PrivateKey)
+ if (account.Options.Type == WalletType.PrivateKey || account.Options.Type == WalletType.Embedded)
{
var signedTx = account.SignTransaction(transaction, contract.Chain);
Console.WriteLine($"Signed transaction: {signedTx}");
diff --git a/Thirdweb/Thirdweb.Wallets/IWallet.cs b/Thirdweb/Thirdweb.Wallets/IWallet.cs
index 963bf15..4fb8086 100644
--- a/Thirdweb/Thirdweb.Wallets/IWallet.cs
+++ b/Thirdweb/Thirdweb.Wallets/IWallet.cs
@@ -4,13 +4,16 @@
namespace Thirdweb
{
- internal interface IWallet
+ public interface IWallet
{
- string GetAddress();
- string EthSign(string message);
- string PersonalSign(string message);
- string SignTypedDataV4(string json);
- string SignTypedDataV4(T data, TypedData typedData);
- string SignTransaction(TransactionInput transaction, BigInteger chainId);
+ internal Task Initialize();
+ internal string GetAddress();
+ internal string EthSign(string message);
+ internal string PersonalSign(string message);
+ internal string SignTypedDataV4(string json);
+ internal string SignTypedDataV4(T data, TypedData typedData);
+ internal string SignTransaction(TransactionInput transaction, BigInteger chainId);
+ internal bool IsConnected();
+ internal Task Disconnect();
}
}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs
new file mode 100644
index 0000000..7192bdc
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs
@@ -0,0 +1,209 @@
+using System.Numerics;
+using System.Text;
+using Nethereum.ABI.EIP712;
+using Nethereum.Hex.HexConvertors.Extensions;
+using Nethereum.Hex.HexTypes;
+using Nethereum.Model;
+using Nethereum.RPC.Eth.DTOs;
+using Nethereum.RPC.Eth.Mappers;
+using Nethereum.Signer;
+using Nethereum.Signer.EIP712;
+using Thirdweb;
+using Thirdweb.EWS;
+
+public class Embedded : IWallet
+{
+ EmbeddedWallet _embeddedWallet;
+ User _user;
+ EthECKey _ecKey;
+ string _email;
+
+ internal Embedded(ThirdwebClient client, string email)
+ {
+ if (string.IsNullOrEmpty(email))
+ {
+ throw new ArgumentException("Email must be provided to use Embedded Wallets.");
+ }
+
+ _embeddedWallet = new EmbeddedWallet(client);
+ _email = email;
+ }
+
+ public async Task Initialize()
+ {
+ try
+ {
+ _user = await _embeddedWallet.GetUserAsync(_email, "EmailOTP");
+ _ecKey = new EthECKey(_user.Account.PrivateKey);
+ }
+ catch
+ {
+ Console.WriteLine("User not found. Please call Embedded.LoginWithOTP() to create a new user.");
+ _user = null;
+ _ecKey = null;
+ }
+ }
+
+ #region Email OTP Flow
+
+ public async Task SendOTP()
+ {
+ if (string.IsNullOrEmpty(_email))
+ {
+ throw new Exception("Email is required for OTP login");
+ }
+
+ try
+ {
+ (bool isNewUser, bool isNewDevice, bool needsRecoveryCode) = await _embeddedWallet.SendOtpEmailAsync(_email);
+ Console.WriteLine("OTP sent to email. Please call SubmitOTP to login.");
+ }
+ catch (Exception e)
+ {
+ throw new Exception("Failed to send OTP email", e);
+ }
+ }
+
+ public async Task<(string, bool)> SubmitOTP(string otp)
+ {
+ var res = await _embeddedWallet.VerifyOtpAsync(_email, otp, null);
+ if (res.User == null)
+ {
+ bool canRetry = res.CanRetry;
+ if (canRetry)
+ {
+ Console.WriteLine("Invalid OTP. Please try again.");
+ }
+ else
+ {
+ Console.WriteLine("Invalid OTP. Please request a new OTP.");
+ }
+ return (null, canRetry);
+ }
+ else
+ {
+ _user = res.User;
+ _ecKey = new EthECKey(_user.Account.PrivateKey);
+ return (GetAddress(), false);
+ }
+ }
+
+ #endregion
+
+ public string GetAddress()
+ {
+ return _ecKey.GetPublicAddress();
+ }
+
+ public string EthSign(string message)
+ {
+ if (message == null)
+ {
+ throw new ArgumentNullException(nameof(message), "Message to sign cannot be null.");
+ }
+
+ var signer = new MessageSigner();
+ var signature = signer.Sign(Encoding.UTF8.GetBytes(message), _ecKey);
+ return signature;
+ }
+
+ public string PersonalSign(string message)
+ {
+ if (message == null)
+ {
+ throw new ArgumentNullException(nameof(message), "Message to sign cannot be null.");
+ }
+
+ var signer = new EthereumMessageSigner();
+ var signature = signer.EncodeUTF8AndSign(message, _ecKey);
+ return signature;
+ }
+
+ public string SignTypedDataV4(string json)
+ {
+ if (json == null)
+ {
+ throw new ArgumentNullException(nameof(json), "Json to sign cannot be null.");
+ }
+
+ var signer = new Eip712TypedDataSigner();
+ var signature = signer.SignTypedDataV4(json, _ecKey);
+ return signature;
+ }
+
+ public string SignTypedDataV4(T data, TypedData typedData)
+ {
+ if (data == null)
+ {
+ throw new ArgumentNullException(nameof(data), "Data to sign cannot be null.");
+ }
+
+ var signer = new Eip712TypedDataSigner();
+ var signature = signer.SignTypedDataV4(data, typedData, _ecKey);
+ return signature;
+ }
+
+ public string SignTransaction(TransactionInput transaction, BigInteger chainId)
+ {
+ if (transaction == null)
+ {
+ throw new ArgumentNullException(nameof(transaction));
+ }
+
+ if (string.IsNullOrWhiteSpace(transaction.From))
+ {
+ transaction.From = GetAddress();
+ }
+ else if (transaction.From != GetAddress())
+ {
+ throw new Exception("Transaction 'From' address does not match the wallet address");
+ }
+
+ var nonce = transaction.Nonce ?? throw new ArgumentNullException(nameof(transaction), "Transaction nonce has not been set");
+
+ var gasLimit = transaction.Gas;
+ var value = transaction.Value ?? new HexBigInteger(0);
+
+ string signedTransaction;
+ if (transaction.Type != null && transaction.Type.Value == TransactionType.EIP1559.AsByte())
+ {
+ var maxPriorityFeePerGas = transaction.MaxPriorityFeePerGas.Value;
+ var maxFeePerGas = transaction.MaxFeePerGas.Value;
+ var transaction1559 = new Transaction1559(
+ chainId,
+ nonce,
+ maxPriorityFeePerGas,
+ maxFeePerGas,
+ gasLimit,
+ transaction.To,
+ value,
+ transaction.Data,
+ transaction.AccessList.ToSignerAccessListItemArray()
+ );
+
+ var signer = new Transaction1559Signer();
+ signer.SignTransaction(_ecKey, transaction1559);
+ signedTransaction = transaction1559.GetRLPEncoded().ToHex();
+ }
+ else
+ {
+ var gasPrice = transaction.GasPrice;
+ var legacySigner = new LegacyTransactionSigner();
+ signedTransaction = legacySigner.SignTransaction(_ecKey.GetPrivateKey(), chainId, transaction.To, value.Value, nonce, gasPrice.Value, gasLimit.Value, transaction.Data);
+ }
+
+ return "0x" + signedTransaction;
+ }
+
+ public bool IsConnected()
+ {
+ return _ecKey != null;
+ }
+
+ public async Task Disconnect()
+ {
+ await _embeddedWallet.SignOutAsync();
+ _user = null;
+ _ecKey = null;
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs
new file mode 100644
index 0000000..b1624f1
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Amazon;
+using Amazon.CognitoIdentity;
+using Amazon.CognitoIdentityProvider;
+using Amazon.CognitoIdentityProvider.Model;
+using Amazon.Extensions.CognitoAuthentication;
+using Amazon.Lambda;
+using Amazon.Lambda.Model;
+using Amazon.Runtime;
+
+namespace Thirdweb.EWS
+{
+ internal class AWS
+ {
+ private static readonly RegionEndpoint awsRegion = RegionEndpoint.USWest2;
+ private const string cognitoAppClientId = "2e02ha2ce6du13ldk8pai4h3d0";
+ private static readonly string cognitoIdentityPoolId = $"{awsRegion.SystemName}:2ad7ab1e-f48b-48a6-adfa-ac1090689c26";
+ private static readonly string cognitoUserPoolId = $"{awsRegion.SystemName}_UFwLcZIpq";
+ private static readonly string recoverySharePasswordLambdaFunctionName =
+ $"arn:aws:lambda:{awsRegion.SystemName}:324457261097:function:recovery-share-password-GenerateRecoverySharePassw-bbE5ZbVAToil";
+
+ internal static async Task SignUpCognitoUserAsync(string emailAddress, string userName)
+ {
+ emailAddress ??= "cognito@thirdweb.com";
+ AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion);
+ CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider);
+ Dictionary userAttributes = new() { { "email", emailAddress }, };
+ await userPool.SignUpAsync(userName, Secrets.Random(12), userAttributes, new Dictionary());
+ }
+
+ internal static async Task StartCognitoUserAuth(string userName)
+ {
+ // https://stackoverflow.com/questions/66258459/how-to-get-aws-cognito-access-token-with-username-and-password-in-net-core-3-1
+ AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion);
+ CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider);
+ CognitoUser user = new(userName, cognitoAppClientId, userPool, provider);
+ InitiateCustomAuthRequest customRequest =
+ new()
+ {
+ AuthParameters = new Dictionary() { { "USERNAME", userName }, },
+ ClientMetadata = new Dictionary(),
+ };
+ try
+ {
+ AuthFlowResponse authResponse = await user.StartWithCustomAuthAsync(customRequest);
+ return authResponse.SessionID;
+ }
+ catch (UserNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ internal static async Task FinishCognitoUserAuth(string userName, string otp, string sessionId)
+ {
+ AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion);
+ CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider);
+ CognitoUser user = new(userName, cognitoAppClientId, userPool, provider);
+ RespondToCustomChallengeRequest challengeRequest =
+ new()
+ {
+ ChallengeParameters = new Dictionary() { { "USERNAME", userName }, { "ANSWER", otp }, },
+ ClientMetadata = new Dictionary(),
+ SessionID = sessionId,
+ };
+ try
+ {
+ AuthFlowResponse authResponse = await user.RespondToCustomAuthAsync(challengeRequest);
+ AuthenticationResultType result = authResponse.AuthenticationResult ?? throw new VerificationException("The OTP is incorrect", true);
+ return new TokenCollection(result.AccessToken, result.IdToken, result.RefreshToken);
+ }
+ catch (NotAuthorizedException)
+ {
+ throw new VerificationException("The session expired", false);
+ }
+ catch (UserNotFoundException)
+ {
+ throw new InvalidOperationException("The user was not found");
+ }
+ }
+
+ internal static async Task InvokeRecoverySharePasswordLambdaAsync(string idToken, string invokePayload)
+ {
+ InvokeRequest request = new() { FunctionName = recoverySharePasswordLambdaFunctionName, Payload = invokePayload, };
+ CognitoAWSCredentials credentials = new(cognitoIdentityPoolId, awsRegion);
+ string providerName = $"cognito-idp.{awsRegion.SystemName}.amazonaws.com/{cognitoUserPoolId}";
+ credentials.AddLogin(providerName, idToken);
+ AmazonLambdaClient client = new(credentials, awsRegion);
+ InvokeResponse lambdaResponse = await client.InvokeAsync(request);
+ return lambdaResponse.Payload;
+ }
+ }
+
+ internal class TokenCollection
+ {
+ internal TokenCollection(string accessToken, string idToken, string refreshToken)
+ {
+ AccessToken = accessToken;
+ IdToken = idToken;
+ RefreshToken = refreshToken;
+ }
+
+ public string AccessToken { get; }
+ public string IdToken { get; }
+ public string RefreshToken { get; }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs
new file mode 100644
index 0000000..3f4f39b
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs
@@ -0,0 +1,293 @@
+using System.Linq;
+using System.Runtime.Serialization;
+
+namespace Thirdweb.EWS
+{
+ internal partial class Server
+ {
+ internal class VerifyResult
+ {
+ internal VerifyResult(bool isNewUser, string authToken, string walletUserId, string recoveryCode, string email)
+ {
+ IsNewUser = isNewUser;
+ AuthToken = authToken;
+ WalletUserId = walletUserId;
+ RecoveryCode = recoveryCode;
+ Email = email;
+ }
+
+ internal bool IsNewUser { get; }
+ internal string AuthToken { get; }
+ internal string WalletUserId { get; }
+ internal string RecoveryCode { get; }
+ internal string Email { get; }
+ }
+
+#pragma warning disable CS0169, CS8618, IDE0051 // Deserialization will construct the following classes.
+ [DataContract]
+ private class AuthVerifiedTokenReturnType
+ {
+ [DataMember(Name = "verifiedToken")]
+ internal VerifiedTokenType VerifiedToken { get; set; }
+
+ [DataMember(Name = "verifiedTokenJwtString")]
+ internal string VerifiedTokenJwtString { get; set; }
+
+ [DataContract]
+ internal class VerifiedTokenType
+ {
+ [DataMember(Name = "authDetails")]
+ internal UserAuthDetails AuthDetails { get; set; }
+
+ [DataMember]
+ private string authProvider;
+
+ [DataMember]
+ private string developerClientId;
+
+ [DataMember(Name = "isNewUser")]
+ internal bool IsNewUser { get; set; }
+
+ [DataMember]
+ private string rawToken;
+
+ [DataMember]
+ private string userId;
+ }
+ }
+
+ [DataContract]
+ private class GetUserStatusApiReturnType
+ {
+ [DataMember]
+#pragma warning disable CS0649 // Deserialization will populate this field.
+ private string status;
+#pragma warning restore CS0649 // Field 'Server.GetUserStatusApiReturnType.status' is never assigned to, and will always have its default value null
+ internal UserStatus Status => (UserStatus)status.Length;
+
+ [DataMember]
+ private StoredTokenType storedToken;
+
+ [DataMember(Name = "user")]
+ internal UserType User { get; set; }
+
+ [DataContract]
+ internal class UserType
+ {
+ [DataMember(Name = "authDetails")]
+ internal UserAuthDetails AuthDetails { get; set; }
+
+ [DataMember]
+ private string walletAddress;
+ }
+ }
+
+ [DataContract]
+ private class HttpErrorWithMessage
+ {
+ [DataMember(Name = "error")]
+ internal string Error { get; set; } = "";
+
+ [DataMember(Name = "message")]
+ internal string Message { get; set; } = "";
+ }
+
+ [DataContract]
+ private class SharesGetResponse
+ {
+ [DataMember(Name = "authShare")]
+ internal string AuthShare { get; set; }
+
+ [DataMember(Name = "maybeEncryptedRecoveryShares")]
+ internal string[] MaybeEncryptedRecoveryShares { get; set; }
+ }
+
+ [DataContract]
+ private class IsEmailUserOtpValidResponse
+ {
+ [DataMember(Name = "isValid")]
+ internal bool IsValid { get; set; }
+ }
+
+ [DataContract]
+ private class IsEmailKmsOtpValidResponse
+ {
+ [DataMember(Name = "isOtpValid")]
+ internal bool IsOtpValid { get; set; }
+ }
+
+ [DataContract]
+ private class HeadlessOauthLoginLinkResponse
+ {
+ [DataMember(Name = "googleLoginLink")]
+ internal string GoogleLoginLink { get; set; }
+
+ [DataMember(Name = "platformLoginLink")]
+ internal string PlatformLoginLink { get; set; }
+
+ [DataMember(Name = "oauthLoginLink")]
+ internal string OauthLoginLink { get; set; }
+ }
+
+ [DataContract]
+ internal class StoredTokenType
+ {
+ [DataMember]
+ private string jwtToken;
+
+ [DataMember]
+ private string authProvider;
+
+ [DataMember(Name = "authDetails")]
+ internal UserAuthDetails AuthDetails { get; set; }
+
+ [DataMember]
+ private string developerClientId;
+
+ [DataMember]
+ private string cookieString;
+
+ [DataMember]
+ private bool isNewUser;
+ }
+
+ [DataContract]
+ internal class UserAuthDetails
+ {
+ [DataMember(Name = "email")]
+ internal string Email { get; set; }
+
+ [DataMember(Name = "userWalletId")]
+ internal string WalletUserId { get; set; }
+
+ [DataMember(Name = "recoveryShareManagement")]
+ internal string RecoveryShareManagement { get; set; }
+
+ [DataMember(Name = "recoveryCode")]
+ internal string RecoveryCode { get; set; }
+
+ [DataMember(Name = "backupRecoveryCodes")]
+ internal string[] BackupRecoveryCodes { get; set; }
+ }
+
+ [DataContract]
+ internal class UserWallet
+ {
+ [DataMember(Name = "status")]
+ internal string Status { get; set; }
+
+ [DataMember(Name = "isNewUser")]
+ internal bool IsNewUser { get; set; }
+
+ [DataMember(Name = "walletUserId")]
+ internal string WalletUserId { get; set; }
+
+ [DataMember(Name = "recoveryShareManagement")]
+ internal string RecoveryShareManagement { get; set; }
+
+ [DataMember(Name = "storedToken")]
+ internal StoredTokenType StoredToken { get; set; }
+ }
+
+ [DataContract]
+ private class IdTokenResponse
+ {
+ [DataMember(Name = "accessToken")]
+ internal string AccessToken { get; set; }
+
+ [DataMember(Name = "idToken")]
+ internal string IdToken { get; set; }
+ }
+
+ [DataContract]
+ private class RecoverySharePasswordResponse
+ {
+ [DataMember(Name = "body")]
+ internal string Body { get; set; }
+
+ [DataMember(Name = "recoveryShareEncKey")]
+ internal string RecoverySharePassword { get; set; }
+ }
+
+ [DataContract]
+ internal class RecoveryShareManagementResponse
+ {
+ internal string Value => data.oauth.FirstOrDefault()?.recovery_share_management;
+#pragma warning disable CS0649 // Deserialization will populate these fields.
+ [DataMember]
+ private RecoveryShareManagementResponse data;
+
+ [DataMember]
+ private RecoveryShareManagementResponse[] oauth;
+
+ [DataMember]
+ private string recovery_share_management;
+#pragma warning restore CS0649 // Field 'Server.RecoveryShareManagementResponse.*' is never assigned to, and will always have its default value null
+ }
+
+ [DataContract]
+ internal class AuthResultType_OAuth
+ {
+ [DataMember(Name = "storedToken")]
+ internal StoredTokenType_OAuth StoredToken { get; set; }
+
+ [DataMember(Name = "walletDetails")]
+ internal WalletDetailsType_OAuth WalletDetails { get; set; }
+ }
+
+ [DataContract]
+ internal class StoredTokenType_OAuth
+ {
+ [DataMember(Name = "jwtToken")]
+ internal string JwtToken { get; set; }
+
+ [DataMember(Name = "authProvider")]
+ internal string AuthProvider { get; set; }
+
+ [DataMember(Name = "authDetails")]
+ internal AuthDetailsType_OAuth AuthDetails { get; set; }
+
+ [DataMember(Name = "developerClientId")]
+ internal string DeveloperClientId { get; set; }
+
+ [DataMember(Name = "cookieString")]
+ internal string CookieString { get; set; }
+
+ [DataMember(Name = "shouldStoreCookieString")]
+ internal bool ShouldStoreCookieString { get; set; }
+
+ [DataMember(Name = "isNewUser")]
+ internal bool IsNewUser { get; set; }
+
+ [DataContract]
+ internal class AuthDetailsType_OAuth
+ {
+ [DataMember(Name = "email")]
+ internal string Email { get; set; }
+
+ [DataMember(Name = "userWalletId")]
+ internal string UserWalletId { get; set; }
+
+ [DataMember(Name = "recoveryCode")]
+ internal string RecoveryCode { get; set; }
+ }
+ }
+
+ [DataContract]
+ internal class WalletDetailsType_OAuth
+ {
+ [DataMember(Name = "deviceShareStored")]
+ internal string DeviceShareStored { get; set; }
+
+ [DataMember(Name = "isIframeStorageEnabled")]
+ internal bool IsIframeStorageEnabled { get; set; }
+
+ [DataMember(Name = "walletAddress")]
+ internal string WalletAddress { get; set; }
+ }
+
+#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
+#pragma warning restore CS0169 // The field 'Server.*' is never used
+#pragma warning restore IDE0051 // The field 'Server.*' is unused
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs
new file mode 100644
index 0000000..bf8ce01
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs
@@ -0,0 +1,571 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace Thirdweb.EWS
+{
+ internal abstract class ServerBase
+ {
+ internal abstract Task VerifyThirdwebClientIdAsync(string domain);
+ internal abstract Task FetchDeveloperWalletSettings();
+ internal abstract Task FetchUserDetailsAsync(string emailAddress, string authToken);
+ internal abstract Task StoreAddressAndSharesAsync(string walletAddress, string authShare, string encryptedRecoveryShare, string authToken, string[] backupRecoveryShares);
+
+ internal abstract Task<(string authShare, string recoveryShare)> FetchAuthAndRecoverySharesAsync(string authToken);
+ internal abstract Task FetchAuthShareAsync(string authToken);
+ internal abstract Task FetchHeadlessOauthLoginLinkAsync(string authProvider);
+
+ internal abstract Task CheckIsEmailKmsOtpValidAsync(string userName, string otp);
+ internal abstract Task CheckIsEmailUserOtpValidAsync(string emailAddress, string otp);
+
+ internal abstract Task SendUserOtpEmailAsync(string emailAddress);
+ internal abstract Task SendRecoveryCodeEmailAsync(string authToken, string recoveryCode, string email);
+ internal abstract Task VerifyUserOtpAsync(string emailAddress, string otp);
+
+ internal abstract Task SendKmsOtpEmailAsync(string emailAddress);
+ internal abstract Task VerifyKmsOtpAsync(string emailAddress, string otp, string sessionId);
+
+ internal abstract Task SendKmsPhoneOtpAsync(string phoneNumber);
+ internal abstract Task VerifyKmsPhoneOtpAsync(string phoneNumber, string otp, string sessionId);
+
+ internal abstract Task VerifyJwtAsync(string jwtToken);
+
+ internal abstract Task VerifyOAuthAsync(string authVerifiedToken);
+
+ internal abstract Task VerifyAuthEndpointAsync(string payload);
+ }
+
+ internal partial class Server : ServerBase
+ {
+ private const string ROOT_URL = "https://embedded-wallet.thirdweb.com";
+ private const string ROOT_URL_LEGACY = "https://ews.thirdweb.com";
+ private const string API_ROOT_PATH = "/api/2023-10-20";
+ private const string API_ROOT_PATH_LEGACY = "/api/2022-08-12";
+ private const string BUNDLE_ID_HEADER = "x-bundle-id";
+ private const string THIRDWEB_CLIENT_ID_HEADER = "x-thirdweb-client-id";
+ private const string THIRDWEB_SECRET_KEY_HEADER = "x-thirdweb-client-id";
+ private const string SESSION_NONCE_HEADER = "x-session-nonce";
+ private const string EMBEDDED_WALLET_VERSION_HEADER = "x-embedded-wallet-version";
+
+ private static readonly MediaTypeHeaderValue jsonContentType = MediaTypeHeaderValue.Parse("application/json");
+ private static readonly HttpClient httpClient = new();
+
+ private readonly string clientId;
+
+ internal Server(string clientId, string bundleId, string platform, string version, string secretKey)
+ {
+ this.clientId = clientId;
+
+ httpClient.DefaultRequestHeaders.Clear();
+
+ if (!string.IsNullOrEmpty(clientId))
+ {
+ httpClient.DefaultRequestHeaders.Add(THIRDWEB_CLIENT_ID_HEADER, clientId);
+ }
+
+ if (!string.IsNullOrEmpty(bundleId))
+ {
+ httpClient.DefaultRequestHeaders.Add(BUNDLE_ID_HEADER, bundleId);
+ }
+
+ if (!string.IsNullOrEmpty(secretKey))
+ {
+ httpClient.DefaultRequestHeaders.Add(THIRDWEB_SECRET_KEY_HEADER, secretKey);
+ }
+
+ httpClient.DefaultRequestHeaders.Add(SESSION_NONCE_HEADER, Guid.NewGuid().ToString());
+ httpClient.DefaultRequestHeaders.Add(EMBEDDED_WALLET_VERSION_HEADER, $"{platform}:{version}");
+ }
+
+ // embedded-wallet/verify-thirdweb-client-id
+ internal override async Task VerifyThirdwebClientIdAsync(string parentDomain)
+ {
+ Dictionary queryParams = new() { { "clientId", clientId }, { "parentDomain", parentDomain } };
+ Uri uri = MakeUri("/embedded-wallet/verify-thirdweb-client-id", queryParams);
+ StringContent content = MakeHttpContent(new { clientId, parentDomain });
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var error = await DeserializeAsync(response);
+ return error.Error;
+ }
+
+ // embedded-wallet/developer-wallet-settings
+ internal override async Task FetchDeveloperWalletSettings()
+ {
+ try
+ {
+ Dictionary queryParams = new() { { "clientId", clientId }, };
+ Uri uri = MakeUri("/embedded-wallet/developer-wallet-settings", queryParams);
+ HttpResponseMessage response = await httpClient.GetAsync(uri);
+ var responseContent = await DeserializeAsync(response);
+ return responseContent.Value ?? "AWS_MANAGED";
+ }
+ catch (System.Exception e)
+ {
+ Console.WriteLine("Could not fetch recovery share management type, defaulting to managed: " + e.Message);
+ return "AWS_MANAGED";
+ }
+ }
+
+ // embedded-wallet/embedded-wallet-user-details
+ internal override async Task FetchUserDetailsAsync(string emailAddress, string authToken)
+ {
+ Dictionary queryParams = new();
+ if (emailAddress == null && authToken == null)
+ {
+ throw new InvalidOperationException("Must provide either email address or auth token");
+ }
+
+ queryParams.Add("email", emailAddress ?? "uninitialized");
+ queryParams.Add("clientId", clientId);
+
+ Uri uri = MakeUri("/embedded-wallet/embedded-wallet-user-details", queryParams);
+ HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken ?? "");
+ await CheckStatusCodeAsync(response);
+ var rv = await DeserializeAsync(response);
+ return rv;
+ }
+
+ // embedded-wallet/embedded-wallet-shares POST
+ internal override async Task StoreAddressAndSharesAsync(string walletAddress, string authShare, string encryptedRecoveryShare, string authToken, string[] backupRecoveryShares)
+ {
+ var encryptedRecoveryShares =
+ backupRecoveryShares == null
+ ? new[] { new { share = encryptedRecoveryShare, isClientEncrypted = "true" } }
+ : new[] { new { share = encryptedRecoveryShare, isClientEncrypted = "true" } }.Concat(backupRecoveryShares.Select((s) => new { share = s, isClientEncrypted = "true" })).ToArray();
+
+ HttpRequestMessage httpRequestMessage =
+ new(HttpMethod.Post, MakeUri("/embedded-wallet/embedded-wallet-shares"))
+ {
+ Content = MakeHttpContent(
+ new
+ {
+ authShare,
+ maybeEncryptedRecoveryShares = encryptedRecoveryShares,
+ walletAddress,
+ }
+ ),
+ };
+ HttpResponseMessage response = await SendHttpWithAuthAsync(httpRequestMessage, authToken);
+ await CheckStatusCodeAsync(response);
+ }
+
+ // embedded-wallet/embedded-wallet-shares GET
+ internal override async Task<(string authShare, string recoveryShare)> FetchAuthAndRecoverySharesAsync(string authToken)
+ {
+ SharesGetResponse sharesGetResponse = await FetchRemoteSharesAsync(authToken, true);
+ string authShare = sharesGetResponse.AuthShare ?? throw new InvalidOperationException("Server failed to return auth share");
+ string encryptedRecoveryShare = sharesGetResponse.MaybeEncryptedRecoveryShares?.FirstOrDefault() ?? throw new InvalidOperationException("Server failed to return recovery share");
+ return (authShare, encryptedRecoveryShare);
+ }
+
+ // embedded-wallet/embedded-wallet-shares GET
+ internal override async Task FetchAuthShareAsync(string authToken)
+ {
+ SharesGetResponse sharesGetResponse = await FetchRemoteSharesAsync(authToken, false);
+ return sharesGetResponse.AuthShare ?? throw new InvalidOperationException("Server failed to return auth share");
+ }
+
+ // embedded-wallet/embedded-wallet-shares GET
+ private async Task FetchRemoteSharesAsync(string authToken, bool wantsRecoveryShare)
+ {
+ Dictionary queryParams =
+ new()
+ {
+ { "getEncryptedAuthShare", "true" },
+ { "getEncryptedRecoveryShare", wantsRecoveryShare ? "true" : "false" },
+ { "useSealedSecret", "false" }
+ };
+ Uri uri = MakeUri("/embedded-wallet/embedded-wallet-shares", queryParams);
+ HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken);
+ await CheckStatusCodeAsync(response);
+ var rv = await DeserializeAsync(response);
+ return rv;
+ }
+
+ // embedded-wallet/cognito-id-token
+ private async Task FetchCognitoIdTokenAsync(string authToken)
+ {
+ Uri uri = MakeUri("/embedded-wallet/cognito-id-token");
+ HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken);
+ await CheckStatusCodeAsync(response);
+ return await DeserializeAsync(response);
+ }
+
+ // embedded-wallet/headless-oauth-login-link
+ internal override async Task FetchHeadlessOauthLoginLinkAsync(string authProvider)
+ {
+ // based on above unity implementation, adapt to this class
+ Uri uri = MakeUri(
+ "/embedded-wallet/headless-oauth-login-link",
+ new Dictionary
+ {
+ { "platform", "unity" },
+ { "authProvider", authProvider },
+ { "baseUrl", "https://embedded-wallet.thirdweb.com" }
+ }
+ );
+
+ HttpResponseMessage response = await httpClient.GetAsync(uri);
+ await CheckStatusCodeAsync(response);
+ var rv = await DeserializeAsync(response);
+ return rv.PlatformLoginLink;
+ }
+
+ // /embedded-wallet/is-cognito-otp-valid
+ internal override async Task CheckIsEmailKmsOtpValidAsync(string email, string otp)
+ {
+ Uri uri = MakeUriLegacy(
+ "/embedded-wallet/is-cognito-otp-valid",
+ new Dictionary
+ {
+ { "email", email },
+ { "code", otp },
+ { "clientId", clientId }
+ }
+ );
+ HttpResponseMessage response = await httpClient.GetAsync(uri);
+ await CheckStatusCodeAsync(response);
+ var result = await DeserializeAsync(response);
+ return result.IsOtpValid;
+ }
+
+ // embedded-wallet/is-thirdweb-email-otp-valid
+ internal override async Task CheckIsEmailUserOtpValidAsync(string email, string otp)
+ {
+ Uri uri = MakeUri("/embedded-wallet/is-thirdweb-email-otp-valid");
+ StringContent content = MakeHttpContent(
+ new
+ {
+ email,
+ otp,
+ clientId,
+ }
+ );
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var result = await DeserializeAsync(response);
+ return result.IsValid;
+ }
+
+ // embedded-wallet/send-user-managed-email-otp
+ internal override async Task SendUserOtpEmailAsync(string emailAddress)
+ {
+ Uri uri = MakeUri("/embedded-wallet/send-user-managed-email-otp");
+ StringContent content = MakeHttpContent(new { clientId, email = emailAddress });
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ }
+
+ // embedded-wallet/send-wallet-recovery-code
+ internal override async Task SendRecoveryCodeEmailAsync(string authToken, string recoveryCode, string email)
+ {
+ HttpRequestMessage httpRequestMessage =
+ new(HttpMethod.Post, MakeUri("/embedded-wallet/send-wallet-recovery-code"))
+ {
+ Content = MakeHttpContent(
+ new
+ {
+ strategy = "email",
+ clientId,
+ email,
+ recoveryCode
+ }
+ ),
+ };
+ try
+ {
+ HttpResponseMessage response = await SendHttpWithAuthAsync(httpRequestMessage, authToken);
+ await CheckStatusCodeAsync(response);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Error sending recovery code email", ex);
+ }
+ }
+
+ // embedded-wallet/validate-thirdweb-email-otp
+ internal override async Task VerifyUserOtpAsync(string emailAddress, string otp)
+ {
+ Uri uri = MakeUri("/embedded-wallet/validate-thirdweb-email-otp");
+ StringContent content = MakeHttpContent(
+ new
+ {
+ clientId,
+ email = emailAddress,
+ otp
+ }
+ );
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var authVerifiedToken = await DeserializeAsync(response);
+ return new VerifyResult(
+ authVerifiedToken.VerifiedToken.IsNewUser,
+ authVerifiedToken.VerifiedTokenJwtString,
+ authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId,
+ authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode,
+ authVerifiedToken.VerifiedToken.AuthDetails.Email
+ );
+ }
+
+ // KMS Send
+ internal override async Task SendKmsOtpEmailAsync(string emailAddress)
+ {
+ string userName = MakeCognitoUserName(emailAddress, "email");
+ string sessionId = await AWS.StartCognitoUserAuth(userName);
+ if (sessionId == null)
+ {
+ await AWS.SignUpCognitoUserAsync(emailAddress, userName);
+ for (int i = 0; i < 3; ++i)
+ {
+ await Task.Delay(3333 * i);
+ sessionId = await AWS.StartCognitoUserAuth(userName);
+ if (sessionId != null)
+ {
+ break;
+ }
+ }
+ if (sessionId == null)
+ {
+ throw new InvalidOperationException("Cannot find user within timeout period");
+ }
+ }
+ return sessionId;
+ }
+
+ // embedded-wallet/validate-cognito-email-otp
+ internal override async Task VerifyKmsOtpAsync(string emailAddress, string otp, string sessionId)
+ {
+ string userName = MakeCognitoUserName(emailAddress, "email");
+ TokenCollection tokens = await AWS.FinishCognitoUserAuth(userName, otp, sessionId);
+ Uri uri = MakeUri("/embedded-wallet/validate-cognito-email-otp");
+ ByteArrayContent content = MakeHttpContent(
+ new
+ {
+ developerClientId = clientId,
+ access_token = tokens.AccessToken,
+ id_token = tokens.IdToken,
+ refresh_token = tokens.RefreshToken,
+ otpMethod = "email",
+ }
+ );
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var authVerifiedToken = await DeserializeAsync(response);
+ bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser;
+ string authToken = authVerifiedToken.VerifiedTokenJwtString;
+ string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId;
+ var idTokenResponse = await FetchCognitoIdTokenAsync(authToken);
+ string idToken = idTokenResponse.IdToken;
+ string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken });
+ MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload);
+ JsonSerializer jsonSerializer = new();
+ var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload)));
+ payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body)));
+ return new VerifyResult(isNewUser, authToken, walletUserId, payload.RecoverySharePassword, authVerifiedToken.VerifiedToken.AuthDetails.Email);
+ }
+
+ internal override async Task SendKmsPhoneOtpAsync(string phoneNumber)
+ {
+ string userName = MakeCognitoUserName(phoneNumber, "sms");
+ string sessionId = await AWS.StartCognitoUserAuth(userName);
+ if (sessionId == null)
+ {
+ await AWS.SignUpCognitoUserAsync(null, userName);
+ for (int i = 0; i < 3; ++i)
+ {
+ await Task.Delay(3333 * i);
+ sessionId = await AWS.StartCognitoUserAuth(userName);
+ if (sessionId != null)
+ {
+ break;
+ }
+ }
+ if (sessionId == null)
+ {
+ throw new InvalidOperationException("Cannot find user within timeout period");
+ }
+ }
+ return sessionId;
+ }
+
+ // embedded-wallet/validate-cognito-email-otp
+ internal override async Task VerifyKmsPhoneOtpAsync(string phoneNumber, string otp, string sessionId)
+ {
+ string userName = MakeCognitoUserName(phoneNumber, "sms");
+ TokenCollection tokens = await AWS.FinishCognitoUserAuth(userName, otp, sessionId);
+ Uri uri = MakeUri("/embedded-wallet/validate-cognito-email-otp");
+ ByteArrayContent content = MakeHttpContent(
+ new
+ {
+ developerClientId = clientId,
+ access_token = tokens.AccessToken,
+ id_token = tokens.IdToken,
+ refresh_token = tokens.RefreshToken,
+ otpMethod = "email",
+ }
+ );
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var authVerifiedToken = await DeserializeAsync(response);
+ bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser;
+ string authToken = authVerifiedToken.VerifiedTokenJwtString;
+ string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId;
+ var idTokenResponse = await FetchCognitoIdTokenAsync(authToken);
+ string idToken = idTokenResponse.IdToken;
+ string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken });
+ MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload);
+ JsonSerializer jsonSerializer = new();
+ var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload)));
+ payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body)));
+ return new VerifyResult(isNewUser, authToken, walletUserId, payload.RecoverySharePassword, authVerifiedToken.VerifiedToken.AuthDetails.Email);
+ }
+
+ // embedded-wallet/validate-custom-jwt
+ internal override async Task VerifyJwtAsync(string jwtToken)
+ {
+ var requestContent = new { jwt = jwtToken, developerClientId = clientId };
+ StringContent content = MakeHttpContent(requestContent);
+ Uri uri = MakeUri("/embedded-wallet/validate-custom-jwt");
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var authVerifiedToken = await DeserializeAsync(response);
+ bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser;
+ string authToken = authVerifiedToken.VerifiedTokenJwtString;
+ string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId;
+ string email = authVerifiedToken.VerifiedToken.AuthDetails.Email;
+ string recoveryCode = authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode;
+ return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, email);
+ }
+
+ // embedded-wallet/validate-custom-auth-endpoint
+ internal override async Task VerifyAuthEndpointAsync(string payload)
+ {
+ var requestContent = new { payload, developerClientId = clientId };
+ StringContent content = MakeHttpContent(requestContent);
+ Uri uri = MakeUri("/embedded-wallet/validate-custom-auth-endpoint");
+ HttpResponseMessage response = await httpClient.PostAsync(uri, content);
+ await CheckStatusCodeAsync(response);
+ var authVerifiedToken = await DeserializeAsync(response);
+ bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser;
+ string authToken = authVerifiedToken.VerifiedTokenJwtString;
+ string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId;
+ string email = authVerifiedToken.VerifiedToken.AuthDetails.Email;
+ string recoveryCode = authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode;
+ return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, email);
+ }
+
+ internal override async Task VerifyOAuthAsync(string authResultStr)
+ {
+ var authResult = JsonConvert.DeserializeObject(authResultStr);
+ bool isNewUser = authResult.StoredToken.IsNewUser;
+ string authToken = authResult.StoredToken.CookieString;
+ string walletUserId = authResult.StoredToken.AuthDetails.UserWalletId;
+ bool isUserManaged = (await FetchUserDetailsAsync(authResult.StoredToken.AuthDetails.Email, authToken)).RecoveryShareManagement == "USER_MANAGED";
+ string recoveryCode = null;
+ if (!isUserManaged)
+ {
+ var idTokenResponse = await FetchCognitoIdTokenAsync(authToken);
+ string idToken = idTokenResponse.IdToken;
+ string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken });
+ MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload);
+ JsonSerializer jsonSerializer = new();
+ var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload)));
+ payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body)));
+ recoveryCode = payload.RecoverySharePassword;
+ }
+ return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, authResult.StoredToken.AuthDetails.Email);
+ }
+
+ #region Misc
+
+ private Task SendHttpWithAuthAsync(HttpRequestMessage httpRequestMessage, string authToken)
+ {
+ httpRequestMessage.Headers.Add("Authorization", $"Bearer embedded-wallet-token:{authToken}");
+#if DEBUG
+ Console.WriteLine($"Request: {JsonConvert.SerializeObject(httpRequestMessage)}");
+#endif
+ return httpClient.SendAsync(httpRequestMessage);
+ }
+
+ private Task SendHttpWithAuthAsync(Uri uri, string authToken)
+ {
+ HttpRequestMessage httpRequestMessage = new(HttpMethod.Get, uri);
+#if DEBUG
+ Console.WriteLine($"Request: {JsonConvert.SerializeObject(httpRequestMessage)}");
+#endif
+ return SendHttpWithAuthAsync(httpRequestMessage, authToken);
+ }
+
+ private static async Task CheckStatusCodeAsync(HttpResponseMessage response)
+ {
+#if DEBUG
+ Console.WriteLine($"Response: {await response.Content.ReadAsStringAsync()}");
+#endif
+ if (!response.IsSuccessStatusCode)
+ {
+ var error = await DeserializeAsync(response);
+ throw new InvalidOperationException(string.IsNullOrEmpty(error.Error) ? error.Message : error.Error);
+ }
+ }
+
+ private static async Task DeserializeAsync(HttpResponseMessage response)
+ {
+ JsonSerializer jsonSerializer = new();
+ TextReader textReader = new StreamReader(await response.Content.ReadAsStreamAsync());
+ var rv = jsonSerializer.Deserialize(new JsonTextReader(textReader));
+ return rv;
+ }
+
+ private static Uri MakeUri(string path, IDictionary parameters = null)
+ {
+ UriBuilder b = new(ROOT_URL) { Path = API_ROOT_PATH + path, };
+ if (parameters != null && parameters.Any())
+ {
+ string queryString = string.Join('&', parameters.Select((p) => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
+ b.Query = queryString;
+ }
+ return b.Uri;
+ }
+
+ private static Uri MakeUriLegacy(string path, IDictionary parameters = null)
+ {
+ UriBuilder b = new(ROOT_URL_LEGACY) { Path = API_ROOT_PATH_LEGACY + path, };
+ if (parameters != null && parameters.Any())
+ {
+ string queryString = string.Join('&', parameters.Select((p) => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
+ b.Query = queryString;
+ }
+ return b.Uri;
+ }
+
+ private static StringContent MakeHttpContent(object data)
+ {
+ StringContent stringContent = new(Serialize(data));
+ stringContent.Headers.ContentType = jsonContentType;
+ return stringContent;
+ }
+
+ private static string Serialize(object data)
+ {
+ JsonSerializer jsonSerializer = new() { NullValueHandling = NullValueHandling.Ignore, };
+ StringWriter stringWriter = new();
+ jsonSerializer.Serialize(stringWriter, data);
+ string rv = stringWriter.ToString();
+
+ return rv;
+ }
+
+ private string MakeCognitoUserName(string userData, string type)
+ {
+ return $"{userData}:{type}:{clientId}";
+ }
+
+ #endregion
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs
new file mode 100644
index 0000000..e5d0787
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Nethereum.Web3.Accounts;
+using Org.BouncyCastle.Crypto.Engines;
+using Org.BouncyCastle.Crypto.Modes;
+using Org.BouncyCastle.Crypto.Parameters;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ private string DecryptShare(string encryptedShare, string password)
+ {
+ string[] parts = encryptedShare.Split(ENCRYPTION_SEPARATOR);
+ byte[] ciphertextWithTag = Convert.FromBase64String(parts[0]);
+ byte[] iv = Convert.FromBase64String(parts[1]);
+ byte[] salt = Convert.FromBase64String(parts[2]);
+
+ int iterationCount;
+ if (parts.Length > 3 && int.TryParse(parts[3], out var parsedIterationCount))
+ {
+ iterationCount = parsedIterationCount;
+ }
+ else
+ {
+ iterationCount = DEPRECATED_ITERATION_COUNT;
+ }
+
+ byte[] key = GetEncryptionKey(password, salt, iterationCount);
+
+ byte[] encodedShare;
+ try
+ {
+ // Bouncy Castle expects the authentication tag after the ciphertext.
+ GcmBlockCipher cipher = new(new AesEngine());
+ cipher.Init(forEncryption: false, new AeadParameters(new KeyParameter(key), 8 * TAG_SIZE, iv));
+ encodedShare = new byte[cipher.GetOutputSize(ciphertextWithTag.Length)];
+ int offset = cipher.ProcessBytes(ciphertextWithTag, 0, ciphertextWithTag.Length, encodedShare, 0);
+ cipher.DoFinal(encodedShare, offset);
+ }
+ catch
+ {
+ try
+ {
+ int ciphertextSize = ciphertextWithTag.Length - TAG_SIZE;
+ var ciphertext = new byte[ciphertextSize];
+ Array.Copy(ciphertextWithTag, ciphertext, ciphertext.Length);
+ var tag = new byte[TAG_SIZE];
+ Array.Copy(ciphertextWithTag, ciphertextSize, tag, 0, tag.Length);
+ encodedShare = new byte[ciphertext.Length];
+ using AesGcm crypto = new(key);
+ crypto.Decrypt(iv, ciphertext, tag, encodedShare);
+ }
+ catch (CryptographicException)
+ {
+ throw new VerificationException("Invalid recovery code", true);
+ }
+ }
+ string share = Encoding.ASCII.GetString(encodedShare);
+ return share;
+ }
+
+ private async Task EncryptShareAsync(string share, string password)
+ {
+ const int saltSize = 16;
+ var salt = new byte[saltSize];
+ RandomNumberGenerator.Fill(salt);
+ byte[] key = GetEncryptionKey(password, salt, CURRENT_ITERATION_COUNT);
+ byte[] encodedShare = Encoding.ASCII.GetBytes(share);
+ const int ivSize = 12;
+ var iv = new byte[ivSize];
+ await ivGenerator.ComputeIvAsync(iv);
+ byte[] encryptedShare;
+ try
+ {
+ // Bouncy Castle includes the authentication tag after the ciphertext.
+ GcmBlockCipher cipher = new(new AesEngine());
+ cipher.Init(forEncryption: true, new AeadParameters(new KeyParameter(key), 8 * TAG_SIZE, iv));
+ encryptedShare = new byte[cipher.GetOutputSize(encodedShare.Length)];
+ int offset = cipher.ProcessBytes(encodedShare, 0, encodedShare.Length, encryptedShare, 0);
+ cipher.DoFinal(encryptedShare, offset);
+ }
+ catch
+ {
+ var tag = new byte[TAG_SIZE];
+ encryptedShare = new byte[encodedShare.Length];
+ using AesGcm crypto = new(key);
+ crypto.Encrypt(iv, encodedShare, encryptedShare, tag);
+ encryptedShare = encryptedShare.Concat(tag).ToArray();
+ }
+ string rv =
+ $"{Convert.ToBase64String(encryptedShare)}{ENCRYPTION_SEPARATOR}{Convert.ToBase64String(iv)}{ENCRYPTION_SEPARATOR}{Convert.ToBase64String(salt)}{ENCRYPTION_SEPARATOR}{CURRENT_ITERATION_COUNT}";
+ return rv;
+ }
+
+ private (string deviceShare, string recoveryShare, string authShare) CreateShares(string secret)
+ {
+ Secrets secrets = new();
+ secret = $"{WALLET_PRIVATE_KEY_PREFIX}{secret}";
+ string encodedSecret = Secrets.GetHexString(Encoding.ASCII.GetBytes(secret));
+ List shares = secrets.Share(encodedSecret, 3, 2);
+ return (shares[0], shares[1], shares[2]);
+ }
+
+ private static byte[] GetEncryptionKey(string password, byte[] salt, int iterationCount)
+ {
+ using Rfc2898DeriveBytes pbkdf2 = new(password, salt, iterationCount, HashAlgorithmName.SHA256);
+ byte[] keyMaterial = pbkdf2.GetBytes(KEY_SIZE);
+ return keyMaterial;
+ }
+
+ private Account MakeAccountFromShares(params string[] shares)
+ {
+ Secrets secrets = new();
+ string encodedSecret = secrets.Combine(shares);
+ string secret = Encoding.ASCII.GetString(Secrets.GetBytes(encodedSecret));
+ if (!secret.StartsWith(WALLET_PRIVATE_KEY_PREFIX))
+ {
+ throw new InvalidOperationException($"Corrupted share encountered {secret}");
+ }
+ return new Account(secret.Split(WALLET_PRIVATE_KEY_PREFIX)[1]);
+ }
+
+ private string MakeRecoveryCode()
+ {
+ const int codeSize = 16;
+ const string characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+ string recoveryCode = new(Enumerable.Range(0, codeSize).Select((_) => characters[RandomNumberGenerator.GetInt32(characters.Length)]).ToArray());
+ return recoveryCode;
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs
new file mode 100644
index 0000000..50754fc
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs
@@ -0,0 +1,73 @@
+using System.Security.Cryptography;
+#if UNITY_5_3_OR_NEWER
+using UnityEngine;
+#endif
+
+namespace Thirdweb.EWS
+{
+ internal abstract class IvGeneratorBase
+ {
+ internal abstract Task ComputeIvAsync(byte[] iv);
+ }
+
+ internal class IvGenerator : IvGeneratorBase
+ {
+ private long prbsValue;
+ private readonly string ivFilePath;
+ private const int nPrbsBits = 48;
+ private const long prbsPeriod = (1L << nPrbsBits) - 1;
+ private static readonly long taps = new int[] { nPrbsBits, 47, 21, 20 }.Aggregate(0L, (a, b) => a + (1L << (nPrbsBits - b))); // https://docs.xilinx.com/v/u/en-US/xapp052, page 5
+
+ internal IvGenerator()
+ {
+ string directory;
+#if UNITY_5_3_OR_NEWER
+ directory = Application.persistentDataPath;
+#else
+ directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+#endif
+ directory = Path.Combine(directory, "EWS");
+ Directory.CreateDirectory(directory);
+ ivFilePath = Path.Combine(directory, "iv.txt");
+ try
+ {
+ prbsValue = long.Parse(File.ReadAllText(ivFilePath));
+ }
+ catch (Exception)
+ {
+ prbsValue = (0x434a49445a27 ^ DateTime.Now.Ticks) & prbsPeriod;
+ }
+ }
+
+ ///
+ /// Compute IV using half LFSR-generated and half random bytes.
+ ///
+ /// https://crypto.stackexchange.com/questions/84357/what-are-the-rules-for-using-aes-gcm-correctly
+ /// The IV byte array to fill. This must be twelve bytes in size.
+ internal override async Task ComputeIvAsync(byte[] iv)
+ {
+ RandomNumberGenerator.Fill(iv);
+ prbsValue = ComputeNextPrbsValue(prbsValue);
+ await File.WriteAllTextAsync(ivFilePath, prbsValue.ToString());
+ byte[] prbsBytes = Enumerable.Range(0, nPrbsBits / 8).Select((i) => (byte)(prbsValue >> (8 * i))).ToArray();
+ Array.Copy(prbsBytes, iv, prbsBytes.Length);
+ }
+
+ ///
+ /// Compute the next value of a PRBS using a 48-bit Galois LFSR.
+ ///
+ /// https://en.wikipedia.org/wiki/Linear-feedback_shift_register
+ /// The current PRBS value.
+ /// The next value.
+ private static long ComputeNextPrbsValue(long prbsValue)
+ {
+ prbsValue <<= 1;
+ if ((prbsValue & (1L << nPrbsBits)) != 0)
+ {
+ prbsValue ^= taps;
+ prbsValue &= prbsPeriod;
+ }
+ return prbsValue;
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs
new file mode 100644
index 0000000..8c6266d
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs
@@ -0,0 +1,476 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Thirdweb.EWS
+{
+ internal class Secrets
+ {
+ private Config config = new(Defaults.nBits);
+ private const int nHexDigitBits = 4;
+ private readonly Func GetRandomInt32 = (nBits) => RandomNumberGenerator.GetInt32(1, 1 << nBits);
+ private static readonly string padding = string.Join("", Enumerable.Repeat("0", Defaults.maxPaddingMultiple));
+ private static readonly string[] nybbles = { "0000", "0001", "0010", "0011", "0100", "0101", "0110", "0111", "1000", "1001", "1010", "1011", "1100", "1101", "1110", "1111", };
+
+ ///
+ /// Reconsitute a secret from .
+ ///
+ ///
+ /// The return value will not be the original secret if the number of shares provided is less than the threshold
+ /// number of shares.
+ /// Duplicate shares do not count toward the threshold.
+ ///
+ /// The shares used to reconstitute the secret.
+ /// The reconstituted secret.
+ public string Combine(IReadOnlyList shares)
+ {
+ return Combine(shares, 0);
+ }
+
+ ///
+ /// Convert a string of hexadecimal digits into a byte array.
+ ///
+ /// The string of hexadecimal digits to convert.
+ /// A byte array.
+ public static byte[] GetBytes(string s)
+ {
+ byte[] bytes = Enumerable.Range(0, s.Length / 2).Select((i) => byte.Parse(s.Substring(i * 2, 2), NumberStyles.HexNumber)).ToArray();
+ return bytes;
+ }
+
+ ///
+ /// Convert a byte array into a string of hexadecimal digits.
+ ///
+ /// The byte array to convert.
+ /// A string of hexadecimal digits.
+ public static string GetHexString(byte[] bytes)
+ {
+ return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
+ }
+
+ ///
+ /// Generate a new share identified as .
+ ///
+ ///
+ /// The return value will be invalid if the number of shares provided is less than the threshold number of shares.
+ /// If is the identifier of a share in and the number of shares
+ /// provided is at least the threshold number of shares, the return value will be the same as the identified share.
+ /// Duplicate shares do not count toward the threshold.
+ ///
+ /// The identifier of the share to generate.
+ /// The shares from which to generate the new share.
+ /// A hexadecimal string of the new share.
+ ///
+ ///
+ public string NewShare(int shareId, IReadOnlyList shares)
+ {
+ if (shareId <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(shareId), $"{nameof(shareId)} must be greater than zero.");
+ }
+ else if (shares == null || !shares.Any() || string.IsNullOrEmpty(shares[0]))
+ {
+ throw new ArgumentException($"{nameof(shares)} cannot be empty.", nameof(shares));
+ }
+ ShareComponents share = ExtractShareComponents(shares[0]);
+ return ConstructPublicShareString(share.nBits, Convert.ToString(shareId, Defaults.radix), Combine(shares, shareId));
+ }
+
+ ///
+ /// Generate a random value expressed as a string of hexadecimal digits that contains bytes using a
+ /// secure random number generator.
+ ///
+ /// The number of bytes of output.
+ /// A hexadecimal string of the value.
+ ///
+ public static string Random(int nBytes)
+ {
+ const int maxnBytes = (1 << 16) / 8;
+ if (nBytes < 1 || nBytes > maxnBytes)
+ {
+ throw new ArgumentOutOfRangeException(nameof(nBytes), $"{nameof(nBytes)} must be in the range [1, {maxnBytes}].");
+ }
+ var bytes = new byte[nBytes];
+ RandomNumberGenerator.Fill(bytes);
+ string rv = GetHexString(bytes);
+ return rv;
+ }
+
+ ///
+ /// Divide a into
+ /// shares, requiring shares to
+ /// reconstruct the secret. Optionally, initialize with . Optionally, zero-pad the secret to a length
+ /// that is a multiple of (default 128) before sharing.
+ ///
+ /// A secret value expressed as a string of hexadecimal digits.
+ /// The number of shares to produce.
+ /// The number of shares required to reconstruct the secret.
+ /// The number of bits to use to create the shares.
+ /// The amount of zero-padding to apply to the secret before sharing.
+ /// A list of strings of hexadecimal digits.
+ ///
+ ///
+ public List Share(string secret, int nShares, int threshold, int nBits = 0, int paddingMultiple = 128)
+ {
+ // Initialize based on nBits if it's specified.
+ if (nBits != 0)
+ {
+ if (nBits < Defaults.minnBits || nBits > Defaults.maxnBits)
+ {
+ throw new ArgumentOutOfRangeException(nameof(nBits), $"{nameof(nBits)} must be in the range [{Defaults.minnBits}, {Defaults.maxnBits}].");
+ }
+ config = new(nBits);
+ }
+
+ // Validate the parameters.
+ if (string.IsNullOrEmpty(secret))
+ {
+ throw new ArgumentException($"{nameof(secret)} cannot be empty.", nameof(secret));
+ }
+ else if (!secret.All((ch) => char.IsDigit(ch) || (ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f')))
+ {
+ throw new ArgumentException($"{nameof(secret)} must consist only of hexadecimal digits.", nameof(secret));
+ }
+ else if (nShares < 2 || nShares > Math.Min(config.maxnShares, Defaults.maxnShares))
+ {
+ if (nShares > Defaults.maxnShares)
+ {
+ throw new ArgumentOutOfRangeException(nameof(nShares), $"The maximum number of shares is {Defaults.maxnShares} since the maximum bit count is {Defaults.maxnBits}.");
+ }
+ else if (nShares > config.maxnShares)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(nShares),
+ $"{nameof(nShares)} must be in the range [2, {config.maxnShares}]. To create {nShares} shares, specify at least {Math.Ceiling(Math.Log(nShares + 1, 2))} bits."
+ );
+ }
+ throw new ArgumentOutOfRangeException(nameof(nShares), $"{nameof(nShares)} must be in the range [2, {config.maxnShares}].");
+ }
+ else if (threshold < 2 || threshold > nShares)
+ {
+ throw new ArgumentOutOfRangeException(nameof(threshold), $"{nameof(threshold)} must be in the range [2, {nShares}].");
+ }
+ else if (paddingMultiple < 0 || paddingMultiple > 1024)
+ {
+ throw new ArgumentOutOfRangeException(nameof(paddingMultiple), $"{nameof(paddingMultiple)} must be in the range [0, {Defaults.maxPaddingMultiple}].");
+ }
+
+ // Prepend a 1 as a marker to preserve the correct number of leading zeros in the secret.
+ secret = "1" + Hex2bin(secret);
+
+ // Create the shares. For additional security, pad in multiples of 128 bits by default. This is a small trade-off in larger
+ // share size to help prevent leakage of information about small secrets and increase the difficulty of attacking them.
+ List l = SplitNumStringToIntArray(secret, paddingMultiple);
+ var x = new string[nShares];
+ var y = new string[nShares];
+ foreach (int value in l)
+ {
+ var subShares = GetShares(value, nShares, threshold);
+ for (int i = 0; i < nShares; ++i)
+ {
+ x[i] = Convert.ToString(subShares[i].x, Defaults.radix);
+ y[i] = PadLeft(Convert.ToString(subShares[i].y, 2), config.nBits) + (y[i] ?? "");
+ }
+ }
+ for (int i = 0; i < nShares; ++i)
+ {
+ x[i] = ConstructPublicShareString(config.nBits, x[i], Bin2hex(y[i]));
+ }
+ return x.ToList();
+ }
+
+ private static string Bin2hex(string value)
+ {
+ value = PadLeft(value, nHexDigitBits);
+ StringBuilder sb = new();
+ for (int i = 0; i < value.Length; i += nHexDigitBits)
+ {
+ int num = Convert.ToInt32(value.Substring(i, nHexDigitBits), 2);
+ sb.Append(Convert.ToString(num, 16));
+ }
+ return sb.ToString();
+ }
+
+ private string Combine(IReadOnlyList shares, int shareId)
+ {
+ // Zip distinct shares. E.g.
+ // [ [ 193, 186, 29, 177, 196 ],
+ // [ 53, 105, 139, 127, 149 ],
+ // [ 146, 211, 249, 206, 81 ] ]
+ // becomes
+ // [ [ 193, 53, 146 ],
+ // [ 186, 105, 211 ],
+ // [ 29, 139, 249 ],
+ // [ 177, 127, 206 ],
+ // [ 196, 149, 81 ] ]
+ int nBits = 0;
+ List x = new();
+ List> y = new();
+ foreach (ShareComponents share in shares.Select((s) => ExtractShareComponents(s)))
+ {
+ // All shares must have the same bits settings.
+ if (nBits == 0)
+ {
+ nBits = share.nBits;
+
+ // Reconfigure based on the bits settings of the shares.
+ if (config.nBits != nBits)
+ {
+ config = new(nBits);
+ }
+ }
+ else if (share.nBits != nBits)
+ {
+ throw new ArgumentException("Shares are mismatched due to different bits settings.", nameof(shares));
+ }
+
+ // Spread the share across the arrays if the share.id is not already in array `x`.
+ if (x.IndexOf(share.id) == -1)
+ {
+ x.Add(share.id);
+ List splitShare = SplitNumStringToIntArray(Hex2bin(share.data));
+ for (int i = 0, n = splitShare.Count; i < n; ++i)
+ {
+ if (i >= y.Count)
+ {
+ y.Add(new List());
+ }
+ y[i].Add(splitShare[i]);
+ }
+ }
+ }
+
+ // Extract the secret from the zipped share data.
+ StringBuilder sb = new();
+ foreach (List y_ in y)
+ {
+ sb.Insert(0, PadLeft(Convert.ToString(Lagrange(shareId, x, y_), 2), nBits));
+ }
+ string result = sb.ToString();
+
+ // If `shareId` is not zero, NewShare invoked Combine. In this case, return the new share data directly. Otherwise, find
+ // the first '1' which was added in the Share method as a padding marker and return only the data after the padding and the
+ // marker. Convert the binary string, which is the derived secret, to hexadecimal.
+ return Bin2hex(shareId >= 1 ? result : result[(result.IndexOf("1") + 1)..]);
+ }
+
+ private static string ConstructPublicShareString(int nBits, string shareId, string data)
+ {
+ int id = Convert.ToInt32(shareId, Defaults.radix);
+ string base36Bits = char.ConvertFromUtf32(nBits > 9 ? nBits - 10 + 'A' : nBits + '0');
+ int idMax = (1 << nBits) - 1;
+ int paddingMultiple = Convert.ToString(idMax, Defaults.radix).Length;
+ string hexId = PadLeft(Convert.ToString(id, Defaults.radix), paddingMultiple);
+ if (id < 1 || id > idMax)
+ {
+ throw new ArgumentOutOfRangeException(nameof(shareId), $"{nameof(shareId)} must be in the range [1, {idMax}].");
+ }
+ string share = base36Bits + hexId + data;
+ return share;
+ }
+
+ private static ShareComponents ExtractShareComponents(string share)
+ {
+ // Extract the first character which represents the number of bits in base 36.
+ int nBits = GetLargeBaseValue(share[0]);
+ if (nBits < Defaults.minnBits || nBits > Defaults.maxnBits)
+ {
+ throw new ArgumentException($"Unexpected {nBits}-bit share outside of the range [{Defaults.minnBits}, {Defaults.maxnBits}].", nameof(share));
+ }
+
+ // Calculate the maximum number of shares allowed for the given number of bits.
+ int maxnShares = (1 << nBits) - 1;
+
+ // Derive the identifier length from the bit count.
+ int idLength = Convert.ToString(maxnShares, Defaults.radix).Length;
+
+ // Extract all the parts now that the segment sizes are known.
+ var rx = new Regex("^([3-9A-Ka-k]{1})([0-9A-Fa-f]{" + idLength + "})([0-9A-Fa-f]+)$");
+ MatchCollection shareComponents = rx.Matches(share);
+ GroupCollection groups = shareComponents.FirstOrDefault()?.Groups;
+ if (groups == null || groups.Count != 4)
+ {
+ throw new ArgumentException("Malformed share", nameof(share));
+ }
+
+ // Convert the identifier from a string of hexadecimal digits into an integer.
+ int id = Convert.ToInt32(groups[2].Value, Defaults.radix);
+
+ // Return the components of the share.
+ ShareComponents rv = new(nBits, id, groups[3].Value);
+ return rv;
+ }
+
+ private static int GetLargeBaseValue(char ch)
+ {
+ int rv =
+ ch >= 'a'
+ ? ch - 'a' + 10
+ : ch >= 'A'
+ ? ch - 'A' + 10
+ : ch - '0';
+ return rv;
+ }
+
+ private (int x, int y)[] GetShares(int secret, int nShares, int threshold)
+ {
+ int[] coefficients = Enumerable.Range(0, threshold - 1).Select((i) => GetRandomInt32(config.nBits)).Concat(new[] { secret }).ToArray();
+ var shares = Enumerable.Range(1, nShares).Select((i) => (i, Horner(i, coefficients))).ToArray();
+ return shares;
+ }
+
+ private static string Hex2bin(string value)
+ {
+ StringBuilder sb = new();
+ foreach (char ch in value)
+ {
+ sb.Append(nybbles[GetLargeBaseValue(ch)]);
+ }
+ return sb.ToString();
+ }
+
+ // Evaluate the polynomial at `x` using Horner's Method.
+ // NOTE: fx = fx * x + coefficients[i] -> exp(log(fx) + log(x)) + coefficients[i], so if fx is zero, set fx to coefficients[i]
+ // since using the exponential or logarithmic form will result in an incorrect value.
+ private int Horner(int x, IEnumerable coefficients)
+ {
+ int logx = config.logarithms[x];
+ int fx = 0;
+ foreach (int coefficient in coefficients)
+ {
+ fx = fx == 0 ? coefficient : config.exponents[(logx + config.logarithms[fx]) % config.maxnShares] ^ coefficient;
+ }
+ return fx;
+ }
+
+ // Evaluate the Lagrange interpolation polynomial at x = `shareId` using x and y arrays that are of the same length, with
+ // corresponding elements constituting points on the polynomial.
+ private int Lagrange(int shareId, IReadOnlyList x, IReadOnlyList y)
+ {
+ int sum = 0;
+ foreach (int i in Enumerable.Range(0, x.Count))
+ {
+ if (i < y.Count && y[i] != 0)
+ {
+ int product = config.logarithms[y[i]];
+ foreach (int j in Enumerable.Range(0, x.Count).Where((j) => i != j))
+ {
+ if (shareId == x[j])
+ {
+ // This happens when computing a share that is in the list of shares used to compute it.
+ product = -1;
+ break;
+ }
+
+ // Ensure it's not negative.
+ product = (product + config.logarithms[shareId ^ x[j]] - config.logarithms[x[i] ^ x[j]] + config.maxnShares) % config.maxnShares;
+ }
+ sum = product == -1 ? sum : sum ^ config.exponents[product];
+ }
+ }
+ return sum;
+ }
+
+ private static string PadLeft(string value, int paddingMultiple)
+ {
+ if (paddingMultiple == 1)
+ {
+ return value;
+ }
+ else if (paddingMultiple < 2 || paddingMultiple > Defaults.maxPaddingMultiple)
+ {
+ throw new ArgumentOutOfRangeException(nameof(paddingMultiple), $"{nameof(paddingMultiple)} must be in the range [0, {Defaults.maxPaddingMultiple}].");
+ }
+ if (value.Any())
+ {
+ int extra = value.Length % paddingMultiple;
+ if (extra > 0)
+ {
+ string s = padding + value;
+ value = s[^(paddingMultiple - extra + value.Length)..];
+ }
+ }
+ return value;
+ }
+
+ private List SplitNumStringToIntArray(string value, int paddingMultiple = 0)
+ {
+ if (paddingMultiple > 0)
+ {
+ value = PadLeft(value, paddingMultiple);
+ }
+ List parts = new();
+ int i;
+ for (i = value.Length; i > config.nBits; i -= config.nBits)
+ {
+ parts.Add(Convert.ToInt32(value.Substring(i - config.nBits, config.nBits), 2));
+ }
+ parts.Add(Convert.ToInt32(value[..i], 2));
+ return parts;
+ }
+
+ private class Config
+ {
+ internal readonly int[] exponents;
+ internal readonly int[] logarithms;
+ internal readonly int maxnShares;
+ internal readonly int nBits;
+
+ internal Config(int nBits)
+ {
+ // Set the scalar values.
+ this.nBits = nBits;
+ int size = 1 << nBits;
+ maxnShares = size - 1;
+
+ // Construct the exponent and logarithm tables for multiplication.
+ int primitive = Defaults.primitivePolynomialCoefficients[nBits];
+ exponents = new int[size];
+ logarithms = new int[size];
+ for (int x = 1, i = 0; i < size; ++i)
+ {
+ exponents[i] = x;
+ logarithms[x] = i;
+ x <<= 1;
+ if (x >= size)
+ {
+ x ^= primitive;
+ x &= maxnShares;
+ }
+ }
+ }
+ }
+
+ private class Defaults
+ {
+ internal const int minnBits = 3;
+ internal const int maxnBits = 20; // up to 1,048,575 shares
+ internal const int maxnShares = (1 << maxnBits) - 1;
+ internal const int maxPaddingMultiple = 1024;
+ internal const int nBits = 8;
+ internal const int radix = 16; // hexadecimal
+
+ // These are primitive polynomial coefficients for Galois Fields GF(2^n) for 2 <= n <= 20. The index of each term in the
+ // array corresponds to the n for that polynomial.
+ internal static readonly int[] primitivePolynomialCoefficients = { -1, -1, 1, 3, 3, 5, 3, 3, 29, 17, 9, 5, 83, 27, 43, 3, 45, 9, 39, 39, 9, };
+ }
+
+ private class ShareComponents
+ {
+ internal int nBits;
+ internal int id;
+ internal string data;
+
+ internal ShareComponents(int nBits, int id, string data)
+ {
+ this.nBits = nBits;
+ this.id = id;
+ this.data = data;
+ }
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs
new file mode 100644
index 0000000..8f1c987
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Runtime.Serialization;
+
+namespace Thirdweb.EWS
+{
+ [Serializable]
+ internal class VerificationException : Exception
+ {
+ internal bool CanRetry { get; }
+
+ public VerificationException(string message, bool canRetry)
+ : base(message)
+ {
+ CanRetry = canRetry;
+ }
+
+ protected VerificationException(SerializationInfo info, StreamingContext context)
+ : base(info, context) { }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs
new file mode 100644
index 0000000..b9acd7c
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs
@@ -0,0 +1,16 @@
+using Nethereum.Web3.Accounts;
+
+namespace Thirdweb.EWS
+{
+ internal class User
+ {
+ internal User(Account account, string emailAddress)
+ {
+ Account = account;
+ EmailAddress = emailAddress;
+ }
+
+ public Account Account { get; internal set; }
+ public string EmailAddress { get; internal set; }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs
new file mode 100644
index 0000000..a42e694
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs
@@ -0,0 +1,10 @@
+namespace Thirdweb.EWS
+{
+ internal enum UserStatus
+ {
+ SignedOut = 10,
+ SignedInWalletUninitialized = 31,
+ SignedInNewDevice = 21,
+ SignedInWalletInitialized = 29,
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs
new file mode 100644
index 0000000..8aa7eed
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs
@@ -0,0 +1,72 @@
+using System.Runtime.Serialization;
+
+namespace Thirdweb.EWS
+{
+ internal partial class LocalStorage : LocalStorageBase
+ {
+ [DataContract]
+ internal class DataStorage
+ {
+ internal string AuthToken => authToken;
+ internal string DeviceShare => deviceShare;
+ internal string EmailAddress => emailAddress;
+ internal string WalletUserId => walletUserId;
+ internal string AuthProvider => authProvider;
+
+ [DataMember]
+ private string authToken;
+
+ [DataMember]
+ private string deviceShare;
+
+ [DataMember]
+ private string emailAddress;
+
+ [DataMember]
+ private string walletUserId;
+
+ [DataMember]
+ private string authProvider;
+
+ internal DataStorage(string authToken, string deviceShare, string emailAddress, string walletUserId, string authProvider)
+ {
+ this.authToken = authToken;
+ this.deviceShare = deviceShare;
+ this.emailAddress = emailAddress;
+ this.walletUserId = walletUserId;
+ this.authProvider = authProvider;
+ }
+
+ internal void ClearAuthToken() => authToken = null;
+ }
+
+ [DataContract]
+ internal class SessionStorage
+ {
+ internal string Id => id;
+ internal bool IsKmsWallet => isKmsWallet;
+
+ [DataMember]
+ private string id;
+
+ [DataMember]
+ private bool isKmsWallet;
+
+ internal SessionStorage(string id, bool isKmsWallet)
+ {
+ this.id = id;
+ this.isKmsWallet = isKmsWallet;
+ }
+ }
+
+ [DataContract]
+ private class Storage
+ {
+ [DataMember]
+ internal DataStorage Data { get; set; }
+
+ [DataMember]
+ internal SessionStorage Session { get; set; }
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs
new file mode 100644
index 0000000..03fee78
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs
@@ -0,0 +1,108 @@
+using System;
+using System.IO;
+using System.Runtime.Serialization.Json;
+using System.Security;
+using System.Threading.Tasks;
+#if UNITY_5_3_OR_NEWER
+using UnityEngine;
+#endif
+
+namespace Thirdweb.EWS
+{
+ internal abstract class LocalStorageBase
+ {
+ internal abstract LocalStorage.DataStorage Data { get; }
+ internal abstract LocalStorage.SessionStorage Session { get; }
+
+ internal abstract Task RemoveAuthTokenAsync();
+ internal abstract Task RemoveSessionAsync();
+ internal abstract Task SaveDataAsync(LocalStorage.DataStorage data);
+ internal abstract Task SaveSessionAsync(string sessionId, bool isKmsWallet);
+ }
+
+ internal partial class LocalStorage : LocalStorageBase
+ {
+ internal override DataStorage Data => storage.Data;
+ internal override SessionStorage Session => storage.Session;
+ private readonly Storage storage;
+ private readonly string filePath;
+
+ internal LocalStorage(string clientId)
+ {
+ string directory;
+#if UNITY_5_3_OR_NEWER
+ directory = Application.persistentDataPath;
+#else
+ directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ Console.WriteLine($"Embedded Wallet Storage: Using '{directory}'");
+#endif
+ directory = Path.Combine(directory, "EWS");
+ Directory.CreateDirectory(directory);
+ filePath = Path.Combine(directory, $"{clientId}.txt");
+ try
+ {
+ byte[] json = File.ReadAllBytes(filePath);
+ DataContractJsonSerializer serializer = new(typeof(Storage));
+ MemoryStream fin = new(json);
+ storage = (Storage)serializer.ReadObject(fin);
+ }
+ catch (Exception)
+ {
+ storage = new Storage();
+ }
+ }
+
+ internal override Task RemoveAuthTokenAsync()
+ {
+ return UpdateDataAsync(() =>
+ {
+ if (storage.Data?.AuthToken != null)
+ {
+ storage.Data.ClearAuthToken();
+ return true;
+ }
+ return false;
+ });
+ }
+
+ private async Task UpdateDataAsync(Func fn)
+ {
+ if (fn())
+ {
+ DataContractJsonSerializer serializer = new(typeof(Storage));
+ MemoryStream fout = new();
+ serializer.WriteObject(fout, storage);
+ await File.WriteAllBytesAsync(filePath, fout.ToArray());
+ return true;
+ }
+ return false;
+ }
+
+ internal override Task SaveDataAsync(DataStorage data)
+ {
+ return UpdateDataAsync(() =>
+ {
+ storage.Data = data;
+ return true;
+ });
+ }
+
+ internal override Task SaveSessionAsync(string sessionId, bool isKmsWallet)
+ {
+ return UpdateDataAsync(() =>
+ {
+ storage.Session = new SessionStorage(sessionId, isKmsWallet);
+ return true;
+ });
+ }
+
+ internal override Task RemoveSessionAsync()
+ {
+ return UpdateDataAsync(() =>
+ {
+ storage.Session = null;
+ return true;
+ });
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs
new file mode 100644
index 0000000..1850cdd
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task SignInWithAuthEndpointAsync(string payload, string encryptionKey, string recoveryCode)
+ {
+ Server.VerifyResult result = await server.VerifyAuthEndpointAsync(payload);
+ return await PostAuthSetup(result, recoveryCode, encryptionKey, "AuthEndpoint");
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs
new file mode 100644
index 0000000..ccd2f64
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task<(bool isNewUser, bool isNewDevice, bool needsPassword)> SendOtpEmailAsync(string emailAddress)
+ {
+ Server.UserWallet userWallet = await server.FetchUserDetailsAsync(emailAddress, null);
+ bool isKmsWallet = userWallet.RecoveryShareManagement != "USER_MANAGED";
+ string sessionId = "";
+ if (isKmsWallet)
+ {
+ sessionId = await server.SendKmsOtpEmailAsync(emailAddress);
+ }
+ else
+ {
+ await server.SendUserOtpEmailAsync(emailAddress);
+ }
+ await localStorage.SaveSessionAsync(sessionId, isKmsWallet);
+ bool isNewDevice = userWallet.IsNewUser || localStorage.Data?.WalletUserId != userWallet.WalletUserId;
+ return (userWallet.IsNewUser, isNewDevice, !isKmsWallet);
+ }
+
+ public async Task VerifyOtpAsync(string emailAddress, string otp, string recoveryCode)
+ {
+ if (localStorage.Session == null)
+ {
+ throw new InvalidOperationException($"Must first invoke {nameof(SendOtpEmailAsync)}", new NullReferenceException());
+ }
+ try
+ {
+ if (localStorage.Session.IsKmsWallet)
+ {
+ if (!await server.CheckIsEmailKmsOtpValidAsync(emailAddress, otp))
+ {
+ throw new VerificationException("Invalid OTP", true);
+ }
+ Server.VerifyResult result = await server.VerifyKmsOtpAsync(emailAddress, otp, localStorage.Session.Id);
+ await localStorage.RemoveSessionAsync();
+ return await PostAuthSetup(result, recoveryCode, null, "EmailOTP");
+ }
+ else
+ {
+ if (!await server.CheckIsEmailUserOtpValidAsync(emailAddress, otp))
+ {
+ throw new VerificationException("Invalid OTP", true);
+ }
+ Server.VerifyResult result = await server.VerifyUserOtpAsync(emailAddress, otp);
+ await localStorage.RemoveSessionAsync();
+ return await PostAuthSetup(result, recoveryCode, null, "EmailOTP");
+ }
+ }
+ catch (VerificationException ex)
+ {
+ Console.WriteLine("VerifyOtpAsync Error: " + ex.Message);
+ return new VerifyResult(ex.CanRetry);
+ }
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs
new file mode 100644
index 0000000..fb4c31c
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task SignInWithJwtAsync(string jwt, string encryptionKey, string recoveryCode)
+ {
+ Server.VerifyResult result = await server.VerifyJwtAsync(jwt);
+ return await PostAuthSetup(result, recoveryCode, encryptionKey, "JWT");
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs
new file mode 100644
index 0000000..acd6013
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs
@@ -0,0 +1,223 @@
+using System;
+using System.Threading.Tasks;
+using Nethereum.Web3.Accounts;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task VerifyThirdwebClientIdAsync(string domain)
+ {
+ var error = await server.VerifyThirdwebClientIdAsync(domain);
+ if (error != "")
+ {
+ throw new InvalidOperationException($"Invalid thirdweb client id for domain {domain} | {error}");
+ }
+ }
+
+ private async Task PostAuthSetup(Server.VerifyResult result, string userRecoveryCode, string twManagedRecoveryCodeOverride, string authProvider)
+ {
+ // Define necessary variables from the result.
+ Account account;
+ string walletUserId = result.WalletUserId;
+ string authToken = result.AuthToken;
+ string emailAddress = result.Email;
+ string deviceShare = localStorage.Data?.DeviceShare;
+
+ // Fetch user details from the server.
+ Server.UserWallet userDetails = await server.FetchUserDetailsAsync(emailAddress, authToken);
+ bool isUserManaged = userDetails.RecoveryShareManagement == "USER_MANAGED";
+ bool isNewUser = userDetails.IsNewUser;
+ User user;
+
+ // Initialize variables related to recovery codes and email status.
+ string mainRecoveryCode = null;
+ string[] backupRecoveryCodes = null;
+ bool? wasEmailed = null;
+
+ if (!isUserManaged)
+ {
+ mainRecoveryCode = twManagedRecoveryCodeOverride ?? result.RecoveryCode;
+ if (mainRecoveryCode == null)
+ throw new InvalidOperationException("Server failed to return recovery code.");
+ (account, deviceShare) = result.IsNewUser ? await CreateAccountAsync(result.AuthToken, mainRecoveryCode) : await RecoverAccountAsync(result.AuthToken, mainRecoveryCode);
+ user = await MakeUserAsync(emailAddress, account, authToken, walletUserId, deviceShare, authProvider);
+ return new VerifyResult(user, mainRecoveryCode, backupRecoveryCodes, wasEmailed);
+ }
+
+ if (isNewUser)
+ {
+ // Create recovery code for user-managed accounts.
+ mainRecoveryCode = MakeRecoveryCode();
+
+ // Commented out section for future use: Generating multiple backup recovery codes.
+ /*
+ backupRecoveryCodes = new string[7];
+ for (int i = 0; i < backupRecoveryCodes.Length; i++)
+ backupRecoveryCodes[i] = MakeRecoveryCode();
+ */
+
+ // Create a new account and handle the recovery codes.
+ (account, deviceShare) = await CreateAccountAsync(authToken, mainRecoveryCode, backupRecoveryCodes);
+
+ // Attempt to send the recovery code via email and record the outcome.
+ try
+ {
+ if (emailAddress == null)
+ throw new ArgumentNullException(nameof(emailAddress));
+ await server.SendRecoveryCodeEmailAsync(authToken, mainRecoveryCode, emailAddress);
+ wasEmailed = true;
+ }
+ catch
+ {
+ wasEmailed = false;
+ }
+ }
+ else
+ {
+ // Handling for existing users.
+ if (userRecoveryCode == null)
+ {
+ if (deviceShare == null)
+ throw new ArgumentNullException(nameof(userRecoveryCode));
+
+ // Fetch the auth share and create an account from shares.
+ string authShare = await server.FetchAuthShareAsync(authToken);
+ account = MakeAccountFromShares(authShare, deviceShare);
+ }
+ else
+ {
+ // Recover the account using the provided recovery code.
+ (account, deviceShare) = await RecoverAccountAsync(authToken, userRecoveryCode);
+ }
+ }
+
+ // Validate the device share returned from server operations.
+ if (deviceShare == null)
+ {
+ throw new InvalidOperationException("Server failed to return account");
+ }
+
+ // Construct the user object and prepare the result.
+ user = await MakeUserAsync(emailAddress, account, authToken, walletUserId, deviceShare, authProvider);
+ return new VerifyResult(user, mainRecoveryCode, backupRecoveryCodes, wasEmailed);
+ }
+
+ public async Task SignOutAsync()
+ {
+ user = null;
+ await localStorage.RemoveAuthTokenAsync();
+ }
+
+ public async Task GetUserAsync(string email, string authProvider)
+ {
+ if (user != null)
+ {
+ return user;
+ }
+ else if (localStorage.Data?.AuthToken == null)
+ {
+ throw new InvalidOperationException("User is not signed in");
+ }
+
+ Server.UserWallet userWallet = await server.FetchUserDetailsAsync(null, localStorage.Data.AuthToken);
+ switch (userWallet.Status)
+ {
+ case "Logged Out":
+ await SignOutAsync();
+ throw new InvalidOperationException("User is logged out");
+ case "Logged In, Wallet Uninitialized":
+ await SignOutAsync();
+ throw new InvalidOperationException("User is logged in but wallet is uninitialized");
+ case "Logged In, Wallet Initialized":
+ if (string.IsNullOrEmpty(localStorage.Data?.DeviceShare))
+ {
+ await SignOutAsync();
+ throw new InvalidOperationException("User is logged in but wallet is uninitialized");
+ }
+
+ string authShare = await server.FetchAuthShareAsync(localStorage.Data.AuthToken);
+ string emailAddress = userWallet.StoredToken?.AuthDetails.Email;
+
+ if (email != null && email != emailAddress)
+ {
+ await SignOutAsync();
+ throw new InvalidOperationException("User email does not match");
+ }
+ else if (email == null && localStorage.Data.AuthProvider != authProvider)
+ {
+ await SignOutAsync();
+ throw new InvalidOperationException($"User auth provider does not match. Expected {localStorage.Data.AuthProvider}, got {authProvider}");
+ }
+ else if (authShare == null)
+ {
+ throw new InvalidOperationException("Server failed to return auth share");
+ }
+ user = new User(MakeAccountFromShares(new[] { authShare, localStorage.Data.DeviceShare }), emailAddress);
+ return user;
+ }
+ throw new InvalidOperationException($"Unexpected user status '{userWallet.Status}'");
+ }
+
+ private async Task MakeUserAsync(string emailAddress, Account account, string authToken, string walletUserId, string deviceShare, string authProvider)
+ {
+ var data = new LocalStorage.DataStorage(authToken, deviceShare, emailAddress ?? "", walletUserId, authProvider);
+ await localStorage.SaveDataAsync(data);
+ user = new User(account, emailAddress ?? "");
+ return user;
+ }
+
+ private async Task<(Account account, string deviceShare)> CreateAccountAsync(string authToken, string recoveryCode, string[] backupRecoveryCodes = null)
+ {
+ string secret = Secrets.Random(KEY_SIZE);
+ (string deviceShare, string recoveryShare, string authShare) = CreateShares(secret);
+ string encryptedRecoveryShare = await EncryptShareAsync(recoveryShare, recoveryCode);
+ Account account = new(secret);
+
+ string[] backupRecoveryShares = null;
+ if (backupRecoveryCodes != null)
+ {
+ backupRecoveryShares = new string[backupRecoveryCodes.Length];
+ for (int i = 0; i < backupRecoveryCodes.Length; i++)
+ {
+ backupRecoveryShares[i] = await EncryptShareAsync(recoveryShare, backupRecoveryCodes[i]);
+ }
+ }
+ await server.StoreAddressAndSharesAsync(account.Address, authShare, encryptedRecoveryShare, authToken, backupRecoveryShares);
+ return (account, deviceShare);
+ }
+
+ private async Task<(Account account, string deviceShare)> RecoverAccountAsync(string authToken, string recoveryCode)
+ {
+ (string authShare, string encryptedRecoveryShare) = await server.FetchAuthAndRecoverySharesAsync(authToken);
+ // make below async
+ string recoveryShare = await Task.Run(() => DecryptShare(encryptedRecoveryShare, recoveryCode));
+ Account account = MakeAccountFromShares(authShare, recoveryShare);
+ Secrets secrets = new();
+ string deviceShare = secrets.NewShare(DEVICE_SHARE_ID, new[] { authShare, recoveryShare });
+ return (account, deviceShare);
+ }
+
+ public class VerifyResult
+ {
+ public User User { get; }
+ public bool CanRetry { get; }
+ public string MainRecoveryCode { get; }
+ public string[] BackupRecoveryCodes { get; }
+ public bool? WasEmailed { get; }
+
+ public VerifyResult(User user, string mainRecoveryCode, string[] backupRecoveryCodes, bool? wasEmailed)
+ {
+ User = user;
+ MainRecoveryCode = mainRecoveryCode;
+ BackupRecoveryCodes = backupRecoveryCodes;
+ WasEmailed = wasEmailed;
+ }
+
+ public VerifyResult(bool canRetry)
+ {
+ CanRetry = canRetry;
+ }
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs
new file mode 100644
index 0000000..d3edd33
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using Nethereum.Web3.Accounts;
+using Newtonsoft.Json;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task SignInWithOauthAsync(string authProvider, string authResult, string recoveryCode)
+ {
+ Server.VerifyResult result = await server.VerifyOAuthAsync(authResult);
+ return await PostAuthSetup(result, recoveryCode, null, authProvider);
+ }
+
+ public async Task FetchHeadlessOauthLoginLinkAsync(string authProvider)
+ {
+ return await server.FetchHeadlessOauthLoginLinkAsync(authProvider);
+ }
+
+ public async Task IsRecoveryCodeNeededAsync(string authResultStr)
+ {
+ var authResult = JsonConvert.DeserializeObject(authResultStr);
+ Server.UserWallet userWallet = await server.FetchUserDetailsAsync(authResult.StoredToken.AuthDetails.Email, null);
+ return userWallet.RecoveryShareManagement == "USER_MANAGED" && !userWallet.IsNewUser && localStorage.Data?.DeviceShare == null;
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs
new file mode 100644
index 0000000..f079d1c
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ public async Task<(bool isNewUser, bool isNewDevice, bool needsPassword)> SendOtpPhoneAsync(string phoneNumber)
+ {
+ string sessionId = await server.SendKmsPhoneOtpAsync(phoneNumber);
+ bool isKmsWallet = true;
+ await localStorage.SaveSessionAsync(sessionId, isKmsWallet);
+ bool isNewUser = true;
+ bool isNewDevice = true;
+ return (isNewUser, isNewDevice, !isKmsWallet);
+ }
+
+ public async Task VerifyPhoneOtpAsync(string phoneNumber, string otp, string recoveryCode)
+ {
+ if (localStorage.Session == null)
+ {
+ throw new InvalidOperationException($"Must first invoke {nameof(SendOtpPhoneAsync)}", new NullReferenceException());
+ }
+ try
+ {
+ // if (!await server.CheckIsPhoneKmsOtpValidAsync(phoneNumber, otp))
+ // {
+ // throw new VerificationException("Invalid OTP", true);
+ // }
+ Server.VerifyResult result = await server.VerifyKmsPhoneOtpAsync(phoneNumber, otp, localStorage.Session.Id);
+ await localStorage.RemoveSessionAsync();
+ return await PostAuthSetup(result, recoveryCode, null, "PhoneOTP");
+ }
+ catch (VerificationException ex)
+ {
+ Console.WriteLine("VerifyPhoneOtpAsync Error: " + ex.Message);
+ return new VerifyResult(ex.CanRetry);
+ }
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs
new file mode 100644
index 0000000..918bc60
--- /dev/null
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs
@@ -0,0 +1,25 @@
+namespace Thirdweb.EWS
+{
+ internal partial class EmbeddedWallet
+ {
+ private readonly LocalStorageBase localStorage;
+ private readonly ServerBase server;
+ private readonly IvGeneratorBase ivGenerator;
+ private User user;
+
+ private const int DEVICE_SHARE_ID = 1;
+ private const int KEY_SIZE = 256 / 8;
+ private const int TAG_SIZE = 16;
+ private const int CURRENT_ITERATION_COUNT = 650_000;
+ private const int DEPRECATED_ITERATION_COUNT = 5_000_000;
+ private const string WALLET_PRIVATE_KEY_PREFIX = "thirdweb_";
+ private const string ENCRYPTION_SEPARATOR = ":";
+
+ public EmbeddedWallet(ThirdwebClient client)
+ {
+ localStorage = new LocalStorage(client.ClientId);
+ server = new Server(client.ClientId, client.BundleId, "dotnet", Constants.Version, client.SecretKey);
+ ivGenerator = new IvGenerator();
+ }
+ }
+}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs
deleted file mode 100644
index 3e1db48..0000000
--- a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using Thirdweb;
-
-internal class EmbeddedWallet : PrivateKeyWallet
-{
- internal EmbeddedWallet(string privateKeyHex)
- : base(privateKeyHex) { }
-}
diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs
similarity index 90%
rename from Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs
rename to Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs
index 3fa977b..1e24421 100644
--- a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs
+++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs
@@ -10,11 +10,11 @@
using Nethereum.Signer.EIP712;
using Thirdweb;
-internal class PrivateKeyWallet : IWallet
+public class PrivateKey : IWallet
{
- private readonly EthECKey _ecKey;
+ private EthECKey _ecKey;
- internal PrivateKeyWallet(string privateKeyHex)
+ internal PrivateKey(string privateKeyHex)
{
if (string.IsNullOrEmpty(privateKeyHex))
{
@@ -24,6 +24,12 @@ internal PrivateKeyWallet(string privateKeyHex)
_ecKey = new EthECKey(privateKeyHex);
}
+ public Task Initialize()
+ {
+ // No initialization required for private key wallets
+ return Task.CompletedTask;
+ }
+
public string GetAddress()
{
return _ecKey.GetPublicAddress();
@@ -128,4 +134,15 @@ public string SignTransaction(TransactionInput transaction, BigInteger chainId)
return "0x" + signedTransaction;
}
+
+ public bool IsConnected()
+ {
+ return _ecKey != null;
+ }
+
+ public Task Disconnect()
+ {
+ _ecKey = null;
+ return Task.CompletedTask;
+ }
}
diff --git a/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs b/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs
index ce549a7..e4110e0 100644
--- a/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs
+++ b/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs
@@ -8,7 +8,7 @@ public class ThirdwebAccount
{
internal ThirdwebAccountOptions Options { get; private set; }
- private IWallet _wallet;
+ public IWallet Wallet { get; private set; }
public ThirdwebAccount(ThirdwebAccountOptions options)
{
@@ -19,46 +19,57 @@ public ThirdwebAccount(ThirdwebAccountOptions options)
Options = options;
- InitializeWallet();
- }
-
- private void InitializeWallet()
- {
- _wallet = Options.Type switch
+ Wallet = Options.Type switch
{
- WalletType.PrivateKey => new PrivateKeyWallet(Options.PrivateKey),
+ WalletType.PrivateKey => new PrivateKey(Options.PrivateKey),
+ WalletType.Embedded => new Embedded(Options.Client, Options.Email),
_ => throw new ArgumentException("Invalid wallet type"),
};
}
+ public async Task Initialize()
+ {
+ await Wallet.Initialize();
+ }
+
public string GetAddress()
{
- return _wallet.GetAddress();
+ return Wallet.GetAddress();
}
public string EthSign(string message)
{
- return _wallet.EthSign(message);
+ return Wallet.EthSign(message);
}
public string PersonalSign(string message)
{
- return _wallet.PersonalSign(message);
+ return Wallet.PersonalSign(message);
}
public string SignTypedDataV4(string json)
{
- return _wallet.SignTypedDataV4(json);
+ return Wallet.SignTypedDataV4(json);
}
public string SignTypedDataV4(T data, TypedData typedData)
{
- return _wallet.SignTypedDataV4(data, typedData);
+ return Wallet.SignTypedDataV4(data, typedData);
}
public string SignTransaction(TransactionInput transaction, BigInteger chainId)
{
- return _wallet.SignTransaction(transaction, chainId);
+ return Wallet.SignTransaction(transaction, chainId);
+ }
+
+ public bool IsConnected()
+ {
+ return Wallet.IsConnected();
+ }
+
+ public async Task Disconnect()
+ {
+ await Wallet.Disconnect();
}
}
}
diff --git a/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs b/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs
index 5ace94a..e09c9d7 100644
--- a/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs
+++ b/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs
@@ -6,12 +6,14 @@ public class ThirdwebAccountOptions
{
internal ThirdwebClient Client { get; private set; }
internal WalletType Type { get; private set; }
+ internal string Email { get; private set; }
internal string PrivateKey { get; private set; }
- public ThirdwebAccountOptions(ThirdwebClient client, WalletType type, string privateKey)
+ public ThirdwebAccountOptions(ThirdwebClient client, WalletType type, string email = null, string privateKey = null)
{
Client = client;
Type = type;
+ Email = email;
PrivateKey = privateKey;
}
}
diff --git a/Thirdweb/Thirdweb.csproj b/Thirdweb/Thirdweb.csproj
index cdb9025..ab2f649 100644
--- a/Thirdweb/Thirdweb.csproj
+++ b/Thirdweb/Thirdweb.csproj
@@ -33,6 +33,9 @@
+
+
+