feat: la nota mide solo lo que dependía de ti

Antes mezclaba dos cosas en un número: tus errores y la exposición que provoca
un tercero. Alguien impecable que cobró de un exchange sacaba peor nota que un
descuidado que cobró de un particular, y no tenía forma de mejorarla. Una nota
que no puedes subir no es una evaluación, es un reproche.

La nota se queda con los checks donde hay decisión tuya. El resto pasa a un
bloque propio, 'lo que hicieron otros', con su nivel de exposición.

Con la nota restringida a lo propio aparecía otro problema: una transacción con
el cambio identificable sacaba 93 y la etiqueta 'privacidad aceptable'. Ahora
cualquier fallo propio de peso >=10 impide la banda alta.

Y OP_RETURN deja de forzar banda BAJA por decreto: condenaba igual a una
inscripción y a un sello de OpenTimestamps.
This commit is contained in:
Aitor
2026-08-08 18:57:23 +02:00
parent 320db70ee5
commit 3d490b32d4
3 changed files with 177 additions and 42 deletions
+104 -42
View File
@@ -1517,23 +1517,22 @@
// de rastreo forense, que necesita saber POR CUÁL output concreto seguir.
// Cortes de banda de la nota de privacidad.
//
// Estaban en 75 y 45 cuando el denominador de la nota era 196. Ese
// denominador ha cambiado a 233 al corregir los pesos y añadir dust_spent,
// así que todas las notas suben ~4 puntos: mantener 75/45 habría relajado
// el listón sin que nadie lo decidiera. Estos valores son los que hacen
// que una transacción reciba hoy exactamente la misma banda que recibía
// antes de tocar nada:
// 75 sobre 196 -> 49 puntos de deducción -> 79 sobre 233
// 45 sobre 196 -> 108 puntos de deducción -> 54 sobre 233
// Desde la v1.17 la nota mide SOLO lo que dependía de ti, así que el
// listón puede ser más exigente: ya no hay nada dentro que no puedas
// arreglar. El denominador es 142 (la suma de `weights`).
//
// AVISO ESTRUCTURAL: normalizar por la suma de todos los pesos tiene un
// efecto perverso — cada check nuevo agranda el denominador y hace que
// todos los problemas anteriores parezcan menos graves. Por eso hay que
// recalibrar estos dos números cada vez que se añade un check, y por eso
// conviene decidir algún día si la nota debe seguir normalizándose o
// restar puntos directamente sobre 100 (como hace analyzeAddress).
const BANDA_ALTA = 79;
const BANDA_MEDIA = 54;
// El corte de ALTA no basta por sí solo — se combina con la regla de
// "fallo grave" en analyzeTx: cualquier check propio que falle con peso
// ≥10 impide la banda ALTA aunque el número dé. Sin esa regla, una
// transacción con el cambio identificable sacaría 93 y la etiqueta
// "privacidad aceptable".
//
// AVISO ESTRUCTURAL: normalizar por la suma de los pesos tiene un efecto
// perverso — cada check nuevo agranda el denominador y hace que los
// problemas anteriores parezcan menos graves. Hay que recalibrar estos dos
// números cada vez que se añade un check con peso.
const BANDA_ALTA = 85;
const BANDA_MEDIA = 55;
// ¿Es una cifra "redonda"? Devuelve la fuerza de la señal, no un sí/no.
//
@@ -1700,17 +1699,28 @@
// una nota negativa, tapada solo por el Math.max(0, …).
// Con los dos desajustes corregidos, un peso de 25 sobre un total de
// 213 significa lo que parece.
// Lo que hiciste TÚ. Solo esto entra en la nota, porque solo sobre esto
// puedes actuar. Una nota que mezcla tu conducta con la de quien te pagó
// deja de significar nada: hasta la v1.16 alguien impecable que cobró de
// un exchange sacaba peor nota que un descuidado que cobró de un
// particular, y no había forma de que la mejorase.
const weights = {
input_reuse: 25, input_linkage: 30, input_type_mixing: 14,
output_type_mismatch: 8, round_numbers: 10,
unnecessary_input: 12, dust: 8, dust_spent: 20, change_detection: 10,
wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15,
round_numbers: 10, unnecessary_input: 12, dust_spent: 20,
change_detection: 10, wallet_fingerprint: 6, legacy_type: 15,
};
// Lo que te hicieron OTROS. Se informa aparte, con su propio recuento,
// y NO resta de la nota. No es que dé igual —te expone lo mismo— es que
// no es una calificación de nada tuyo: es contexto heredado.
const heredado = {
batch_payment: 45, op_return: 30, dust: 8, output_type_mismatch: 8,
};
// Checks informativos: no restan nunca, así que no tienen peso ni entran
// en el denominador. entity_ofac y rbf están aquí por decisión razonada
// (ninguno reduce tu privacidad); peeling porque una tx suelta 1-in/2-out
// no es una cadena de peeling. Ver cada check para el razonamiento.
let deductions = 0;
let deductions = 0; // resta de la nota
let expuesto = 0; // no resta: se informa aparte
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
@@ -1889,9 +1899,9 @@
actionability: "no_corregible",
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
didactic: "Tu wallet devuelve el cambio a una dirección del mismo tipo que las que gasta. Así que si pagas a alguien con un formato distinto al tuyo, el cambio se delata solo: es la salida que coincide con tus entradas. La señal es fuerte, pero no infalible — si quien cobra usa tu mismo formato, o si te pagas a ti mismo, deja de distinguirse. Por eso es probable y no certeza.",
penalty: weights.output_type_mismatch,
penalty: heredado.output_type_mismatch,
});
if (hasOutputMismatch) deductions += weights.output_type_mismatch;
if (hasOutputMismatch) expuesto += heredado.output_type_mismatch;
// ── 4. CoinJoin — informativo, sin penalización ──────────────────
checks.push({
@@ -2041,25 +2051,25 @@
actionability: "no_corregible",
detail: `${dustOutputs.length} output(s) por debajo del umbral de dust (${dustOutputs.map(d=>d.value+"sat").join(", ")}). Hecho: hay salidas por debajo del mínimo económico de la red. Interpretación: podría ser un ataque de dusting, pero también un cambio diminuto de tu propio wallet o una salida de protocolo (Lightning, inscripciones). No se puede distinguir solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos — que es justo lo que persigue un ataque de dusting.`,
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance — pero no toda salida diminuta es un ataque: también las hay legítimas (cambios pequeños, salidas de protocolo). La marca se materializa solo si el dust se gasta junto a otras monedas.",
penalty: weights.dust,
penalty: heredado.dust,
});
deductions += weights.dust;
expuesto += heredado.dust;
} else if (hasDusting) {
checks.push({
id:"dust", label:"Salida muy pequeña (posible dusting)", certainty:"POSIBLE", pass:false,
actionability: "no_corregible",
detail: `${dustingOutputs.length} output(s) de cantidad muy pequeña (${dustingOutputs.map(d=>d.value+"sat").join(", ")}), por encima del mínimo técnico pero inusualmente bajos. Hecho: son salidas diminutas pero gastables. Interpretación: compatible con un dusting de privacidad (cantidades pequeñas enviadas a propósito por encima del umbral de dust para que el receptor las conserve), aunque también con un pago o cambio pequeño legítimo. No se puede confirmar solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos.`,
didactic: "El umbral de dust técnico (≈546 sats) marca lo que la red considera antieconómico de gastar. Pero un dusting de privacidad suele usar cantidades algo mayores —gastables a propósito— para que el receptor las mantenga en su wallet y, al gastarlas, revele qué UTXOs son suyos. Por eso una salida pequeña por encima del umbral técnico merece atención, aunque no sea concluyente.",
penalty: weights.dust,
penalty: heredado.dust,
});
deductions += weights.dust;
expuesto += heredado.dust;
} else {
checks.push({
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:true,
actionability: "no_corregible",
detail: "Sin outputs de dust ni salidas inusualmente pequeñas (según el umbral de cada tipo de salida).",
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance.",
penalty: weights.dust, informational: true,
penalty: heredado.dust, informational: true,
});
}
@@ -2219,9 +2229,9 @@
? `${tx.vout.length} outputs, casi todos con valores distintos: compatible con un exchange o servicio que paga a muchos destinatarios en una sola transacción. Compartes los mismos inputs —y por tanto el mismo origen— con los otros destinatarios del lote, lo que permite a un observador agruparos.`
: "No se detecta patrón de batch payment.",
didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar comisiones. Recibir de un batch reduce tu privacidad: los demás destinatarios comparten origen contigo, así que un observador puede correlacionar todos los pagos del lote. Y si el origen es un exchange con KYC, ese servicio conoce la identidad asociada. Por eso baja la valoración aunque tú no controles cómo te pagaron.",
penalty: isBatch ? weights.batch_payment : 0,
penalty: isBatch ? heredado.batch_payment : 0,
});
if (isBatch) deductions += weights.batch_payment;
if (isBatch) expuesto += heredado.batch_payment;
// ── 15. OP_RETURN ─────────────────────────────────────────────────
// Detecta outputs OP_RETURN (nulldata): presencia y tamaño total en bytes.
@@ -2241,9 +2251,9 @@
? `${opReturnOuts.length} output${opReturnOuts.length > 1 ? "s" : ""} OP_RETURN con ${opReturnBytes} bytes de datos arbitrarios. Revela que esta transacción usa un protocolo o servicio que escribe datos en la cadena (Stamps, Ordinals, OpenTimestamps, Omni, etc.). Se informa la presencia y el tamaño, no el contenido.`
: "No se detectan outputs OP_RETURN.",
didactic: "Un output OP_RETURN contiene datos arbitrarios que cualquiera puede leer. Su presencia revela el uso de un protocolo concreto y puede vincular esta transacción con actividad on-chain identificable. El contenido no se muestra — para la privacidad lo relevante es que existe, no qué dice.",
penalty: hasOpReturn ? weights.op_return : 0,
penalty: hasOpReturn ? heredado.op_return : 0,
});
if (hasOpReturn) deductions += weights.op_return;
if (hasOpReturn) expuesto += heredado.op_return;
// ── 16. Timing analysis — informativo ────────────────────────────
let timingDetail = "No hay timestamp disponible (transacción pendiente o sin confirmar).";
@@ -2364,17 +2374,40 @@
});
}
// ── Score compuesto ───────────────────────────────────────────────
// ── Nota (solo lo que dependía de ti) ─────────────────────────────
const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0);
const rawScore = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
// OP_RETURN es incompatible con privacidad aceptable — fuerza banda BAJA como máximo.
const score = hasOpReturn ? Math.min(rawScore, BANDA_MEDIA - 1) : rawScore;
const score = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
// Un fallo grave impide la banda ALTA aunque el número dé de sobra.
// Sin esta regla, una transacción cuyo cambio es identificable —una fuga
// real y concreta— saldría con 93 sobre 100 y la etiqueta "privacidad
// aceptable", que es exactamente el tipo de afirmación de más que esta
// herramienta existe para no hacer. Un promedio alto no borra un fallo
// concreto.
const falloGrave = checks.some(c =>
!c.informational && c.pass === false && (c.penalty || 0) >= 10 && weights[c.id] !== undefined
);
const band = (score >= BANDA_ALTA && !falloGrave) ? "ALTA"
: score >= BANDA_MEDIA ? "MEDIA" : "BAJA";
// ── Exposición heredada (lo que hicieron otros) ───────────────────
// Cuenta aparte. No resta de la nota porque no califica nada tuyo, pero
// te expone igual y por eso se informa con su propio nivel.
const heredadoMax = Object.values(heredado).reduce((a,b)=>a+b,0);
const itemsHeredados = checks.filter(c =>
!c.informational && c.pass === false && heredado[c.id] !== undefined
).map(c => ({ id: c.id, label: c.label }));
const exposicion = {
puntos: expuesto,
max: heredadoMax,
items: itemsHeredados,
nivel: expuesto === 0 ? "ninguna" : expuesto >= heredadoMax * 0.5 ? "alta" : "moderada",
};
return {
score, checks,
band: score>=BANDA_ALTA?"ALTA":score>=BANDA_MEDIA?"MEDIA":"BAJA",
summary: score>=BANDA_ALTA?"Privacidad aceptable":score>=BANDA_MEDIA?"Privacidad mejorable":"Privacidad baja",
summaryColor: score>=BANDA_ALTA?C.green:score>=BANDA_MEDIA?C.amber:C.red,
score, checks, band, exposicion,
summary: band==="ALTA"?"Privacidad aceptable":band==="MEDIA"?"Privacidad mejorable":"Privacidad baja",
summaryColor: band==="ALTA"?C.green:band==="MEDIA"?C.amber:C.red,
wallets: detectedWallets,
};
}
@@ -2772,6 +2805,7 @@
accionabilidad: c.actionability || null,
penalizacion: c.penalty || 0, detalle: c.detail,
})),
exposicion_heredada: analysis.exposicion || null,
};
descargar(JSON.stringify(datos, null, 2), nombreBase()+".json", "application/json;charset=utf-8");
};
@@ -2782,7 +2816,13 @@
L.push("");
L.push(`**Transacción:** \`${tx?.txid || "—"}\` `);
L.push(`**Generado:** ${new Date().toLocaleString("es-ES")} `);
L.push(`**Nivel de privacidad:** ${band} (${score}/100) — ${summary}`);
L.push(`**Lo que dependía de ti:** ${band} (${score}/100) — ${summary}`);
if (analysis.exposicion) {
L.push("");
L.push(analysis.exposicion.items.length === 0
? `**Lo que hicieron otros:** exposición ninguna. Nada en esta transacción te expone por decisión de un tercero.`
: `**Lo que hicieron otros:** exposición ${analysis.exposicion.nivel} — ${analysis.exposicion.items.map(i=>i.label).join(", ")}. Te expone igual, pero no lo decidiste tú, así que no resta de la nota.`);
}
L.push("");
if (wallets && wallets.length > 0) {
L.push(`**Wallet posible:** ${wallets.map(w=>w.name||w).join(", ")}`);
@@ -2836,7 +2876,9 @@
<Card glow={summaryColor} style={{display:"flex",alignItems:"center",gap:16,padding:"14px 18px",marginBottom:10}}>
<ScoreRing score={score} color={summaryColor} size={72} label={band}/>
<div style={{flex:1}}>
<div style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>NIVEL DE PRIVACIDAD</div>
<div style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>
{tx ? "LO QUE DEPENDÍA DE TI" : "NIVEL DE PRIVACIDAD"}
</div>
<div style={{fontSize:"1rem",color:summaryColor,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>{summary}</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>
{analysis.unused
@@ -2851,6 +2893,26 @@
</div>
)}
{/* Exposición heredada — cuenta aparte, a propósito.
La nota de arriba solo mide lo que podías haber hecho de otra
manera. Esto te expone igual, pero no lo decidiste tú, así
que no baja una nota que no podrías subir. */}
{analysis.exposicion && (
<div style={{marginTop:10,paddingTop:8,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:3}}>
LO QUE HICIERON OTROS
<span style={{marginLeft:6,color: analysis.exposicion.nivel==="ninguna"?C.green:analysis.exposicion.nivel==="alta"?C.red:C.amber}}>
exposición {analysis.exposicion.nivel}
</span>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,lineHeight:1.5}}>
{analysis.exposicion.items.length === 0
? "Nada en esta transacción te expone por decisión de un tercero."
: <>Te expone, pero no lo decidiste tú: <span style={{color:C.amber}}>{analysis.exposicion.items.map(i=>i.label).join(" · ")}</span>. No resta de la nota porque no hay nada que pudieras haber hecho distinto — pero conviene saberlo.</>}
</div>
</div>
)}
</div>
</Card>
@@ -4814,7 +4876,7 @@
// ── 3. Cronología ────────────────────────────────────────────────────
const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({
level: "HECHO", txid, hop, blockTime, blockHeight, amount, refs:[txid],
band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.score>=90?"CERTEZA":analysis.score>=45?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" },
band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.band==="ALTA"?"CERTEZA":analysis.band==="MEDIA"?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" },
fingerprint: { level:"INFERENCIA", certainty: fingerprint?.[0]?.confidence || null, value: fingerprint?.[0]?.name || "sin huella clara" },
entityMarks: entityMarks || [],
});