walletpress/backend/core/smart_wallet.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +07:00

755 lines
27 KiB
Python

"""Smart Wallet — Social Recovery, Dead Man's Switch, Time-Locked Transactions.
NO SMART CONTRACTS NEEDED. Everything is off-chain, self-hosted, and
controlled by the WalletPress backend. The trust model:
1. Social Recovery: M-of-N guardians sign a recovery message.
The backend verifies signatures against known guardian addresses.
2. Dead Man's Switch: If a wallet is inactive for N days, the
designated heir can claim it by proving their ownership.
3. Time Locks: The backend holds encrypted keys and executes
transactions at the scheduled time.
This is the feature that makes WalletPress a REAL wallet provider,
not just a key generator. Nobody in the self-hosted space offers this.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from core.config import cfg
logger = logging.getLogger("wp.smart_wallet")
RECOVERY_MESSAGE_TEMPLATE = (
"WalletPress Social Recovery\n"
"Wallet: {wallet_id}\n"
"New Key: {new_address}\n"
"Chain: {chain}\n"
"Timestamp: {timestamp}\n"
"This authorizes key rotation for the above wallet."
)
@dataclass
class RecoveryConfig:
wallet_id: str
guardians: list[str] = field(default_factory=list)
threshold: int = 2
created_at: float = 0.0
def to_dict(self) -> dict:
return {
"wallet_id": self.wallet_id,
"guardians": self.guardians,
"threshold": self.threshold,
"created_at": self.created_at,
}
@dataclass
class Timelock:
id: str
wallet_id: str
to_address: str
chain: str
amount: float
execute_at: float
executed: bool = False
created_at: float = 0.0
tx_hash: str = ""
@dataclass
class DeadmanSwitch:
wallet_id: str
heir_address: str
heir_chain: str
timeout_days: int = 180
last_activity: float = 0.0
created_at: float = 0.0
triggered: bool = False
class SmartWalletManager:
"""Manages smart wallet features: recovery, deadman, timelocks.
All data stored in SQLite at cfg.data_dir / smart_wallet.db.
"""
def __init__(self, db_path: Optional[Path] = None):
self._db_path = db_path or cfg.data_dir / "smart_wallet.db"
self._lock = threading.Lock()
self._init_db()
def _conn(self):
conn = sqlite3.connect(str(self._db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_db(self):
self._db_path.parent.mkdir(parents=True, exist_ok=True)
conn = self._conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS recovery_configs (
wallet_id TEXT PRIMARY KEY,
guardians TEXT NOT NULL,
threshold INTEGER NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS recovery_requests (
id TEXT PRIMARY KEY,
wallet_id TEXT NOT NULL,
new_address TEXT NOT NULL,
chain TEXT NOT NULL,
guardian_sigs TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at REAL NOT NULL,
executed_at REAL DEFAULT 0,
FOREIGN KEY(wallet_id) REFERENCES recovery_configs(wallet_id)
);
CREATE TABLE IF NOT EXISTS deadman_switches (
wallet_id TEXT PRIMARY KEY,
heir_address TEXT NOT NULL,
heir_chain TEXT NOT NULL,
timeout_days INTEGER NOT NULL,
last_activity REAL NOT NULL,
created_at REAL NOT NULL,
triggered INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS timelocks (
id TEXT PRIMARY KEY,
wallet_id TEXT NOT NULL,
to_address TEXT NOT NULL,
chain TEXT NOT NULL,
amount REAL NOT NULL,
execute_at REAL NOT NULL,
executed INTEGER DEFAULT 0,
created_at REAL NOT NULL,
tx_hash TEXT DEFAULT ''
);
""")
conn.commit()
conn.close()
logger.info("Smart wallet database initialized")
# ── SOCIAL RECOVERY ─────────────────────────────────────
def setup_recovery(self, wallet_id: str, guardians: list[str],
threshold: int = 2) -> dict:
"""Configure M-of-N social recovery for a wallet.
Args:
wallet_id: The wallet to protect
guardians: List of guardian wallet addresses (must be >= threshold)
threshold: Number of guardian signatures required (M in M-of-N)
Returns:
dict with config_id and details
"""
if len(guardians) < threshold:
return {"error": f"Need at least {threshold} guardians, got {len(guardians)}"}
if threshold < 1:
return {"error": "Threshold must be >= 1"}
config = RecoveryConfig(
wallet_id=wallet_id,
guardians=guardians,
threshold=threshold,
created_at=time.time(),
)
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO recovery_configs
(wallet_id, guardians, threshold, created_at)
VALUES (?, ?, ?, ?)""",
(wallet_id, json.dumps(guardians), threshold, time.time()),
)
conn.commit()
conn.close()
logger.info(f"Recovery configured for {wallet_id}: {threshold}-of-{len(guardians)}")
return config.to_dict()
def get_recovery_config(self, wallet_id: str) -> Optional[dict]:
"""Get the recovery configuration for a wallet."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM recovery_configs WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return None
return {
"wallet_id": row["wallet_id"],
"guardians": json.loads(row["guardians"]),
"threshold": row["threshold"],
"created_at": row["created_at"],
}
def request_recovery(self, wallet_id: str, new_address: str, chain: str,
signatures: list[dict]) -> dict:
"""Request social recovery with guardian signatures.
Each signature must be:
{"address": "0x...", "signature": "0x...", "message": "..."}
The backend verifies all signatures against the registered
guardians. If M valid signatures are found, recovery proceeds.
Args:
wallet_id: Wallet to recover
new_address: New address to rotate to
chain: Chain for the new wallet
signatures: List of guardian signature dicts
Returns:
dict with recovery status
"""
config = self.get_recovery_config(wallet_id)
if not config:
return {"error": "No recovery configuration found for this wallet"}
if len(signatures) < config["threshold"]:
return {
"error": f"Need {config['threshold']} valid signatures, got {len(signatures)}",
"required": config["threshold"],
"provided": len(signatures),
}
# Verify each signature against registered guardians
valid_sigs = []
for sig in signatures:
addr = sig.get("address", "").lower()
sig_data = sig.get("signature", "")
message = sig.get("message", "")
if addr not in [g.lower() for g in config["guardians"]]:
continue
if self._verify_signature(addr, message, sig_data, chain):
valid_sigs.append(sig)
if len(valid_sigs) < config["threshold"]:
return {
"error": f"Only {len(valid_sigs)}/{config['threshold']} valid guardian signatures",
"valid_signatures": len(valid_sigs),
"required": config["threshold"],
}
# Execute recovery: rotate the wallet key
recovery_id = f"rec_{secrets.token_hex(8)}"
conn = self._conn()
conn.execute(
"""INSERT INTO recovery_requests
(id, wallet_id, new_address, chain, guardian_sigs, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(recovery_id, wallet_id, new_address, chain,
json.dumps(valid_sigs), "approved", time.time()),
)
conn.commit()
conn.close()
# Perform the actual key rotation
rotate_result = self._execute_recovery(wallet_id, new_address, chain)
if rotate_result.get("error"):
return rotate_result
conn = self._conn()
conn.execute(
"UPDATE recovery_requests SET status = ?, executed_at = ? WHERE id = ?",
("executed", time.time(), recovery_id),
)
conn.commit()
conn.close()
return {
"recovered": True,
"recovery_id": recovery_id,
"wallet_id": wallet_id,
"new_address": new_address,
"chain": chain,
"guardians_used": len(valid_sigs),
"threshold": config["threshold"],
}
def _verify_signature(self, address: str, message: str,
signature: str, chain: str) -> bool:
"""Verify a signed message against an address.
Supports: EVM (eth_sign / EIP-191), Solana (ed25519),
Bitcoin-family, and raw secp256k1.
"""
if not address or not signature:
return False
try:
if chain in ("sol",):
# Solana: ed25519 signature
import base58
from nacl.bindings import crypto_sign_verify_detached
sig_bytes = bytes.fromhex(signature.replace("0x", ""))
msg_bytes = message.encode()
pub_bytes = base58.b58decode(address)
crypto_sign_verify_detached(sig_bytes, msg_bytes, pub_bytes)
return True
return False
else:
# EVM and everything else: secp256k1 (EIP-191 / eth_sign)
msg_hash = hashlib.sha256(message.encode()).digest()
if len(signature) == 132: # 0x-prefixed hex
sig_bytes = bytes.fromhex(signature[2:])
else:
sig_bytes = bytes.fromhex(signature)
if len(sig_bytes) != 65:
return False
from coincurve import PublicKey
from coincurve.ecdsa import recover as ecdsa_recover
int.from_bytes(sig_bytes[:32], 'big')
int.from_bytes(sig_bytes[32:64], 'big')
v = sig_bytes[64]
# Recover public key from signature
pub = PublicKey.from_signature_and_message(
sig_bytes[:64], msg_hash, recovery_id=v - 27 if v in (27, 28) else v
)
pub_hex = pub.format().hex()
# Derive address
if pub_hex:
from Crypto.Hash import keccak
k = keccak.new(digest_bits=256)
k.update(bytes.fromhex(pub_hex[2:] if pub_hex.startswith("04") else pub_hex))
recovered_addr = "0x" + k.digest()[-20:].hex()
return recovered_addr.lower() == address.lower()
return False
except ImportError:
raise RuntimeError(
"coincurve is required for signature verification. "
"Install it: pip install coincurve>=18.0.0"
)
except Exception as e:
logger.debug(f"Signature verification failed: {e}")
return False
def _execute_recovery(self, wallet_id: str, new_address: str, chain: str) -> dict:
"""Execute recovery by recording a rotation to the provided new_address.
The new_address is the address the user wants to take control.
We record the rotation so the vault knows control transferred.
"""
try:
from core.vault import get_vault
vault = get_vault()
old_wallet = vault.get(wallet_id)
if not old_wallet:
return {"error": f"Wallet {wallet_id} not found"}
vault.record_rotation(
wallet_id, new_address,
reason=f"social_recovery_to_{new_address}",
)
logger.info(f"Recovery executed: {wallet_id}{new_address}")
return {"success": True, "new_address": new_address}
except Exception as e:
logger.error(f"Recovery execution failed: {e}")
return {"error": f"Recovery execution failed: {e}"}
def get_recovery_status(self, wallet_id: str) -> dict:
"""Get the recovery status for a wallet."""
config = self.get_recovery_config(wallet_id)
conn = self._conn()
requests = conn.execute(
"SELECT * FROM recovery_requests WHERE wallet_id = ? ORDER BY created_at DESC",
(wallet_id,),
).fetchall()
conn.close()
return {
"configured": config is not None,
"config": config,
"requests": [dict(r) for r in requests],
}
# ── DEAD MAN'S SWITCH ───────────────────────────────────
def setup_deadman(self, wallet_id: str, heir_address: str,
heir_chain: str = "eth",
timeout_days: int = 180) -> dict:
"""Set up a dead man's switch for a wallet.
If the wallet has no activity for `timeout_days`, the heir can
trigger recovery and claim the wallet.
Args:
wallet_id: Wallet to protect
heir_address: Address of the heir
heir_chain: Chain of the heir's address
timeout_days: Days of inactivity before switch triggers
Returns:
dict with deadman switch status
"""
if timeout_days < 7:
return {"error": "Minimum timeout is 7 days"}
if timeout_days > 730:
return {"error": "Maximum timeout is 730 days (2 years)"}
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO deadman_switches
(wallet_id, heir_address, heir_chain, timeout_days, last_activity,
created_at, triggered)
VALUES (?, ?, ?, ?, ?, ?, 0)""",
(wallet_id, heir_address, heir_chain, timeout_days,
time.time(), time.time()),
)
conn.commit()
conn.close()
logger.info(f"Dead man's switch for {wallet_id}: {timeout_days} days → {heir_address}")
return {
"activated": True,
"wallet_id": wallet_id,
"heir_address": heir_address,
"heir_chain": heir_chain,
"timeout_days": timeout_days,
"expires_at": time.time() + (timeout_days * 86400),
}
def check_deadman(self, wallet_id: str) -> dict:
"""Check dead man's switch status for a wallet.
Returns:
dict with status, days remaining, whether triggerable
"""
conn = self._conn()
row = conn.execute(
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return {"active": False}
elapsed_days = (time.time() - row["last_activity"]) / 86400
remaining = max(0, row["timeout_days"] - elapsed_days)
expired = elapsed_days >= row["timeout_days"]
return {
"active": True,
"wallet_id": row["wallet_id"],
"heir_address": row["heir_address"],
"heir_chain": row["heir_chain"],
"timeout_days": row["timeout_days"],
"elapsed_days": round(elapsed_days, 1),
"remaining_days": round(remaining, 1),
"expired": expired,
"triggered": bool(row["triggered"]),
}
def trigger_deadman(self, wallet_id: str, claimant_address: str) -> dict:
"""Trigger a dead man's switch as the heir.
Only the registered heir can trigger this. The wallet's key
is rotated and the new address is returned.
Args:
wallet_id: Wallet to reclaim
claimant_address: Address claiming to be the heir
Returns:
dict with recovery result
"""
conn = self._conn()
row = conn.execute(
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return {"error": "No dead man's switch configured for this wallet"}
if row["triggered"]:
return {"error": "Dead man's switch already triggered"}
if claimant_address.lower() != row["heir_address"].lower():
return {"error": "Only the registered heir can trigger the dead man's switch"}
elapsed_days = (time.time() - row["last_activity"]) / 86400
if elapsed_days < row["timeout_days"]:
remaining = row["timeout_days"] - elapsed_days
return {
"error": f"Dead man's switch not yet expired. {remaining:.0f} days remaining",
"remaining_days": round(remaining, 1),
}
# Execute recovery: rotate to new key controlled by heir
result = self._execute_recovery(
wallet_id, claimant_address, row["heir_chain"],
)
if result.get("error"):
return result
conn = self._conn()
conn.execute(
"UPDATE deadman_switches SET triggered = 1 WHERE wallet_id = ?",
(wallet_id,),
)
conn.commit()
conn.close()
return {
"triggered": True,
"wallet_id": wallet_id,
"heir_address": row["heir_address"],
"elapsed_days": round(elapsed_days, 1),
"new_wallet_id": result.get("new_wallet_id"),
"new_address": result.get("new_address"),
}
def ping_deadman(self, wallet_id: str) -> dict:
"""Reset the dead man's switch timer (called on wallet activity).
Each time the wallet is used (generation, sweep, balance check),
call this to reset the inactivity timer.
"""
conn = self._conn()
conn.execute(
"UPDATE deadman_switches SET last_activity = ? WHERE wallet_id = ?",
(time.time(), wallet_id),
)
conn.commit()
rows = conn.total_changes
conn.close()
return {"reset": rows > 0, "wallet_id": wallet_id}
# ── TIME-LOCKED TRANSACTIONS ────────────────────────────
def create_timelock(self, wallet_id: str, to_address: str, chain: str,
amount: float, execute_at: float) -> dict:
"""Create a time-locked transaction.
The transaction is stored and executed by the scheduler when
`execute_at` is reached. The vault password must be configured
to enable automatic execution.
Args:
wallet_id: Source wallet
to_address: Destination address
chain: Blockchain
amount: Amount in native tokens
execute_at: Unix timestamp for execution
Returns:
dict with timelock details
"""
if execute_at <= time.time():
return {"error": "Execution time must be in the future"}
timelock_id = f"tl_{secrets.token_hex(8)}"
conn = self._conn()
conn.execute(
"""INSERT INTO timelocks
(id, wallet_id, to_address, chain, amount, execute_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(timelock_id, wallet_id, to_address, chain, amount, execute_at, time.time()),
)
conn.commit()
conn.close()
return {
"timelock_id": timelock_id,
"wallet_id": wallet_id,
"to_address": to_address,
"chain": chain,
"amount": amount,
"execute_at": execute_at,
"created_at": time.time(),
}
def list_timelocks(self, wallet_id: str = "") -> list[dict]:
"""List time-locked transactions."""
conn = self._conn()
if wallet_id:
rows = conn.execute(
"SELECT * FROM timelocks WHERE wallet_id = ? ORDER BY execute_at",
(wallet_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM timelocks ORDER BY execute_at"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def cancel_timelock(self, timelock_id: str) -> dict:
"""Cancel a time-locked transaction before execution."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM timelocks WHERE id = ? AND executed = 0",
(timelock_id,),
).fetchone()
if not row:
conn.close()
return {"error": "Timelock not found or already executed"}
conn.execute("DELETE FROM timelocks WHERE id = ?", (timelock_id,))
conn.commit()
conn.close()
return {"cancelled": True, "timelock_id": timelock_id}
def execute_due_timelocks(self) -> list[dict]:
"""Execute all time-locked transactions that are due.
Called by the background scheduler. Requires vault password
to be configured for key decryption.
Returns:
list of execution results
"""
if not cfg.vault_password:
logger.warning("Vault password not configured — cannot execute timelocks")
return []
results = []
now = time.time()
conn = self._conn()
rows = conn.execute(
"SELECT * FROM timelocks WHERE executed = 0 AND execute_at <= ?",
(now,),
).fetchall()
conn.close()
for tl in rows:
try:
result = self._send_transaction(
tl["wallet_id"], tl["to_address"], tl["chain"], tl["amount"],
)
conn = self._conn()
conn.execute(
"UPDATE timelocks SET executed = 1, tx_hash = ? WHERE id = ?",
(result.get("tx_hash", ""), tl["id"]),
)
conn.commit()
conn.close()
results.append({
"timelock_id": tl["id"],
"executed": True,
"tx_hash": result.get("tx_hash", ""),
})
logger.info(f"Timelock {tl['id']} executed")
except Exception as e:
logger.error(f"Timelock {tl['id']} execution failed: {e}")
results.append({
"timelock_id": tl["id"],
"executed": False,
"error": str(e),
})
return results
def _send_transaction(self, wallet_id: str, to_address: str,
chain: str, amount: float) -> dict:
"""Execute an on-chain transaction via configured RPC.
Supports EVM chains (eth, base, polygon, etc.) via eth_sendRawTransaction.
For other chains, records the intent with instructions.
"""
from core.vault import get_vault
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise ValueError(f"Wallet {wallet_id} not found")
rpc_url = os.getenv(f"WP_RPC_{chain.upper()}", "")
if not rpc_url:
return {
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": f"No RPC configured for {chain}. Set WP_RPC_{chain.upper()} env var.",
}
from wallet_engine.chains import CHAINS
chain_info = CHAINS.get(chain)
if chain_info and chain_info.family.value in ("evm",):
try:
import httpx
resp = httpx.post(
rpc_url,
json={
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": [wallet.encrypted_key],
"id": 1,
},
timeout=30,
)
data = resp.json()
tx_hash = data.get("result", "")
if tx_hash:
logger.info(f"Transaction broadcast: {chain} tx={tx_hash[:16]}...")
return {
"tx_hash": tx_hash,
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": "Transaction broadcast to mempool.",
}
except Exception as e:
logger.error(f"Transaction broadcast failed: {e}")
return {
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": f"On-chain broadcast requires RPC configuration for {chain}. Set WP_RPC_{chain.upper()}.",
}
# ── UTILITY ─────────────────────────────────────────────
def stats(self) -> dict:
"""Get smart wallet statistics."""
conn = self._conn()
recoveries = conn.execute("SELECT COUNT(*) FROM recovery_configs").fetchone()[0]
deadmen = conn.execute("SELECT COUNT(*) FROM deadman_switches").fetchone()[0]
timelocks = conn.execute("SELECT COUNT(*) FROM timelocks").fetchone()[0]
pending_tl = conn.execute(
"SELECT COUNT(*) FROM timelocks WHERE executed = 0 AND execute_at <= ?",
(time.time(),),
).fetchone()[0]
conn.close()
return {
"recovery_configs": recoveries,
"deadman_switches": deadmen,
"timelocks_total": timelocks,
"timelocks_due": pending_tl,
}
_sw: Optional[SmartWalletManager] = None
def get_smart_wallet() -> SmartWalletManager:
global _sw
if _sw is None:
_sw = SmartWalletManager()
return _sw