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
755 lines
27 KiB
Python
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
|