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_*
|
||||
# Default: exclude integration tests (run with: pytest -m integration)
|
||||
addopts = -v --tb=short -m "not integration"
|
||||
filterwarnings =
|
||||
ignore:'crypt' is deprecated and slated for removal in Python 3\.13:DeprecationWarning
|
||||
|
||||
markers =
|
||||
live_testnet: marks tests as requiring live testnet API keys (deselect with '-m "not live_testnet"')
|
||||
|
|
|
|||
|
|
@ -13,4 +13,7 @@ Flask-Cors~=3.0.10
|
|||
email_validator~=2.2.0
|
||||
aiohttp>=3.9.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 trade import Trades
|
||||
from edm_client import EdmClient, EdmWebSocketClient
|
||||
from wallet import WalletManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -71,6 +72,18 @@ class BrighterTrades:
|
|||
edm_client=self.edm_client)
|
||||
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
|
||||
def _coerce_user_id(user_id: Any) -> int | None:
|
||||
if user_id is None or user_id == '':
|
||||
|
|
@ -752,7 +765,12 @@ class BrighterTrades:
|
|||
# This ensures subscribers run with the creator's indicator definitions
|
||||
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
|
||||
strategy_full = self.strategies.get_strategy_by_tbl_key(strategy_id)
|
||||
required_exchanges = extract_required_exchanges(strategy_full)
|
||||
|
|
@ -933,6 +951,28 @@ class BrighterTrades:
|
|||
# Paper mode: random UUID since paper state is ephemeral
|
||||
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(
|
||||
mode=mode,
|
||||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
self.strategies.active_instances[instance_key] = instance
|
||||
|
||||
|
|
@ -980,6 +1029,8 @@ class BrighterTrades:
|
|||
return result
|
||||
|
||||
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)
|
||||
return {"success": False, "message": f"Failed to start strategy: {str(e)}"}
|
||||
|
||||
|
|
@ -1028,9 +1079,20 @@ class BrighterTrades:
|
|||
if hasattr(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")
|
||||
|
||||
return {
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Strategy '{strategy_name}' stopped.",
|
||||
"strategy_id": strategy_id,
|
||||
|
|
@ -1040,6 +1102,11 @@ class BrighterTrades:
|
|||
"final_stats": final_stats,
|
||||
}
|
||||
|
||||
if fee_settlement:
|
||||
result["fee_settlement"] = fee_settlement
|
||||
|
||||
return result
|
||||
|
||||
def get_strategy_status(
|
||||
self,
|
||||
user_id: int,
|
||||
|
|
@ -1809,3 +1876,81 @@ class BrighterTrades:
|
|||
if msg_type == 'reply':
|
||||
# If the message is a reply log the response to the terminal.
|
||||
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):
|
||||
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 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:
|
||||
cur = con.cursor()
|
||||
|
|
@ -103,6 +107,37 @@ class Database:
|
|||
else:
|
||||
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:
|
||||
"""
|
||||
Retrieves all rows from a table.
|
||||
|
|
|
|||
|
|
@ -839,3 +839,45 @@ class StrategyInstance:
|
|||
Retrieves the available balance for the strategy.
|
||||
"""
|
||||
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)
|
||||
)
|
||||
|
||||
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:
|
||||
"""
|
||||
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):
|
||||
self.modify_user_data(username=username, field_name="status", new_data="logged_in")
|
||||
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()
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -216,13 +238,60 @@ class UserAccountManagement(BaseUser):
|
|||
"""
|
||||
# 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='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
|
||||
self._remove_user_from_memory(user_name=username)
|
||||
|
||||
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:
|
||||
"""
|
||||
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.
|
||||
:return: The hashed text.
|
||||
"""
|
||||
return bcrypt.hash(some_text, rounds=13)
|
||||
return bcrypt.using(rounds=13).hash(some_text)
|
||||
|
||||
|
||||
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
|
||||
from BrighterTrades import BrighterTrades # noqa: E402
|
||||
from utils import sanitize_for_json # noqa: E402
|
||||
from wallet import WalletBackgroundJobs # noqa: E402
|
||||
|
||||
# Set up logging
|
||||
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
|
||||
# This is the source of truth for WebSocket authentication - never trust client payloads
|
||||
socket_user_mapping = {} # request.sid -> user_id
|
||||
wallet_jobs = None
|
||||
_wallet_jobs_started = False
|
||||
|
||||
# Set the app directly with the globals.
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
if user_id is None or user_id == '':
|
||||
return None
|
||||
|
|
@ -764,6 +802,240 @@ def _validate_blockly_xml(xml_string: str) -> bool:
|
|||
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'])
|
||||
def edm_health():
|
||||
"""
|
||||
|
|
@ -787,4 +1059,7 @@ if __name__ == '__main__':
|
|||
start_strategy_loop()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -955,6 +955,16 @@ class LiveBroker(BaseBroker):
|
|||
|
||||
# Emit fill event if order was 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({
|
||||
'type': 'fill',
|
||||
'order_id': order_id,
|
||||
|
|
@ -965,7 +975,10 @@ class LiveBroker(BaseBroker):
|
|||
'filled_qty': order.filled_qty,
|
||||
'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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -230,6 +230,11 @@ class PaperBroker(BaseBroker):
|
|||
order.status = OrderStatus.FILLED
|
||||
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
|
||||
order_value = order.size * fill_price
|
||||
|
||||
|
|
@ -261,10 +266,15 @@ class PaperBroker(BaseBroker):
|
|||
# Update position
|
||||
if order.symbol in self._positions:
|
||||
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
|
||||
position.realized_pnl += realized_pnl
|
||||
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
|
||||
if position.size <= 0:
|
||||
del self._positions[order.symbol]
|
||||
|
|
@ -405,7 +415,10 @@ class PaperBroker(BaseBroker):
|
|||
'filled_qty': order.filled_qty,
|
||||
'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}")
|
||||
|
|
|
|||
|
|
@ -375,6 +375,21 @@ class LiveStrategyInstance(StrategyInstance):
|
|||
})
|
||||
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
|
||||
self._update_balances()
|
||||
|
||||
|
|
|
|||
|
|
@ -240,6 +240,19 @@ class PaperStrategyInstance(StrategyInstance):
|
|||
'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
|
||||
self.exec_context['current_candle'] = candle_data
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
alert("Please provide a name for the strategy.");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -390,7 +390,7 @@ height: 500px;
|
|||
font-size: 15px;
|
||||
}
|
||||
|
||||
.active, .collapsible:hover {
|
||||
.collapsible.active, .collapsible:hover {
|
||||
background-color: #0A07DF;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class User_Interface {
|
|||
this.signals = new Signals(this);
|
||||
this.backtesting = new Backtesting(this);
|
||||
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
|
||||
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("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
|
||||
this.backtesting.initialize();
|
||||
} catch (error) {
|
||||
|
|
@ -69,6 +78,7 @@ class User_Interface {
|
|||
this.exchanges.initialize();
|
||||
this.strats.initialize('strats_display', 'new_strat_form', this.data);
|
||||
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='backtesting.js') }}"></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>
|
||||
</head>
|
||||
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
{% include "trade_details_popup.html" %}
|
||||
{% include "indicator_popup.html" %}
|
||||
{% include "exchange_config_popup.html" %}
|
||||
{% include "account_settings_dialog.html" %}
|
||||
<!-- Container for the whole user app -->
|
||||
<div id="master_panel" class="master_panel"
|
||||
style="width 1550px; height 800px;display: grid;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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()">
|
||||
Sign in
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -35,9 +35,14 @@
|
|||
</div>
|
||||
|
||||
<!-- Fee input field -->
|
||||
<div style="grid-column: 1;">
|
||||
<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>
|
||||
<div style="grid-column: 1; position: relative;">
|
||||
<label for="fee_box" style="display: inline-block; width: 20%;">Fee:</label>
|
||||
<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>
|
||||
|
||||
<!-- Default Trading Source Section -->
|
||||
|
|
@ -154,6 +159,31 @@
|
|||
z-index: 10;
|
||||
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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.strategies = MagicMock()
|
||||
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
|
||||
|
||||
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