@arbius/aa-wallet
Version:
A secure and flexible Account Abstraction wallet implementation for Arbitrum One chain applications.
363 lines (362 loc) • 18.3 kB
JavaScript
// --- Constants ---
// Storage key for the derived wallet cache
const DERIVED_WALLET_STORAGE_KEY = 'derivedWalletCache';
// AIUS Token Contract Address on Arbitrum
const AIUS_TOKEN_ADDRESS = '0x4a24B101728e07A52053c13FB4dB2BcF490CAbc3';
// Minimal ERC20 ABI required for balance, decimals, and transfer
const ERC20_ABI = [
"function balanceOf(address owner) view returns (uint256)",
"function decimals() view returns (uint8)",
"function transfer(address to, uint256 amount) returns (bool)"
];
/**
* Initializes or retrieves a cached deterministic wallet.
* This wallet is derived from the owner's signature, is unique to the ownerAddress,
* and is connected to the provided provider.
*
* @param appEthers The ethers.js library object provided by the consuming application.
* Pass the full ethers object (e.g., initDeterministicWallet(ethers, ...))
* @param ownerAddress The address of the EOA wallet that owns the deterministic wallet.
* @param signMessage A function that takes a message string and returns a Promise resolving to the signature.
* @param provider A provider instance.
* @returns A Promise that resolves to a Wallet instance of the deterministic wallet.
*/
export async function initDeterministicWallet(appEthers, ownerAddress, signMessage, provider) {
if (!appEthers || !ownerAddress || !signMessage || !provider) {
throw new Error("appEthers, ownerAddress, signMessage, and provider are required.");
}
// Determine if we're using ethers v5 or v6
const isV5 = typeof appEthers.utils !== 'undefined' || typeof appEthers.keccak256 === 'undefined';
// If we only have utils, we need to get the full ethers object
const fullEthers = appEthers.Wallet ? appEthers : window.ethers;
if (!fullEthers || !fullEthers.Wallet) {
throw new Error("Could not find the Wallet constructor. Make sure you're passing the full ethers object.");
}
const lowerOwnerAddress = ownerAddress.toLowerCase();
const cachedWalletJson = localStorage.getItem(DERIVED_WALLET_STORAGE_KEY);
let cachedWalletData = null;
if (cachedWalletJson) {
try {
const parsed = JSON.parse(cachedWalletJson);
if (parsed.ownerAddress.toLowerCase() === lowerOwnerAddress) {
cachedWalletData = parsed;
console.log('Found cached derived wallet data for', lowerOwnerAddress);
}
}
catch (e) {
console.error('Error parsing cached wallet:', e);
localStorage.removeItem(DERIVED_WALLET_STORAGE_KEY); // Clear invalid cache
}
}
let walletInstance;
if (cachedWalletData) {
walletInstance = new fullEthers.Wallet(cachedWalletData.derivedPrivateKey);
console.log('Using cached derived wallet:', walletInstance.address);
}
else {
console.log('No cached wallet found for', lowerOwnerAddress, '. Creating new one...');
const messageToSign = `Create deterministic wallet for ${lowerOwnerAddress}`;
const signatureCacheKey = `signature_${lowerOwnerAddress}`;
let signature = localStorage.getItem(signatureCacheKey);
if (!signature) {
console.log('No cached signature found, signing message for', lowerOwnerAddress);
signature = await signMessage(messageToSign);
localStorage.setItem(signatureCacheKey, signature);
console.log('Signature obtained and cached for', lowerOwnerAddress);
}
else {
console.log('Using cached signature for', lowerOwnerAddress);
}
// Create UTF-8 bytes from the signature using native TextEncoder
const utf8Bytes = new TextEncoder().encode(signature);
// Hash the bytes using the appropriate method
let hashedSignature;
if (isV5) {
// ethers v5
if (appEthers.utils && appEthers.utils.keccak256) {
// Full ethers object with utils
hashedSignature = appEthers.utils.keccak256(appEthers.utils.toUtf8Bytes ? appEthers.utils.toUtf8Bytes(signature) : utf8Bytes);
}
else if (appEthers.keccak256) {
// Just the utils object
hashedSignature = appEthers.keccak256(appEthers.toUtf8Bytes ? appEthers.toUtf8Bytes(signature) : utf8Bytes);
}
else {
// Fallback to full ethers from window
hashedSignature = fullEthers.utils.keccak256(fullEthers.utils.toUtf8Bytes(signature));
}
}
else {
// ethers v6
hashedSignature = appEthers.keccak256(utf8Bytes);
}
walletInstance = new fullEthers.Wallet(hashedSignature);
const newCacheData = {
ownerAddress: lowerOwnerAddress,
derivedPrivateKey: walletInstance.privateKey,
derivedAddress: walletInstance.address
};
localStorage.setItem(DERIVED_WALLET_STORAGE_KEY, JSON.stringify(newCacheData));
console.log('New derived wallet created and cached:', walletInstance.address);
}
// Connect the wallet to the provider
try {
// ethers v5 style
if (typeof walletInstance.connect === 'function') {
return walletInstance.connect(provider);
}
else {
// Some other way to connect
throw new Error('Cannot connect wallet to provider. Wallet.connect method not found.');
}
}
catch (error) {
console.error('Error connecting wallet to provider:', error);
throw error;
}
}
/**
* Retrieves the address of the deterministic wallet for deposit purposes.
* It ensures the wallet is initialized (created and cached if it's the first time).
*
* @param appEthers The ethers.js library object provided by the consuming application.
* @param ownerAddress The address of the EOA wallet.
* @param signMessage A function that takes a message string and returns a Promise resolving to the signature.
* @param provider A provider instance.
* @returns A Promise that resolves to the address (string) of the deterministic wallet.
*/
export async function getDeterministicWalletAddressForDeposit(appEthers, ownerAddress, signMessage, provider) {
const wallet = await initDeterministicWallet(appEthers, ownerAddress, signMessage, provider);
return wallet.address;
}
/**
* Withdraws funds (ETH or AIUS token) from the deterministic wallet to a recipient address.
*
* @param appEthers The ethers.js library object provided by the consuming application.
* @param deterministicWallet The initialized ethers.Wallet instance of the deterministic wallet (should be from appEthers.Wallet).
* @param recipientAddress The address to withdraw funds to (typically the owner's EOA).
* @param options An object containing withdrawal options:
* - amount?: The amount to withdraw (string). If not provided for ETH, attempts to withdraw max.
* For AIUS, if not provided, withdraws the entire token balance.
* - token: Specifies whether to withdraw 'ETH' or 'AIUS'.
* @returns A Promise that resolves to the transaction hash (string) if successful, or null otherwise.
* @throws Will throw an error if the withdrawal process fails.
*/
export async function withdrawFromDeterministicWallet(appEthers, deterministicWallet, recipientAddress, options) {
if (!deterministicWallet.provider) {
console.error('Deterministic wallet is not connected to a provider.');
throw new Error('Deterministic wallet is not connected to a provider.');
}
// The provider is already part of the connected deterministicWallet instance
const provider = deterministicWallet.provider;
try {
const tokenType = options.token;
const specifiedAmountStr = options.amount;
if (tokenType === 'ETH') {
const balance = await provider.getBalance(deterministicWallet.address);
if (balance === 0n && !specifiedAmountStr) { // Only throw if trying to withdraw max from zero balance
throw new Error('No ETH available to withdraw from deterministic wallet.');
}
if (balance === 0n && specifiedAmountStr && appEthers.parseEther(specifiedAmountStr) > 0n) {
throw new Error('No ETH available to withdraw, requested amount cannot be fulfilled.');
}
let valueToSend;
if (specifiedAmountStr) {
const parsedAmount = appEthers.parseEther(specifiedAmountStr);
if (parsedAmount <= 0n) {
throw new Error('ETH withdrawal amount must be greater than zero.');
}
// Estimate gas for the specified amount
const gasEstimate = await provider.estimateGas({
from: deterministicWallet.address,
to: recipientAddress,
value: parsedAmount,
});
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice || appEthers.parseUnits("1", "gwei"); // Fallback
const gasCost = gasEstimate * gasPrice;
if (parsedAmount + gasCost > balance) {
throw new Error(`Withdrawal amount (${appEthers.formatEther(parsedAmount)} ETH) plus estimated gas ` +
`(${appEthers.formatEther(gasCost)} ETH) exceeds available balance (${appEthers.formatEther(balance)} ETH).`);
}
valueToSend = parsedAmount;
}
else { // Withdraw max ETH
if (balance === 0n)
throw new Error('No ETH available to withdraw.');
const gasEstimate = await provider.estimateGas({
from: deterministicWallet.address,
to: recipientAddress,
value: balance, // Estimate for potentially full balance transfer
});
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice || appEthers.parseUnits("1", "gwei");
const gasCost = gasEstimate * gasPrice;
// Apply a 20% buffer to the calculated gas cost for safety
const bufferedGasCost = BigInt(gasCost) * BigInt(120) / BigInt(100);
valueToSend = balance - bufferedGasCost;
if (valueToSend <= 0n) {
throw new Error(`Insufficient ETH balance (${appEthers.formatEther(balance)} ETH) to cover estimated gas costs ` +
`(${appEthers.formatEther(bufferedGasCost)} ETH) for withdrawal.`);
}
}
const txResponse = await deterministicWallet.sendTransaction({
to: recipientAddress,
value: valueToSend,
});
await txResponse.wait(); // Wait for confirmation
console.log('ETH withdrawal successful. TxHash:', txResponse.hash);
return txResponse.hash;
}
else if (tokenType === 'AIUS') {
const tokenContract = new appEthers.Contract(AIUS_TOKEN_ADDRESS, ERC20_ABI, deterministicWallet);
const tokenBalance = await tokenContract.balanceOf(deterministicWallet.address);
if (tokenBalance === 0n && !specifiedAmountStr) {
throw new Error('No AIUS tokens available to withdraw.');
}
if (tokenBalance === 0n && specifiedAmountStr && appEthers.parseUnits(specifiedAmountStr, await tokenContract.decimals()) > 0n) {
throw new Error('No AIUS tokens available to withdraw, requested amount cannot be fulfilled.');
}
const decimals = await tokenContract.decimals(); // Ethers v6 returns bigint for decimals()
let valueToSend;
if (specifiedAmountStr) {
valueToSend = appEthers.parseUnits(specifiedAmountStr, Number(decimals)); // parseUnits takes number for decimals
if (valueToSend <= 0n) {
throw new Error('AIUS withdrawal amount must be greater than zero.');
}
if (valueToSend > tokenBalance) {
throw new Error(`Withdrawal amount (${specifiedAmountStr} AIUS) exceeds available token balance ` +
`(${appEthers.formatUnits(tokenBalance, Number(decimals))} AIUS).`);
}
}
else { // Withdraw max AIUS
if (tokenBalance === 0n)
throw new Error('No AIUS tokens available to withdraw.');
valueToSend = tokenBalance;
}
// Check ETH balance for gas on token transfer
const ethBalance = await provider.getBalance(deterministicWallet.address);
const transferTxPopulated = await tokenContract.transfer.populateTransaction(recipientAddress, valueToSend);
const gasEstimate = await provider.estimateGas({
...transferTxPopulated,
from: deterministicWallet.address // Gas is paid by the deterministic wallet
});
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice || appEthers.parseUnits("1", "gwei");
const gasCost = gasEstimate * gasPrice;
if (gasCost > ethBalance) {
throw new Error(`Insufficient ETH in deterministic wallet (${appEthers.formatEther(ethBalance)} ETH) to cover gas costs ` +
`(${appEthers.formatEther(gasCost)} ETH) for AIUS withdrawal.`);
}
const txResponse = await tokenContract.transfer(recipientAddress, valueToSend);
await txResponse.wait(); // Wait for confirmation
console.log('AIUS withdrawal successful. TxHash:', txResponse.hash);
return txResponse.hash;
}
else {
// Should not happen due to TypeScript types, but good for robustness
console.error('Invalid token type specified:', options.token);
throw new Error("Invalid token type specified. Must be 'ETH' or 'AIUS'.");
}
}
catch (error) {
console.error('Failed to withdraw funds from deterministic wallet:', error);
// Rethrow the error so the calling application can handle it
if (error instanceof Error) {
throw error;
}
throw new Error(String(error)); // Ensure it's always an Error object
}
}
/**
* Sends a transaction from the deterministic wallet to a contract
* @param appEthers The ethers instance from the application
* @param wallet The deterministic wallet instance
* @param to The contract address to call
* @param data The encoded function data
* @param value Optional ETH value to send with the transaction
* @returns Transaction hash or null if failed
*/
export async function sendContractTransaction(appEthers, wallet, to, data, value = '0') {
try {
// Get the current gas price with a 20% buffer for price fluctuations
const gasPrice = await wallet.provider?.getFeeData();
if (!gasPrice?.gasPrice) {
return {
hash: null,
error: {
message: 'Failed to get gas price',
code: 'GAS_PRICE_ERROR'
}
};
}
// Use BigNumber operations instead of BigInt
const bufferedGasPrice = gasPrice.gasPrice.mul(120).div(100);
// Estimate gas for the transaction
const gasEstimate = await wallet.provider?.estimateGas({
to,
data,
value: appEthers.parseEther(value)
});
if (!gasEstimate) {
return {
hash: null,
error: {
message: 'Failed to estimate gas',
code: 'GAS_ESTIMATE_ERROR'
}
};
}
// Use BigNumber operations instead of BigInt
const bufferedGasLimit = gasEstimate.mul(120).div(100);
// Send the transaction
const tx = await wallet.sendTransaction({
to,
data,
value: appEthers.parseEther(value),
gasPrice: bufferedGasPrice,
gasLimit: bufferedGasLimit
});
return { hash: tx.hash };
}
catch (error) {
return {
hash: null,
error: {
message: error?.message || 'Unknown error',
code: error?.code || 'UNKNOWN_ERROR',
data: error?.data,
transaction: error?.transaction
}
};
}
}
/**
* Gets the ETH and AIUS token balances of the deterministic wallet
* @param appEthers The ethers instance from the application
* @param wallet The deterministic wallet instance
* @returns Object containing ETH and AIUS balances in string format
*/
export async function getDeterministicWalletBalances(appEthers, wallet) {
if (!wallet.provider) {
throw new Error('Deterministic wallet is not connected to a provider');
}
try {
// Get ETH balance
const ethBalance = await wallet.provider.getBalance(wallet.address);
const formattedEthBalance = appEthers.formatEther(ethBalance);
// Get AIUS balance
const tokenContract = new appEthers.Contract(AIUS_TOKEN_ADDRESS, ERC20_ABI, wallet);
const aiusBalance = await tokenContract.balanceOf(wallet.address);
const decimals = await tokenContract.decimals();
const formattedAiusBalance = appEthers.formatUnits(aiusBalance, decimals);
return {
eth: formattedEthBalance,
aius: formattedAiusBalance
};
}
catch (error) {
console.error('Failed to get wallet balances:', error);
throw error;
}
}