Add Bitcoin wallet system with configurable strategy fees
Features: - Two-address custody model (Fee Address + Sweep Address) - Credits ledger for instant, reliable strategy fee transactions - Configurable fee percentage (1-100% of exchange commission) - Background jobs for auto-sweep, deposit detection, withdrawal processing - Account settings dialog accessible via username click - $50 balance cap with auto-sweep to user's sweep address Security improvements: - Atomic withdrawal reservation prevents partial state - Fee accumulation cleanup on strategy startup failure - Deposit monitoring includes disabled wallets for recovery - Null tx hash checks prevent silent failures - Key export disabled by default Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cd9a69f1d4
commit
7e77f55837
|
|
@ -5,6 +5,8 @@ python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
# Default: exclude integration tests (run with: pytest -m integration)
|
# Default: exclude integration tests (run with: pytest -m integration)
|
||||||
addopts = -v --tb=short -m "not integration"
|
addopts = -v --tb=short -m "not integration"
|
||||||
|
filterwarnings =
|
||||||
|
ignore:'crypt' is deprecated and slated for removal in Python 3\.13:DeprecationWarning
|
||||||
|
|
||||||
markers =
|
markers =
|
||||||
live_testnet: marks tests as requiring live testnet API keys (deselect with '-m "not live_testnet"')
|
live_testnet: marks tests as requiring live testnet API keys (deselect with '-m "not live_testnet"')
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,6 @@ email_validator~=2.2.0
|
||||||
aiohttp>=3.9.0
|
aiohttp>=3.9.0
|
||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
# Bitcoin wallet and encryption
|
||||||
|
bit>=0.8.0
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
@ -13,6 +13,7 @@ from indicators import Indicators
|
||||||
from Signals import Signals
|
from Signals import Signals
|
||||||
from trade import Trades
|
from trade import Trades
|
||||||
from edm_client import EdmClient, EdmWebSocketClient
|
from edm_client import EdmClient, EdmWebSocketClient
|
||||||
|
from wallet import WalletManager
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -71,6 +72,18 @@ class BrighterTrades:
|
||||||
edm_client=self.edm_client)
|
edm_client=self.edm_client)
|
||||||
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
|
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
|
||||||
|
|
||||||
|
# Wallet manager for Bitcoin wallets and credits ledger
|
||||||
|
wallet_config = self.config.get_setting('wallet') or {}
|
||||||
|
wallet_keys = wallet_config.get('encryption_keys', {1: 'default_dev_key_change_in_production'})
|
||||||
|
# Ensure keys are int -> str mapping
|
||||||
|
wallet_keys = {int(k): v for k, v in wallet_keys.items()}
|
||||||
|
self.wallet_manager = WalletManager(
|
||||||
|
database=self.data.db,
|
||||||
|
encryption_keys=wallet_keys,
|
||||||
|
default_network=wallet_config.get('bitcoin_network', 'testnet')
|
||||||
|
)
|
||||||
|
logger.info(f"Wallet manager initialized (network: {wallet_config.get('bitcoin_network', 'testnet')})")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_user_id(user_id: Any) -> int | None:
|
def _coerce_user_id(user_id: Any) -> int | None:
|
||||||
if user_id is None or user_id == '':
|
if user_id is None or user_id == '':
|
||||||
|
|
@ -752,7 +765,12 @@ class BrighterTrades:
|
||||||
# This ensures subscribers run with the creator's indicator definitions
|
# This ensures subscribers run with the creator's indicator definitions
|
||||||
indicator_owner_id = creator_id if is_subscribed and not is_owner else None
|
indicator_owner_id = creator_id if is_subscribed and not is_owner else None
|
||||||
|
|
||||||
# Early exchange requirements validation
|
# Check for strategy fees (only for non-owners running in paper/live mode)
|
||||||
|
strategy_fee = float(strategy_row.get('fee', 0.0))
|
||||||
|
has_fee = strategy_fee > 0 and not is_owner and mode in ['paper', 'live']
|
||||||
|
strategy_run_id = None # Will be set if fee accumulation is started
|
||||||
|
|
||||||
|
# Early exchange requirements validation (BEFORE fee accumulation to avoid orphaned fees)
|
||||||
from exchange_validation import extract_required_exchanges, validate_exchange_requirements
|
from exchange_validation import extract_required_exchanges, validate_exchange_requirements
|
||||||
strategy_full = self.strategies.get_strategy_by_tbl_key(strategy_id)
|
strategy_full = self.strategies.get_strategy_by_tbl_key(strategy_id)
|
||||||
required_exchanges = extract_required_exchanges(strategy_full)
|
required_exchanges = extract_required_exchanges(strategy_full)
|
||||||
|
|
@ -933,6 +951,28 @@ class BrighterTrades:
|
||||||
# Paper mode: random UUID since paper state is ephemeral
|
# Paper mode: random UUID since paper state is ephemeral
|
||||||
strategy_instance_id = str(uuid.uuid4())
|
strategy_instance_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Start fee accumulation only after all startup validation has passed.
|
||||||
|
if has_fee and creator_id is not None:
|
||||||
|
strategy_run_id = f"{strategy_id}_{user_id}_{uuid.uuid4().hex[:8]}"
|
||||||
|
fee_result = self.wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id=strategy_run_id,
|
||||||
|
user_id=user_id,
|
||||||
|
creator_user_id=creator_id,
|
||||||
|
fee_percent=int(strategy_fee), # 1-100% of exchange commission
|
||||||
|
estimated_trades=10 # Check for ~10 trades worth of credits
|
||||||
|
)
|
||||||
|
|
||||||
|
if not fee_result['success']:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': fee_result.get('error', 'Failed to start fee accumulation'),
|
||||||
|
'balance_available': fee_result.get('available', 0),
|
||||||
|
'recommended_minimum': fee_result.get('recommended_minimum', 10000),
|
||||||
|
'need_deposit': True
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Started fee accumulation for strategy {strategy_id}: run_id={strategy_run_id}")
|
||||||
|
|
||||||
instance = self.strategies.create_strategy_instance(
|
instance = self.strategies.create_strategy_instance(
|
||||||
mode=mode,
|
mode=mode,
|
||||||
strategy_instance_id=strategy_instance_id,
|
strategy_instance_id=strategy_instance_id,
|
||||||
|
|
@ -950,6 +990,15 @@ class BrighterTrades:
|
||||||
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
|
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store fee tracking info on the instance
|
||||||
|
if strategy_run_id:
|
||||||
|
instance.strategy_run_id = strategy_run_id
|
||||||
|
instance.has_fee = True
|
||||||
|
instance.wallet_manager = self.wallet_manager
|
||||||
|
else:
|
||||||
|
instance.strategy_run_id = None
|
||||||
|
instance.has_fee = False
|
||||||
|
|
||||||
# Store the active instance
|
# Store the active instance
|
||||||
self.strategies.active_instances[instance_key] = instance
|
self.strategies.active_instances[instance_key] = instance
|
||||||
|
|
||||||
|
|
@ -980,6 +1029,8 @@ class BrighterTrades:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if strategy_run_id:
|
||||||
|
self.wallet_manager.cancel_fee_accumulation(strategy_run_id)
|
||||||
logger.error(f"Failed to create strategy instance: {e}", exc_info=True)
|
logger.error(f"Failed to create strategy instance: {e}", exc_info=True)
|
||||||
return {"success": False, "message": f"Failed to start strategy: {str(e)}"}
|
return {"success": False, "message": f"Failed to start strategy: {str(e)}"}
|
||||||
|
|
||||||
|
|
@ -1028,9 +1079,20 @@ class BrighterTrades:
|
||||||
if hasattr(instance, 'trade_history'):
|
if hasattr(instance, 'trade_history'):
|
||||||
final_stats['total_trades'] = len(instance.trade_history)
|
final_stats['total_trades'] = len(instance.trade_history)
|
||||||
|
|
||||||
|
# Settle accumulated fees if this was a paid strategy
|
||||||
|
fee_settlement = None
|
||||||
|
if hasattr(instance, 'strategy_run_id') and instance.strategy_run_id:
|
||||||
|
settle_result = self.wallet_manager.settle_accumulated_fees(instance.strategy_run_id)
|
||||||
|
fee_settlement = {
|
||||||
|
'fees_settled': settle_result.get('settled', 0),
|
||||||
|
'trades_charged': settle_result.get('trades', 0)
|
||||||
|
}
|
||||||
|
logger.info(f"Settled {settle_result.get('settled', 0)} sats for "
|
||||||
|
f"{settle_result.get('trades', 0)} trades on strategy {strategy_id}")
|
||||||
|
|
||||||
logger.info(f"Stopped strategy '{strategy_name}' for user {user_id} in {mode} mode")
|
logger.info(f"Stopped strategy '{strategy_name}' for user {user_id} in {mode} mode")
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Strategy '{strategy_name}' stopped.",
|
"message": f"Strategy '{strategy_name}' stopped.",
|
||||||
"strategy_id": strategy_id,
|
"strategy_id": strategy_id,
|
||||||
|
|
@ -1040,6 +1102,11 @@ class BrighterTrades:
|
||||||
"final_stats": final_stats,
|
"final_stats": final_stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fee_settlement:
|
||||||
|
result["fee_settlement"] = fee_settlement
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_strategy_status(
|
def get_strategy_status(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|
@ -1809,3 +1876,81 @@ class BrighterTrades:
|
||||||
if msg_type == 'reply':
|
if msg_type == 'reply':
|
||||||
# If the message is a reply log the response to the terminal.
|
# If the message is a reply log the response to the terminal.
|
||||||
print(f"\napp.py:Received reply: {msg_data}")
|
print(f"\napp.py:Received reply: {msg_data}")
|
||||||
|
|
||||||
|
# ===== Wallet Methods =====
|
||||||
|
|
||||||
|
def create_user_wallet(self, user_id: int, user_sweep_address: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
Create BTC wallet for registered user (two-address model).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to create wallet for.
|
||||||
|
user_sweep_address: Optional user-provided sweep address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and wallet details.
|
||||||
|
"""
|
||||||
|
user = self.users.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return {'success': False, 'error': 'User not found'}
|
||||||
|
|
||||||
|
# Check if user is a guest (guests can't have wallets)
|
||||||
|
username = user.get('user_name', '')
|
||||||
|
if username == 'guest' or user.get('is_guest', False):
|
||||||
|
return {'success': False, 'error': 'Only registered users can create wallets'}
|
||||||
|
|
||||||
|
return self.wallet_manager.create_wallet(user_id, user_sweep_address=user_sweep_address)
|
||||||
|
|
||||||
|
def get_user_wallet(self, user_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get user's wallet info (without private key).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with wallet info or None.
|
||||||
|
"""
|
||||||
|
return self.wallet_manager.get_wallet(user_id)
|
||||||
|
|
||||||
|
def get_credits_balance(self, user_id: int) -> int:
|
||||||
|
"""
|
||||||
|
Get user's spendable credits balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Balance in satoshis.
|
||||||
|
"""
|
||||||
|
return self.wallet_manager.get_credits_balance(user_id)
|
||||||
|
|
||||||
|
def request_withdrawal(self, user_id: int, amount_satoshis: int,
|
||||||
|
destination_address: str) -> dict:
|
||||||
|
"""
|
||||||
|
Request BTC withdrawal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID requesting withdrawal.
|
||||||
|
amount_satoshis: Amount to withdraw.
|
||||||
|
destination_address: Bitcoin address to send to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status.
|
||||||
|
"""
|
||||||
|
return self.wallet_manager.request_withdrawal(
|
||||||
|
user_id, amount_satoshis, destination_address
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_transaction_history(self, user_id: int, limit: int = 20) -> list:
|
||||||
|
"""
|
||||||
|
Get user's recent ledger transactions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to look up.
|
||||||
|
limit: Maximum number of transactions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts.
|
||||||
|
"""
|
||||||
|
return self.wallet_manager.get_transaction_history(user_id, limit)
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,16 @@ class Database:
|
||||||
def __init__(self, db_file: str = None):
|
def __init__(self, db_file: str = None):
|
||||||
self.db_file = db_file
|
self.db_file = db_file
|
||||||
|
|
||||||
def execute_sql(self, sql: str, params: list = None) -> None:
|
def execute_sql(self, sql: str, params: tuple = None,
|
||||||
|
fetch_one: bool = False, fetch_all: bool = False) -> Any:
|
||||||
"""
|
"""
|
||||||
Executes a raw SQL statement with optional parameters.
|
Executes a raw SQL statement with optional parameters and fetch modes.
|
||||||
|
|
||||||
:param sql: SQL statement to execute.
|
:param sql: SQL statement to execute.
|
||||||
:param params: Optional tuple of parameters to pass with the SQL statement.
|
:param params: Optional tuple of parameters to pass with the SQL statement.
|
||||||
|
:param fetch_one: If True, returns a single row (or None).
|
||||||
|
:param fetch_all: If True, returns all rows as a list (or empty list).
|
||||||
|
:return: None, single row, or list of rows depending on fetch parameters.
|
||||||
"""
|
"""
|
||||||
with SQLite(self.db_file) as con:
|
with SQLite(self.db_file) as con:
|
||||||
cur = con.cursor()
|
cur = con.cursor()
|
||||||
|
|
@ -103,6 +107,37 @@ class Database:
|
||||||
else:
|
else:
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
|
|
||||||
|
if fetch_one:
|
||||||
|
return cur.fetchone()
|
||||||
|
elif fetch_all:
|
||||||
|
return cur.fetchall()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def execute_in_transaction(self, statements: list) -> bool:
|
||||||
|
"""
|
||||||
|
Executes multiple SQL statements within a single transaction.
|
||||||
|
All statements succeed or all fail (atomic).
|
||||||
|
|
||||||
|
:param statements: List of (sql, params) tuples to execute.
|
||||||
|
:return: True if all statements succeeded.
|
||||||
|
:raises: Exception if any statement fails (transaction rolled back).
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_file)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for sql, params in statements:
|
||||||
|
if params is None:
|
||||||
|
cur.execute(sql)
|
||||||
|
else:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_all_rows(self, table_name: str) -> pd.DataFrame:
|
def get_all_rows(self, table_name: str) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
Retrieves all rows from a table.
|
Retrieves all rows from a table.
|
||||||
|
|
|
||||||
|
|
@ -839,3 +839,45 @@ class StrategyInstance:
|
||||||
Retrieves the available balance for the strategy.
|
Retrieves the available balance for the strategy.
|
||||||
"""
|
"""
|
||||||
return self.trades.get_available_balance(self.strategy_id)
|
return self.trades.get_available_balance(self.strategy_id)
|
||||||
|
|
||||||
|
def accumulate_trade_fee(self, trade_value_usd: float, commission_rate: float,
|
||||||
|
is_profitable: bool) -> dict:
|
||||||
|
"""
|
||||||
|
Accumulate fee for a completed trade (only called for paid strategies).
|
||||||
|
|
||||||
|
This method is called when a trade fills to accumulate fees that will
|
||||||
|
be settled when the strategy stops.
|
||||||
|
|
||||||
|
:param trade_value_usd: The USD value of the trade.
|
||||||
|
:param commission_rate: The exchange commission rate (e.g., 0.001 for 0.1%).
|
||||||
|
:param is_profitable: Whether the trade was profitable.
|
||||||
|
:return: Dict with fee charged amount.
|
||||||
|
"""
|
||||||
|
# Check if this strategy has fee tracking enabled
|
||||||
|
if not getattr(self, 'has_fee', False) or not getattr(self, 'strategy_run_id', None):
|
||||||
|
return {'success': True, 'fee_charged': 0, 'reason': 'no_fees_enabled'}
|
||||||
|
|
||||||
|
wallet_manager = getattr(self, 'wallet_manager', None)
|
||||||
|
if not wallet_manager:
|
||||||
|
return {'success': False, 'error': 'No wallet manager available'}
|
||||||
|
|
||||||
|
# Calculate exchange fee in USD, then convert to satoshis
|
||||||
|
# Assume 1 BTC = $50,000 for conversion (rough estimate, could be made dynamic)
|
||||||
|
btc_price_usd = 50000 # This could be fetched from exchange
|
||||||
|
exchange_fee_usd = trade_value_usd * commission_rate
|
||||||
|
|
||||||
|
# Convert to satoshis: 1 BTC = 100,000,000 satoshis
|
||||||
|
exchange_fee_btc = exchange_fee_usd / btc_price_usd
|
||||||
|
exchange_fee_satoshis = int(exchange_fee_btc * 100_000_000)
|
||||||
|
|
||||||
|
# Accumulate the fee
|
||||||
|
result = wallet_manager.accumulate_trade_fee(
|
||||||
|
strategy_run_id=self.strategy_run_id,
|
||||||
|
exchange_fee_satoshis=exchange_fee_satoshis,
|
||||||
|
is_profitable=is_profitable
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('fee_charged', 0) > 0:
|
||||||
|
logger.debug(f"Accumulated fee: {result['fee_charged']} sats for trade worth ${trade_value_usd:.2f}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
|
||||||
77
src/Users.py
77
src/Users.py
|
|
@ -60,6 +60,25 @@ class BaseUser:
|
||||||
filter_vals=('id', user_id)
|
filter_vals=('id', user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> dict | None:
|
||||||
|
"""
|
||||||
|
Retrieves user data as a dict based on the user ID.
|
||||||
|
|
||||||
|
:param user_id: The ID of the user.
|
||||||
|
:return: A dict containing the user's data, or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_df = self.data.get_rows_from_datacache(
|
||||||
|
cache_name='users', filter_vals=[('id', user_id)])
|
||||||
|
|
||||||
|
if user_df is None or user_df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert first row to dict
|
||||||
|
return user_df.iloc[0].to_dict()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _remove_user_from_memory(self, user_name: str) -> None:
|
def _remove_user_from_memory(self, user_name: str) -> None:
|
||||||
"""
|
"""
|
||||||
Private method to remove a user's data from the cache (memory).
|
Private method to remove a user's data from the cache (memory).
|
||||||
|
|
@ -202,8 +221,11 @@ class UserAccountManagement(BaseUser):
|
||||||
"""
|
"""
|
||||||
if self.validate_password(username=username, password=password):
|
if self.validate_password(username=username, password=password):
|
||||||
self.modify_user_data(username=username, field_name="status", new_data="logged_in")
|
self.modify_user_data(username=username, field_name="status", new_data="logged_in")
|
||||||
self.modify_user_data(username=username, field_name="signin_time",
|
self.modify_user_data(
|
||||||
new_data=dt.datetime.utcnow().timestamp())
|
username=username,
|
||||||
|
field_name="signin_time",
|
||||||
|
new_data=dt.datetime.now(dt.timezone.utc).timestamp()
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -216,13 +238,60 @@ class UserAccountManagement(BaseUser):
|
||||||
"""
|
"""
|
||||||
# Update the user's status and sign-in time in both cache and database
|
# Update the user's status and sign-in time in both cache and database
|
||||||
self.modify_user_data(username=username, field_name='status', new_data='logged_out')
|
self.modify_user_data(username=username, field_name='status', new_data='logged_out')
|
||||||
self.modify_user_data(username=username, field_name='signin_time', new_data=dt.datetime.utcnow().timestamp())
|
self.modify_user_data(
|
||||||
|
username=username,
|
||||||
|
field_name='signin_time',
|
||||||
|
new_data=dt.datetime.now(dt.timezone.utc).timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
# Remove the user's data from the cache
|
# Remove the user's data from the cache
|
||||||
self._remove_user_from_memory(user_name=username)
|
self._remove_user_from_memory(user_name=username)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def update_email(self, user_id: int, email: str) -> bool:
|
||||||
|
"""
|
||||||
|
Updates the user's email address.
|
||||||
|
|
||||||
|
:param user_id: The ID of the user.
|
||||||
|
:param email: The new email address.
|
||||||
|
:return: True on success, False on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
username = self.get_username(user_id)
|
||||||
|
if not username:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.modify_user_data(username=username, field_name='email', new_data=email)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_password(self, user_id: int, current_password: str, new_password: str) -> dict:
|
||||||
|
"""
|
||||||
|
Updates the user's password after validating the current password.
|
||||||
|
|
||||||
|
:param user_id: The ID of the user.
|
||||||
|
:param current_password: The current password for verification.
|
||||||
|
:param new_password: The new password to set.
|
||||||
|
:return: Dict with 'success' key and optionally 'error' key.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
username = self.get_username(user_id)
|
||||||
|
if not username:
|
||||||
|
return {'success': False, 'error': 'User not found'}
|
||||||
|
|
||||||
|
# Validate current password
|
||||||
|
if not self.validate_password(username=username, password=current_password):
|
||||||
|
return {'success': False, 'error': 'Current password is incorrect'}
|
||||||
|
|
||||||
|
# Hash and store new password
|
||||||
|
encrypted_password = self.scramble_text(new_password)
|
||||||
|
self.modify_user_data(username=username, field_name='password', new_data=encrypted_password)
|
||||||
|
return {'success': True}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
def log_out_all_users(self, enforcement: str = 'hard') -> None:
|
def log_out_all_users(self, enforcement: str = 'hard') -> None:
|
||||||
"""
|
"""
|
||||||
Logs out all users by updating their status in the database and clearing the data.
|
Logs out all users by updating their status in the database and clearing the data.
|
||||||
|
|
@ -378,7 +447,7 @@ class UserAccountManagement(BaseUser):
|
||||||
:param some_text: The text to encrypt.
|
:param some_text: The text to encrypt.
|
||||||
:return: The hashed text.
|
:return: The hashed text.
|
||||||
"""
|
"""
|
||||||
return bcrypt.hash(some_text, rounds=13)
|
return bcrypt.using(rounds=13).hash(some_text)
|
||||||
|
|
||||||
|
|
||||||
class UserExchangeManagement(UserAccountManagement):
|
class UserExchangeManagement(UserAccountManagement):
|
||||||
|
|
|
||||||
275
src/app.py
275
src/app.py
|
|
@ -20,6 +20,7 @@ from email_validator import validate_email, EmailNotValidError # noqa: E402
|
||||||
# Local application imports
|
# Local application imports
|
||||||
from BrighterTrades import BrighterTrades # noqa: E402
|
from BrighterTrades import BrighterTrades # noqa: E402
|
||||||
from utils import sanitize_for_json # noqa: E402
|
from utils import sanitize_for_json # noqa: E402
|
||||||
|
from wallet import WalletBackgroundJobs # noqa: E402
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
log_level_name = os.getenv('BRIGHTER_LOG_LEVEL', 'INFO').upper()
|
log_level_name = os.getenv('BRIGHTER_LOG_LEVEL', 'INFO').upper()
|
||||||
|
|
@ -72,6 +73,8 @@ CORS_HEADERS = 'Content-Type'
|
||||||
# Socket ID to authenticated user_id mapping
|
# Socket ID to authenticated user_id mapping
|
||||||
# This is the source of truth for WebSocket authentication - never trust client payloads
|
# This is the source of truth for WebSocket authentication - never trust client payloads
|
||||||
socket_user_mapping = {} # request.sid -> user_id
|
socket_user_mapping = {} # request.sid -> user_id
|
||||||
|
wallet_jobs = None
|
||||||
|
_wallet_jobs_started = False
|
||||||
|
|
||||||
# Set the app directly with the globals.
|
# Set the app directly with the globals.
|
||||||
app.config.from_object(__name__)
|
app.config.from_object(__name__)
|
||||||
|
|
@ -247,6 +250,41 @@ def start_strategy_loop():
|
||||||
# Start the loop when the app starts (will be called from main block)
|
# Start the loop when the app starts (will be called from main block)
|
||||||
|
|
||||||
|
|
||||||
|
def start_wallet_background_jobs():
|
||||||
|
"""
|
||||||
|
Start wallet background jobs once per process.
|
||||||
|
|
||||||
|
This supports both `python src/app.py` and WSGI imports.
|
||||||
|
Jobs are skipped in test contexts.
|
||||||
|
"""
|
||||||
|
global wallet_jobs, _wallet_jobs_started
|
||||||
|
|
||||||
|
if _wallet_jobs_started:
|
||||||
|
return
|
||||||
|
|
||||||
|
if app.config.get('TESTING') or os.getenv('PYTEST_CURRENT_TEST'):
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.getenv('BRIGHTER_DISABLE_WALLET_JOBS', '').lower() in ('1', 'true', 'yes'):
|
||||||
|
logging.info("Wallet background jobs disabled by BRIGHTER_DISABLE_WALLET_JOBS")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not getattr(brighter_trades, 'wallet_manager', None):
|
||||||
|
logging.warning("Wallet manager not initialized - background jobs disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
wallet_jobs = WalletBackgroundJobs(brighter_trades.wallet_manager, socketio)
|
||||||
|
wallet_jobs.start_all_jobs()
|
||||||
|
_wallet_jobs_started = True
|
||||||
|
logging.info("Wallet background jobs started")
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def ensure_background_jobs_started():
|
||||||
|
"""Ensure wallet background jobs are running in non-`__main__` deployments."""
|
||||||
|
start_wallet_background_jobs()
|
||||||
|
|
||||||
|
|
||||||
def _coerce_user_id(user_id):
|
def _coerce_user_id(user_id):
|
||||||
if user_id is None or user_id == '':
|
if user_id is None or user_id == '':
|
||||||
return None
|
return None
|
||||||
|
|
@ -764,6 +802,240 @@ def _validate_blockly_xml(xml_string: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Wallet API Routes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _get_current_user_id():
|
||||||
|
"""Get user_id from session. Returns None if not logged in."""
|
||||||
|
user_name = session.get('user')
|
||||||
|
if not user_name:
|
||||||
|
return None
|
||||||
|
return brighter_trades.users.get_id(user_name)
|
||||||
|
|
||||||
|
|
||||||
|
# === User Profile APIs ===
|
||||||
|
|
||||||
|
@app.route('/api/user/profile', methods=['GET'])
|
||||||
|
def get_user_profile():
|
||||||
|
"""Get current user's profile info."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
user = brighter_trades.users.get_user_by_id(user_id)
|
||||||
|
if user:
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'profile': {
|
||||||
|
'username': user.get('user_name'),
|
||||||
|
'email': user.get('email', '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return jsonify({'success': False, 'error': 'User not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/email', methods=['POST'])
|
||||||
|
def update_user_email():
|
||||||
|
"""Update current user's email address."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email', '').strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return jsonify({'success': False, 'error': 'Email is required'}), 400
|
||||||
|
|
||||||
|
# Basic email validation
|
||||||
|
if '@' not in email or '.' not in email:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid email format'}), 400
|
||||||
|
|
||||||
|
result = brighter_trades.users.update_email(user_id, email)
|
||||||
|
if result:
|
||||||
|
return jsonify({'success': True})
|
||||||
|
return jsonify({'success': False, 'error': 'Failed to update email'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/password', methods=['POST'])
|
||||||
|
def update_user_password():
|
||||||
|
"""Update current user's password."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
current_password = data.get('current_password', '')
|
||||||
|
new_password = data.get('new_password', '')
|
||||||
|
|
||||||
|
if not current_password or not new_password:
|
||||||
|
return jsonify({'success': False, 'error': 'Both current and new password are required'}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
|
||||||
|
|
||||||
|
result = brighter_trades.users.update_password(user_id, current_password, new_password)
|
||||||
|
if result.get('success'):
|
||||||
|
return jsonify({'success': True})
|
||||||
|
return jsonify({'success': False, 'error': result.get('error', 'Failed to update password')}), 400
|
||||||
|
|
||||||
|
|
||||||
|
# === Wallet APIs ===
|
||||||
|
|
||||||
|
@app.route('/api/wallet', methods=['GET'])
|
||||||
|
def get_wallet():
|
||||||
|
"""Get current user's wallet info (without private key)."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
wallet = brighter_trades.get_user_wallet(user_id)
|
||||||
|
if wallet:
|
||||||
|
return jsonify({'success': True, 'wallet': wallet})
|
||||||
|
return jsonify({'success': True, 'wallet': None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/wallet/create', methods=['POST'])
|
||||||
|
def create_wallet():
|
||||||
|
"""Create a new wallet for the current user (two-address model)."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
user_sweep_address = data.get('user_sweep_address') # Optional user-provided sweep address
|
||||||
|
|
||||||
|
result = brighter_trades.create_user_wallet(user_id, user_sweep_address=user_sweep_address)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/wallet/credits', methods=['GET'])
|
||||||
|
def get_credits():
|
||||||
|
"""Get user's spendable credits balance (from ledger)."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
balance = brighter_trades.get_credits_balance(user_id)
|
||||||
|
return jsonify({'success': True, 'balance': balance})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/wallet/withdraw', methods=['POST'])
|
||||||
|
def request_withdrawal():
|
||||||
|
"""Request BTC withdrawal (processed async)."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
amount = data.get('amount_satoshis')
|
||||||
|
destination = data.get('destination_address')
|
||||||
|
|
||||||
|
if not amount or not destination:
|
||||||
|
return jsonify({'success': False, 'error': 'Missing amount or destination'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = int(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid amount'}), 400
|
||||||
|
|
||||||
|
# SECURITY: Prevent negative withdrawal (would credit balance)
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
||||||
|
|
||||||
|
result = brighter_trades.request_withdrawal(user_id, amount, destination)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/wallet/transactions', methods=['GET'])
|
||||||
|
def get_transactions():
|
||||||
|
"""Get user's recent transaction history."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
limit = request.args.get('limit', 20, type=int)
|
||||||
|
transactions = brighter_trades.get_transaction_history(user_id, limit)
|
||||||
|
return jsonify({'success': True, 'transactions': transactions})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/wallet/keys', methods=['GET'])
|
||||||
|
def get_wallet_keys():
|
||||||
|
"""
|
||||||
|
Get fee address keys (for viewing after wallet creation).
|
||||||
|
Note: Sweep address private key is NOT stored and cannot be retrieved.
|
||||||
|
"""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
# Keep key export disabled by default; can be opt-in for controlled POC sessions.
|
||||||
|
wallet_cfg = brighter_trades.config.get_setting('wallet') or {}
|
||||||
|
if not wallet_cfg.get('allow_key_export', False):
|
||||||
|
return jsonify({'success': False, 'error': 'Key export is disabled'}), 403
|
||||||
|
|
||||||
|
keys = brighter_trades.wallet_manager.get_wallet_keys(user_id)
|
||||||
|
if keys:
|
||||||
|
return jsonify({'success': True, **keys})
|
||||||
|
return jsonify({'success': False, 'error': 'Wallet not found or keys unavailable'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/credit', methods=['POST'])
|
||||||
|
def admin_credit_user():
|
||||||
|
"""
|
||||||
|
Admin endpoint to manually credit a user's wallet.
|
||||||
|
For POC testing until deposit detection is implemented.
|
||||||
|
|
||||||
|
SECURITY: In POC mode, users can only credit themselves.
|
||||||
|
In production, add proper admin role checking.
|
||||||
|
"""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
target_user_id = data.get('user_id')
|
||||||
|
amount = data.get('amount_satoshis', 10000) # Default 10k sats
|
||||||
|
reason = data.get('reason', 'POC test credit')
|
||||||
|
|
||||||
|
# SECURITY: In POC mode, only allow crediting yourself
|
||||||
|
# Remove this restriction when proper admin auth is implemented
|
||||||
|
if target_user_id is not None:
|
||||||
|
try:
|
||||||
|
if int(target_user_id) != user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Can only credit your own account in POC mode'}), 403
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid user_id'}), 400
|
||||||
|
|
||||||
|
target_user_id = user_id # Force to self
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = int(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid amount'}), 400
|
||||||
|
|
||||||
|
# SECURITY: Prevent negative credits
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
||||||
|
|
||||||
|
# SECURITY: Limit POC credits to prevent abuse
|
||||||
|
MAX_POC_CREDIT = 100000 # 0.001 BTC max per credit
|
||||||
|
if amount > MAX_POC_CREDIT:
|
||||||
|
return jsonify({'success': False, 'error': f'POC credit limited to {MAX_POC_CREDIT} satoshis'}), 400
|
||||||
|
|
||||||
|
result = brighter_trades.wallet_manager.admin_credit(
|
||||||
|
user_id=target_user_id,
|
||||||
|
amount_satoshis=amount,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Health Check Routes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
@app.route('/health/edm', methods=['GET'])
|
@app.route('/health/edm', methods=['GET'])
|
||||||
def edm_health():
|
def edm_health():
|
||||||
"""
|
"""
|
||||||
|
|
@ -787,4 +1059,7 @@ if __name__ == '__main__':
|
||||||
start_strategy_loop()
|
start_strategy_loop()
|
||||||
logging.info("Strategy execution loop started in background")
|
logging.info("Strategy execution loop started in background")
|
||||||
|
|
||||||
|
# Start wallet background jobs (auto-sweep, deposit detection, withdrawal processing)
|
||||||
|
start_wallet_background_jobs()
|
||||||
|
|
||||||
socketio.run(app, host='127.0.0.1', port=5002, debug=False, use_reloader=False)
|
socketio.run(app, host='127.0.0.1', port=5002, debug=False, use_reloader=False)
|
||||||
|
|
|
||||||
|
|
@ -955,6 +955,16 @@ class LiveBroker(BaseBroker):
|
||||||
|
|
||||||
# Emit fill event if order was filled
|
# Emit fill event if order was filled
|
||||||
if order.status == OrderStatus.FILLED and old_status != OrderStatus.FILLED:
|
if order.status == OrderStatus.FILLED and old_status != OrderStatus.FILLED:
|
||||||
|
# Calculate profitability for sell orders (before position update)
|
||||||
|
is_profitable = False
|
||||||
|
realized_pnl = 0.0
|
||||||
|
entry_price = 0.0
|
||||||
|
if order.side == OrderSide.SELL and order.symbol in self._positions:
|
||||||
|
pos = self._positions[order.symbol]
|
||||||
|
entry_price = pos.entry_price
|
||||||
|
realized_pnl = (order.filled_price - pos.entry_price) * order.filled_qty - order.commission
|
||||||
|
is_profitable = realized_pnl > 0
|
||||||
|
|
||||||
events.append({
|
events.append({
|
||||||
'type': 'fill',
|
'type': 'fill',
|
||||||
'order_id': order_id,
|
'order_id': order_id,
|
||||||
|
|
@ -965,7 +975,10 @@ class LiveBroker(BaseBroker):
|
||||||
'filled_qty': order.filled_qty,
|
'filled_qty': order.filled_qty,
|
||||||
'price': order.filled_price,
|
'price': order.filled_price,
|
||||||
'filled_price': order.filled_price,
|
'filled_price': order.filled_price,
|
||||||
'commission': order.commission
|
'commission': order.commission,
|
||||||
|
'is_profitable': is_profitable,
|
||||||
|
'realized_pnl': realized_pnl,
|
||||||
|
'entry_price': entry_price
|
||||||
})
|
})
|
||||||
logger.info(f"Order filled: {order_id} - {order.side.value} {order.filled_qty} {order.symbol} @ {order.filled_price}")
|
logger.info(f"Order filled: {order_id} - {order.side.value} {order.filled_qty} {order.symbol} @ {order.filled_price}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,11 @@ class PaperBroker(BaseBroker):
|
||||||
order.status = OrderStatus.FILLED
|
order.status = OrderStatus.FILLED
|
||||||
order.filled_at = datetime.now(timezone.utc)
|
order.filled_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Calculate profitability for sell orders (for fee tracking)
|
||||||
|
order.is_profitable = False
|
||||||
|
order.realized_pnl = 0.0
|
||||||
|
order.entry_price = 0.0
|
||||||
|
|
||||||
# Update balances and positions
|
# Update balances and positions
|
||||||
order_value = order.size * fill_price
|
order_value = order.size * fill_price
|
||||||
|
|
||||||
|
|
@ -261,10 +266,15 @@ class PaperBroker(BaseBroker):
|
||||||
# Update position
|
# Update position
|
||||||
if order.symbol in self._positions:
|
if order.symbol in self._positions:
|
||||||
position = self._positions[order.symbol]
|
position = self._positions[order.symbol]
|
||||||
|
order.entry_price = position.entry_price # Store for fee calculation
|
||||||
realized_pnl = (fill_price - position.entry_price) * order.size - order.commission
|
realized_pnl = (fill_price - position.entry_price) * order.size - order.commission
|
||||||
position.realized_pnl += realized_pnl
|
position.realized_pnl += realized_pnl
|
||||||
position.size -= order.size
|
position.size -= order.size
|
||||||
|
|
||||||
|
# Track profitability for fee calculation
|
||||||
|
order.realized_pnl = realized_pnl
|
||||||
|
order.is_profitable = realized_pnl > 0
|
||||||
|
|
||||||
# Remove position if fully closed
|
# Remove position if fully closed
|
||||||
if position.size <= 0:
|
if position.size <= 0:
|
||||||
del self._positions[order.symbol]
|
del self._positions[order.symbol]
|
||||||
|
|
@ -405,7 +415,10 @@ class PaperBroker(BaseBroker):
|
||||||
'filled_qty': order.filled_qty,
|
'filled_qty': order.filled_qty,
|
||||||
'price': order.filled_price,
|
'price': order.filled_price,
|
||||||
'filled_price': order.filled_price,
|
'filled_price': order.filled_price,
|
||||||
'commission': order.commission
|
'commission': order.commission,
|
||||||
|
'is_profitable': getattr(order, 'is_profitable', False),
|
||||||
|
'realized_pnl': getattr(order, 'realized_pnl', 0.0),
|
||||||
|
'entry_price': getattr(order, 'entry_price', 0.0)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"PaperBroker: Limit order filled: {order.side.value} {order.size} {order.symbol} @ {fill_price:.4f}")
|
logger.info(f"PaperBroker: Limit order filled: {order.side.value} {order.size} {order.symbol} @ {fill_price:.4f}")
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,21 @@ class LiveStrategyInstance(StrategyInstance):
|
||||||
})
|
})
|
||||||
logger.info(f"Order filled: {event}")
|
logger.info(f"Order filled: {event}")
|
||||||
|
|
||||||
|
# Accumulate strategy fees for paid strategies
|
||||||
|
# Fee is charged on profitable sell orders (closing positions)
|
||||||
|
if event.get('side') == 'sell':
|
||||||
|
filled_qty = event.get('filled_qty', 0)
|
||||||
|
filled_price = event.get('filled_price', 0)
|
||||||
|
trade_value = filled_qty * filled_price
|
||||||
|
commission_rate = self.live_broker._commission
|
||||||
|
# Use actual profitability from broker (based on realized PnL)
|
||||||
|
is_profitable = event.get('is_profitable', False)
|
||||||
|
self.accumulate_trade_fee(
|
||||||
|
trade_value_usd=trade_value,
|
||||||
|
commission_rate=commission_rate,
|
||||||
|
is_profitable=is_profitable
|
||||||
|
)
|
||||||
|
|
||||||
# Update balance attributes
|
# Update balance attributes
|
||||||
self._update_balances()
|
self._update_balances()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,19 @@ class PaperStrategyInstance(StrategyInstance):
|
||||||
'filled_price': event.get('filled_price', event.get('price')),
|
'filled_price': event.get('filled_price', event.get('price')),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Accumulate strategy fees for paid strategies
|
||||||
|
# Fee is charged on profitable sells (closing a position)
|
||||||
|
if event.get('side') == 'sell':
|
||||||
|
trade_value = event.get('filled_qty', 0) * event.get('filled_price', 0)
|
||||||
|
commission_rate = self.paper_broker._commission
|
||||||
|
# Use actual profitability from broker (based on realized PnL)
|
||||||
|
is_profitable = event.get('is_profitable', False)
|
||||||
|
self.accumulate_trade_fee(
|
||||||
|
trade_value_usd=trade_value,
|
||||||
|
commission_rate=commission_rate,
|
||||||
|
is_profitable=is_profitable
|
||||||
|
)
|
||||||
|
|
||||||
# Update exec context with current data
|
# Update exec context with current data
|
||||||
self.exec_context['current_candle'] = candle_data
|
self.exec_context['current_candle'] = candle_data
|
||||||
self.exec_context['current_price'] = price
|
self.exec_context['current_price'] = price
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,695 @@
|
||||||
|
/**
|
||||||
|
* Account - Manages user wallet and credits with two-address custody model
|
||||||
|
*
|
||||||
|
* Two Address Model:
|
||||||
|
* - Fee Address: We store private key, used for strategy fees (max ~$50)
|
||||||
|
* - Sweep Address: We don't store private key, user controls, receives excess
|
||||||
|
*/
|
||||||
|
class Account {
|
||||||
|
constructor() {
|
||||||
|
this.walletInfo = null;
|
||||||
|
this.creditsBalance = 0;
|
||||||
|
this.transactions = [];
|
||||||
|
this.userProfile = null;
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the account panel
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the account settings dialog
|
||||||
|
*/
|
||||||
|
async showAccountSettings() {
|
||||||
|
// Check if user is logged in (not a guest)
|
||||||
|
const username = document.getElementById('username_display')?.textContent || '';
|
||||||
|
if (username.startsWith('guest') || !username) {
|
||||||
|
alert('Please sign in to access account settings.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dialog
|
||||||
|
document.getElementById('account_settings_form').style.display = 'block';
|
||||||
|
|
||||||
|
// Load profile and wallet data
|
||||||
|
await Promise.all([
|
||||||
|
this.loadProfile(),
|
||||||
|
this.refresh()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Default to profile tab
|
||||||
|
this.switchTab('profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the account settings dialog
|
||||||
|
*/
|
||||||
|
closeAccountSettings() {
|
||||||
|
document.getElementById('account_settings_form').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch between tabs in the account settings
|
||||||
|
*/
|
||||||
|
switchTab(tabName) {
|
||||||
|
// Update tab buttons
|
||||||
|
document.querySelectorAll('.account-tab').forEach(tab => {
|
||||||
|
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tab panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.toggle('active', panel.id === `${tabName}_tab`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user profile data
|
||||||
|
*/
|
||||||
|
async loadProfile() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/profile');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.userProfile = data.profile;
|
||||||
|
const emailEl = document.getElementById('current_email');
|
||||||
|
if (emailEl) {
|
||||||
|
emailEl.textContent = data.profile.email || 'Not set';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profile:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user email
|
||||||
|
*/
|
||||||
|
async updateEmail() {
|
||||||
|
const newEmail = document.getElementById('new_email').value.trim();
|
||||||
|
if (!newEmail) {
|
||||||
|
alert('Please enter a new email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
if (!newEmail.includes('@') || !newEmail.includes('.')) {
|
||||||
|
alert('Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: newEmail })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Email updated successfully.');
|
||||||
|
document.getElementById('current_email').textContent = newEmail;
|
||||||
|
document.getElementById('new_email').value = '';
|
||||||
|
} else {
|
||||||
|
alert('Failed to update email: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating email:', error);
|
||||||
|
alert('Failed to update email: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user password
|
||||||
|
*/
|
||||||
|
async updatePassword() {
|
||||||
|
const currentPassword = document.getElementById('current_password').value;
|
||||||
|
const newPassword = document.getElementById('new_password').value;
|
||||||
|
const confirmPassword = document.getElementById('confirm_password').value;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
alert('Please fill in all password fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('New passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
alert('Password must be at least 6 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Password updated successfully.');
|
||||||
|
document.getElementById('current_password').value = '';
|
||||||
|
document.getElementById('new_password').value = '';
|
||||||
|
document.getElementById('confirm_password').value = '';
|
||||||
|
} else {
|
||||||
|
alert('Failed to update password: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating password:', error);
|
||||||
|
alert('Failed to update password: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup checkbox event listeners for modals
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Terms checkbox enables create button
|
||||||
|
const termsCheckbox = document.getElementById('terms_checkbox');
|
||||||
|
const createBtn = document.getElementById('create_wallet_btn');
|
||||||
|
if (termsCheckbox && createBtn) {
|
||||||
|
termsCheckbox.addEventListener('change', () => {
|
||||||
|
createBtn.disabled = !termsCheckbox.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys saved checkbox enables close button
|
||||||
|
const keysSavedCheckbox = document.getElementById('keys_saved_checkbox');
|
||||||
|
const closeKeysBtn = document.getElementById('close_keys_btn');
|
||||||
|
if (keysSavedCheckbox && closeKeysBtn) {
|
||||||
|
keysSavedCheckbox.addEventListener('change', () => {
|
||||||
|
closeKeysBtn.disabled = !keysSavedCheckbox.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh wallet info, credits balance, and transactions
|
||||||
|
*/
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
// Load wallet and credits in parallel
|
||||||
|
const [walletRes, creditsRes, txRes] = await Promise.all([
|
||||||
|
fetch('/api/wallet'),
|
||||||
|
fetch('/api/wallet/credits'),
|
||||||
|
fetch('/api/wallet/transactions?limit=20')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const walletData = await walletRes.json();
|
||||||
|
const creditsData = await creditsRes.json();
|
||||||
|
const txData = await txRes.json();
|
||||||
|
|
||||||
|
if (walletData.success && walletData.wallet) {
|
||||||
|
this.walletInfo = walletData.wallet;
|
||||||
|
this.displayWallet();
|
||||||
|
} else {
|
||||||
|
this.walletInfo = null;
|
||||||
|
this.displayNoWallet();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditsData.success) {
|
||||||
|
this.creditsBalance = creditsData.balance || 0;
|
||||||
|
this.updateCreditsDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txData.success) {
|
||||||
|
this.transactions = txData.transactions || [];
|
||||||
|
this.displayTransactions();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing account:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display wallet info
|
||||||
|
*/
|
||||||
|
displayWallet() {
|
||||||
|
const noWallet = document.getElementById('no_wallet');
|
||||||
|
const walletInfo = document.getElementById('wallet_info');
|
||||||
|
|
||||||
|
if (noWallet) noWallet.style.display = 'none';
|
||||||
|
if (walletInfo) walletInfo.style.display = 'block';
|
||||||
|
|
||||||
|
const addressEl = document.getElementById('btc_address');
|
||||||
|
const sweepAddressEl = document.getElementById('sweep_address');
|
||||||
|
const networkEl = document.getElementById('wallet_network');
|
||||||
|
const warningEl = document.getElementById('wallet_disabled_warning');
|
||||||
|
|
||||||
|
if (addressEl) {
|
||||||
|
addressEl.textContent = this.walletInfo.fee_address || this.walletInfo.address;
|
||||||
|
}
|
||||||
|
if (sweepAddressEl) {
|
||||||
|
sweepAddressEl.textContent = this.walletInfo.sweep_address || 'Not configured';
|
||||||
|
}
|
||||||
|
if (networkEl) {
|
||||||
|
networkEl.textContent = this.walletInfo.network === 'testnet' ? '(Testnet)' : '(Mainnet)';
|
||||||
|
}
|
||||||
|
if (warningEl) {
|
||||||
|
warningEl.style.display = this.walletInfo.is_disabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display no wallet state
|
||||||
|
*/
|
||||||
|
displayNoWallet() {
|
||||||
|
const noWallet = document.getElementById('no_wallet');
|
||||||
|
const walletInfo = document.getElementById('wallet_info');
|
||||||
|
|
||||||
|
if (noWallet) noWallet.style.display = 'block';
|
||||||
|
if (walletInfo) walletInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update credits balance display
|
||||||
|
*/
|
||||||
|
updateCreditsDisplay() {
|
||||||
|
const balanceEl = document.getElementById('credits_balance');
|
||||||
|
if (balanceEl) {
|
||||||
|
balanceEl.textContent = this.formatSatoshis(this.creditsBalance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format satoshis as BTC with appropriate precision
|
||||||
|
*/
|
||||||
|
formatSatoshis(satoshis) {
|
||||||
|
const btc = satoshis / 100000000;
|
||||||
|
if (btc === 0) return '0';
|
||||||
|
if (btc < 0.0001) return btc.toFixed(8);
|
||||||
|
if (btc < 1) return btc.toFixed(6);
|
||||||
|
return btc.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display transaction history
|
||||||
|
*/
|
||||||
|
displayTransactions() {
|
||||||
|
const tbody = document.getElementById('transactions_body');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!this.transactions || this.transactions.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr class="no-transactions"><td colspan="4">No transactions yet</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = this.transactions.map(tx => {
|
||||||
|
const date = this.escapeHtml(new Date(tx.date).toLocaleString());
|
||||||
|
const amountClass = tx.amount >= 0 ? 'tx-positive' : 'tx-negative';
|
||||||
|
const amountStr = (tx.amount >= 0 ? '+' : '') + this.formatSatoshis(tx.amount);
|
||||||
|
const typeDisplay = this.formatTxType(tx.type);
|
||||||
|
const refFull = this.escapeHtml(tx.reference || '');
|
||||||
|
const refShort = tx.reference ? this.escapeHtml(tx.reference.substring(0, 16)) + '...' : '-';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td>${typeDisplay}</td>
|
||||||
|
<td class="${amountClass}">${amountStr}</td>
|
||||||
|
<td title="${refFull}">${refShort}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format transaction type for display
|
||||||
|
*/
|
||||||
|
formatTxType(type) {
|
||||||
|
const typeMap = {
|
||||||
|
'deposit': 'Deposit',
|
||||||
|
'withdrawal': 'Withdrawal',
|
||||||
|
'fee_paid': 'Fee Paid',
|
||||||
|
'fee_received': 'Fee Received',
|
||||||
|
'admin_credit': 'Credit',
|
||||||
|
'withdrawal_reversal': 'Reversal',
|
||||||
|
'auto_sweep': 'Auto-Sweep'
|
||||||
|
};
|
||||||
|
return typeMap[type] || this.escapeHtml(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS attacks
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show wallet setup dialog with terms
|
||||||
|
*/
|
||||||
|
showSetupDialog() {
|
||||||
|
// Reset checkbox
|
||||||
|
const termsCheckbox = document.getElementById('terms_checkbox');
|
||||||
|
const createBtn = document.getElementById('create_wallet_btn');
|
||||||
|
if (termsCheckbox) termsCheckbox.checked = false;
|
||||||
|
if (createBtn) createBtn.disabled = true;
|
||||||
|
|
||||||
|
// Reset sweep option to "generate"
|
||||||
|
const generateRadio = document.querySelector('input[name="sweep_option"][value="generate"]');
|
||||||
|
if (generateRadio) generateRadio.checked = true;
|
||||||
|
this.toggleSweepOption();
|
||||||
|
|
||||||
|
// Clear any previous user input
|
||||||
|
const userAddressInput = document.getElementById('user_sweep_address');
|
||||||
|
if (userAddressInput) userAddressInput.value = '';
|
||||||
|
|
||||||
|
document.getElementById('wallet_setup_modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle sweep address input visibility based on radio selection
|
||||||
|
*/
|
||||||
|
toggleSweepOption() {
|
||||||
|
const ownRadio = document.querySelector('input[name="sweep_option"][value="own"]');
|
||||||
|
const ownAddressSection = document.getElementById('own_address_input');
|
||||||
|
|
||||||
|
if (ownRadio && ownAddressSection) {
|
||||||
|
ownAddressSection.style.display = ownRadio.checked ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close wallet setup dialog
|
||||||
|
*/
|
||||||
|
closeSetupDialog() {
|
||||||
|
document.getElementById('wallet_setup_modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet (two-address model)
|
||||||
|
*/
|
||||||
|
async createWallet() {
|
||||||
|
const termsCheckbox = document.getElementById('terms_checkbox');
|
||||||
|
if (!termsCheckbox || !termsCheckbox.checked) {
|
||||||
|
alert('Please agree to the terms before creating a wallet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is providing their own sweep address
|
||||||
|
const ownRadio = document.querySelector('input[name="sweep_option"][value="own"]');
|
||||||
|
const userSweepAddress = document.getElementById('user_sweep_address');
|
||||||
|
let sweepAddress = null;
|
||||||
|
|
||||||
|
if (ownRadio && ownRadio.checked) {
|
||||||
|
sweepAddress = userSweepAddress ? userSweepAddress.value.trim() : '';
|
||||||
|
if (!sweepAddress) {
|
||||||
|
alert('Please enter your Bitcoin address for the sweep address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('create_wallet_btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Generating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {};
|
||||||
|
if (sweepAddress) {
|
||||||
|
requestBody.user_sweep_address = sweepAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/wallet/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Close setup dialog
|
||||||
|
this.closeSetupDialog();
|
||||||
|
|
||||||
|
// Show keys modal - requires acknowledgment before closing
|
||||||
|
document.getElementById('modal_network').textContent = data.network;
|
||||||
|
document.getElementById('modal_fee_address').textContent = data.fee_address;
|
||||||
|
document.getElementById('modal_fee_private_key').textContent = data.fee_private_key;
|
||||||
|
document.getElementById('modal_sweep_address').textContent = data.sweep_address;
|
||||||
|
|
||||||
|
// Handle sweep private key - only shown if we generated it
|
||||||
|
const sweepPrivateKeyEl = document.getElementById('modal_sweep_private_key');
|
||||||
|
const sweepPrivateKeyRow = sweepPrivateKeyEl ? sweepPrivateKeyEl.closest('.key-row') : null;
|
||||||
|
|
||||||
|
if (data.sweep_private_key) {
|
||||||
|
// We generated the sweep address - show private key
|
||||||
|
sweepPrivateKeyEl.textContent = data.sweep_private_key;
|
||||||
|
if (sweepPrivateKeyRow) sweepPrivateKeyRow.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
// User provided their own address - no private key to show
|
||||||
|
if (sweepPrivateKeyRow) sweepPrivateKeyRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the sweep section header based on whether we generated it
|
||||||
|
const sweepSection = document.querySelector('.key-section.sweep-section h5');
|
||||||
|
if (sweepSection) {
|
||||||
|
if (data.sweep_private_key) {
|
||||||
|
sweepSection.textContent = 'Sweep Address (You Control - SAVE THIS KEY!)';
|
||||||
|
} else {
|
||||||
|
sweepSection.textContent = 'Sweep Address (Your Provided Address)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset checkbox and button
|
||||||
|
const keysSavedCheckbox = document.getElementById('keys_saved_checkbox');
|
||||||
|
const closeKeysBtn = document.getElementById('close_keys_btn');
|
||||||
|
if (keysSavedCheckbox) keysSavedCheckbox.checked = false;
|
||||||
|
if (closeKeysBtn) closeKeysBtn.disabled = true;
|
||||||
|
|
||||||
|
document.getElementById('wallet_keys_modal').style.display = 'block';
|
||||||
|
|
||||||
|
// Update wallet info
|
||||||
|
this.walletInfo = {
|
||||||
|
fee_address: data.fee_address,
|
||||||
|
sweep_address: data.sweep_address,
|
||||||
|
network: data.network,
|
||||||
|
is_disabled: false
|
||||||
|
};
|
||||||
|
this.creditsBalance = 0;
|
||||||
|
this.displayWallet();
|
||||||
|
this.updateCreditsDisplay();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create wallet: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating wallet:', error);
|
||||||
|
alert('Failed to create wallet: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Create Wallet';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the keys modal (only if acknowledged)
|
||||||
|
*/
|
||||||
|
closeKeysModal() {
|
||||||
|
const keysSavedCheckbox = document.getElementById('keys_saved_checkbox');
|
||||||
|
if (!keysSavedCheckbox || !keysSavedCheckbox.checked) {
|
||||||
|
alert('Please confirm that you have saved your keys before closing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('wallet_keys_modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show view keys dialog (for fee address only - sweep key not stored)
|
||||||
|
*/
|
||||||
|
async showViewKeysDialog() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wallet/keys');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('view_network').textContent = data.network;
|
||||||
|
document.getElementById('view_fee_address').textContent = data.fee_address;
|
||||||
|
document.getElementById('view_fee_private_key').textContent = data.fee_private_key;
|
||||||
|
document.getElementById('view_keys_modal').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert('Failed to retrieve keys: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving keys:', error);
|
||||||
|
alert('Failed to retrieve keys: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close view keys dialog
|
||||||
|
*/
|
||||||
|
closeViewKeysDialog() {
|
||||||
|
document.getElementById('view_keys_modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy wallet address to clipboard
|
||||||
|
*/
|
||||||
|
copyAddress(type) {
|
||||||
|
if (!this.walletInfo) return;
|
||||||
|
|
||||||
|
const address = type === 'sweep'
|
||||||
|
? (this.walletInfo.sweep_address || '')
|
||||||
|
: (this.walletInfo.fee_address || this.walletInfo.address);
|
||||||
|
|
||||||
|
if (!address) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(address).then(() => {
|
||||||
|
// Brief visual feedback would be nice here
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a key value to clipboard
|
||||||
|
*/
|
||||||
|
copyKey(elementId) {
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(el.textContent).then(() => {
|
||||||
|
// Could add visual feedback
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show withdrawal dialog
|
||||||
|
*/
|
||||||
|
showWithdrawDialog() {
|
||||||
|
document.getElementById('withdraw_amount').value = '';
|
||||||
|
document.getElementById('withdraw_address').value = '';
|
||||||
|
document.getElementById('withdraw_modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close withdrawal dialog
|
||||||
|
*/
|
||||||
|
closeWithdrawDialog() {
|
||||||
|
document.getElementById('withdraw_modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit withdrawal request
|
||||||
|
*/
|
||||||
|
async submitWithdrawal() {
|
||||||
|
const amountStr = document.getElementById('withdraw_amount').value.trim();
|
||||||
|
const address = document.getElementById('withdraw_address').value.trim();
|
||||||
|
|
||||||
|
if (!amountStr || !address) {
|
||||||
|
alert('Please enter amount and destination address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btcAmount = parseFloat(amountStr);
|
||||||
|
if (isNaN(btcAmount) || btcAmount <= 0) {
|
||||||
|
alert('Invalid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const satoshis = Math.floor(btcAmount * 100000000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wallet/withdraw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount_satoshis: satoshis,
|
||||||
|
destination_address: address
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Withdrawal request submitted. It will be processed shortly.');
|
||||||
|
this.closeWithdrawDialog();
|
||||||
|
this.refresh();
|
||||||
|
} else {
|
||||||
|
alert('Withdrawal failed: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error requesting withdrawal:', error);
|
||||||
|
alert('Withdrawal failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show test credit dialog
|
||||||
|
*/
|
||||||
|
showCreditDialog() {
|
||||||
|
document.getElementById('credit_amount').value = '10000';
|
||||||
|
document.getElementById('credit_modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close test credit dialog
|
||||||
|
*/
|
||||||
|
closeCreditDialog() {
|
||||||
|
document.getElementById('credit_modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit test credit
|
||||||
|
*/
|
||||||
|
async submitCredit() {
|
||||||
|
const amountStr = document.getElementById('credit_amount').value.trim();
|
||||||
|
const amount = parseInt(amountStr);
|
||||||
|
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
alert('Invalid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/credit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount_satoshis: amount,
|
||||||
|
reason: 'POC test credit from UI'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(`Added ${amount} satoshis to your credits`);
|
||||||
|
this.closeCreditDialog();
|
||||||
|
this.refresh();
|
||||||
|
} else {
|
||||||
|
alert('Credit failed: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding credit:', error);
|
||||||
|
alert('Credit failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in UI namespace
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.Account = Account;
|
||||||
|
}
|
||||||
|
|
@ -1681,6 +1681,12 @@ class Strategies {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate fee is 1-100 if public
|
||||||
|
if (strategyData.public === 1 && (strategyData.fee < 1 || strategyData.fee > 100)) {
|
||||||
|
alert("Fee must be between 1 and 100 (percentage of exchange commission).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!strategyData.name) {
|
if (!strategyData.name) {
|
||||||
alert("Please provide a name for the strategy.");
|
alert("Please provide a name for the strategy.");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,7 @@ height: 500px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active, .collapsible:hover {
|
.collapsible.active, .collapsible:hover {
|
||||||
background-color: #0A07DF;
|
background-color: #0A07DF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class User_Interface {
|
||||||
this.signals = new Signals(this);
|
this.signals = new Signals(this);
|
||||||
this.backtesting = new Backtesting(this);
|
this.backtesting = new Backtesting(this);
|
||||||
this.statistics = new Statistics(this.data.comms);
|
this.statistics = new Statistics(this.data.comms);
|
||||||
|
this.account = new Account();
|
||||||
|
|
||||||
// Register a callback function for when indicator updates are received from the data object
|
// Register a callback function for when indicator updates are received from the data object
|
||||||
this.data.registerCallback_i_updates(this.indicators.update);
|
this.data.registerCallback_i_updates(this.indicators.update);
|
||||||
|
|
@ -35,6 +36,14 @@ class User_Interface {
|
||||||
this.initializeResizablePopup("new_trade_form", null, "trade_draggable_header", "resize-trade");
|
this.initializeResizablePopup("new_trade_form", null, "trade_draggable_header", "resize-trade");
|
||||||
this.initializeResizablePopup("ai_strategy_form", null, "ai_strategy_header", "resize-ai-strategy");
|
this.initializeResizablePopup("ai_strategy_form", null, "ai_strategy_header", "resize-ai-strategy");
|
||||||
|
|
||||||
|
// Account settings popups
|
||||||
|
this.initializeResizablePopup("account_settings_form", null, "account_settings_header", "resize-account");
|
||||||
|
this.initializeResizablePopup("wallet_setup_modal", null, "wallet_setup_header", "resize-wallet-setup");
|
||||||
|
this.initializeResizablePopup("wallet_keys_modal", null, "wallet_keys_header", "resize-wallet-keys");
|
||||||
|
this.initializeResizablePopup("view_keys_modal", null, "view_keys_header", "resize-view-keys");
|
||||||
|
this.initializeResizablePopup("withdraw_modal", null, "withdraw_header", "resize-withdraw");
|
||||||
|
this.initializeResizablePopup("credit_modal", null, "credit_header", "resize-credit");
|
||||||
|
|
||||||
// Initialize Backtesting's DOM elements
|
// Initialize Backtesting's DOM elements
|
||||||
this.backtesting.initialize();
|
this.backtesting.initialize();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -69,6 +78,7 @@ class User_Interface {
|
||||||
this.exchanges.initialize();
|
this.exchanges.initialize();
|
||||||
this.strats.initialize('strats_display', 'new_strat_form', this.data);
|
this.strats.initialize('strats_display', 'new_strat_form', this.data);
|
||||||
this.backtesting.fetchSavedTests();
|
this.backtesting.fetchSavedTests();
|
||||||
|
this.account.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,520 @@
|
||||||
|
<!-- Account Settings Popup -->
|
||||||
|
<div class="form-popup" id="account_settings_form" style="display: none; overflow: hidden; position: absolute; width: 500px; height: 550px; border-radius: 10px;">
|
||||||
|
|
||||||
|
<!-- Draggable Header Section -->
|
||||||
|
<div id="account_settings_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Account Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="account-tabs">
|
||||||
|
<button class="account-tab active" data-tab="profile" onclick="UI.account.switchTab('profile')">Profile</button>
|
||||||
|
<button class="account-tab" data-tab="wallet" onclick="UI.account.switchTab('wallet')">Wallet</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="form-container" style="padding: 15px; overflow-y: auto; height: calc(100% - 100px);">
|
||||||
|
|
||||||
|
<!-- Profile Tab -->
|
||||||
|
<div id="profile_tab" class="tab-panel active">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>Email</h4>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Current Email:</label>
|
||||||
|
<span id="current_email">Not set</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input type="email" id="new_email" placeholder="Enter new email">
|
||||||
|
<button class="btn" onclick="UI.account.updateEmail()">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>Password</h4>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input type="password" id="current_password" placeholder="Current password">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input type="password" id="new_password" placeholder="New password">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input type="password" id="confirm_password" placeholder="Confirm new password">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<button class="btn" onclick="UI.account.updatePassword()" style="width: 200px;">Change Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallet Tab -->
|
||||||
|
<div id="wallet_tab" class="tab-panel">
|
||||||
|
<div id="wallet_section">
|
||||||
|
<div id="no_wallet" style="display: none;">
|
||||||
|
<p style="font-size: 13px; margin-bottom: 5px;">You haven't created a wallet yet.</p>
|
||||||
|
<p style="font-size: 11px; color: #888; margin: 8px 0;">
|
||||||
|
Generate a wallet to deposit BTC and pay for premium strategies.
|
||||||
|
Two addresses will be created:
|
||||||
|
</p>
|
||||||
|
<ul style="font-size: 11px; color: #666; margin: 5px 0 10px 20px; padding: 0;">
|
||||||
|
<li><strong>Fee Address</strong> - For strategy fees (we manage, limited to ~$50)</li>
|
||||||
|
<li><strong>Sweep Address</strong> - Your personal address (only you control)</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn" onclick="UI.account.showSetupDialog()">Setup Wallet</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wallet_info" style="display: none;">
|
||||||
|
<!-- Fee Address -->
|
||||||
|
<div class="wallet-row">
|
||||||
|
<label>Fee Address <span id="wallet_network" style="font-size: 10px; color: #888;"></span>:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<code id="btc_address" style="word-break: break-all; font-size: 11px; background: #f8f9fa; padding: 5px 8px; border-radius: 3px; flex: 1;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyAddress('fee')" style="padding: 3px 10px; font-size: 10px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 10px; color: #888; margin: 3px 0 0 0;">Deposit here for strategy fees. Max ~$50. Excess auto-sweeps.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sweep Address -->
|
||||||
|
<div class="wallet-row">
|
||||||
|
<label>Sweep Address (Your Control):</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<code id="sweep_address" style="word-break: break-all; font-size: 11px; background: #f8f9fa; padding: 5px 8px; border-radius: 3px; flex: 1;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyAddress('sweep')" style="padding: 3px 10px; font-size: 10px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 10px; color: #888; margin: 3px 0 0 0;">Excess funds auto-sent here. Only you have the private key.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wallet-row">
|
||||||
|
<label>Spendable Credits:</label>
|
||||||
|
<span id="credits_balance" style="font-weight: bold; font-size: 14px;">0</span>
|
||||||
|
<span style="font-size: 12px; color: #666;">BTC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wallet_disabled_warning" style="display: none; background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 8px; border-radius: 4px; font-size: 11px; margin: 10px 0;">
|
||||||
|
Wallet disabled - balance over $50 cap. Funds will be auto-swept to your sweep address.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 10px 0;">
|
||||||
|
<button class="btn" onclick="UI.account.showWithdrawDialog()">Withdraw</button>
|
||||||
|
<button class="btn" onclick="UI.account.showViewKeysDialog()">View Keys</button>
|
||||||
|
<button class="btn" onclick="UI.account.showCreditDialog()">Test Credit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 11px; color: #888; margin: 8px 0;">
|
||||||
|
<strong>Reminder:</strong> Keep only funds you intend to use. Excess over ~$50 is auto-swept to your sweep address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction History -->
|
||||||
|
<div class="settings-section" style="margin-top: 15px;">
|
||||||
|
<h4>Recent Activity</h4>
|
||||||
|
<div id="transactions_container" style="max-height: 150px; overflow-y: auto;">
|
||||||
|
<table id="transactions_table" style="width: 100%; border-collapse: collapse; font-size: 11px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f8f9fa;">
|
||||||
|
<th style="text-align: left; padding: 5px; border-bottom: 1px solid #ddd;">Date</th>
|
||||||
|
<th style="text-align: left; padding: 5px; border-bottom: 1px solid #ddd;">Type</th>
|
||||||
|
<th style="text-align: left; padding: 5px; border-bottom: 1px solid #ddd;">Amount</th>
|
||||||
|
<th style="text-align: left; padding: 5px; border-bottom: 1px solid #ddd;">Reference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="transactions_body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center; color: #888; font-style: italic; padding: 10px;">No transactions yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="close-btn" onclick="UI.account.closeAccountSettings()">X</div>
|
||||||
|
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="resize-account" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallet Setup Modal with Terms -->
|
||||||
|
<div class="form-popup" id="wallet_setup_modal" style="display: none; overflow: hidden; position: absolute; width: 550px; height: 600px; border-radius: 10px;">
|
||||||
|
<div id="wallet_setup_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Setup Bitcoin Wallet</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container" style="padding: 15px; overflow-y: auto; height: calc(100% - 60px);">
|
||||||
|
<div class="terms-section">
|
||||||
|
<h5 style="margin-top: 0; font-size: 13px;">Terms of Service - Wallet Custody</h5>
|
||||||
|
<div style="background: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; padding: 15px; max-height: 200px; overflow-y: auto; font-size: 12px; margin-bottom: 15px;">
|
||||||
|
<p style="margin: 10px 0 5px 0;"><strong>Two-Address System:</strong></p>
|
||||||
|
<ul style="margin: 5px 0 10px 20px; padding: 0;">
|
||||||
|
<li style="margin-bottom: 5px;"><strong>Fee Address:</strong> We generate and store the private key for this address to manage strategy fee transactions. You will receive a copy of the private key for your records.</li>
|
||||||
|
<li style="margin-bottom: 5px;"><strong>Sweep Address:</strong> Your personal wallet where excess funds are sent. You can provide your own address (e.g., from PayPal, Coinbase, hardware wallet) or we can generate one for you.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin: 10px 0 5px 0;"><strong>Balance Cap & Auto-Sweep:</strong></p>
|
||||||
|
<ul style="margin: 5px 0 10px 20px; padding: 0;">
|
||||||
|
<li style="margin-bottom: 5px;">The Fee Address has a ~$50 USD balance cap to limit liability.</li>
|
||||||
|
<li style="margin-bottom: 5px;">Any amount exceeding this cap will be automatically transferred to your Sweep Address.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin: 10px 0 5px 0;"><strong>Your Responsibilities:</strong></p>
|
||||||
|
<ul style="margin: 5px 0 10px 20px; padding: 0;">
|
||||||
|
<li style="margin-bottom: 5px;">You are the sole custodian of both Bitcoin addresses.</li>
|
||||||
|
<li style="margin-bottom: 5px;">You can deposit and withdraw from the Fee Address at any time.</li>
|
||||||
|
<li style="margin-bottom: 5px;">Only keep funds you intend to use for strategy fees in the Fee Address.</li>
|
||||||
|
<li style="margin-bottom: 5px;"><strong>Save your private keys securely</strong> - we cannot recover keys we don't store.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin: 10px 0 5px 0;"><strong>Disclaimers:</strong></p>
|
||||||
|
<ul style="margin: 5px 0 10px 20px; padding: 0;">
|
||||||
|
<li style="margin-bottom: 5px;">We are not responsible for blockchain transaction failures, delays, or fees.</li>
|
||||||
|
<li style="margin-bottom: 5px;">While we strive to keep our systems secure, we are not responsible for losses due to security breaches. Do not store excess funds.</li>
|
||||||
|
<li style="margin-bottom: 5px;">You grant us permission to withdraw strategy fees from the Fee Address and transfer excess funds to your Sweep Address.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sweep Address Option -->
|
||||||
|
<div style="margin: 15px 0; padding: 15px; background: #f8f9fa; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Sweep Address</h5>
|
||||||
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">Where should excess funds (over ~$50) be sent?</p>
|
||||||
|
|
||||||
|
<div style="margin: 10px 0;">
|
||||||
|
<label style="display: block; padding: 10px; margin: 5px 0; background: white; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; height: 64px;">
|
||||||
|
<input type="radio" name="sweep_option" value="generate" checked onchange="UI.account.toggleSweepOption()" style="margin-right: 8px;">
|
||||||
|
<span style="font-size: 12px;">Generate a new address for me</span>
|
||||||
|
<span style="display: block; margin-left: 22px; font-size: 11px; color: #666;">(We'll create a new address and show you the private key)</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: block; padding: 10px; margin: 5px 0; background: white; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; height: 64px;">
|
||||||
|
<input type="radio" name="sweep_option" value="own" onchange="UI.account.toggleSweepOption()" style="margin-right: 8px;">
|
||||||
|
<span style="font-size: 12px;">Use my own address</span>
|
||||||
|
<span style="display: block; margin-left: 22px; font-size: 11px; color: #666;">(Enter an address from PayPal, Coinbase, hardware wallet, etc.)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="own_address_input" style="display: none; margin-top: 10px; padding: 10px; background: #fff8e1; border: 1px solid #ffc107; border-radius: 4px;">
|
||||||
|
<label style="display: block; font-size: 12px; margin-bottom: 5px;">Your Bitcoin Address:</label>
|
||||||
|
<input type="text" id="user_sweep_address" placeholder="Enter your Bitcoin address (tb1..., m..., n..., etc.)" style="width: 100%; padding: 8px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||||
|
<p style="font-size: 10px; color: #666; margin: 5px 0 0 0;">Make sure this is a valid testnet address that you control.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 10px; margin: 15px 0; font-size: 12px;">
|
||||||
|
<input type="checkbox" id="terms_checkbox" style="margin-top: 3px;">
|
||||||
|
<label for="terms_checkbox" style="cursor: pointer;">I have read, understand, and agree to these terms. I understand that I am responsible for my wallet keys.</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<button class="btn cancel" onclick="UI.account.closeSetupDialog()">Cancel</button>
|
||||||
|
<button class="btn" id="create_wallet_btn" onclick="UI.account.createWallet()" disabled>Create Wallet</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="close-btn" onclick="UI.account.closeSetupDialog()">X</div>
|
||||||
|
<div id="resize-wallet-setup" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keys Display Modal (shown after creation, requires acknowledgment) -->
|
||||||
|
<div class="form-popup" id="wallet_keys_modal" style="display: none; overflow: hidden; position: absolute; width: 550px; height: 500px; border-radius: 10px;">
|
||||||
|
<div id="wallet_keys_header" style="cursor: move; padding: 10px; background-color: #c00; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Your Wallet Keys - SAVE THESE NOW</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container" style="padding: 15px; overflow-y: auto; height: calc(100% - 60px);">
|
||||||
|
<p style="color: #c00; font-size: 12px; margin-bottom: 15px;">
|
||||||
|
<strong>CRITICAL:</strong> Save these keys securely before closing this dialog.
|
||||||
|
The Sweep Address private key will <strong>NOT</strong> be stored and <strong>CANNOT</strong> be recovered.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin: 15px 0;">
|
||||||
|
<div style="background: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 15px;">
|
||||||
|
<h5 style="margin: 0 0 10px 0; font-size: 12px;">Fee Address (We Manage)</h5>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Network:</label>
|
||||||
|
<code id="modal_network"></code>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Address:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="modal_fee_address" style="flex: 1; word-break: break-all; font-size: 11px; background: white; padding: 6px 8px; border-radius: 3px; border: 1px solid #ddd;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('modal_fee_address')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Private Key (WIF):</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="modal_fee_private_key" style="flex: 1; word-break: break-all; font-size: 11px; background: #ffe0e0; padding: 6px 8px; border-radius: 3px; border: 1px solid #f5c6cb;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('modal_fee_private_key')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sweep-section" style="background: #fff8e1; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin-bottom: 15px;">
|
||||||
|
<h5 style="margin: 0 0 10px 0; font-size: 12px;">Sweep Address (You Control - SAVE THIS KEY!)</h5>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Address:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="modal_sweep_address" style="flex: 1; word-break: break-all; font-size: 11px; background: white; padding: 6px 8px; border-radius: 3px; border: 1px solid #ddd;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('modal_sweep_address')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Private Key (WIF) - <span style="color: #c00; font-weight: bold;">SAVE THIS - NOT STORED!</span>:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="modal_sweep_private_key" style="flex: 1; word-break: break-all; font-size: 11px; background: #ffcccc; padding: 6px 8px; border-radius: 3px; border: 1px solid #ff6666;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('modal_sweep_private_key')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 10px; margin: 15px 0; font-size: 12px;">
|
||||||
|
<input type="checkbox" id="keys_saved_checkbox" style="margin-top: 3px;">
|
||||||
|
<label for="keys_saved_checkbox" style="cursor: pointer;">I have securely saved both private keys. I understand the Sweep Address key cannot be recovered.</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<button class="btn" id="close_keys_btn" onclick="UI.account.closeKeysModal()" disabled>
|
||||||
|
I've Saved My Keys - Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resize-wallet-keys" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Keys Modal (for viewing fee address keys later) -->
|
||||||
|
<div class="form-popup" id="view_keys_modal" style="display: none; overflow: hidden; position: absolute; width: 450px; height: 320px; border-radius: 10px;">
|
||||||
|
<div id="view_keys_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Fee Address Keys</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container" style="padding: 15px; overflow-y: auto; height: calc(100% - 60px);">
|
||||||
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">These are the keys for your Fee Address (managed wallet).</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Network:</label>
|
||||||
|
<code id="view_network"></code>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Address:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="view_fee_address" style="flex: 1; word-break: break-all; font-size: 11px; background: #f8f9fa; padding: 6px 8px; border-radius: 3px; border: 1px solid #ddd;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('view_fee_address')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label style="display: block; font-size: 11px; color: #666; margin-bottom: 3px;">Private Key (WIF):</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<code id="view_fee_private_key" style="flex: 1; word-break: break-all; font-size: 11px; background: #ffe0e0; padding: 6px 8px; border-radius: 3px; border: 1px solid #f5c6cb;"></code>
|
||||||
|
<button class="btn" onclick="UI.account.copyKey('view_fee_private_key')" style="padding: 2px 8px; font-size: 9px;">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #c00; font-size: 12px; margin-top: 15px;">
|
||||||
|
Note: Sweep Address private key is not stored. If you lost it, those funds are unrecoverable.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<button class="btn" onclick="UI.account.closeViewKeysDialog()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="close-btn" onclick="UI.account.closeViewKeysDialog()">X</div>
|
||||||
|
<div id="resize-view-keys" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Withdraw Dialog -->
|
||||||
|
<div class="form-popup" id="withdraw_modal" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 250px; border-radius: 10px;">
|
||||||
|
<div id="withdraw_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Withdraw BTC</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container" style="padding: 15px;">
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<label style="display: block; font-size: 12px; margin-bottom: 4px;">Amount (BTC):</label>
|
||||||
|
<input type="text" id="withdraw_amount" placeholder="0.0001" style="width: 100%; padding: 8px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<label style="display: block; font-size: 12px; margin-bottom: 4px;">Destination Address:</label>
|
||||||
|
<input type="text" id="withdraw_address" placeholder="tb1... or m/n..." style="width: 100%; padding: 8px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<button class="btn cancel" onclick="UI.account.closeWithdrawDialog()">Cancel</button>
|
||||||
|
<button class="btn" onclick="UI.account.submitWithdrawal()">Withdraw</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="close-btn" onclick="UI.account.closeWithdrawDialog()">X</div>
|
||||||
|
<div id="resize-withdraw" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Credit Dialog -->
|
||||||
|
<div class="form-popup" id="credit_modal" style="display: none; overflow: hidden; position: absolute; width: 350px; height: 220px; border-radius: 10px;">
|
||||||
|
<div id="credit_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px;">Add Test Credit</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container" style="padding: 15px;">
|
||||||
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">This is for POC testing. Credits will be added instantly.</p>
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<label style="display: block; font-size: 12px; margin-bottom: 4px;">Amount (satoshis):</label>
|
||||||
|
<input type="number" id="credit_amount" placeholder="10000" value="10000" style="width: 100%; padding: 8px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<button class="btn cancel" onclick="UI.account.closeCreditDialog()">Cancel</button>
|
||||||
|
<button class="btn" onclick="UI.account.submitCredit()">Add Credit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="close-btn" onclick="UI.account.closeCreditDialog()">X</div>
|
||||||
|
<div id="resize-credit" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Username link style - styled as a clickable badge/pill */
|
||||||
|
.username-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #3E3AF2 0%, #6366f1 100%);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(62, 58, 242, 0.3);
|
||||||
|
margin-right: 10px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-link:hover {
|
||||||
|
background: linear-gradient(135deg, #5754f7 0%, #818cf8 100%);
|
||||||
|
box-shadow: 0 4px 8px rgba(62, 58, 242, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-link:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px rgba(62, 58, 242, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a user icon before the username */
|
||||||
|
.username-link::before {
|
||||||
|
content: "\1F464"; /* Unicode user silhouette */
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account tabs */
|
||||||
|
.account-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab.active {
|
||||||
|
color: #3E3AF2;
|
||||||
|
border-bottom-color: #3E3AF2;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab panels */
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings sections */
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row input:focus {
|
||||||
|
border-color: #3E3AF2;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wallet row */
|
||||||
|
.wallet-row {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transaction table colors */
|
||||||
|
.tx-positive {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-negative {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handle (if not defined elsewhere) */
|
||||||
|
.resize-handle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #000;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
<script src="{{ url_for('static', filename='trade.js') }}"></script>
|
<script src="{{ url_for('static', filename='trade.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='backtesting.js') }}"></script>
|
<script src="{{ url_for('static', filename='backtesting.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='Statistics.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='Statistics.js') }}?v=1"></script>
|
||||||
|
<script src="{{ url_for('static', filename='Account.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='general.js') }}"></script>
|
<script src="{{ url_for('static', filename='general.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
{% include "trade_details_popup.html" %}
|
{% include "trade_details_popup.html" %}
|
||||||
{% include "indicator_popup.html" %}
|
{% include "indicator_popup.html" %}
|
||||||
{% include "exchange_config_popup.html" %}
|
{% include "exchange_config_popup.html" %}
|
||||||
|
{% include "account_settings_dialog.html" %}
|
||||||
<!-- Container for the whole user app -->
|
<!-- Container for the whole user app -->
|
||||||
<div id="master_panel" class="master_panel"
|
<div id="master_panel" class="master_panel"
|
||||||
style="width 1550px; height 800px;display: grid;
|
style="width 1550px; height 800px;display: grid;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div id="user_login">
|
<div id="user_login">
|
||||||
<span>{{user_name}}</span>
|
<span id="username_display" class="username-link" onclick="UI.account.showAccountSettings()">{{user_name}}</span>
|
||||||
<button class="btn" type="button" id="login_button" name="login" onclick="UI.users.toggleLogin()">
|
<button class="btn" type="button" id="login_button" name="login" onclick="UI.users.toggleLogin()">
|
||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fee input field -->
|
<!-- Fee input field -->
|
||||||
<div style="grid-column: 1;">
|
<div style="grid-column: 1; position: relative;">
|
||||||
<label for="fee_box" style="display: inline-block; width: 20%;">Fee(%):</label>
|
<label for="fee_box" style="display: inline-block; width: 20%;">Fee:</label>
|
||||||
<input class="ietextbox" type="number" id="fee_box" name="fee_box" placeholder="0.00" step="0.01" min="0" style="width: 65px; display: inline-block;" disabled>
|
<input class="ietextbox" type="number" id="fee_box" name="fee_box" placeholder="10" step="1" min="1" max="100" value="10" style="width: 50px; display: inline-block;" disabled>
|
||||||
|
<span style="display: inline-block; margin-left: 2px;">%</span>
|
||||||
|
<span class="fee-info-icon" title="Fee is a percentage of the exchange's trading commission.
|
||||||
|
Example: If the exchange charges 0.1% commission on a $1000 trade ($1),
|
||||||
|
and you set fee to 50%, you earn $0.50 per profitable trade.
|
||||||
|
Set 1-100%. Only charged on profitable trades.">ⓘ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Trading Source Section -->
|
<!-- Default Trading Source Section -->
|
||||||
|
|
@ -154,6 +159,31 @@
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fee info icon tooltip */
|
||||||
|
.fee-info-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #3E3AF2;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
cursor: help;
|
||||||
|
margin-left: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-info-icon:hover {
|
||||||
|
background: #0A07DF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native tooltip styling via title attribute - multiline support */
|
||||||
|
.fee-info-icon[title] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Bitcoin wallet and credits ledger module for strategy fees."""
|
||||||
|
|
||||||
|
from .wallet_manager import WalletManager
|
||||||
|
from .bitcoin_service import BitcoinService
|
||||||
|
from .encryption import KeyEncryption
|
||||||
|
from .background_jobs import WalletBackgroundJobs
|
||||||
|
|
||||||
|
__all__ = ['WalletManager', 'BitcoinService', 'KeyEncryption', 'WalletBackgroundJobs']
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""
|
||||||
|
Background jobs for wallet operations.
|
||||||
|
|
||||||
|
- Auto-sweep: Transfer excess funds (over $50 cap) to user's sweep address
|
||||||
|
- Deposit detection: Monitor for incoming deposits and credit user ledgers
|
||||||
|
- Withdrawal processing: Process pending withdrawal requests
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .wallet_manager import WalletManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Job intervals in seconds
|
||||||
|
AUTO_SWEEP_INTERVAL = 300 # 5 minutes
|
||||||
|
DEPOSIT_CHECK_INTERVAL = 60 # 1 minute
|
||||||
|
WITHDRAWAL_PROCESS_INTERVAL = 30 # 30 seconds
|
||||||
|
|
||||||
|
|
||||||
|
class WalletBackgroundJobs:
|
||||||
|
"""
|
||||||
|
Manages background jobs for wallet operations.
|
||||||
|
Uses eventlet for async execution compatible with Flask-SocketIO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wallet_manager: 'WalletManager', socketio=None):
|
||||||
|
self.wallet_manager = wallet_manager
|
||||||
|
self.socketio = socketio
|
||||||
|
self._running = False
|
||||||
|
self._jobs_started = False
|
||||||
|
|
||||||
|
def start_all_jobs(self):
|
||||||
|
"""Start all background jobs."""
|
||||||
|
if self._jobs_started:
|
||||||
|
logger.warning("Wallet background jobs already started")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._jobs_started = True
|
||||||
|
|
||||||
|
if self.socketio:
|
||||||
|
# Use SocketIO's background task mechanism
|
||||||
|
self.socketio.start_background_task(self._auto_sweep_loop)
|
||||||
|
self.socketio.start_background_task(self._deposit_detection_loop)
|
||||||
|
self.socketio.start_background_task(self._withdrawal_processing_loop)
|
||||||
|
logger.info("Wallet background jobs started via SocketIO")
|
||||||
|
else:
|
||||||
|
# Fallback to eventlet directly
|
||||||
|
import eventlet
|
||||||
|
eventlet.spawn(self._auto_sweep_loop)
|
||||||
|
eventlet.spawn(self._deposit_detection_loop)
|
||||||
|
eventlet.spawn(self._withdrawal_processing_loop)
|
||||||
|
logger.info("Wallet background jobs started via eventlet")
|
||||||
|
|
||||||
|
def stop_all_jobs(self):
|
||||||
|
"""Stop all background jobs."""
|
||||||
|
self._running = False
|
||||||
|
logger.info("Wallet background jobs stopping...")
|
||||||
|
|
||||||
|
# === Auto-Sweep Job ===
|
||||||
|
|
||||||
|
def _auto_sweep_loop(self):
|
||||||
|
"""Background loop for auto-sweep functionality."""
|
||||||
|
logger.info("Auto-sweep job started")
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._process_auto_sweeps()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto-sweep error: {e}")
|
||||||
|
self._sleep(AUTO_SWEEP_INTERVAL)
|
||||||
|
|
||||||
|
def _process_auto_sweeps(self):
|
||||||
|
"""Check all wallets and sweep excess funds."""
|
||||||
|
try:
|
||||||
|
# Get all wallets that might need sweeping
|
||||||
|
wallets = self.wallet_manager.get_wallets_over_cap()
|
||||||
|
|
||||||
|
for wallet in wallets:
|
||||||
|
user_id = wallet['user_id']
|
||||||
|
balance = wallet['balance']
|
||||||
|
sweep_address = wallet['sweep_address']
|
||||||
|
cap = self.wallet_manager.BALANCE_CAP_SATOSHIS
|
||||||
|
|
||||||
|
if not sweep_address:
|
||||||
|
logger.warning(f"User {user_id} has no sweep address configured")
|
||||||
|
continue
|
||||||
|
|
||||||
|
excess = balance - cap
|
||||||
|
if excess <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Keep a small buffer (1000 sats) to avoid sweeping tiny amounts
|
||||||
|
if excess < 1000:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Auto-sweeping {excess} satoshis for user {user_id}")
|
||||||
|
|
||||||
|
# Perform the sweep
|
||||||
|
result = self.wallet_manager.auto_sweep(user_id, excess)
|
||||||
|
if result.get('success'):
|
||||||
|
logger.info(f"Auto-sweep successful for user {user_id}: {result.get('tx_hash', 'simulated')}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Auto-sweep failed for user {user_id}: {result.get('error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing auto-sweeps: {e}")
|
||||||
|
|
||||||
|
# === Deposit Detection Job ===
|
||||||
|
|
||||||
|
def _deposit_detection_loop(self):
|
||||||
|
"""Background loop for deposit detection."""
|
||||||
|
logger.info("Deposit detection job started")
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._check_for_deposits()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Deposit detection error: {e}")
|
||||||
|
self._sleep(DEPOSIT_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
def _check_for_deposits(self):
|
||||||
|
"""Check all wallet addresses for new deposits."""
|
||||||
|
try:
|
||||||
|
# Include disabled wallets so incoming deposits are still credited.
|
||||||
|
wallets = self.wallet_manager.get_wallets_for_deposit_monitoring()
|
||||||
|
|
||||||
|
for wallet in wallets:
|
||||||
|
user_id = wallet['user_id']
|
||||||
|
fee_address = wallet['fee_address']
|
||||||
|
network = wallet['network']
|
||||||
|
|
||||||
|
# Check for new deposits at the fee address
|
||||||
|
new_deposits = self.wallet_manager.check_address_for_deposits(
|
||||||
|
user_id=user_id,
|
||||||
|
address=fee_address,
|
||||||
|
network=network
|
||||||
|
)
|
||||||
|
|
||||||
|
for deposit in new_deposits:
|
||||||
|
logger.info(f"New deposit detected for user {user_id}: "
|
||||||
|
f"{deposit['amount_satoshis']} sats, tx: {deposit['tx_hash']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking for deposits: {e}")
|
||||||
|
|
||||||
|
# === Withdrawal Processing Job ===
|
||||||
|
|
||||||
|
def _withdrawal_processing_loop(self):
|
||||||
|
"""Background loop for processing pending withdrawals."""
|
||||||
|
logger.info("Withdrawal processing job started")
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._process_pending_withdrawals()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Withdrawal processing error: {e}")
|
||||||
|
self._sleep(WITHDRAWAL_PROCESS_INTERVAL)
|
||||||
|
|
||||||
|
def _process_pending_withdrawals(self):
|
||||||
|
"""Process all pending withdrawal requests."""
|
||||||
|
try:
|
||||||
|
# Get pending withdrawals (status = 'reserved')
|
||||||
|
pending = self.wallet_manager.get_pending_withdrawals()
|
||||||
|
|
||||||
|
for withdrawal in pending:
|
||||||
|
withdrawal_id = withdrawal['id']
|
||||||
|
user_id = withdrawal['user_id']
|
||||||
|
amount = withdrawal['amount_satoshis']
|
||||||
|
destination = withdrawal['destination_address']
|
||||||
|
|
||||||
|
logger.info(f"Processing withdrawal {withdrawal_id} for user {user_id}: "
|
||||||
|
f"{amount} sats to {destination}")
|
||||||
|
|
||||||
|
# Process the withdrawal
|
||||||
|
result = self.wallet_manager.process_withdrawal(withdrawal_id)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.info(f"Withdrawal {withdrawal_id} completed: {result.get('tx_hash', 'simulated')}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Withdrawal {withdrawal_id} failed: {result.get('error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing withdrawals: {e}")
|
||||||
|
|
||||||
|
def _sleep(self, seconds):
|
||||||
|
"""Sleep that respects the running flag and works with eventlet."""
|
||||||
|
import eventlet
|
||||||
|
# Sleep in small increments to allow quick shutdown
|
||||||
|
remaining = seconds
|
||||||
|
while remaining > 0 and self._running:
|
||||||
|
sleep_time = min(remaining, 5)
|
||||||
|
eventlet.sleep(sleep_time)
|
||||||
|
remaining -= sleep_time
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""Bitcoin network operations using bit library."""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BitcoinService:
|
||||||
|
"""
|
||||||
|
Service for Bitcoin network operations.
|
||||||
|
Handles address generation, balance checking, and transactions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, testnet: bool = True):
|
||||||
|
"""
|
||||||
|
Initialize Bitcoin service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
testnet: If True, use testnet; otherwise use mainnet.
|
||||||
|
"""
|
||||||
|
self.testnet = testnet
|
||||||
|
|
||||||
|
def _get_key_class(self):
|
||||||
|
"""Get the appropriate key class based on network."""
|
||||||
|
from bit import Key, PrivateKeyTestnet
|
||||||
|
return PrivateKeyTestnet if self.testnet else Key
|
||||||
|
|
||||||
|
def generate_keypair(self) -> dict:
|
||||||
|
"""
|
||||||
|
Generate new Bitcoin keypair.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'address', 'public_key', and 'private_key' (WIF format).
|
||||||
|
"""
|
||||||
|
key_class = self._get_key_class()
|
||||||
|
key = key_class()
|
||||||
|
return {
|
||||||
|
'address': key.address,
|
||||||
|
'public_key': key.public_key.hex(),
|
||||||
|
'private_key': key.to_wif() # Wallet Import Format
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_balance(self, address: str) -> int:
|
||||||
|
"""
|
||||||
|
Get balance in satoshis from blockchain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bitcoin address to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Balance in satoshis, or 0 on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bit.network import NetworkAPI
|
||||||
|
if self.testnet:
|
||||||
|
balance = NetworkAPI.get_balance_testnet(address)
|
||||||
|
else:
|
||||||
|
balance = NetworkAPI.get_balance(address)
|
||||||
|
return balance
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch balance for {address}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_unspent(self, address: str) -> list:
|
||||||
|
"""
|
||||||
|
Get unspent transaction outputs for address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bitcoin address to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UTXOs, or empty list on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bit.network import NetworkAPI
|
||||||
|
if self.testnet:
|
||||||
|
return NetworkAPI.get_unspent_testnet(address)
|
||||||
|
return NetworkAPI.get_unspent(address)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch UTXOs for {address}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def send_transaction(self, private_key_wif: str, to_address: str,
|
||||||
|
amount_satoshis: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Send BTC transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
private_key_wif: Sender's private key in WIF format.
|
||||||
|
to_address: Recipient's Bitcoin address.
|
||||||
|
amount_satoshis: Amount to send in satoshis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction hash on success, None on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key_class = self._get_key_class()
|
||||||
|
key = key_class(private_key_wif)
|
||||||
|
tx_hash = key.send([(to_address, amount_satoshis, 'satoshi')])
|
||||||
|
logger.info(f"Sent {amount_satoshis} satoshis to {to_address}: {tx_hash}")
|
||||||
|
return tx_hash
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send transaction: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_address(self, address: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate Bitcoin address format and checksum.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Address string to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid for this network, False otherwise.
|
||||||
|
"""
|
||||||
|
if not address or not isinstance(address, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
address = address.strip()
|
||||||
|
|
||||||
|
if self.testnet:
|
||||||
|
# Testnet addresses: m/n (legacy), 2 (P2SH), tb1 (bech32)
|
||||||
|
if address.startswith(('m', 'n', '2')):
|
||||||
|
# Base58 testnet address
|
||||||
|
return 26 <= len(address) <= 35
|
||||||
|
elif address.startswith('tb1'):
|
||||||
|
# Bech32 testnet address
|
||||||
|
return 42 <= len(address) <= 62
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Mainnet addresses: 1 (legacy), 3 (P2SH), bc1 (bech32)
|
||||||
|
if address.startswith(('1', '3')):
|
||||||
|
# Base58 mainnet address
|
||||||
|
return 26 <= len(address) <= 35
|
||||||
|
elif address.startswith('bc1'):
|
||||||
|
# Bech32 mainnet address
|
||||||
|
return 42 <= len(address) <= 62
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def estimate_fee(self, num_inputs: int = 1, num_outputs: int = 2) -> int:
|
||||||
|
"""
|
||||||
|
Estimate transaction fee in satoshis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num_inputs: Number of transaction inputs.
|
||||||
|
num_outputs: Number of transaction outputs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated fee in satoshis.
|
||||||
|
"""
|
||||||
|
# Rough estimate: ~148 bytes per input, ~34 bytes per output, ~10 overhead
|
||||||
|
tx_size = (num_inputs * 148) + (num_outputs * 34) + 10
|
||||||
|
# Use a reasonable fee rate (satoshis per byte)
|
||||||
|
# Could be made dynamic based on network conditions
|
||||||
|
fee_rate = 10
|
||||||
|
return tx_size * fee_rate
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Symmetric encryption for wallet keys with versioning."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
|
||||||
|
class KeyEncryption:
|
||||||
|
"""
|
||||||
|
Versioned encryption for wallet keys.
|
||||||
|
Supports key rotation by maintaining multiple key versions.
|
||||||
|
"""
|
||||||
|
CURRENT_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, master_keys: dict):
|
||||||
|
"""
|
||||||
|
Initialize with versioned master keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
master_keys: Dict mapping version numbers to master key strings.
|
||||||
|
Example: {1: 'key_v1', 2: 'key_v2'}
|
||||||
|
"""
|
||||||
|
self.fernets = {}
|
||||||
|
for version, key in master_keys.items():
|
||||||
|
# Derive unique salt per version (deterministic but version-specific)
|
||||||
|
salt = hashlib.sha256(f"brighter_wallet_v{version}".encode()).digest()[:16]
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
)
|
||||||
|
derived_key = base64.urlsafe_b64encode(kdf.derive(key.encode()))
|
||||||
|
self.fernets[int(version)] = Fernet(derived_key)
|
||||||
|
|
||||||
|
def encrypt(self, data: str, version: int = None) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt data with current (or specified) key version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Plaintext string to encrypt.
|
||||||
|
version: Key version to use (defaults to CURRENT_VERSION).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted data as string.
|
||||||
|
"""
|
||||||
|
version = version or self.CURRENT_VERSION
|
||||||
|
if version not in self.fernets:
|
||||||
|
raise ValueError(f"Unknown encryption key version: {version}")
|
||||||
|
return self.fernets[version].encrypt(data.encode()).decode()
|
||||||
|
|
||||||
|
def decrypt(self, encrypted_data: str, version: int) -> str:
|
||||||
|
"""
|
||||||
|
Decrypt data using the specified key version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: Encrypted string to decrypt.
|
||||||
|
version: Key version that was used to encrypt.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted plaintext string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the key version is unknown.
|
||||||
|
"""
|
||||||
|
if version not in self.fernets:
|
||||||
|
raise ValueError(f"Unknown encryption key version: {version}")
|
||||||
|
return self.fernets[version].decrypt(encrypted_data.encode()).decode()
|
||||||
|
|
||||||
|
def re_encrypt(self, encrypted_data: str, old_version: int,
|
||||||
|
new_version: int = None) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Re-encrypt data from old version to new version.
|
||||||
|
Used during key rotation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: Data encrypted with old_version.
|
||||||
|
old_version: Version the data is currently encrypted with.
|
||||||
|
new_version: Target version (defaults to CURRENT_VERSION).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (new_encrypted_data, new_version).
|
||||||
|
"""
|
||||||
|
new_version = new_version or self.CURRENT_VERSION
|
||||||
|
plaintext = self.decrypt(encrypted_data, old_version)
|
||||||
|
return self.encrypt(plaintext, new_version), new_version
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -334,6 +334,9 @@ class TestStopStrategy:
|
||||||
bt = BrighterTrades(MagicMock())
|
bt = BrighterTrades(MagicMock())
|
||||||
bt.strategies = MagicMock()
|
bt.strategies = MagicMock()
|
||||||
bt.strategies.active_instances = {}
|
bt.strategies.active_instances = {}
|
||||||
|
# Mock wallet_manager for fee settlement
|
||||||
|
bt.wallet_manager = MagicMock()
|
||||||
|
bt.wallet_manager.settle_accumulated_fees.return_value = {'success': True, 'settled': 0}
|
||||||
return bt
|
return bt
|
||||||
|
|
||||||
def test_stop_strategy_not_running(self, mock_brighter_trades):
|
def test_stop_strategy_not_running(self, mock_brighter_trades):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,827 @@
|
||||||
|
"""
|
||||||
|
Tests for the wallet module.
|
||||||
|
Tests encryption, Bitcoin service, and wallet manager functionality.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Add src to path for imports
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
|
|
||||||
|
from wallet.encryption import KeyEncryption
|
||||||
|
from wallet.bitcoin_service import BitcoinService
|
||||||
|
from wallet.wallet_manager import WalletManager, BALANCE_CAP_SATOSHIS
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyEncryption:
|
||||||
|
"""Tests for KeyEncryption."""
|
||||||
|
|
||||||
|
def test_encrypt_decrypt(self):
|
||||||
|
"""Test basic encryption and decryption."""
|
||||||
|
keys = {1: 'test_master_key_v1'}
|
||||||
|
encryption = KeyEncryption(keys)
|
||||||
|
|
||||||
|
original = "my_secret_private_key"
|
||||||
|
encrypted = encryption.encrypt(original)
|
||||||
|
decrypted = encryption.decrypt(encrypted, version=1)
|
||||||
|
|
||||||
|
assert decrypted == original
|
||||||
|
assert encrypted != original
|
||||||
|
|
||||||
|
def test_versioned_keys(self):
|
||||||
|
"""Test encryption with multiple key versions."""
|
||||||
|
keys = {1: 'key_v1', 2: 'key_v2'}
|
||||||
|
encryption = KeyEncryption(keys)
|
||||||
|
|
||||||
|
data = "secret_data"
|
||||||
|
encrypted_v1 = encryption.encrypt(data, version=1)
|
||||||
|
encrypted_v2 = encryption.encrypt(data, version=2)
|
||||||
|
|
||||||
|
# Both should decrypt correctly with their respective versions
|
||||||
|
assert encryption.decrypt(encrypted_v1, version=1) == data
|
||||||
|
assert encryption.decrypt(encrypted_v2, version=2) == data
|
||||||
|
|
||||||
|
# Encrypted data should be different for different versions
|
||||||
|
assert encrypted_v1 != encrypted_v2
|
||||||
|
|
||||||
|
def test_re_encrypt(self):
|
||||||
|
"""Test re-encryption from old version to new version."""
|
||||||
|
keys = {1: 'old_key', 2: 'new_key'}
|
||||||
|
encryption = KeyEncryption(keys)
|
||||||
|
|
||||||
|
data = "secret_data"
|
||||||
|
encrypted_v1 = encryption.encrypt(data, version=1)
|
||||||
|
|
||||||
|
# Re-encrypt to version 2
|
||||||
|
encrypted_v2, new_version = encryption.re_encrypt(encrypted_v1, old_version=1, new_version=2)
|
||||||
|
|
||||||
|
assert new_version == 2
|
||||||
|
assert encryption.decrypt(encrypted_v2, version=2) == data
|
||||||
|
|
||||||
|
def test_unknown_version_raises(self):
|
||||||
|
"""Test that decrypting with unknown version raises."""
|
||||||
|
keys = {1: 'test_key'}
|
||||||
|
encryption = KeyEncryption(keys)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unknown encryption key version"):
|
||||||
|
encryption.decrypt("some_data", version=99)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinService:
|
||||||
|
"""Tests for BitcoinService."""
|
||||||
|
|
||||||
|
def test_generate_keypair_testnet(self):
|
||||||
|
"""Test generating testnet keypair."""
|
||||||
|
service = BitcoinService(testnet=True)
|
||||||
|
keypair = service.generate_keypair()
|
||||||
|
|
||||||
|
assert 'address' in keypair
|
||||||
|
assert 'public_key' in keypair
|
||||||
|
assert 'private_key' in keypair
|
||||||
|
|
||||||
|
# Testnet addresses start with m, n, or tb1
|
||||||
|
address = keypair['address']
|
||||||
|
assert address.startswith(('m', 'n', 'tb1'))
|
||||||
|
|
||||||
|
def test_validate_testnet_address(self):
|
||||||
|
"""Test validating testnet addresses."""
|
||||||
|
service = BitcoinService(testnet=True)
|
||||||
|
|
||||||
|
# Generate a valid testnet address
|
||||||
|
keypair = service.generate_keypair()
|
||||||
|
assert service.validate_address(keypair['address'])
|
||||||
|
|
||||||
|
# Invalid addresses
|
||||||
|
assert not service.validate_address('')
|
||||||
|
assert not service.validate_address(None)
|
||||||
|
assert not service.validate_address('invalid')
|
||||||
|
assert not service.validate_address('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2') # Mainnet
|
||||||
|
|
||||||
|
def test_validate_mainnet_address(self):
|
||||||
|
"""Test validating mainnet addresses."""
|
||||||
|
service = BitcoinService(testnet=False)
|
||||||
|
|
||||||
|
# Valid mainnet addresses
|
||||||
|
assert service.validate_address('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')
|
||||||
|
assert service.validate_address('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy')
|
||||||
|
assert service.validate_address('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')
|
||||||
|
|
||||||
|
# Testnet addresses should be invalid on mainnet
|
||||||
|
assert not service.validate_address('mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt')
|
||||||
|
|
||||||
|
def test_estimate_fee(self):
|
||||||
|
"""Test fee estimation."""
|
||||||
|
service = BitcoinService(testnet=True)
|
||||||
|
|
||||||
|
fee = service.estimate_fee(num_inputs=1, num_outputs=2)
|
||||||
|
assert fee > 0
|
||||||
|
assert isinstance(fee, int)
|
||||||
|
|
||||||
|
# More inputs/outputs should cost more
|
||||||
|
fee_larger = service.estimate_fee(num_inputs=3, num_outputs=5)
|
||||||
|
assert fee_larger > fee
|
||||||
|
|
||||||
|
|
||||||
|
class MockDatabase:
|
||||||
|
"""Mock database for testing WalletManager."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tables = {}
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
def execute_sql(self, sql, params=None, fetch_one=False, fetch_all=False):
|
||||||
|
"""Simple mock SQL execution."""
|
||||||
|
sql_lower = sql.lower().strip()
|
||||||
|
|
||||||
|
if sql_lower.startswith('create table') or sql_lower.startswith('create index'):
|
||||||
|
# Table creation - just ignore
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sql_lower.startswith('alter table'):
|
||||||
|
# Migration - add sweep_address column
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sql_lower.startswith('insert'):
|
||||||
|
# Store insert data
|
||||||
|
if 'wallets' in sql_lower:
|
||||||
|
# Two-address model: params are (user_id, btc_address, public_key, private_key, sweep_address, version, network)
|
||||||
|
self.data.setdefault('wallets', {})[params[0]] = {
|
||||||
|
'user_id': params[0],
|
||||||
|
'btc_address': params[1],
|
||||||
|
'public_key_encrypted': params[2],
|
||||||
|
'private_key_encrypted': params[3],
|
||||||
|
'sweep_address': params[4] if len(params) > 6 else None,
|
||||||
|
'encryption_key_version': params[5] if len(params) > 6 else params[4],
|
||||||
|
'network': params[6] if len(params) > 6 else params[5],
|
||||||
|
'is_disabled': 0,
|
||||||
|
'created_at': 'now'
|
||||||
|
}
|
||||||
|
elif 'credits_ledger' in sql_lower:
|
||||||
|
self.data.setdefault('credits_ledger', []).append({
|
||||||
|
'user_id': params[0],
|
||||||
|
'amount_satoshis': params[1],
|
||||||
|
'tx_type': params[2],
|
||||||
|
'reference_id': params[3],
|
||||||
|
'idempotency_key': params[-1],
|
||||||
|
})
|
||||||
|
elif 'pending_strategy_fees' in sql_lower:
|
||||||
|
self.data.setdefault('pending_fees', {})[params[0]] = {
|
||||||
|
'strategy_run_id': params[0],
|
||||||
|
'user_id': params[1],
|
||||||
|
'creator_user_id': params[2],
|
||||||
|
'fee_percent': params[3] if len(params) > 3 else 10,
|
||||||
|
'accumulated_satoshis': 0,
|
||||||
|
'trade_count': 0,
|
||||||
|
}
|
||||||
|
elif 'withdrawal_requests' in sql_lower:
|
||||||
|
self.data.setdefault('withdrawals', []).append({
|
||||||
|
'user_id': params[0],
|
||||||
|
'amount_satoshis': params[1],
|
||||||
|
'destination_address': params[2],
|
||||||
|
'status': 'reserved', # status is hardcoded in SQL
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sql_lower.startswith('select'):
|
||||||
|
if fetch_one:
|
||||||
|
if 'wallets' in sql_lower and 'user_id' in sql_lower:
|
||||||
|
wallet = self.data.get('wallets', {}).get(params[0])
|
||||||
|
if wallet:
|
||||||
|
# Check if it's the get_wallet_keys query (includes encrypted keys)
|
||||||
|
if 'private_key_encrypted' in sql_lower:
|
||||||
|
return (wallet['btc_address'], wallet['public_key_encrypted'],
|
||||||
|
wallet['private_key_encrypted'], wallet['encryption_key_version'],
|
||||||
|
wallet['network'])
|
||||||
|
# Regular get_wallet query (5 columns including sweep_address)
|
||||||
|
return (wallet['btc_address'], wallet['network'],
|
||||||
|
wallet['is_disabled'], wallet['created_at'],
|
||||||
|
wallet.get('sweep_address'))
|
||||||
|
elif 'credits_ledger' in sql_lower and 'sum' in sql_lower:
|
||||||
|
ledger = self.data.get('credits_ledger', [])
|
||||||
|
user_total = sum(e['amount_satoshis'] for e in ledger if e['user_id'] == params[0])
|
||||||
|
return (user_total,)
|
||||||
|
elif 'credits_ledger' in sql_lower and 'idempotency' in sql_lower:
|
||||||
|
ledger = self.data.get('credits_ledger', [])
|
||||||
|
for entry in ledger:
|
||||||
|
if entry.get('idempotency_key') == params[0]:
|
||||||
|
return (1,)
|
||||||
|
return None
|
||||||
|
elif 'pending_strategy_fees' in sql_lower:
|
||||||
|
fees = self.data.get('pending_fees', {}).get(params[0])
|
||||||
|
if fees:
|
||||||
|
# Different queries select different columns
|
||||||
|
if 'fee_percent' in sql_lower and 'accumulated' not in sql_lower:
|
||||||
|
# accumulate_trade_fee: SELECT fee_percent
|
||||||
|
return (fees.get('fee_percent', 10),)
|
||||||
|
elif 'accumulated_satoshis, trade_count' in sql_lower and 'user_id' not in sql_lower.split('select')[1].split('from')[0]:
|
||||||
|
# get_pending_fees: SELECT accumulated_satoshis, trade_count
|
||||||
|
return (fees['accumulated_satoshis'], fees['trade_count'])
|
||||||
|
else:
|
||||||
|
# settle_accumulated_fees: SELECT user_id, creator_user_id, accumulated_satoshis, trade_count
|
||||||
|
return (fees['user_id'], fees['creator_user_id'],
|
||||||
|
fees['accumulated_satoshis'], fees['trade_count'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
if fetch_all:
|
||||||
|
if 'credits_ledger' in sql_lower:
|
||||||
|
ledger = self.data.get('credits_ledger', [])
|
||||||
|
user_entries = [e for e in ledger if e['user_id'] == params[0]]
|
||||||
|
return [(e['tx_type'], e['amount_satoshis'], e['reference_id'], 'now')
|
||||||
|
for e in user_entries[-params[1]:]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if sql_lower.startswith('update'):
|
||||||
|
if 'wallets' in sql_lower and 'is_disabled' in sql_lower:
|
||||||
|
user_id = params[0] # Only param is user_id
|
||||||
|
if user_id in self.data.get('wallets', {}):
|
||||||
|
# Parse the value from the SQL itself (is_disabled = 0 or is_disabled = 1)
|
||||||
|
if 'is_disabled = 1' in sql_lower:
|
||||||
|
self.data['wallets'][user_id]['is_disabled'] = 1
|
||||||
|
else:
|
||||||
|
self.data['wallets'][user_id]['is_disabled'] = 0
|
||||||
|
elif 'pending_strategy_fees' in sql_lower and 'accumulated_satoshis' in sql_lower:
|
||||||
|
# Update: accumulated_satoshis = accumulated_satoshis + ?, trade_count = trade_count + 1
|
||||||
|
fee_amount = params[0]
|
||||||
|
run_id = params[1]
|
||||||
|
if run_id in self.data.get('pending_fees', {}):
|
||||||
|
self.data['pending_fees'][run_id]['accumulated_satoshis'] += fee_amount
|
||||||
|
self.data['pending_fees'][run_id]['trade_count'] += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sql_lower.startswith('delete'):
|
||||||
|
if 'pending_strategy_fees' in sql_lower:
|
||||||
|
run_id = params[0]
|
||||||
|
if run_id in self.data.get('pending_fees', {}):
|
||||||
|
del self.data['pending_fees'][run_id]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def execute_in_transaction(self, statements):
|
||||||
|
"""Execute multiple statements atomically (mock version)."""
|
||||||
|
# In mock, just execute each statement in order
|
||||||
|
for sql, params in statements:
|
||||||
|
self.execute_sql(sql, params)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWalletManager:
|
||||||
|
"""Tests for WalletManager."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wallet_manager(self):
|
||||||
|
"""Create a wallet manager with mock database."""
|
||||||
|
db = MockDatabase()
|
||||||
|
keys = {1: 'test_encryption_key'}
|
||||||
|
return WalletManager(db, keys, default_network='testnet')
|
||||||
|
|
||||||
|
def test_create_wallet(self, wallet_manager):
|
||||||
|
"""Test creating a new wallet (two-address model)."""
|
||||||
|
result = wallet_manager.create_wallet(user_id=1)
|
||||||
|
|
||||||
|
assert result['success']
|
||||||
|
# Fee Address (we store keys)
|
||||||
|
assert 'fee_address' in result
|
||||||
|
assert 'fee_private_key' in result
|
||||||
|
# Sweep Address (we don't store private key)
|
||||||
|
assert 'sweep_address' in result
|
||||||
|
assert 'sweep_private_key' in result
|
||||||
|
assert result['network'] == 'testnet'
|
||||||
|
|
||||||
|
def test_create_wallet_duplicate(self, wallet_manager):
|
||||||
|
"""Test creating duplicate wallet fails."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
result = wallet_manager.create_wallet(user_id=1)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'already exists' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_get_wallet(self, wallet_manager):
|
||||||
|
"""Test getting wallet info."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet = wallet_manager.get_wallet(user_id=1)
|
||||||
|
|
||||||
|
assert wallet is not None
|
||||||
|
assert 'address' in wallet
|
||||||
|
assert wallet['network'] == 'testnet'
|
||||||
|
assert 'private_key' not in wallet # Private key should not be exposed
|
||||||
|
|
||||||
|
def test_credits_balance_empty(self, wallet_manager):
|
||||||
|
"""Test credits balance starts at zero."""
|
||||||
|
balance = wallet_manager.get_credits_balance(user_id=1)
|
||||||
|
assert balance == 0
|
||||||
|
|
||||||
|
def test_admin_credit(self, wallet_manager):
|
||||||
|
"""Test admin credit adds to balance."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=10000, reason='test')
|
||||||
|
balance = wallet_manager.get_credits_balance(user_id=1)
|
||||||
|
assert balance == 10000
|
||||||
|
|
||||||
|
def test_credit_deposit(self, wallet_manager):
|
||||||
|
"""Test deposit crediting."""
|
||||||
|
result = wallet_manager.credit_deposit(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=50000,
|
||||||
|
network='testnet',
|
||||||
|
tx_hash='abc123',
|
||||||
|
vout=0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['success']
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 50000
|
||||||
|
|
||||||
|
def test_deposit_idempotency(self, wallet_manager):
|
||||||
|
"""Test deposit is idempotent."""
|
||||||
|
wallet_manager.credit_deposit(
|
||||||
|
user_id=1, amount_satoshis=50000,
|
||||||
|
network='testnet', tx_hash='abc123', vout=0
|
||||||
|
)
|
||||||
|
wallet_manager.credit_deposit(
|
||||||
|
user_id=1, amount_satoshis=50000,
|
||||||
|
network='testnet', tx_hash='abc123', vout=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only be credited once
|
||||||
|
balance = wallet_manager.get_credits_balance(user_id=1)
|
||||||
|
assert balance == 50000
|
||||||
|
|
||||||
|
def test_fee_accumulation(self, wallet_manager):
|
||||||
|
"""Test fee accumulation during strategy run with default 10% fee."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='initial')
|
||||||
|
|
||||||
|
# Start accumulation with default 10% fee
|
||||||
|
result = wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id='run_001',
|
||||||
|
user_id=1,
|
||||||
|
creator_user_id=2,
|
||||||
|
fee_percent=10, # 10% of exchange fee
|
||||||
|
estimated_trades=5
|
||||||
|
)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Accumulate some fees
|
||||||
|
wallet_manager.accumulate_trade_fee('run_001', exchange_fee_satoshis=10000, is_profitable=True)
|
||||||
|
wallet_manager.accumulate_trade_fee('run_001', exchange_fee_satoshis=5000, is_profitable=True)
|
||||||
|
wallet_manager.accumulate_trade_fee('run_001', exchange_fee_satoshis=8000, is_profitable=False) # Should not accumulate
|
||||||
|
|
||||||
|
# Check pending fees
|
||||||
|
pending = wallet_manager.get_pending_fees('run_001')
|
||||||
|
assert pending['accumulated_satoshis'] == 1500 # 10% of 10000 + 10% of 5000
|
||||||
|
assert pending['trade_count'] == 2 # Only profitable trades counted
|
||||||
|
|
||||||
|
def test_fee_accumulation_custom_percent(self, wallet_manager):
|
||||||
|
"""Test fee accumulation with custom fee percentage (50%)."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='initial')
|
||||||
|
|
||||||
|
# Start accumulation with 50% fee
|
||||||
|
result = wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id='run_002',
|
||||||
|
user_id=1,
|
||||||
|
creator_user_id=2,
|
||||||
|
fee_percent=50, # 50% of exchange fee
|
||||||
|
estimated_trades=5
|
||||||
|
)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Accumulate fee: 50% of 10000 = 5000
|
||||||
|
wallet_manager.accumulate_trade_fee('run_002', exchange_fee_satoshis=10000, is_profitable=True)
|
||||||
|
|
||||||
|
pending = wallet_manager.get_pending_fees('run_002')
|
||||||
|
assert pending['accumulated_satoshis'] == 5000 # 50% of 10000
|
||||||
|
assert pending['trade_count'] == 1
|
||||||
|
|
||||||
|
def test_fee_accumulation_100_percent(self, wallet_manager):
|
||||||
|
"""Test fee accumulation at 100% (same as exchange commission)."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='initial')
|
||||||
|
|
||||||
|
# Start accumulation with 100% fee
|
||||||
|
result = wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id='run_003',
|
||||||
|
user_id=1,
|
||||||
|
creator_user_id=2,
|
||||||
|
fee_percent=100, # 100% of exchange fee
|
||||||
|
estimated_trades=5
|
||||||
|
)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Accumulate fee: 100% of 10000 = 10000
|
||||||
|
wallet_manager.accumulate_trade_fee('run_003', exchange_fee_satoshis=10000, is_profitable=True)
|
||||||
|
|
||||||
|
pending = wallet_manager.get_pending_fees('run_003')
|
||||||
|
assert pending['accumulated_satoshis'] == 10000 # 100% of 10000
|
||||||
|
assert pending['trade_count'] == 1
|
||||||
|
|
||||||
|
def test_fee_settlement(self, wallet_manager):
|
||||||
|
"""Test fee settlement when strategy stops."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='initial')
|
||||||
|
wallet_manager.admin_credit(user_id=2, amount_satoshis=0, reason='creator setup')
|
||||||
|
|
||||||
|
wallet_manager.start_fee_accumulation('run_001', user_id=1, creator_user_id=2)
|
||||||
|
wallet_manager.accumulate_trade_fee('run_001', exchange_fee_satoshis=10000, is_profitable=True)
|
||||||
|
|
||||||
|
# Settle fees
|
||||||
|
result = wallet_manager.settle_accumulated_fees('run_001')
|
||||||
|
|
||||||
|
assert result['success']
|
||||||
|
assert result['settled'] == 1000 # 10% of 10000
|
||||||
|
assert result['trades'] == 1
|
||||||
|
|
||||||
|
# Check balances
|
||||||
|
user_balance = wallet_manager.get_credits_balance(user_id=1)
|
||||||
|
creator_balance = wallet_manager.get_credits_balance(user_id=2)
|
||||||
|
|
||||||
|
assert user_balance == 99000 # 100000 - 1000
|
||||||
|
assert creator_balance == 1000 # Received fee
|
||||||
|
|
||||||
|
def test_insufficient_credits_for_strategy(self, wallet_manager):
|
||||||
|
"""Test that strategy start fails with insufficient credits."""
|
||||||
|
# User has no credits
|
||||||
|
result = wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id='run_001',
|
||||||
|
user_id=1,
|
||||||
|
creator_user_id=2,
|
||||||
|
estimated_trades=10
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'insufficient' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_withdrawal_request(self, wallet_manager):
|
||||||
|
"""Test withdrawal request."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=50000, reason='test')
|
||||||
|
|
||||||
|
result = wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Balance should be reduced immediately
|
||||||
|
balance = wallet_manager.get_credits_balance(user_id=1)
|
||||||
|
assert balance == 40000
|
||||||
|
|
||||||
|
def test_withdrawal_insufficient_balance(self, wallet_manager):
|
||||||
|
"""Test withdrawal fails with insufficient balance."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=5000, reason='test')
|
||||||
|
|
||||||
|
result = wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'insufficient' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_withdrawal_invalid_address(self, wallet_manager):
|
||||||
|
"""Test withdrawal fails with invalid address."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=50000, reason='test')
|
||||||
|
|
||||||
|
result = wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='invalid_address'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'invalid' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_withdrawal_reservation_transaction_failure(self, wallet_manager):
|
||||||
|
"""Test withdrawal reservation fails cleanly when transaction fails."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=50000, reason='test')
|
||||||
|
|
||||||
|
def fail_transaction(_statements):
|
||||||
|
raise RuntimeError('db transaction failed')
|
||||||
|
|
||||||
|
wallet_manager.db.execute_in_transaction = fail_transaction
|
||||||
|
|
||||||
|
result = wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'failed to queue' in result['error'].lower()
|
||||||
|
# Balance should remain unchanged because reservation wasn't committed.
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 50000
|
||||||
|
|
||||||
|
def test_own_strategy_no_fees(self, wallet_manager):
|
||||||
|
"""Test that running own strategy doesn't require fees."""
|
||||||
|
result = wallet_manager.start_fee_accumulation(
|
||||||
|
strategy_run_id='run_001',
|
||||||
|
user_id=1,
|
||||||
|
creator_user_id=1, # Same user
|
||||||
|
estimated_trades=10
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['success']
|
||||||
|
assert 'no fees' in result.get('message', '').lower()
|
||||||
|
|
||||||
|
def test_transaction_history(self, wallet_manager):
|
||||||
|
"""Test getting transaction history."""
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=10000, reason='credit1')
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=5000, reason='credit2')
|
||||||
|
|
||||||
|
history = wallet_manager.get_transaction_history(user_id=1, limit=10)
|
||||||
|
|
||||||
|
assert len(history) == 2
|
||||||
|
assert all('type' in tx for tx in history)
|
||||||
|
assert all('amount' in tx for tx in history)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBalanceCap:
|
||||||
|
"""Tests for balance cap functionality."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wallet_manager(self):
|
||||||
|
db = MockDatabase()
|
||||||
|
keys = {1: 'test_key'}
|
||||||
|
return WalletManager(db, keys, default_network='testnet')
|
||||||
|
|
||||||
|
def test_balance_cap_disables_wallet(self, wallet_manager):
|
||||||
|
"""Test that exceeding balance cap disables wallet."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
|
||||||
|
# Credit above cap
|
||||||
|
wallet_manager.admin_credit(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=BALANCE_CAP_SATOSHIS + 10000,
|
||||||
|
reason='over cap'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = wallet_manager.check_balance_cap(user_id=1)
|
||||||
|
assert result['over_cap']
|
||||||
|
|
||||||
|
# Wallet should show as disabled
|
||||||
|
wallet = wallet_manager.get_wallet(user_id=1)
|
||||||
|
assert wallet['is_disabled']
|
||||||
|
|
||||||
|
def test_balance_under_cap_enables_wallet(self, wallet_manager):
|
||||||
|
"""Test that being under balance cap enables wallet."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
|
||||||
|
# Credit under cap
|
||||||
|
wallet_manager.admin_credit(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=BALANCE_CAP_SATOSHIS - 10000,
|
||||||
|
reason='under cap'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = wallet_manager.check_balance_cap(user_id=1)
|
||||||
|
assert not result['over_cap']
|
||||||
|
|
||||||
|
wallet = wallet_manager.get_wallet(user_id=1)
|
||||||
|
assert not wallet['is_disabled']
|
||||||
|
|
||||||
|
|
||||||
|
class MockDatabaseForBackgroundJobs(MockDatabase):
|
||||||
|
"""Extended mock database for testing background job methods."""
|
||||||
|
|
||||||
|
def execute_sql(self, sql, params=None, fetch_one=False, fetch_all=False):
|
||||||
|
sql_lower = sql.lower().strip()
|
||||||
|
|
||||||
|
# Handle get_wallets_over_cap query
|
||||||
|
if 'left join credits_ledger' in sql_lower and 'having balance' in sql_lower:
|
||||||
|
results = []
|
||||||
|
for user_id, wallet in self.data.get('wallets', {}).items():
|
||||||
|
if not wallet.get('sweep_address'):
|
||||||
|
continue
|
||||||
|
# Calculate balance from ledger
|
||||||
|
ledger = self.data.get('credits_ledger', [])
|
||||||
|
balance = sum(e['amount_satoshis'] for e in ledger if e['user_id'] == user_id)
|
||||||
|
if balance > params[0]: # params[0] is BALANCE_CAP_SATOSHIS
|
||||||
|
results.append((user_id, wallet.get('sweep_address'), balance))
|
||||||
|
if fetch_all:
|
||||||
|
return results
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle get_all_active_wallets query
|
||||||
|
if 'select user_id, btc_address, network' in sql_lower and 'is_disabled = 0' in sql_lower:
|
||||||
|
results = []
|
||||||
|
for user_id, wallet in self.data.get('wallets', {}).items():
|
||||||
|
if not wallet.get('is_disabled'):
|
||||||
|
results.append((user_id, wallet['btc_address'], wallet['network']))
|
||||||
|
if fetch_all:
|
||||||
|
return results
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle get_wallets_for_deposit_monitoring query (includes disabled wallets)
|
||||||
|
if 'select user_id, btc_address, network' in sql_lower and 'from wallets' in sql_lower and 'is_disabled' not in sql_lower:
|
||||||
|
results = []
|
||||||
|
for user_id, wallet in self.data.get('wallets', {}).items():
|
||||||
|
results.append((user_id, wallet['btc_address'], wallet['network']))
|
||||||
|
if fetch_all:
|
||||||
|
return results
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle deposits table queries
|
||||||
|
if 'insert' in sql_lower and 'deposits' in sql_lower:
|
||||||
|
self.data.setdefault('deposits', []).append({
|
||||||
|
'user_id': params[0],
|
||||||
|
'network': params[1],
|
||||||
|
'tx_hash': params[2],
|
||||||
|
'vout': params[3],
|
||||||
|
'amount_satoshis': params[4],
|
||||||
|
'credited': params[5] if len(params) > 5 else 0
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
if 'select' in sql_lower and 'deposits' in sql_lower and 'network' in sql_lower and fetch_one:
|
||||||
|
deposits = self.data.get('deposits', [])
|
||||||
|
for d in deposits:
|
||||||
|
if d['network'] == params[0] and d['tx_hash'] == params[1] and d['vout'] == params[2]:
|
||||||
|
return (d.get('id', 1),)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle get_pending_withdrawals query
|
||||||
|
if 'withdrawal_requests' in sql_lower and "status = 'reserved'" in sql_lower and fetch_all:
|
||||||
|
withdrawals = self.data.get('withdrawals', [])
|
||||||
|
results = []
|
||||||
|
for i, w in enumerate(withdrawals):
|
||||||
|
if w.get('status') == 'reserved':
|
||||||
|
results.append((i + 1, w['user_id'], w['amount_satoshis'], w['destination_address']))
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Handle withdrawal_requests SELECT for process_withdrawal and _fail_withdrawal
|
||||||
|
if 'withdrawal_requests' in sql_lower and 'select' in sql_lower and 'where id' in sql_lower and fetch_one:
|
||||||
|
withdrawals = self.data.get('withdrawals', [])
|
||||||
|
withdrawal_id = params[0]
|
||||||
|
if withdrawal_id > 0 and withdrawal_id <= len(withdrawals):
|
||||||
|
w = withdrawals[withdrawal_id - 1]
|
||||||
|
# process_withdrawal query includes status, _fail_withdrawal does not
|
||||||
|
if 'status' in sql_lower:
|
||||||
|
return (w['user_id'], w['amount_satoshis'], w['destination_address'], w.get('status', 'reserved'))
|
||||||
|
else:
|
||||||
|
return (w['user_id'], w['amount_satoshis'], w['destination_address'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle withdrawal_requests UPDATE
|
||||||
|
if 'update' in sql_lower and 'withdrawal_requests' in sql_lower:
|
||||||
|
withdrawal_id = params[-1] # Last param is the ID in WHERE clause
|
||||||
|
withdrawals = self.data.get('withdrawals', [])
|
||||||
|
if withdrawal_id > 0 and withdrawal_id <= len(withdrawals):
|
||||||
|
if 'processing' in sql_lower:
|
||||||
|
withdrawals[withdrawal_id - 1]['status'] = 'processing'
|
||||||
|
elif 'completed' in sql_lower:
|
||||||
|
withdrawals[withdrawal_id - 1]['status'] = 'completed'
|
||||||
|
withdrawals[withdrawal_id - 1]['btc_txhash'] = params[0]
|
||||||
|
elif 'failed' in sql_lower:
|
||||||
|
withdrawals[withdrawal_id - 1]['status'] = 'failed'
|
||||||
|
withdrawals[withdrawal_id - 1]['error_message'] = params[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fall back to parent implementation
|
||||||
|
return super().execute_sql(sql, params, fetch_one, fetch_all)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundJobSupport:
|
||||||
|
"""Tests for background job support methods in WalletManager."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wallet_manager(self):
|
||||||
|
db = MockDatabaseForBackgroundJobs()
|
||||||
|
keys = {1: 'test_key'}
|
||||||
|
return WalletManager(db, keys, default_network='testnet')
|
||||||
|
|
||||||
|
def test_get_wallets_over_cap(self, wallet_manager):
|
||||||
|
"""Test getting wallets with balance over cap."""
|
||||||
|
# Create wallet with sweep address
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
|
||||||
|
# Credit above cap
|
||||||
|
wallet_manager.admin_credit(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=BALANCE_CAP_SATOSHIS + 50000,
|
||||||
|
reason='over cap'
|
||||||
|
)
|
||||||
|
|
||||||
|
wallets = wallet_manager.get_wallets_over_cap()
|
||||||
|
|
||||||
|
assert len(wallets) == 1
|
||||||
|
assert wallets[0]['user_id'] == 1
|
||||||
|
assert wallets[0]['balance'] == BALANCE_CAP_SATOSHIS + 50000
|
||||||
|
assert wallets[0]['sweep_address'] is not None
|
||||||
|
|
||||||
|
def test_get_wallets_over_cap_empty(self, wallet_manager):
|
||||||
|
"""Test getting wallets when none are over cap."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=10000, reason='under cap')
|
||||||
|
|
||||||
|
wallets = wallet_manager.get_wallets_over_cap()
|
||||||
|
assert len(wallets) == 0
|
||||||
|
|
||||||
|
def test_get_all_active_wallets(self, wallet_manager):
|
||||||
|
"""Test getting all active (non-disabled) wallets."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.create_wallet(user_id=2)
|
||||||
|
|
||||||
|
# Disable user 2's wallet
|
||||||
|
wallet_manager.db.data['wallets'][2]['is_disabled'] = 1
|
||||||
|
|
||||||
|
wallets = wallet_manager.get_all_active_wallets()
|
||||||
|
|
||||||
|
assert len(wallets) == 1
|
||||||
|
assert wallets[0]['user_id'] == 1
|
||||||
|
|
||||||
|
def test_get_wallets_for_deposit_monitoring_includes_disabled(self, wallet_manager):
|
||||||
|
"""Deposit monitoring should include disabled wallets."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.create_wallet(user_id=2)
|
||||||
|
wallet_manager.db.data['wallets'][2]['is_disabled'] = 1
|
||||||
|
|
||||||
|
wallets = wallet_manager.get_wallets_for_deposit_monitoring()
|
||||||
|
user_ids = {w['user_id'] for w in wallets}
|
||||||
|
|
||||||
|
assert user_ids == {1, 2}
|
||||||
|
|
||||||
|
def test_get_pending_withdrawals(self, wallet_manager):
|
||||||
|
"""Test getting pending withdrawal requests."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='test')
|
||||||
|
|
||||||
|
# Request withdrawal
|
||||||
|
wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = wallet_manager.get_pending_withdrawals()
|
||||||
|
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0]['user_id'] == 1
|
||||||
|
assert pending[0]['amount_satoshis'] == 10000
|
||||||
|
|
||||||
|
def test_auto_sweep_no_wallet(self, wallet_manager):
|
||||||
|
"""Test auto_sweep fails when no wallet exists."""
|
||||||
|
result = wallet_manager.auto_sweep(user_id=999, amount_satoshis=10000)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'no wallet' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_auto_sweep_no_sweep_address(self, wallet_manager):
|
||||||
|
"""Test auto_sweep fails when no sweep address configured."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
# Remove sweep address
|
||||||
|
wallet_manager.db.data['wallets'][1]['sweep_address'] = None
|
||||||
|
|
||||||
|
result = wallet_manager.auto_sweep(user_id=1, amount_satoshis=10000)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'sweep address' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_process_withdrawal_not_found(self, wallet_manager):
|
||||||
|
"""Test processing non-existent withdrawal."""
|
||||||
|
result = wallet_manager.process_withdrawal(withdrawal_id=999)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'not found' in result['error'].lower()
|
||||||
|
|
||||||
|
def test_fail_withdrawal_reverses_credit(self, wallet_manager):
|
||||||
|
"""Test that failing a withdrawal reverses the ledger debit."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='test')
|
||||||
|
|
||||||
|
# Request withdrawal (debits 10000)
|
||||||
|
wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Balance should be reduced
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 90000
|
||||||
|
|
||||||
|
# Fail the withdrawal
|
||||||
|
wallet_manager._fail_withdrawal(withdrawal_id=1, error_message='Test failure')
|
||||||
|
|
||||||
|
# Balance should be restored
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 100000
|
||||||
|
|
||||||
|
def test_process_withdrawal_none_txhash_reverses_credit(self, wallet_manager):
|
||||||
|
"""None tx hash from send_transaction should fail and reverse reservation."""
|
||||||
|
wallet_manager.create_wallet(user_id=1)
|
||||||
|
wallet_manager.admin_credit(user_id=1, amount_satoshis=100000, reason='test')
|
||||||
|
wallet_manager.request_withdrawal(
|
||||||
|
user_id=1,
|
||||||
|
amount_satoshis=10000,
|
||||||
|
destination_address='mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
|
||||||
|
)
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 90000
|
||||||
|
|
||||||
|
with patch('wallet.wallet_manager.BitcoinService.send_transaction', return_value=None):
|
||||||
|
result = wallet_manager.process_withdrawal(withdrawal_id=1)
|
||||||
|
|
||||||
|
assert not result['success']
|
||||||
|
assert 'no tx hash' in result['error'].lower()
|
||||||
|
assert wallet_manager.get_credits_balance(user_id=1) == 100000
|
||||||
Loading…
Reference in New Issue