Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -3,6 +3,11 @@ import gradio as gr
|
|
3 |
import networkx as nx
|
4 |
import plotly.graph_objects as go
|
5 |
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
def get_transaction(tx_id):
|
8 |
"""
|
@@ -45,10 +50,8 @@ def generar_grafo(tx_data):
|
|
45 |
Nodos secundarios: direcciones de inputs y outputs.
|
46 |
"""
|
47 |
G = nx.DiGraph()
|
48 |
-
|
49 |
txid = tx_data.get("txid", "desconocido")
|
50 |
G.add_node(txid, color="#FF6B6B", size=20, tipo="Transacción")
|
51 |
-
|
52 |
# Inputs
|
53 |
for inp in tx_data.get("vin", []):
|
54 |
prevout = inp.get("prevout", {})
|
@@ -56,28 +59,25 @@ def generar_grafo(tx_data):
|
|
56 |
if addr:
|
57 |
G.add_node(addr, color="#4ECDC4", size=15, tipo="Input")
|
58 |
G.add_edge(addr, txid)
|
59 |
-
|
60 |
# Outputs
|
61 |
for out in tx_data.get("vout", []):
|
62 |
addr = out.get("scriptpubkey_address")
|
63 |
if addr:
|
64 |
G.add_node(addr, color="#45B7D1", size=15, tipo="Output")
|
65 |
G.add_edge(txid, addr)
|
66 |
-
|
67 |
return G
|
68 |
|
69 |
def check_blockchain_tags(tx_id):
|
70 |
"""
|
71 |
Consulta la API pública de blockchain.com para ver si el TXID (o sus metadatos)
|
72 |
incluye información que indique que la transacción ha sido etiquetada como MIXER.
|
73 |
-
Esta función es un ejemplo y
|
74 |
"""
|
75 |
url = f"https://blockchain.info/rawtx/{tx_id}?format=json"
|
76 |
try:
|
77 |
response = requests.get(url, timeout=10)
|
78 |
if response.status_code == 200:
|
79 |
data = response.json()
|
80 |
-
# Se asume que la API podría incluir un campo 'tags' o 'category'
|
81 |
tags = data.get("tags", [])
|
82 |
if "MIXER" in tags:
|
83 |
return True
|
@@ -92,106 +92,75 @@ def check_blockchain_tags(tx_id):
|
|
92 |
def analizar_transaccion(tx_id):
|
93 |
"""
|
94 |
Analiza una transacción Bitcoin:
|
95 |
-
- Obtiene
|
96 |
- Calcula totales en BTC y fee, mostrando también su equivalente en USD.
|
97 |
-
- Muestra información adicional
|
98 |
-
- Aplica
|
99 |
-
|
100 |
-
* Si tiene >2 outputs y 2 o menos montos únicos.
|
101 |
-
- Incorpora información adicional de blockchain.com (si está disponible) para detectar etiquetas MIXER.
|
102 |
- Genera un grafo interactivo.
|
103 |
-
- Retorna un
|
104 |
-
|
105 |
-
Nota: La detección se basa en heurísticas y en la información adicional de blockchain.com.
|
106 |
-
Se recomienda complementar con herramientas especializadas para un análisis forense completo.
|
107 |
"""
|
108 |
tx_data = get_transaction(tx_id)
|
109 |
if not tx_data:
|
110 |
-
return ("❌ Transacción no encontrada o error al obtener datos. "
|
111 |
-
"Asegúrate de ingresar un TXID de Bitcoin válido."), None
|
112 |
|
113 |
try:
|
114 |
-
# Datos básicos
|
115 |
num_inputs = len(tx_data.get("vin", []))
|
116 |
num_outputs = len(tx_data.get("vout", []))
|
117 |
montos = [out.get("value", 0) / 1e8 for out in tx_data.get("vout", [])]
|
118 |
montos_unicos = len(set(montos))
|
119 |
-
|
120 |
-
# Cálculos forenses en BTC
|
121 |
total_input_value = sum(inp.get("prevout", {}).get("value", 0) for inp in tx_data.get("vin", [])) / 1e8
|
122 |
total_output_value = sum(out.get("value", 0) for out in tx_data.get("vout", [])) / 1e8
|
123 |
fee = total_input_value - total_output_value
|
124 |
|
125 |
-
# Heurística para detectar
|
126 |
heuristic_mixer = ((num_inputs > 5 and num_outputs > 5 and montos_unicos < 3) or
|
127 |
(num_outputs > 2 and montos_unicos <= 2))
|
128 |
-
|
129 |
-
# Verificar información adicional vía blockchain.com
|
130 |
es_mixer_blockchain = check_blockchain_tags(tx_id)
|
131 |
-
|
132 |
-
# Combinamos ambas señales: si alguna indica mixer, marcamos la transacción.
|
133 |
es_mixer = heuristic_mixer or es_mixer_blockchain
|
134 |
|
135 |
-
# Construir mensaje detallado según la fuente de la alerta
|
136 |
if es_mixer:
|
137 |
if heuristic_mixer and es_mixer_blockchain:
|
138 |
mixer_message = (
|
139 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
140 |
-
"La transacción
|
141 |
-
"y la API de blockchain.com indica que está etiquetada como MIXER. Se recomienda un análisis forense adicional.</p>"
|
142 |
)
|
143 |
elif heuristic_mixer:
|
144 |
mixer_message = (
|
145 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
146 |
-
"La transacción
|
147 |
-
"Se recomienda un análisis forense adicional.</p>"
|
148 |
)
|
149 |
elif es_mixer_blockchain:
|
150 |
mixer_message = (
|
151 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
152 |
-
"La API de blockchain.com
|
153 |
-
"Se recomienda un análisis forense adicional.</p>"
|
154 |
)
|
155 |
else:
|
156 |
mixer_message = (
|
157 |
"<p style='color: #4CAF50;'><strong>Sin indicios de mezcla:</strong> "
|
158 |
-
"La transacción no
|
159 |
)
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
if btc_price is None:
|
164 |
-
btc_price = 0
|
165 |
-
|
166 |
-
# Datos adicionales de la transacción
|
167 |
version = tx_data.get("version", "N/A")
|
168 |
size = tx_data.get("size", None)
|
169 |
weight = tx_data.get("weight", "N/A")
|
170 |
-
if size
|
171 |
-
|
172 |
-
fee_rate_str = f"{fee_rate:.2f} sat/byte"
|
173 |
-
else:
|
174 |
-
fee_rate_str = "N/A"
|
175 |
-
|
176 |
-
# Formatear fecha y hora (si la transacción está confirmada)
|
177 |
status = tx_data.get("status", {})
|
178 |
block_time = status.get("block_time")
|
179 |
-
if block_time
|
180 |
-
|
181 |
-
|
182 |
-
fecha_hora_str = "Desconocida"
|
183 |
-
|
184 |
-
# Generar grafo de la transacción
|
185 |
G = generar_grafo(tx_data)
|
186 |
pos = nx.spring_layout(G, seed=42)
|
187 |
-
|
188 |
edge_x, edge_y = [], []
|
189 |
for edge in G.edges():
|
190 |
x0, y0 = pos[edge[0]]
|
191 |
x1, y1 = pos[edge[1]]
|
192 |
edge_x.extend([x0, x1, None])
|
193 |
edge_y.extend([y0, y1, None])
|
194 |
-
|
195 |
node_x, node_y, hover_texts, node_colors, node_sizes = [], [], [], [], []
|
196 |
for node in G.nodes():
|
197 |
x, y = pos[node]
|
@@ -201,7 +170,6 @@ def analizar_transaccion(tx_id):
|
|
201 |
hover_texts.append(f"Tipo: {tipo}<br>ID: {node[:8]}...")
|
202 |
node_colors.append(G.nodes[node].get("color", "#FFFFFF"))
|
203 |
node_sizes.append(G.nodes[node].get("size", 10))
|
204 |
-
|
205 |
fig = go.Figure(
|
206 |
data=[
|
207 |
go.Scatter(
|
@@ -228,40 +196,35 @@ def analizar_transaccion(tx_id):
|
|
228 |
template="plotly_dark"
|
229 |
)
|
230 |
)
|
231 |
-
|
232 |
-
#
|
233 |
-
unique_outputs_dict = {}
|
234 |
-
for out in tx_data.get("vout", []):
|
235 |
-
amount = out.get("value", 0) / 1e8
|
236 |
-
addr = out.get("scriptpubkey_address")
|
237 |
-
if addr:
|
238 |
-
unique_outputs_dict.setdefault(amount, []).append(addr)
|
239 |
-
|
240 |
-
unique_outputs_details = "<ul>"
|
241 |
-
for amt, addrs in unique_outputs_dict.items():
|
242 |
-
unique_outputs_details += f"<li>{amt:.8f} BTC: {', '.join(addrs)}</li>"
|
243 |
-
unique_outputs_details += "</ul>"
|
244 |
-
|
245 |
-
# Si se detecta posible mixer, se muestran métricas adicionales de outputs;
|
246 |
-
# de lo contrario, se omiten.
|
247 |
if es_mixer:
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
254 |
else:
|
255 |
mixer_metrics = ""
|
256 |
-
|
257 |
-
# Formateo de montos con equivalentes en USD
|
258 |
total_input_str = f"{total_input_value:.8f} BTC"
|
259 |
total_input_str += f" (${total_input_value * btc_price:,.2f})" if btc_price else ""
|
260 |
total_output_str = f"{total_output_value:.8f} BTC"
|
261 |
total_output_str += f" (${total_output_value * btc_price:,.2f})" if btc_price else ""
|
262 |
fee_str = f"{fee:.8f} BTC"
|
263 |
fee_str += f" (${fee * btc_price:,.2f})" if btc_price else ""
|
264 |
-
|
|
|
265 |
reporte = f"""
|
266 |
<div style="padding: 20px; border-radius: 10px;">
|
267 |
<h3 style="color: {'#FF5252' if es_mixer else '#4CAF50'};">
|
@@ -282,21 +245,82 @@ def analizar_transaccion(tx_id):
|
|
282 |
<p>🔔 <strong>Estado de Confirmación:</strong> {"Confirmada" if status.get("confirmed", False) else "No confirmada"}</p>
|
283 |
{mixer_message}
|
284 |
<p style="font-size: 0.9em;">
|
285 |
-
Nota: La detección se basa en heurísticas y en
|
286 |
</p>
|
287 |
</div>
|
288 |
"""
|
289 |
-
|
290 |
-
return reporte, fig
|
291 |
|
292 |
except Exception as e:
|
293 |
-
return f"⚠️ Error durante el análisis: {str(e)}", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
294 |
|
295 |
-
# INTERFAZ GRÁFICA CON GRADIO
|
296 |
with gr.Blocks(
|
297 |
theme=gr.themes.Soft(),
|
298 |
title="🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin",
|
299 |
-
css=".custom-btn { background-color: #ADD8E6 !important; color: black !important; }"
|
300 |
) as demo:
|
301 |
gr.Markdown("# 🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin")
|
302 |
gr.Markdown("Desarrollado por José R. Leonett para la comunidad de Peritos Forenses Digitales de Guatemala [www.forensedigital.gt](http://www.forensedigital.gt)")
|
@@ -305,14 +329,12 @@ with gr.Blocks(
|
|
305 |
with gr.Row():
|
306 |
# Columna Izquierda: Grafo y controles de entrada
|
307 |
with gr.Column(scale=1):
|
308 |
-
# Espacio para el grafo (se actualizará con la transacción analizada)
|
309 |
plot_output = gr.Plot()
|
310 |
-
# Controles de entrada debajo del grafo
|
311 |
tx_input = gr.Textbox(
|
312 |
label="TXID de la Transacción",
|
313 |
placeholder="Ej: 9dd51e2d45f4f7bddcc3f0f7a05c3fd60543a11cfc9fbd0e1ca4434668cfa3e1"
|
314 |
)
|
315 |
-
|
316 |
gr.Markdown("### Ejemplos de TXIDs válidos:")
|
317 |
gr.Examples(
|
318 |
examples=[
|
@@ -325,7 +347,7 @@ with gr.Blocks(
|
|
325 |
# Columna Derecha: Resultados y recuadro explicativo
|
326 |
with gr.Column(scale=1):
|
327 |
reporte_html = gr.HTML()
|
328 |
-
|
329 |
<div style="overflow-y: scroll; height: 220px; border: 1px solid #cccccc; padding: 10px;">
|
330 |
<h4>Explicación de los campos:</h4>
|
331 |
<ul>
|
@@ -343,13 +365,20 @@ with gr.Blocks(
|
|
343 |
<li><strong>Total Salidas:</strong> Suma total de los valores de salida (en BTC y USD).</li>
|
344 |
<li><strong>Fee:</strong> Diferencia entre entradas y salidas, que representa la tarifa de la transacción (en BTC y USD).</li>
|
345 |
<li><strong>Estado de Confirmación:</strong> Indica si la transacción está confirmada o no.</li>
|
346 |
-
<li><strong>Alerta de Mixer / CoinJoin:</strong> Muestra si la transacción presenta patrones de mezcla
|
347 |
</ul>
|
348 |
</div>
|
349 |
-
"""
|
350 |
-
|
|
|
|
|
|
|
|
|
|
|
351 |
|
352 |
-
#
|
353 |
-
|
|
|
|
|
354 |
|
355 |
demo.launch()
|
|
|
3 |
import networkx as nx
|
4 |
import plotly.graph_objects as go
|
5 |
from datetime import datetime
|
6 |
+
import re
|
7 |
+
import os
|
8 |
+
from fpdf import FPDF, HTMLMixin
|
9 |
+
|
10 |
+
# ---------------- Funciones de análisis y grafo -------------------
|
11 |
|
12 |
def get_transaction(tx_id):
|
13 |
"""
|
|
|
50 |
Nodos secundarios: direcciones de inputs y outputs.
|
51 |
"""
|
52 |
G = nx.DiGraph()
|
|
|
53 |
txid = tx_data.get("txid", "desconocido")
|
54 |
G.add_node(txid, color="#FF6B6B", size=20, tipo="Transacción")
|
|
|
55 |
# Inputs
|
56 |
for inp in tx_data.get("vin", []):
|
57 |
prevout = inp.get("prevout", {})
|
|
|
59 |
if addr:
|
60 |
G.add_node(addr, color="#4ECDC4", size=15, tipo="Input")
|
61 |
G.add_edge(addr, txid)
|
|
|
62 |
# Outputs
|
63 |
for out in tx_data.get("vout", []):
|
64 |
addr = out.get("scriptpubkey_address")
|
65 |
if addr:
|
66 |
G.add_node(addr, color="#45B7D1", size=15, tipo="Output")
|
67 |
G.add_edge(txid, addr)
|
|
|
68 |
return G
|
69 |
|
70 |
def check_blockchain_tags(tx_id):
|
71 |
"""
|
72 |
Consulta la API pública de blockchain.com para ver si el TXID (o sus metadatos)
|
73 |
incluye información que indique que la transacción ha sido etiquetada como MIXER.
|
74 |
+
(Esta función es un ejemplo y podría necesitar ajustes según la respuesta real.)
|
75 |
"""
|
76 |
url = f"https://blockchain.info/rawtx/{tx_id}?format=json"
|
77 |
try:
|
78 |
response = requests.get(url, timeout=10)
|
79 |
if response.status_code == 200:
|
80 |
data = response.json()
|
|
|
81 |
tags = data.get("tags", [])
|
82 |
if "MIXER" in tags:
|
83 |
return True
|
|
|
92 |
def analizar_transaccion(tx_id):
|
93 |
"""
|
94 |
Analiza una transacción Bitcoin:
|
95 |
+
- Obtiene datos desde Blockstream.info o mempool.space.
|
96 |
- Calcula totales en BTC y fee, mostrando también su equivalente en USD.
|
97 |
+
- Muestra información adicional (versión, tamaño, peso, fee rate).
|
98 |
+
- Aplica heurística para detectar posibles CoinJoin/mixers.
|
99 |
+
- Incorpora información adicional de blockchain.com (etiqueta MIXER).
|
|
|
|
|
100 |
- Genera un grafo interactivo.
|
101 |
+
- Retorna un informe en HTML y la figura del grafo.
|
|
|
|
|
|
|
102 |
"""
|
103 |
tx_data = get_transaction(tx_id)
|
104 |
if not tx_data:
|
105 |
+
return ("❌ Transacción no encontrada o error al obtener datos. Asegúrate de ingresar un TXID válido."), None, None
|
|
|
106 |
|
107 |
try:
|
|
|
108 |
num_inputs = len(tx_data.get("vin", []))
|
109 |
num_outputs = len(tx_data.get("vout", []))
|
110 |
montos = [out.get("value", 0) / 1e8 for out in tx_data.get("vout", [])]
|
111 |
montos_unicos = len(set(montos))
|
|
|
|
|
112 |
total_input_value = sum(inp.get("prevout", {}).get("value", 0) for inp in tx_data.get("vin", [])) / 1e8
|
113 |
total_output_value = sum(out.get("value", 0) for out in tx_data.get("vout", [])) / 1e8
|
114 |
fee = total_input_value - total_output_value
|
115 |
|
116 |
+
# Heurística para detectar mixer
|
117 |
heuristic_mixer = ((num_inputs > 5 and num_outputs > 5 and montos_unicos < 3) or
|
118 |
(num_outputs > 2 and montos_unicos <= 2))
|
|
|
|
|
119 |
es_mixer_blockchain = check_blockchain_tags(tx_id)
|
|
|
|
|
120 |
es_mixer = heuristic_mixer or es_mixer_blockchain
|
121 |
|
|
|
122 |
if es_mixer:
|
123 |
if heuristic_mixer and es_mixer_blockchain:
|
124 |
mixer_message = (
|
125 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
126 |
+
"La transacción cumple criterios heurísticos y la API de blockchain.com la etiqueta como MIXER.</p>"
|
|
|
127 |
)
|
128 |
elif heuristic_mixer:
|
129 |
mixer_message = (
|
130 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
131 |
+
"La transacción cumple criterios heurísticos compatibles con un mixer.</p>"
|
|
|
132 |
)
|
133 |
elif es_mixer_blockchain:
|
134 |
mixer_message = (
|
135 |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> "
|
136 |
+
"La API de blockchain.com etiqueta esta transacción como MIXER.</p>"
|
|
|
137 |
)
|
138 |
else:
|
139 |
mixer_message = (
|
140 |
"<p style='color: #4CAF50;'><strong>Sin indicios de mezcla:</strong> "
|
141 |
+
"La transacción no presenta patrones de mixer.</p>"
|
142 |
)
|
143 |
+
|
144 |
+
btc_price = get_btc_price() or 0
|
145 |
+
|
|
|
|
|
|
|
|
|
146 |
version = tx_data.get("version", "N/A")
|
147 |
size = tx_data.get("size", None)
|
148 |
weight = tx_data.get("weight", "N/A")
|
149 |
+
fee_rate_str = f"{(fee * 1e8) / size:.2f} sat/byte" if size else "N/A"
|
150 |
+
|
|
|
|
|
|
|
|
|
|
|
151 |
status = tx_data.get("status", {})
|
152 |
block_time = status.get("block_time")
|
153 |
+
fecha_hora_str = datetime.fromtimestamp(block_time).strftime('%Y-%m-%d %H:%M:%S') if block_time else "Desconocida"
|
154 |
+
|
155 |
+
# Generar grafo
|
|
|
|
|
|
|
156 |
G = generar_grafo(tx_data)
|
157 |
pos = nx.spring_layout(G, seed=42)
|
|
|
158 |
edge_x, edge_y = [], []
|
159 |
for edge in G.edges():
|
160 |
x0, y0 = pos[edge[0]]
|
161 |
x1, y1 = pos[edge[1]]
|
162 |
edge_x.extend([x0, x1, None])
|
163 |
edge_y.extend([y0, y1, None])
|
|
|
164 |
node_x, node_y, hover_texts, node_colors, node_sizes = [], [], [], [], []
|
165 |
for node in G.nodes():
|
166 |
x, y = pos[node]
|
|
|
170 |
hover_texts.append(f"Tipo: {tipo}<br>ID: {node[:8]}...")
|
171 |
node_colors.append(G.nodes[node].get("color", "#FFFFFF"))
|
172 |
node_sizes.append(G.nodes[node].get("size", 10))
|
|
|
173 |
fig = go.Figure(
|
174 |
data=[
|
175 |
go.Scatter(
|
|
|
196 |
template="plotly_dark"
|
197 |
)
|
198 |
)
|
199 |
+
|
200 |
+
# Agrupar detalles de outputs (solo si se detecta mixer)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
201 |
if es_mixer:
|
202 |
+
unique_outputs_dict = {}
|
203 |
+
for out in tx_data.get("vout", []):
|
204 |
+
amount = out.get("value", 0) / 1e8
|
205 |
+
addr = out.get("scriptpubkey_address")
|
206 |
+
if addr:
|
207 |
+
unique_outputs_dict.setdefault(amount, []).append(addr)
|
208 |
+
unique_outputs_details = "<ul>"
|
209 |
+
for amt, addrs in unique_outputs_dict.items():
|
210 |
+
unique_outputs_details += f"<li>{amt:.8f} BTC: {', '.join(addrs)}</li>"
|
211 |
+
unique_outputs_details += "</ul>"
|
212 |
+
mixer_metrics = (
|
213 |
+
f"<p>📤 <strong>Outputs:</strong> {num_outputs}</p>"
|
214 |
+
f"<p>💰 <strong>Montos únicos en outputs:</strong> {montos_unicos}</p>"
|
215 |
+
f"<p><strong>Detalles de outputs únicos:</strong></p>{unique_outputs_details}"
|
216 |
+
)
|
217 |
else:
|
218 |
mixer_metrics = ""
|
219 |
+
|
|
|
220 |
total_input_str = f"{total_input_value:.8f} BTC"
|
221 |
total_input_str += f" (${total_input_value * btc_price:,.2f})" if btc_price else ""
|
222 |
total_output_str = f"{total_output_value:.8f} BTC"
|
223 |
total_output_str += f" (${total_output_value * btc_price:,.2f})" if btc_price else ""
|
224 |
fee_str = f"{fee:.8f} BTC"
|
225 |
fee_str += f" (${fee * btc_price:,.2f})" if btc_price else ""
|
226 |
+
|
227 |
+
# Construir el informe HTML
|
228 |
reporte = f"""
|
229 |
<div style="padding: 20px; border-radius: 10px;">
|
230 |
<h3 style="color: {'#FF5252' if es_mixer else '#4CAF50'};">
|
|
|
245 |
<p>🔔 <strong>Estado de Confirmación:</strong> {"Confirmada" if status.get("confirmed", False) else "No confirmada"}</p>
|
246 |
{mixer_message}
|
247 |
<p style="font-size: 0.9em;">
|
248 |
+
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.
|
249 |
</p>
|
250 |
</div>
|
251 |
"""
|
252 |
+
# Además devolvemos (report, fig) para almacenarlos en un estado
|
253 |
+
return reporte, fig, (reporte, fig)
|
254 |
|
255 |
except Exception as e:
|
256 |
+
return f"⚠️ Error durante el análisis: {str(e)}", None, None
|
257 |
+
|
258 |
+
# ---------------- Función para generar el PDF -------------------
|
259 |
+
|
260 |
+
class PDF(FPDF, HTMLMixin):
|
261 |
+
pass
|
262 |
+
|
263 |
+
def generar_pdf(report, fig):
|
264 |
+
"""
|
265 |
+
Genera un PDF que incluye:
|
266 |
+
- Título: ANALISIS FORENSE DE BLOCKCHAIN
|
267 |
+
- El contenido del informe (texto plano, obtenido removiendo etiquetas HTML)
|
268 |
+
- El grafo (convertido a imagen PNG)
|
269 |
+
- Pie de página: "Generado por José R. Leonett"
|
270 |
+
Retorna el PDF como bytes.
|
271 |
+
"""
|
272 |
+
# Guardar el grafo como imagen PNG temporalmente
|
273 |
+
temp_img_path = "temp_graph.png"
|
274 |
+
try:
|
275 |
+
fig.write_image(temp_img_path)
|
276 |
+
except Exception as e:
|
277 |
+
print("Error al guardar la imagen del grafo:", e)
|
278 |
+
temp_img_path = None
|
279 |
+
|
280 |
+
# Extraer texto plano del informe (quitando etiquetas HTML simples)
|
281 |
+
plain_text = re.sub('<[^<]+?>', '', report)
|
282 |
+
|
283 |
+
pdf = PDF()
|
284 |
+
pdf.add_page()
|
285 |
+
# Título
|
286 |
+
pdf.set_font("Arial", "B", 16)
|
287 |
+
pdf.cell(0, 10, "ANALISIS FORENSE DE BLOCKCHAIN", ln=True, align="C")
|
288 |
+
pdf.ln(5)
|
289 |
+
# Contenido del informe
|
290 |
+
pdf.set_font("Arial", "", 12)
|
291 |
+
pdf.multi_cell(0, 10, plain_text)
|
292 |
+
pdf.ln(5)
|
293 |
+
# Agregar la imagen del grafo si existe
|
294 |
+
if temp_img_path and os.path.exists(temp_img_path):
|
295 |
+
pdf.add_page()
|
296 |
+
# Ajustar el ancho a 190 (márgenes de 10) para que se centre
|
297 |
+
pdf.image(temp_img_path, x=10, y=20, w=190)
|
298 |
+
os.remove(temp_img_path)
|
299 |
+
# Pie de página
|
300 |
+
pdf.set_font("Arial", "I", 10)
|
301 |
+
pdf.ln(10)
|
302 |
+
pdf.cell(0, 10, "Generado por José R. Leonett", ln=True, align="C")
|
303 |
+
|
304 |
+
pdf_bytes = pdf.output(dest="S").encode("latin1")
|
305 |
+
return pdf_bytes
|
306 |
+
|
307 |
+
def descargar_pdf(analysis_tuple):
|
308 |
+
"""
|
309 |
+
Función que se activa al presionar el botón DESCARGAR ANALISIS.
|
310 |
+
Recibe como entrada la tupla (report, fig) generada por analizar_transaccion.
|
311 |
+
Si no hay datos, no devuelve nada.
|
312 |
+
"""
|
313 |
+
if not analysis_tuple:
|
314 |
+
return None
|
315 |
+
report, fig = analysis_tuple
|
316 |
+
return generar_pdf(report, fig)
|
317 |
+
|
318 |
+
# ---------------- INTERFAZ GRÁFICA CON GRADIO -------------------
|
319 |
|
|
|
320 |
with gr.Blocks(
|
321 |
theme=gr.themes.Soft(),
|
322 |
title="🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin",
|
323 |
+
css=".custom-btn { background-color: #ADD8E6 !important; color: black !important; padding: 5px 10px; font-size: 12px; }"
|
324 |
) as demo:
|
325 |
gr.Markdown("# 🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin")
|
326 |
gr.Markdown("Desarrollado por José R. Leonett para la comunidad de Peritos Forenses Digitales de Guatemala [www.forensedigital.gt](http://www.forensedigital.gt)")
|
|
|
329 |
with gr.Row():
|
330 |
# Columna Izquierda: Grafo y controles de entrada
|
331 |
with gr.Column(scale=1):
|
|
|
332 |
plot_output = gr.Plot()
|
|
|
333 |
tx_input = gr.Textbox(
|
334 |
label="TXID de la Transacción",
|
335 |
placeholder="Ej: 9dd51e2d45f4f7bddcc3f0f7a05c3fd60543a11cfc9fbd0e1ca4434668cfa3e1"
|
336 |
)
|
337 |
+
analyze_btn = gr.Button("Analizar Transacción", elem_classes=["custom-btn"])
|
338 |
gr.Markdown("### Ejemplos de TXIDs válidos:")
|
339 |
gr.Examples(
|
340 |
examples=[
|
|
|
347 |
# Columna Derecha: Resultados y recuadro explicativo
|
348 |
with gr.Column(scale=1):
|
349 |
reporte_html = gr.HTML()
|
350 |
+
explanation_box = gr.HTML(value="""
|
351 |
<div style="overflow-y: scroll; height: 220px; border: 1px solid #cccccc; padding: 10px;">
|
352 |
<h4>Explicación de los campos:</h4>
|
353 |
<ul>
|
|
|
365 |
<li><strong>Total Salidas:</strong> Suma total de los valores de salida (en BTC y USD).</li>
|
366 |
<li><strong>Fee:</strong> Diferencia entre entradas y salidas, que representa la tarifa de la transacción (en BTC y USD).</li>
|
367 |
<li><strong>Estado de Confirmación:</strong> Indica si la transacción está confirmada o no.</li>
|
368 |
+
<li><strong>Alerta de Mixer / CoinJoin:</strong> Muestra si la transacción presenta patrones de mezcla.</li>
|
369 |
</ul>
|
370 |
</div>
|
371 |
+
""")
|
372 |
+
|
373 |
+
# Estado para almacenar el análisis generado (tupla: (report, fig))
|
374 |
+
analysis_state = gr.State()
|
375 |
+
|
376 |
+
# Al hacer clic en "Analizar Transacción", se actualizan el informe, el grafo y se guarda el estado.
|
377 |
+
analyze_btn.click(fn=analizar_transaccion, inputs=tx_input, outputs=[reporte_html, plot_output, analysis_state])
|
378 |
|
379 |
+
# Botón para descargar el análisis en PDF; se activa sólo si hay datos en analysis_state.
|
380 |
+
download_btn = gr.Button("DESCARGAR ANALISIS", elem_classes=["custom-btn"])
|
381 |
+
pdf_file = gr.File(label="Archivo PDF generado")
|
382 |
+
download_btn.click(fn=descargar_pdf, inputs=analysis_state, outputs=pdf_file)
|
383 |
|
384 |
demo.launch()
|