mixerbtc / app.py
leonett's picture
Update app.py
d2d8373 verified
raw
history blame
15.9 kB
import requests
import gradio as gr
import networkx as nx
import plotly.graph_objects as go
from datetime import datetime
import re
import os
import tempfile
import base64
# ---------------- Funciones de análisis y grafo -------------------
def get_transaction(tx_id):
"""
Obtiene los datos de una transacción Bitcoin usando endpoints públicos.
Se prueba primero con Blockstream.info y, de fallar, con mempool.space.
"""
urls = [
f"https://blockstream.info/api/tx/{tx_id}",
f"https://mempool.space/api/tx/{tx_id}"
]
for url in urls:
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
return response.json()
else:
print(f"Error en {url}: Código de estado {response.status_code}")
except Exception as e:
print(f"Excepción al consultar {url}: {e}")
return None
def get_btc_price():
"""
Obtiene el precio actual de Bitcoin en USD utilizando la API de CoinGecko.
"""
try:
response = requests.get("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", timeout=10)
if response.status_code == 200:
data = response.json()
price = data.get("bitcoin", {}).get("usd")
return price
except Exception as e:
print("Error fetching BTC price:", e)
return None
def generar_grafo(tx_data):
"""
Genera un grafo dirigido a partir de los datos de la transacción.
Nodo central: la transacción.
Nodos secundarios: direcciones de inputs y outputs.
"""
G = nx.DiGraph()
txid = tx_data.get("txid", "desconocido")
G.add_node(txid, color="#FF6B6B", size=20, tipo="Transacción")
for inp in tx_data.get("vin", []):
prevout = inp.get("prevout", {})
addr = prevout.get("scriptpubkey_address")
if addr:
G.add_node(addr, color="#4ECDC4", size=15, tipo="Input")
G.add_edge(addr, txid)
for out in tx_data.get("vout", []):
addr = out.get("scriptpubkey_address")
if addr:
G.add_node(addr, color="#45B7D1", size=15, tipo="Output")
G.add_edge(txid, addr)
return G
def check_blockchain_tags(tx_id):
"""
Consulta la API pública de blockchain.com para ver si el TXID (o sus metadatos)
indica que la transacción ha sido etiquetada como MIXER.
(Esta función es un ejemplo y podría necesitar ajustes según la respuesta real.)
"""
url = f"https://blockchain.info/rawtx/{tx_id}?format=json"
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
tags = data.get("tags", [])
if "MIXER" in tags:
return True
if data.get("category", "").upper() == "MIXER":
return True
else:
print(f"Error consultando blockchain.com: {response.status_code}")
except Exception as e:
print(f"Excepción en check_blockchain_tags: {e}")
return False
def analizar_transaccion(tx_id):
"""
Analiza una transacción Bitcoin:
- Obtiene datos desde Blockstream.info o mempool.space.
- Calcula totales en BTC y fee, mostrando también su equivalente en USD.
- Muestra información adicional (versión, tamaño, peso, fee rate).
- Aplica heurística para detectar posibles CoinJoin/mixers.
- Incorpora información adicional de blockchain.com (etiqueta MIXER).
- Genera un grafo interactivo.
- Retorna un informe en HTML, la figura del grafo y una tupla (reporte, fig) para su uso.
"""
tx_data = get_transaction(tx_id)
if not tx_data:
return ("❌ Transacción no encontrada o error al obtener datos. Asegúrate de ingresar un TXID válido."), None, None
try:
num_inputs = len(tx_data.get("vin", []))
num_outputs = len(tx_data.get("vout", []))
montos = [out.get("value", 0) / 1e8 for out in tx_data.get("vout", [])]
montos_unicos = len(set(montos))
total_input_value = sum(inp.get("prevout", {}).get("value", 0) for inp in tx_data.get("vin", [])) / 1e8
total_output_value = sum(out.get("value", 0) for out in tx_data.get("vout", [])) / 1e8
fee = total_input_value - total_output_value
heuristic_mixer = ((num_inputs > 5 and num_outputs > 5 and montos_unicos < 3) or
(num_outputs > 2 and montos_unicos <= 2))
es_mixer_blockchain = check_blockchain_tags(tx_id)
es_mixer = heuristic_mixer or es_mixer_blockchain
if es_mixer:
if heuristic_mixer and es_mixer_blockchain:
mixer_message = (
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La transacción cumple criterios heurísticos y la API de blockchain.com la etiqueta como MIXER.</p>"
)
elif heuristic_mixer:
mixer_message = (
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La transacción cumple criterios heurísticos compatibles con un mixer.</p>"
)
elif es_mixer_blockchain:
mixer_message = (
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La API de blockchain.com etiqueta esta transacción como MIXER.</p>"
)
else:
mixer_message = (
"<p style='color: #4CAF50;'><strong>Sin indicios de mezcla:</strong> La transacción no presenta patrones de mixer.</p>"
)
btc_price = get_btc_price() or 0
version = tx_data.get("version", "N/A")
size = tx_data.get("size", None)
weight = tx_data.get("weight", "N/A")
fee_rate_str = f"{(fee * 1e8) / size:.2f} sat/byte" if size else "N/A"
status = tx_data.get("status", {})
block_time = status.get("block_time")
fecha_hora_str = datetime.fromtimestamp(block_time).strftime('%Y-%m-%d %H:%M:%S') if block_time else "Desconocida"
# Generar grafo
G = generar_grafo(tx_data)
pos = nx.spring_layout(G, seed=42)
edge_x, edge_y = [], []
for edge in G.edges():
x0, y0 = pos[edge[0]]
x1, y1 = pos[edge[1]]
edge_x.extend([x0, x1, None])
edge_y.extend([y0, y1, None])
node_x, node_y, hover_texts, node_colors, node_sizes = [], [], [], [], []
for node in G.nodes():
x, y = pos[node]
node_x.append(x)
node_y.append(y)
tipo = G.nodes[node].get("tipo", "desconocido")
hover_texts.append(f"Tipo: {tipo}<br>ID: {node[:8]}...")
node_colors.append(G.nodes[node].get("color", "#FFFFFF"))
node_sizes.append(G.nodes[node].get("size", 10))
fig = go.Figure(
data=[
go.Scatter(
x=edge_x, y=edge_y,
line=dict(width=0.5, color="#888"),
hoverinfo="none",
mode="lines"
),
go.Scatter(
x=node_x, y=node_y,
mode="markers+text",
marker=dict(color=node_colors, size=node_sizes, line_width=1),
text=[node[:8] for node in G.nodes()],
hovertext=hover_texts,
hoverinfo="text"
)
],
layout=go.Layout(
title="Visualización de Conexiones de la Transacción",
showlegend=False,
margin=dict(b=0, l=0, r=0, t=40),
xaxis=dict(showgrid=False, zeroline=False, visible=False),
yaxis=dict(showgrid=False, zeroline=False, visible=False),
template="plotly_dark"
)
)
if es_mixer:
unique_outputs_dict = {}
for out in tx_data.get("vout", []):
amount = out.get("value", 0) / 1e8
addr = out.get("scriptpubkey_address")
if addr:
unique_outputs_dict.setdefault(amount, []).append(addr)
unique_outputs_details = "<ul>"
for amt, addrs in unique_outputs_dict.items():
unique_outputs_details += f"<li>{amt:.8f} BTC: {', '.join(addrs)}</li>"
unique_outputs_details += "</ul>"
mixer_metrics = (
f"<p>📤 <strong>Outputs:</strong> {num_outputs}</p>"
f"<p>💰 <strong>Montos únicos en outputs:</strong> {montos_unicos}</p>"
f"<p><strong>Detalles de outputs únicos:</strong></p>{unique_outputs_details}"
)
else:
mixer_metrics = ""
total_input_str = f"{total_input_value:.8f} BTC"
total_input_str += f" (${total_input_value * btc_price:,.2f})" if btc_price else ""
total_output_str = f"{total_output_value:.8f} BTC"
total_output_str += f" (${total_output_value * btc_price:,.2f})" if btc_price else ""
fee_str = f"{fee:.8f} BTC"
fee_str += f" (${fee * btc_price:,.2f})" if btc_price else ""
# Se ha eliminado el <hr> para que la información aparezca de forma continua.
reporte = f"""
<div style="padding: 20px; border-radius: 10px;">
<h3 style="color: {'#FF5252' if es_mixer else '#4CAF50'};">
{'🔮 POSIBLE MIXER / COINJOIN' if es_mixer else '✅ Transacción Normal'}
</h3>
<p><strong>TXID:</strong> {tx_id}</p>
<p><strong>Fecha y Hora:</strong> {fecha_hora_str}</p>
<p><strong>Versión:</strong> {version}</p>
<p><strong>Tamaño:</strong> {size if size else 'N/A'} bytes</p>
<p><strong>Peso:</strong> {weight}</p>
<p><strong>Fee rate:</strong> {fee_rate_str}</p>
<p>📥 <strong>Inputs:</strong> {num_inputs}</p>
{mixer_metrics}
<p><strong>Total Entradas:</strong> {total_input_str}</p>
<p><strong>Total Salidas:</strong> {total_output_str}</p>
<p><strong>Fee:</strong> {fee_str}</p>
<p><strong>Estado de Confirmación:</strong> {"Confirmada" if status.get("confirmed", False) else "No confirmada"}</p>
{mixer_message}
<p style="font-size: 0.9em;">
Nota: La detección se basa en heurísticas y en información adicional de blockchain.com. Se recomienda usar herramientas especializadas para un análisis forense completo.
</p>
</div>
"""
return reporte, fig, (reporte, fig)
except Exception as e:
return f"⚠️ Error durante el análisis: {str(e)}", None, None
def mostrar_modal(analysis_tuple):
"""
A partir de la tupla de análisis, devuelve un HTML que simula un modal emergente con el informe completo.
"""
if not analysis_tuple:
return "<p>No hay análisis generado.</p>"
report, _ = analysis_tuple
html_modal = f'''
<div id="modalOverlay" style="
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 9999; overflow: auto;">
<div style="
position: relative; margin: 5% auto; padding: 20px;
background: inherit; max-width: 800px; border-radius: 10px;">
{report}
<br><br>
<button onclick="document.getElementById('modalOverlay').remove();"
style='padding: 5px 10px; font-size: 12px;'>Cerrar</button>
</div>
</div>
'''
return html_modal
# ---------------- INTERFAZ GRÁFICA CON GRADIO -------------------
with gr.Blocks(
theme=gr.themes.Soft(),
title="🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin",
css=".custom-btn { background-color: #ADD8E6 !important; color: black !important; padding: 5px 10px; font-size: 12px; }"
) as demo:
# Encabezado
gr.Markdown("# 🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin")
gr.Markdown("Desarrollado por José R. Leonett para la comunidad de Peritos Forenses Digitales de Guatemala [www.forensedigital.gt](http://www.forensedigital.gt)")
gr.Markdown("**Nota:** Este analizador funciona únicamente con transacciones de Bitcoin. No se pueden analizar transacciones de Ethereum.")
with gr.Row():
# Columna izquierda: Campo de TXID, ejemplos y explicación de campos
with gr.Column(scale=1):
tx_input = gr.Textbox(
label="TXID de la Transacción",
placeholder="Ej: 9dd51e2d45f4f7bddcc3f0f7a05c3fd60543a11cfc9fbd0e1ca4434668cfa3e1"
)
analyze_btn = gr.Button("Analizar Transacción", elem_classes=["custom-btn"])
gr.Markdown("### Ejemplos de TXIDs válidos:")
gr.Examples(
examples=[
["0042c32659ef432276a0feb59e0418dee97e80ecd0ffa8a3f6228744388e65ac"],
["cdacd625a332f46913209aa3480002fc5beb67d031e5b99a0bfe3fddbaef402b"]
],
inputs=tx_input
)
explanation_box = gr.HTML(value="""
<div style="overflow-y: auto; height: 350px; border: 1px solid #cccccc; padding: 10px;">
<h4>Explicación de los campos:</h4>
<ul>
<li><strong>TXID:</strong> Identificador único de la transacción.</li>
<li><strong>Fecha y Hora:</strong> Momento en que la transacción fue confirmada (según la blockchain).</li>
<li><strong>Versión:</strong> Versión del protocolo de la transacción.</li>
<li><strong>Tamaño:</strong> Tamaño en bytes de la transacción.</li>
<li><strong>Peso:</strong> Peso de la transacción en unidades de peso.</li>
<li><strong>Fee rate:</strong> Tarifa pagada por byte (sat/byte).</li>
<li><strong>Inputs:</strong> Número de entradas de la transacción.</li>
<li><strong>Outputs:</strong> Se muestran solo si se detecta posible mixer.</li>
<li><strong>Montos únicos en outputs:</strong> Se muestran solo si es mixer.</li>
<li><strong>Detalles de outputs únicos:</strong> Lista de cada monto (en BTC) y las direcciones asociadas (solo si es mixer).</li>
<li><strong>Total Entradas:</strong> Suma total de los valores de entrada (en BTC y USD).</li>
<li><strong>Total Salidas:</strong> Suma total de los valores de salida (en BTC y USD).</li>
<li><strong>Fee:</strong> Diferencia entre entradas y salidas (en BTC y USD).</li>
<li><strong>Estado de Confirmación:</strong> Indica si la transacción está confirmada o no.</li>
<li><strong>Alerta de Mixer / CoinJoin:</strong> Muestra si la transacción presenta patrones de mezcla.</li>
</ul>
</div>
""")
# Columna derecha: Grafo, Reporte y botón para mostrar modal con análisis completo
with gr.Column(scale=1):
plot_output = gr.Plot()
reporte_html = gr.HTML()
modal_btn = gr.Button("GENERAR ANALISIS", elem_classes=["custom-btn"])
modal_output = gr.HTML()
# Estado para almacenar el análisis (tupla: (reporte, fig))
analysis_state = gr.State()
# Al pulsar "Analizar Transacción", se generan reporte, grafo y se guarda el análisis.
analyze_btn.click(fn=analizar_transaccion, inputs=tx_input, outputs=[reporte_html, plot_output, analysis_state])
# Al pulsar "GENERAR ANALISIS", se muestra un modal emergente con el informe completo.
modal_btn.click(fn=mostrar_modal, inputs=analysis_state, outputs=modal_output)
demo.launch()