Skip to content

Audit Round 2 + operacional pre-launch review#24

Open
miningtheblocks wants to merge 123 commits into
baseline-pre-auditfrom
master
Open

Audit Round 2 + operacional pre-launch review#24
miningtheblocks wants to merge 123 commits into
baseline-pre-auditfrom
master

Conversation

@miningtheblocks

Copy link
Copy Markdown
Owner

Review pre-launch v1.1.1

miningtheblocks and others added 30 commits June 15, 2026 20:15
… distribuido

Tres CRIT del audit Round 2 que comparten el área del mint processor + feed
realtime multiplayer. Eran independientes pero juntos cierran el bloque
"robustez onchain + realtime":

1. Schema canónico `minedAt` en mineCube.

   Antes se escribía `ts: Date.now()` en mined/{cubeId}, pero
   firestore.indexes.json declara mined[K, minedAt DESC] y DynamicCube201.js
   subscribe con orderBy('minedAt', 'desc'). El index quedaba unused y el
   feed multiplayer recibía snapshot vacío — solo aparentaba funcionar
   por el optimistic local update del usuario que minó.
   (Agentes #2 CRIT + #5 CRIT-1 + #12 cross-ref)

2. tx.wait(SAFE_CONFIRMATIONS=30) en runMintProcessing.

   Polygon PoS confirma checkpoints cada ~30min y reorgs de hasta 100
   bloques se observaron históricamente. tx.wait() sin parámetro vuelve al
   primer block include — un reorg posterior puede revertir el mint
   mientras Firestore ya tiene status:completed. User queda sin NFT
   on-chain y sin trail para reclamo.

   30 bloques @ ~2.2s ≈ 66s extra de espera por mint. Aceptable para un
   cron 5min con queue chica; reduce a ~0% el riesgo de "NFT fantasma".
   (Agentes #3 CRIT-6 + #8 CRIT-1)

3. Lock distribuido en `runtime/mintProcessor` con TTL 10min.

   Antes, `processPendingMints` (onCall admin manual) y
   `mintProcessorScheduled` (onSchedule cron 5min) podían correr
   concurrentes y enviaban mintGem() con el MISMO nonce de la company
   wallet → una falla con "nonce too low", MATIC quemado en gas, race
   en doc.status=processing, backlog stuck.

   Lock pattern: TX read snapshot, check expiresAt, write si libre.
   Auto-release en finally + TTL backup si el worker crashea. Las rules
   son default-deny para /runtime/* — cliente no puede tocarlo.
   (Agentes #3 CRIT-4 + #8 CRIT-3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…heckRevoked, password reset)

Cierra los 3 CRIT del bloque "identity" del Audit Round 2 — todos comparten
la ventana de 60min de account takeover que existía post-revocación.

1. Wallet hot-swap fix en claimGemNFT + submitGemClaim.

   Antes ambas funciones leían walletAddress del body del request → bypaseaban
   el cooldown de 24h de setUserWallet. Durante un takeover (atacante con
   token válido), podía claimear NFTs / submit gem claims a SU wallet aunque
   setUserWallet siguiera bloqueado por cooldown.

   Ahora ambas leen walletAddress server-side desde users/{uid}.walletAddress.
   La única vía para setear/cambiar wallet pasa por setUserWallet (cooldown
   + validación de formato). request.data.walletAddress se ignora silenciosamente
   por backwards-compat con clientes pre-Commit-B; el web claim form sigue
   mostrando el campo pero queda como TODO sacarlo (debe leer userSnap).
   (Agente #1 HIGH-4 + Agente #6 + Agente #8)

2. assertFreshToken helper + aplicación en 3 operaciones financieras.

   El runtime de Firebase Functions decodea el JWT pero NO chequea
   tokensValidAfterTime. Sin esto, un token robado o post-revoke sigue
   válido hasta el TTL JWT (~60min). El helper compara auth_time del token
   contra user.tokensValidAfterTime via Admin SDK (mismo patrón que
   requireAdminFresh). Bonus: chequea user.disabled (ban via Console).

   Aplicado en: claimGemNFT, createCryptoPayment, submitGemClaim.
   submitGemClaim usa la forma onRequest equivalente: verifyIdToken(token, true).
   NO aplicado a mineCube (hot path, ~100 calls/sesión); daño con token
   revocado acotado a picks remanentes.
   (Agente #6 CRIT)

3. requestPasswordReset Cloud Function reemplaza sendPasswordResetEmail directo.

   Antes el cliente llamaba Firebase Auth SDK Web → email genérico de Firebase,
   sin revoke de tokens (account takeover playbook clásico) y sin notify al
   user real durante un reset iniciado por atacante con email comprometido.

   Nueva función:
   - Anti-enumeration: rate-limit 3/hora por email + retorno OK siempre.
   - revokeRefreshTokens(uid) ANTES de mandar el link → todas las sesiones
     existentes invalidadas. User real re-loguea con la new password OK
     (token nuevo con auth_time > tokensValidAfterTime). Atacante con
     sesiones activas las pierde.
   - Email branded con texto explícito "tus sesiones fueron cerradas; si no
     fuiste vos ignorá este email — tu password actual sigue OK".

   Login.js actualizado para usar callRequestPasswordReset en lugar del Web
   SDK directo. sendPasswordResetEmail removido de imports.
   (Agente #6 CRIT)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rkGemRedeemed, payment checkpoint)

Cierra los 3 CRIT del bloque crypto/economy del Audit Round 2 — todos
relacionados con pérdida monetaria silenciosa.

1. effectiveSeed = HMAC(SERVER_SEED, "mtb-seed-v1|serverId|ep:N") en helpers.

   Antes el SERVER_SEED era global: si se filtra (vía malware en máquina
   dev + firebase-tools.json + gcloud secrets versions access), un atacante
   podía pre-calcular TODAS las posiciones de gemas tier-1 ($100k) en TODOS
   los servers presentes Y futuros — leak retroactivo + futuro permanente.

   Con effectiveSeed derivado per (serverId, episodeNumber): un leak del
   SERVER_SEED actual solo compromete las posiciones de los servers
   activos en ese momento. Plan de rotación futura: bumpear el prefix
   "mtb-seed-v1|" a "v2|" + introducir SERVER_SEED_v2 → nuevos servers
   usan v2, los in-flight terminan con v1 (preservando determinismo).

   Backwards-compat: si episodeNumber es null/undefined, se devuelve el
   rootSeed sin derivar — tests existentes (24 passing) pasan sin cambio.
   mineCube siempre pasa episodeNumber explícito.
   (Agente #1 HIGH-12 + Agente #11 CRIT-11-05)

2. runCryptoPaymentProcessing usa checkpoint persistido.

   Antes: ventana fija de 200 bloques hacia atrás (~6.6 min Polygon). Si
   el scheduler se atrasaba (publicnode.com outage, GCF cold start lento,
   function timeout retry), pagos USDC legítimos quedaban fuera de la
   ventana → user paga $15 USDC, doc pendingCryptoPayments expira a los
   30min sin acreditar, soporte humano para reconciliar.

   Ahora: runtime/cryptoPaymentCheckpoint persiste lastBlockProcessed.
   Cada run lee desde checkpoint+1 hasta safeBlock. Si una query RPC falla
   en cualquiera de los 2 contratos USDC, NO se avanza el checkpoint —
   próximo run reintenta el rango (processedTxs/{txHash} previene
   doble-crédito). Cap defensivo de 5000 bloques (~3h) para evitar
   queries gigantes tras un outage largo (los pagos fuera del cap quedan
   en logs para investigación).
   (Agente #8 CRIT)

3. markGemRedeemed Cloud Function para evitar double-pay.

   Antes: el admin pagaba en cash (Stripe/wire/crypto a wallet del user)
   y marcaba la gema como redeemed a mano via Firebase Console. Si el
   admin se olvidaba — o si el user re-submitGemClaim antes de la marca
   — el admin podía pagar 2× la misma gema (hasta $100k tier-1).

   Ahora: callable admin-only que (1) busca la gema por code via
   collectionGroup, (2) TX atómica idempotente que rechaza si ya
   redeemed/minted, (3) registra paymentRef + adminNote + redeemedBy,
   (4) marca el gemClaim asociado (si vino del web claim form),
   (5) audit log en adminActions con código + ownerUid + paymentRef.

   Requiere requireAdminFresh (claim admin verificado fresh via Admin SDK,
   no del token cacheado). No expuesto en src/firebase/functions.js —
   admins lo invocan via script o admin UI futura.
   (Agente #8 CRIT-5)

Tests: 24/24 helpers tests pasan sin cambio. assertFreshToken de Commit B
+ checkRevoked de submitGemClaim ya cubren auth de las funciones tocadas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…list + deep-link

Cierra Fase 2 del Audit Round 2. 6 items que comparten "infraestructura cross-cutting"
del frontend ↔ backend.

1. requireRegistered con whitelist explícito de providers.

   Pre-fix: blacklist ("rechazar 'anonymous'"). Si Firebase habilita un
   provider nuevo (Anonymous via Console, OIDC custom, beforeSignIn blocking
   function que devuelve un provider desconocido), el flow lo aceptaba por
   default. Whitelist = default-deny: solo password / google.com / apple.com
   pasan. Provider desconocido → permission-denied:provider_not_allowed:{X}.
   (Agente #6 CRIT)

2. notifyAllUsers FCM/Expo ramificación + token cleanup automático.

   Pre-fix: TODOS los tokens iban al endpoint Expo Push API (incluyendo los
   FCM nativos que el cliente registra desde V1.1.0+). Expo respondía error
   sin tirar excepción → audit log decía "sent=N" cuando realmente fueron 0.
   La única broadcast feature del producto NO entregaba mensajes y el admin
   nunca se enteraba.

   Fix: ramificar por pushTokenType. FCM via
   getMessaging().sendEachForMulticast (hasta 500 tokens/call, devuelve
   success/error por token). Expo legacy sigue por exp.host. Tokens con
   responses "registration-token-not-registered" / "invalid-registration-token"
   se borran en batch del user doc.
   (Agente #10 CRIT-10-01 + CRIT-10-03)

3. sendPushToUser respeta settings.notify* del user + data payload + cleanup.

   Pre-fix (Agente #4 + #10 CRIT-10-02): los toggles de Config.js
   (notifyAdReady, notifyDaily, notifyRewards, notifyNewLayer) se persistían
   en users/{uid}.settings.* pero el backend nunca los leía — dark pattern
   explícito reportable a Play Store / GDPR.

   Fix: signature ampliada a sendPushToUser(uid, titles, bodies, opts). Si
   opts.notifyKey está y user.settings[notifyKey]===false, skip silencioso.
   Default opt-in (undefined → enviar, conserva flow viejo).

   3 call sites actualizados: mint complete + payment received + referral
   bonus pasan { notifyKey: 'notifyRewards', data: { url: ... } }.

   data payload + Object.fromEntries(String(v)) para que FCM acepte (FCM
   exige todos los values como strings).
   (Agente #10 CRIT-10-02 + CRIT-10-03 + HIGH-10-09)

4. t(key, vars) motor con interpolación + __DEV__ warnings.

   Pre-fix: 17 call sites reimplementaban .replace('{x}', y) — riesgo de
   reentrancia (si value contenía '{other}' el segundo .replace lo
   interpretaba), keys faltantes mostraban el key literal al usuario.

   Nuevo motor:
   - t('msg', { name, h }) interpola {name} y {h} con String(v).
   - En __DEV__ warna si la key no existe o un var declarado no fue provisto.
   - Object.prototype.hasOwnProperty.call para evitar prototype pollution.

   Profile.js migrado a la nueva API (las 2 sites con walletCooldown +
   emailNotVerified). Los otros 15 call sites siguen funcionando con la
   nueva t() porque vars es opcional (backwards-compat).
   (Agente #10 CRIT-10-04 parcial — full migration de los 15 sites queda P2)

5. addNotificationResponseReceivedListener + DeepLinkHandler ampliado.

   Pre-fix: push payload sin data → tap del push solo abre la app en la
   última screen. User que recibe "Tu NFT llegó!" no llega a MyGems.

   Fix: response listener en App.js dispara Linking.openURL(data.url) si
   el push trae data.url con scheme conocido. DeepLinkHandler ampliado de
   solo 'peaks' a peaks/gems/profile/buycredits/config/servers — alineado
   con los modal keys del OverlayModalsProvider.

   Subscription tracked en let-binding + cleanup en outer useEffect return
   para evitar leak (paralelo al notifSetupTimer cleanup).
   (Agente #10 HIGH-10-09 + HIGH-09-04 cross-ref)

Tests: 24/24 helpers tests pasan sin cambio. functions/index.js parsea OK.

Fase 2 cerrada (4 commits + Tier 1):
  Tier 1:   1e1e160 (6 quick wins)
  Commit A: d33f627 (schema + reorg + nonce lock)
  Commit B: c9b2544 (wallet hot-swap + checkRevoked + password reset)
  Commit C: 545c92c (effectiveSeed + payment checkpoint + markGemRedeemed)
  Commit D: <hash>  (push + i18n + providers + deep-link)

14 CRIT del Audit Round 2 cerrados. Próximo: Fase 3 (compliance + ops).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra los items legales y los de accesibilidad del funnel crítico (Login,
Registration, BuyCredits, Config).

1. terms.html (docs/ + public/): fix CNZF Argentina + retention align.

   Pre-fix: la sección "Help Resources" (cláusula 14.3, versión EN) listaba
   "CNZF — Centro Nacional de Zonas Fuera (CNZF)" como helpline para
   Argentina. Esa entidad NO EXISTE — acrónimo inventado en algún copy-paste
   roto. Para una app gambling+crypto, mostrar una organización inexistente
   a víctimas de adicción es un riesgo legal directo (potencial "engaño
   activo" en demanda). La versión ES (cláusula 14.3 español) sí listaba
   correctamente Jugadores Anónimos + CONADIC.
   Fix: EN alineado con ES (Jugadores Anónimos + CONADIC Mexico).

   Pre-fix: privacy.html decía "registros de canje: hasta 5 años";
   terms.html decía "indefinidamente para auditoría". Una autoridad
   regulatoria interpreta la inconsistencia como ambiguous notice →
   violación de lawful basis.
   Fix: 5 años en ambos docs, alineado con AML/KYC. Blockchain TX
   (Polygon) reconocidas como permanentes por diseño.
   (Agente #10 MED-10-39 + MED-10-41)

2. privacy.html (docs/ + public/): add Cloudflare + GitHub + SCC disclosure.

   Pre-fix: la lista de sub-procesadores solo incluía Google/Firebase,
   Polygon, Pinata. Faltaban:
   - Cloudflare (DNS registrar de miningtheblocks.com — procesa metadata
     DNS al visitar el sitio).
   - GitHub (Pages hostea docs/privacy.html, terms.html, adpick.html —
     procesa IPs de visitantes).
   - Standard Contractual Clauses (SCC) post-Schrems II para transferencia
     UE→USA de datos vía Google/Firebase.

   Fix: sección "Sub-procesadores" expandida con links a las políticas de
   ambos, + cláusula SCC explícita para users UE.
   (Agente #10 HIGH-10-36 + MED-10-42)

3. Login.js a11y:
   - TextInput email + password con accessibilityLabel explícito (TalkBack
     sin esto solo lee placeholder en algunos casos).
   - Sign In button accessibilityState={ disabled, busy } para que el SR
     anuncie "no disponible" / "cargando" en lugar de un genérico "botón".
   - Forgot password TouchableOpacity con paddingVertical:12 + hitSlop
     para llegar a 44pt mínimo (WCAG 2.5.5 + Android UX).
   - Language pills con accessibilityState.selected + hitSlop.
   - placeholderTextColor #555 → #888 (WCAG-AA contrast).
   (Agente #10 MED-10-43, MED-10-44, MED-10-45)

4. Registration.js a11y:
   - placeholderTextColor #555 → #888 en TODOS los TextInput (replace_all).
   - accessibilityLabel en los 6 inputs principales (firstName, lastName,
     username, phone, email, password, confirmPassword).
   - Save button accessibilityState={ disabled, busy }.

5. BuyCredits.js a11y:
   - Copy amount TouchableOpacity con accessibilityLabel="Copy {amount} USDC"
     + accessibilityHint con la instrucción "sendExactly".
   - Copy wallet TouchableOpacity con accessibilityLabel descriptivo +
     accessibilityState.disabled cuando wallet inválida.
   - newPayment button con accessibilityState.busy durante loading.

6. Config.js touch target:
   - toggleBtn paddingVertical 8 → 12 + minHeight:44 + justifyContent:center.
     Pre-fix: ~32dp altura, sub-mínimo WCAG. Post: 44dp+.

DEFERRED a P3: a11y completo en Profile, GetPeaks, MyGems, ServerList,
HowToPlay (cubrir los ~70 TouchableOpacity restantes). Patron establecido
acá facilita los grep+replace masivos en una iteración futura.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rywhere)

Cierra dos gaps de compliance / takeover response del Audit Round 2.

1. deleteMyAccount — GDPR Art. 17 + Play Store policy compliance.

   Pre-fix: el user solo podía pedir borrado por email a soporte
   (privacy.html promete <30 días). Bloqueante para Play Store policy
   (mayo 2024: self-serve account deletion in-app obligatorio para apps
   con login) y para GDPR right to erasure escalable.

   Estrategia: soft-delete con anonimización + 5y retention para AML/KYC.
   - users/{uid} preservado pero pierde TODO el PII (email, profile, avatar,
     pushToken, walletAddress, referralCode/referredBy, settings, language).
     Mantiene `deletedAt` timestamp + displayName='[deleted]'.
   - notifications/ → batch delete (no retention requirement).
   - gems/ → SE PRESERVA (NFT records + 5y AML/KYC retention).
   - usernames/{handle} → released para que otros lo puedan reclamar.
   - pendingCryptoPayments waiting → cancelled (libera amount slots).
   - Auth user → deleteUser (revoca refresh tokens automáticamente).
   - Audit log en adminActions.

   Protecciones:
   - requireRegistered + assertFreshToken (~5min token freshness mínimo).
   - Rate-limit 1/hora por uid (anti-accidente).
   - Errores parciales no rompen el flujo total (cada paso try/catch).
   (Agente #6 MED-15 + #10 HIGH-10-38 + #11 MED-11-43)

2. revokeMySessions — self-serve "logout everywhere" para sospecha takeover.

   Pre-fix: si el user sospechaba acceso no autorizado en otro device, la
   única vía era contactar admin → admin via Firebase Console llamaba
   revokeRefreshTokens manualmente. Latencia operacional alta + dependencia
   de canal humano.

   Nueva CF: requireRegistered + assertFreshToken + rate-limit 5/h, llama
   getAuth().revokeRefreshTokens(uid) + audit log. El user sigue logueado
   en este device hasta el próximo refresh (~5min Firebase JWT TTL); la
   UI hace signOut local para cierre inmediato del cliente actual.
   (Agente #6 MED + #11 MED-11-46)

3. UI: "Danger zone" en Profile.js al final del scroll.

   Dos botones agrupados visualmente:
   - 🔐 "Cerrar sesión en todos los dispositivos" — borde rojo suave
     (#3a2222), confirmación con AppAlert + style:cancel/destructive.
   - ⚠️ "Eliminar mi cuenta" — texto rojo (#ff6b6b), borde más oscuro
     (#5a1414), warning explicando retention 5y + irreversibilidad.

   Ambos botones con accessibilityLabel + accessibilityState.busy durante
   ejecución. minHeight:44 para WCAG-AA touch target.

4. i18n: 12 keys nuevas EN+ES bajo profile namespace (dangerZone,
   logoutEverywhere*, deleteAccount*, cancel).

5. callDeleteMyAccount + callRevokeMySessions en src/firebase/functions.js.

Tests: functions/index.js parsea OK. helpers tests no afectados.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… scrub PII)

Cierra 4 items operacionales del Audit Round 2 que tenían en común "señales
que existían pero nadie miraba" + leak de PII en logs.

1. maticBalanceCheckScheduled — alert proactivo de MATIC bajo.

   Pre-fix (Agente #11 CRIT-11-02 + #12 MED-11-49): mints fallaban
   silenciosamente cuando MATIC llegaba a 0. 5 retries × ~30s = 2-3h de
   holders esperando ANTES del email de fail (que solo se manda tras
   FALLAR 5 veces). Sin proactive alert.

   Nueva CF cada 6h: lee balance via Polygon RPC, compara contra
   threshold 2 MATIC (~50 mints futuros @ gas típico). Si < threshold:
   - dedupe via runtime/maticBalanceAlert.lastAlertAt (max 1/día).
   - email a NOTIFY_EMAIL con balance, threshold, instrucciones para top-up.

2. errorLogSummaryWeekly — digest semanal del errorLog Firestore.

   Pre-fix (Agente #11 HIGH-11-21): errorLog era write-only en regla; nadie
   lo leía programáticamente. El dev tenía que abrir Firebase Console
   manualmente y scrollear. Regresiones del cliente quedaban invisibles
   hasta que un user reportaba "la app me crashea" via reportProblem.

   Nueva CF every monday 08:00 ART: query últimos 7 días, agrupa por scope,
   email HTML con top 25 scopes ordenados por count + sample message.

3. adminActionsAnomalyWeekly — anomaly detection en adminActions.

   Pre-fix (Agente #11 HIGH-11-22): adminActions también write-only; nadie
   monitorea. Si un admin se compromete y hace 50 grant_admin +
   addServerCredit + notifyAllUsers, el log existe pero el operador no se
   entera hasta auditoría manual mensual (que no está agendada).

   Nueva CF every monday 08:30 ART: agrupa últimos 7 días por adminUid +
   action. Si CUALQUIER admin tuvo >50 ops/sem, marca ⚠️ HIGH en la fila
   + subject line con flag. Email HTML con 2 tablas (by admin, by action).

4. PII scrub:

   a) sendVerificationEmail: pre-fix logueaba `prefix:
      verificationLink.slice(0, 80)` que incluye el oobCode (1h-valid
      password reset token) + email del user. Fix: usar URL parser para
      loguear solo protocol+host (suficiente para diagnóstico).
      (Agente #11 MED-11-25)

   b) src/utils/logError.js: pre-fix scrubeaba PII solo por nombre de key
      (_SENSITIVE_KEY_RE). Si un caller pasaba `{ formData: { email:
      'a@b.com' } }`, el value leakeaba porque "formData" no matchea.
      Fix: scrub adicional por VALOR con 3 regex (email, wallet 0x{40},
      phone +CC{8-15}) reemplazando por placeholders genéricos preservando
      el resto del contexto para debug.
      (Agente #4 MED-FE-23 + Agente #11 MED-11-41)

Tests: 24/24 helpers pasan, functions/index.js parsea OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra Agente #10 HIGH-10-10 — pre-fix había 1 solo channel Android
('default' con importance HIGH). El usuario solo podía mute/unmute el
channel entero — recibir alerts críticos de NFT minting pero apagar
broadcasts de marketing era imposible.

Cambios:

1. App.js: 5 channels Android declarados al cold-start:
   - default (legacy compat)
   - mint (NFT mints) — HIGH importance
   - payment (credit purchase confirmations) — HIGH
   - referral (referral bonuses) — HIGH
   - marketing (broadcast del equipo) — DEFAULT (menor prioridad, mute fácil)

   Todos con sound:'default' + vibrationPattern uniforme. Solo el
   description difiere para que el user entienda qué mute en
   Settings → App → Notifications.

2. sendPushToUser: nuevo opts.channelId (default a 'default' si caller
   no especifica). FCM payload android.notification.channelId enrutado.

3. 3 call sites internos actualizados:
   - mint complete → channelId='mint'
   - payment received → channelId='payment'
   - referral bonus → channelId='referral'

4. notifyAllUsers (broadcast) → channelId='marketing'. Sin esto, los
   broadcasts caían al default channel HIGH y competían con alerts
   críticos de mint.

Impact: users pueden ahora mute marketing/referrals sin perder los
HIGH-priority alerts financieros. Cumple Android Notification Channels
best practices (oblig. para apps targetSdk 26+).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ict, gradle)

Cierra 3 items operacionales del Audit Round 2 sobre developer experience
y supply chain.

1. Pre-commit hooks (husky v9).

   Pre-fix (Agente #11 HIGH-11-31 + HIGH-11-32): cualquier `git commit`
   directo podía committear secretos accidentales, archivos sensibles
   (keystore, SA JSON), console.log de debug, o código que rompía el
   lint. El CI los pescaba DESPUÉS del push.

   Setup:
   - husky ^9.1.7 agregado a devDependencies.
   - "prepare": "husky || true" se corre al npm install y registra el
     hook. El `|| true` evita romper installs en CI / fresh clones.
   - .husky/pre-commit: shell script con 3 validaciones:
     1. Bloquea archivos sensibles staged (jks/keystore/pkcs12/pem/
        service-account*.json, firebase-adminsdk-*.json, .env, .env.local).
     2. Bloquea secretos obvios en el DIFF (no solo en filenames):
        Ethereum private keys (0x[64hex]), AWS keys (AKIA*), Stripe live
        keys (sk_live_*), GitHub PATs (ghp_*, github_pat_*).
     3. Si hay cambios en functions/, corre `npm run -s lint` antes de
        permitir el commit.

   Para bypass (raro, debe justificarse): git commit --no-verify.

2. npm audit estricto en CI.

   Pre-fix (Agente #11 MED-11-33): `npm audit --omit=dev --audit-level=high
   || true` + `continue-on-error: true` → vulns nunca rompían CI. La
   justificación era "vulns transitivas en dev-deps" pero --omit=dev YA
   las excluía.

   Fix: removido `|| true` + `continue-on-error: false`. Threshold
   `--audit-level=critical` (high+critical son advisories públicos que
   merecen fix antes de merge; moderate/low son ruido para apps mobile
   sin endpoints exposed). Si aparece falso positivo, se documenta la
   exclusión inline con el porqué.

3. Dependabot gradle ecosystem.

   Pre-fix (Agente #11 MED-11-34): Dependabot solo cubría npm + github-
   actions. Vulns en AGP, Kotlin runtime, play-services-* requerían watch
   manual de Android security bulletins. Para una app sideload sin Play
   Store haciendo scanning automático, este gap era operacional.

   Fix: nuevo entry package-ecosystem:gradle directory:/android weekly,
   con ignore explícito de AGP major bumps (afectan config Gradle entera,
   requieren plan deliberado).

DEFERRED a Commit J (RUNBOOK.md) — Sentry RN integration. Requiere setup
externo (DSN en Sentry.io, expo-sentry config plugin) que excede scope de
commit code-only. Documentado como step manual en runbook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ndiente

Cierra Fase 3 con el deliverable de documentación operacional.

Cierra Agente #11 HIGH-11-44 — pre-fix: 0 runbooks, dev improvisaba durante
incidentes. Para single-operator en dominio adversarial (crypto + gambling
+ sideload + dinero real), el gap operacional más grande no era técnico
sino informacional.

Contenido (~400 líneas estructuradas):

1. Quick reference — recursos críticos (Firebase project, contract address,
   wallets, RPCs, keystore, secrets) en una sola tabla.

2. Detección de incidentes — señales automáticas (subjects de email de las
   3 CFs de alerts del Commit G) + señales manuales.

3. Matriz de DR con 12 escenarios completos. Cada uno: detección, impacto,
   tiempo a recovery, mitigación pre-incident, action plan paso a paso.

   1) COMPANY_WALLET_KEY leak / contract owner pwn (irreversible sin multisig)
   2) firebase-tools.json leak / project pwn (irreversible sin SA dedicado)
   3) Keystore JKS pérdida (irreversible sin 3 backups offline)
   4) Cloudflare account compromise / DNS hijack
   5) GitHub account compromise / APK falso en Releases
   6) Gmail compromise / GMAIL_APP_PASSWORD rotation
   7) SERVER_SEED leak (post-Commit C blast radius limitado a server-episode)
   8) pendingMints stuck en processing (gemas perdidas)
   9) MATIC balance = 0 en company wallet
   10) Polygon RPC outage
   11) Firestore corruption / full_reset accidente
   12) DDoS / cost explosion

4. Setup operacional pendiente — 8 acciones que requieren intervención
   manual fuera del repo:
   - Service Account dedicado para scripts admin (cierra CRIT-11-03).
   - Branch protection en master via gh CLI.
   - TTL en Firestore Console (errorLog, rateLimits, processedTxs, adSessions).
   - Budget alerts en Firebase Console ($50/$100/$200/$500).
   - 3 backups offline keystore JKS verificados (sha256).
   - Multisig migration del contrato (1-2 sem pre-launch).
   - Sentry RN integration (3-4h con DSN setup).
   - App Check (2-3h, anti-APK-reverse-engineering).
   - Status page público (1h con betterstack.com free).

5. Tareas recurrentes — trimestral, anual, por release.

6. Comandos útiles — firebase functions:log/secrets/shell, gcloud import,
   apksigner verify, sha256sum.

7. Cierre del Audit Round 2 — síntesis de qué cerraron los commits Tier 1
   → J y qué queda como próximas iteraciones (a11y completo, i18n migration,
   dead code, tests cliente, ops above).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
eslint --fix corrigió 9/10 errors automáticamente:
  - Multi-space comments
  - Brace-style violations en catch blocks de single-line
  - block-spacing inconsistencies

Manual fix sobre el último error (no-empty en catch _): agregué comentario
inline en lugar de --no-empty para mantener la regla activa para futuros
catch que sí deberían loguear.

Tests: 24/24 helpers pasan. functions/index.js parsea OK.
…base, Cache-Control, CSP defensa)

Cierra 4 HIGH/MEDIUM del Agente #7 Audit Round 2. Cambios de texto en
HTML/JSON sin refactor de comportamiento — risk bajo, ROI alto.

1. verifyGemCode CORS: wildcard `*` → allowlist explícito.

   Pre-fix (Agente #7 MED): cualquier origen podía hacer GET a verifyGemCode
   y enumerar códigos de gemas. El rate-limit 30/min/IP mitigaba bulk-enum
   pero la inconsistencia con submitGemClaim (que sí usa
   setRestrictedCorsHeaders) era reportable.

   Fix: setCorsHeaders(res) → setRestrictedCorsHeaders(req, res). Ahora
   solo miningtheblocks.com / www / miningtheblocks.github.io pueden hacer
   el call. Browser/server-to-server desde otro origen → CORS error.

2. SRI (Subresource Integrity) en Firebase SDK de docs/index.html.

   Pre-fix (Agente #7 HIGH-3): <script src="gstatic.../firebase-X.js"> sin
   integrity. gstatic.com es Google CDN (raro pero supply chain attacks
   documentados en otros CDNs como polyfill.io). Para una página que
   tramita gem claims hasta $100k, integrity verification es defense in
   depth real.

   Fix: integrity="sha384-..." + crossorigin="anonymous" en los 2 script
   tags de Firebase 10.13.2 (app-compat + auth-compat). Hashes computados
   con `curl URL | openssl dgst -sha384 -binary | openssl base64 -A`.

   IMPORTANTE: si bumpean SDK Firebase, hay que recomputar los hashes.
   Comentario inline lo recuerda.

3. Cache-Control: no-store para verify.html.

   Pre-fix (Agente #7 MEDIUM): verify.html maneja oobCode (token de reset
   de 1h valid). Sin Cache-Control, el browser cacheaba la página con el
   token en URL; otro user en el mismo device podía abrirla del back button
   + reset password ajeno.

   Fix: header específico para verify.html en firebase.json:
   `Cache-Control: no-store, no-cache, must-revalidate, private`.

4. Meta CSP defensiva en verify.html.

   Pre-fix (Agente #7 MEDIUM): verify.html NO tiene meta CSP, depende 100%
   del header HTTP firebase.json. Si por error un deploy pierde el header
   (config diff, regla de hosting equivocada), no hay CSP en la página.

   Fix: meta http-equiv="Content-Security-Policy" con el MISMO contenido
   del header. Defense in depth — los browsers aplican la POLÍTICA MÁS
   ESTRICTA si ambos están presentes.

5. Extras en firebase.json:
   - Permissions-Policy expandido: payment=(), usb=(), browsing-topics=(),
     interest-cohort=(), accelerometer/gyroscope/magnetometer/midi/serial/
     bluetooth=(). Bloquea APIs sensibles que la web NO usa.
   - CSP tightening: agregamos base-uri, form-action, object-src none,
     upgrade-insecure-requests (defense in depth contra base tag injection
     + insecure form action).

DEFERRED (requieren browser testing — siguiente iteración):
- HIGH-2: unsafe-inline removal en docs/index.html (refactor: extraer 7
  onclick + 250 líneas de <script> inline a archivo externo).
- HIGH-1: sandbox iframe para docs/adpick.html (riesgo de romper ad
  pipeline → revenue).

Tests: 24/24 helpers pasan. firebase.json JSON valid. index.js parsea OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ecesarias)

Cierra Agente #4 MED-FE-17 + Agente #5 MEDIUM (dead code confirmados por
grep como NO importados desde ningún módulo activo).

Archivos eliminados (verificados vía `grep -rn from.*Name` → 0 matches en
src/ y App.js):

  - src/components/MassiveCube.js       (169 LOC) — usaba @react-three/fiber
  - src/components/Layer100Renderer.js  (250 LOC) — exp del renderer alterno
  - src/components/FaceGrid201.js       (484 LOC) — renderer de cara alterno
  - src/utils/ThreeSetup.js             (104 LOC) — helper de three.js sin uso
  - src/utils/CubeCalculations.js       (150 LOC) — math helpers ahora en helpers.js backend
  - src/utils/ads.js                    ( 66 LOC) — RewardedAd manual; el flow real está inline en GetPeaks.js

Total: ~1223 LOC eliminadas del bundle JS del cliente.

Deps removidas de package.json (solo usadas por MassiveCube.js):
  - @react-three/fiber  (^9.3.0)
  - @react-three/drei   (^10.7.6)

Reducción de bundle estimada: ~650KB (deps no minificadas) según Agente #4.
APK más liviano → install rate más alto + cold-start más rápido.

NO ELIMINADO (deferred por scope — refactor de DynamicCube201.js requiere
test browser):
  - State `miningAnimations` + `setMiningAnimations` en DynamicCube201.js:1505
    (state nunca actualizado pero referenciado en 4+ lugares — necesita
    revisión cuidadosa para evitar regresiones en animaciones).
  - Ref `minedPollRef` en DynamicCube201.js:1514 (mismo).

Próximo paso operacional (no código): `npm install` para actualizar
package-lock.json + sacar @react-three/{fiber,drei} de node_modules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…:// scheme, ProGuard tight)

Cierra 5 hallazgos MED del Agente #9 sobre attack surface y fingerprint
del APK.

1. Nuevo scheme `mtb://` agregado al intent filter (MED-09-13).

   Pre-fix: solo `exp+miningtheblocks://`. El prefix `exp+` delata la app
   como Expo a cualquier static analyzer del APK — atacante puede targetear
   payloads conocidos contra Expo Go / dev-client.

   Fix: agregamos `mtb://` como segundo intent filter, MANTENEMOS
   `exp+miningtheblocks://` por backwards-compat con users existentes que
   no actualicen inmediatamente. App.js DeepLinkHandler acepta ambos.
   Backend (functions/index.js + push data.url) sigue mandando `exp+`
   por ahora; futuros releases lo migran después que ~95% de la base
   actualice a v1.2.0+.

2. 18 permisos OEM badge removidos via `tools:node="remove"` (MED-09-11).

   Pre-fix: expo-notifications inyectaba READ/WRITE permissions de
   Samsung, HTC, Sony, Huawei, Oppo, EvMe para badge counts en home
   screen. Pero App.js declara `shouldSetBadge:false` → todos esos
   permisos sin uso real. Cosmético pero asusta a users tech-savvy
   en Settings → App → Permisos.

3. BIND_GET_INSTALL_REFERRER_SERVICE removido (MED-09-10).

   Pre-fix: viene de play-services-measurement para Play Store install
   attribution. En sideload nunca está populated. Service `AppMeasurementJobService`
   removido via `tools:node="remove"`.

4. CropImageActivity exported=false (MED-09-12).

   Pre-fix: canhub.cropper (via expo-image-picker) auto-injecta
   exported=true. Aunque no tiene intent-filter declarado, responde a
   Intent.setComponent + URI arbitraria — apps maliciosas instaladas
   pueden disparar el crop flow con URIs externas. exported=false la
   convierte en interna pura.

5. ProGuard restrictivo en `com.bissi.miningtheblocks.*` (MED-09-37).

   Pre-fix: `-keep class com.bissi.miningtheblocks.** { *; }` preservaba
   TODO el package interno con nombres originales — incluyendo
   `verifyAppSignature` que es trivial bytecode-patch via apktool si
   conservás el nombre del método.

   Fix: solo MainActivity y MainApplication (referenciados por string
   en AndroidManifest, Android requiere los nombres exactos) preservan
   nombres. El resto del package se ofusca con R8 → reverse engineering
   del anti-tamper check requiere más esfuerzo (no imposible, pero +
   barrera).

Tests: App.js parsea OK. AndroidManifest XML válido.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nfig

Continúa el trabajo de Commit E sobre los 5 screens restantes (HowToPlay
es estático sin TouchableOpacity, queda fuera). Cierra el grueso del
gap Agente #10 MED-10-43 (0 accessibilityLabel pre-fix salvo 2 líneas).

Patrón aplicado consistente:
- TextInput: accessibilityLabel descriptivo (sin esto TalkBack solo lee
  placeholder en algunos casos).
- Primary action button: accessibilityLabel + accessibilityState con
  disabled/busy → SR anuncia "no disponible" / "cargando".
- Toggle: accessibilityLabel + accessibilityRole="switch" +
  accessibilityState.checked → SR anuncia el on/off state.
- Radio (language picker): accessibilityRole="radio" +
  accessibilityState.selected → SR anuncia "seleccionado de 2".
- Icon-only button (refresh ↻): accessibilityLabel obligatorio.
- placeholderTextColor #444 → #888 (WCAG-AA contrast).

Por screen:

  Profile.js:
  - Wallet TextInput: accessibilityLabel = t('profile.wallet').
  - Save Wallet button: accessibilityLabel + accessibilityState.busy.
  - placeholderTextColor #444 → #888 en wallet + referral inputs.

  GetPeaks.js:
  - Claim Daily button: accessibilityLabel + accessibilityState.busy.
  - Ad 1 / Ad 2 buttons: accessibilityLabel diferenciado ("(1)" / "(2)")
    + accessibilityState.busy.

  MyGems.js:
  - Code copy TouchableOpacity: accessibilityLabel con el código completo
    para SR + accessibilityHint con la label "Código".
  - Claim NFT button: accessibilityLabel + accessibilityState.busy.
  - Refresh icon ↻: accessibilityLabel + hitSlop 8 (touch target ≥44pt).

  Config.js:
  - 4 notify toggles (adReady, daily, rewards, newLayer):
    accessibilityRole="switch" + accessibilityState.checked.
  - 2 language toggles (en/es): accessibilityRole="radio" +
    accessibilityState.selected. (Recuerda: toggleBtn ya tiene minHeight:44
    + paddingVertical:12 desde Commit E.)

A11y status post-N:
✅ Login (E), Registration (E), BuyCredits (E), Profile (N), GetPeaks (N),
   MyGems (N), Config (E+N)
⚠️ HowToPlay (sin TouchableOpacity — N/A)
⚠️ ServerList (queda como P3 — 19+ TouchableOpacity, scope > 1 commit)
⚠️ ChainHistoryScreen, ActivityScreen (queda como P3)
⚠️ DynamicCube201 (cubo 3D — fundamentalmente inaccesible vía SR, requiere
   alternative experience design para a11y verdadero)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…TURE.md

1. Pre-permission UI antes del system prompt nativo de notificaciones.

   Cierra CRIT (Agente #4 CRIT-FE-01 + Agente #10 HIGH-10-06): pre-fix,
   requestPermissionsAsync se disparaba auto a los 2s del login sin contexto.
   Apple HIG y Google Play recomiendan modal explicativo ANTES del prompt
   nativo — sin esto, el user rechaza por sorpresa y queda imposible de
   re-promptear sin deep-link a Settings.

   Flow nuevo (App.js useEffect [user]):
   1. Cold start con user logueado → leer AsyncStorage NOTIFICATIONS_CONSENT.
   2. Si 'yes' → setupPushToken directo.
   3. Si 'no' → skip (respetar opt-out, NO volver a preguntar).
   4. Si absent (primer login) → Alert.alert con:
      - Title: "¿Querés enterarte cuándo pasa algo?"
      - Body: explica los 3 tipos (NFT mint, payment credit, referral
        bonus) + nota "podés mutear cada categoría en Settings, sin spam".
      - Buttons: "Activar" / "No gracias" (cancelable:false).
      - On accept: setItem 'yes' + setupPushToken (que internamente llama
        requestPermissionsAsync).
      - On decline: setItem 'no' (final — no más prompts).

   StorageKeys.NOTIFICATIONS_CONSENT agregado a constants.js.
   i18n keys EN+ES agregadas (notificationsConsent{Title,Body,Accept,Decline})
   para futuro uso si migran a un modal custom (esta versión usa Alert.alert
   nativo porque i18n context no es trivialmente accesible desde App.js).

2. ARCHITECTURE.md — visión técnica del sistema.

   Cierra Agente #11 MED-11-53 — single-operator hoy, pero si llega otro dev
   o pasan el proyecto, el ramp-up es muy duro sin documentación del por qué
   detrás del cómo.

   Contenido (~350 líneas):
   - Stack (RN+Expo+Firebase+Polygon+Ethereum tooling).
   - Diagrama ASCII de componentes principales.
   - Modelo de datos Firestore (colecciones + subcolecciones + runtime locks).
   - 3 flows críticos paso a paso (mineCube, crypto payment, NFT claim) con
     referencias a qué commits del Audit Round 2 cambiaron qué.
   - Modelo de seguridad: 5 capas de defense in depth con qué cubre cada una.
   - Secrets: tabla de 3 secrets, sus consumers, rotation procedure, blast
     radius si leak.
   - Tradeoffs documentados: por qué sideload, por qué FCM nativo, por qué
     soft-delete, por qué Polygon, por qué single-operator backend.
   - Code layout completo.
   - Roadmap operacional + code-only restantes.

   Complementa RUNBOOK.md (incident response) y reports de auditoría.

Tests: App.js parsea OK. helpers tests no afectados (sin cambios funcionales
en lógica que toquen).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra el cleanup pendiente de Commit D (CRIT-10-04). El motor t() ahora
soporta interpolación desde Commit D, pero los 15 call sites existentes
seguían usando el patrón antiguo .replace('{x}', y) por backwards-compat.
Esta commit los migra al patrón nuevo.

Beneficios del nuevo patrón:
1. Sin riesgo de reentrancia: si un value contiene '{otro}', el motor
   nuevo no lo re-interpola; .replace() encadenado sí lo hacía.
2. En __DEV__ warna si la key no existe O si un var declarado no fue
   provisto — pre-fix las keys faltantes mostraban el key literal al
   user sin aviso.
3. Más legible: 1 llamada vs N replaces encadenados.

Call sites migrados (10 funcionales + 4 multi-replace chains):

  Profile.js:46  — referralShareMsg con {code, url} (chain de 2 replaces)
  ActivityScreen.js:14  — timeMin {m}
  ActivityScreen.js:16  — timeHour {h}
  ActivityScreen.js:17  — timeDay {d}
  ActivityScreen.js:35  — gemFoundSub {k, chain, ep} (chain de 3 replaces)
  ActivityScreen.js:52  — layerCompletedTitle {k}
  ActivityScreen.js:55  — layerNextSub {chain, k} (chain de 2 replaces)
  ActivityScreen.js:71  — episodeTitle {n}
  ActivityScreen.js:74  — episodeSub {chain, n} (chain de 2 replaces)
  ChainHistoryScreen.js:88  — episodeComplete {n}
  ChainHistoryScreen.js:92  — winner {name}
  ChainHistoryScreen.js:93  — blocks {n}
  ChainHistoryScreen.js:112 — episodeStart {n}
  HowToPlay.js:76  — howToPlayUnlockAt {n}
  DynamicCube201.js:4679  — episodeCompleteMsg {n}
  DynamicCube201.js:4683  — episodeMined {n}

Verificación: `grep -rn "t('[^']*').replace" src/ --include='*.js'` → 0
matches post-fix.

Pattern .replace() en el cliente AHORA está reservado para casos donde el
template no proviene de i18n (Share.share message custom, etc.) — busqueda
manual antes de bumpear esta regla en un futuro lint rule.

Tests: helpers tests no afectados. Cliente parsea OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post Commit L (delete src/utils/ads.js como dead code), el SDK de AdMob
quedó sin uso real en cliente:

- ads se sirven via docs/adpick.html (effectivecpmnetwork) → Linking.openURL
  desde GetPeaks.js. No native AdMob rendering.
- App.js solo llamaba MobileAds().initialize() y nada más.
- El plugin de Expo inyectaba el SDK + AppMeasurement service + AD_ID +
  Privacy Sandbox perms en el AndroidManifest sin valor real.

Cleanup:
- App.js: remove `import MobileAds` + `MobileAds().initialize()` call.
- app.json: remove plugin config [react-native-google-mobile-ads, ...].
- package.json: remove "react-native-google-mobile-ads": "^16.3.3".

Impacto estimado:
- APK ~600-800 KB más liviano (SDK + native deps).
- AD_ID + ACCESS_ADSERVICES_AD_ID/TOPICS/ATTRIBUTION removed del manifest
  merged sin necesidad de tools:node="remove" (cierra parte de
  Agente #9 HIGH-09-06 sin disclosure complexity).
- AppMeasurementJobService no se necesita override (cierra Agente #9
  MED-09-10 sin override manual — el service no se inyecta más).

Si en el futuro vuelven a native ads:
1. npm install react-native-google-mobile-ads
2. Re-agregar plugin a app.json con el App ID
3. Re-agregar MobileAds().initialize() en App.js
4. Mover el Unit ID a Remote Config (no hardcoded) — patrón recomendado
   por Agente #9 MED-09-25.

Próximo paso operacional (no código): `npm install` para sacar el package
de node_modules + actualizar package-lock.json. Build de EAS posterior
no debería inyectar más las dependencias nativas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…istory)

Cierra el último gap grande de a11y del cliente (Agente #10 MED-10-43).

ServerList tiene 31 TouchableOpacity. Focus en las 10 más importantes:

  Header:
  - Tab toggle (Active ↔ Finished): accessibilityRole="tab" +
    accessibilityState.selected → SR anuncia "tab seleccionado".
  - Menu button (☰): accessibilityRole="button" + accessibilityLabel
    + accessibilityHint + hitSlop 8 (touch target 44pt).

  Card actions:
  - History button (📋) en active + finished cards: accessibilityLabel
    descriptivo con nombre del server + hitSlop 6.
  - Join/Unlock button: YA tenía a11y (commit anterior).

  Create form:
  - TextInput "server name": accessibilityLabel + placeholderTextColor
    #555 → #888 (WCAG-AA contrast).
  - Create button: accessibilityState.busy/disabled.
  - Cancel button: accessibilityLabel explícito.

  Menu modal:
  - Menu items (peaks, gems, profile, config, howToPlay, buyCredits,
    login, report, signOut): accessibilityRole="menuitem" + label.
  - Overlay tap-to-close: accessibilityLabel "Close menu".

A11y status post-R:
✅ Login (E), Registration (E), BuyCredits (E), Config (E+N), Profile (N),
   GetPeaks (N), MyGems (N), ServerList (R)
⚠️ ChainHistoryScreen (queda como P3 — solo 1-2 TouchableOpacity)
⚠️ ActivityScreen (queda como P3 — read-only feed)
⚠️ HowToPlay (sin TouchableOpacity)
⚠️ DynamicCube201 (cubo 3D — fundamentalmente inaccesible vía SR)

Tests: ServerList parsea OK. helpers tests no afectados.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra 6 hallazgos críticos pendientes pre-launch. Cambios localizados, sin
refactor estructural.

1. activityFeed TTL `expiresAt` (Agente #12 + #11 HIGH-11-40).
   writeActivity ahora agrega expiresAt = now + 7d. Firestore TTL Console
   borrará docs viejos automáticamente (después de activar la policy —
   pendiente operacional en RUNBOOK).
   Pre-fix: feed crecía infinito → cost amplification + PII histórica.

2. mineCube docId `${K}_${cubeNumber}` (Agente #1 CRIT-1).
   Pre-fix: `mined/${cubeNumber}` colisionaba entre layers. K=100 mina N=5;
   cuando K decrementa a 99 y otro user mina N=5 en K=99, el docId "5"
   colisiona → data mixing.
   Pre-launch: 0 mines reales en producción → forward-only safe.

3. processPendingMints audit log en adminActions (Agente #6 HIGH).
   La invocación manual del cron de mints no quedaba en adminActions, solo
   en Cloud Logging (retención 30d). Para forensic post-incidente esencial.

4. Override global de console.log REMOVIDO (Agente #4 CRIT-FE-06).
   Pre-fix: console.log = wrapper que filtraba un warning de expo-gl.
   Anti-pattern: HMR re-importa el módulo → wrappea otra vez → cadena
   infinita de wrappers. Plus dead code en prod (babel strippea console.log).

5. miningAnimations dead state REMOVIDO (Agente #5).
   const [miningAnimations, setMiningAnimations] = useState(new Map())
   declarado pero setMiningAnimations nunca se llamaba. 3 sites
   referenciaban este state muerto: useEffect cleanup forEach (no-op,
   removido), render loop check hasActiveAnimations (siempre false), comments
   documentan migración a useRef si vuelve la necesidad.

6. getFaceRange() lee del cache faceRangesRef (Agente #5 CRIT-4).
   Pre-fix: iteraba 240k cubeNumbers por call. JSX lo llamaba en cada
   re-render = 15-60×/seg × 240k = 14M iter/seg = stutter Android low-end.
   Fix: cache hit primero (faceRangesRef ya populated por
   recomputeFaceRanges en buildLayer). Loop solo en cache miss.

Bonus: setCorsHeaders unused import removido (post Commit K).

Tests: helpers OK, lint clean, parse OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…, UpdateModal hardening, verifyBeforeUpdateEmail)

Cierra 4 hallazgos del bloque auth/identity pendientes post Commit B.

1. mineCube + assertFreshToken (Agente #6 CRIT — deferred en Commit B).

   Commit B aplicó checkRevoked en claimGemNFT, createCryptoPayment,
   submitGemClaim pero defirió mineCube por costo (~100 Auth Admin calls
   por sesión típica de mineo). Re-evaluación: costo real ~$0-10/mes a
   <10k DAU. Aceptable para garantizar que tokens revocados (post password
   reset, post admin-suspensión, post deleteMyAccount) no puedan mintear
   gemas hasta que el JWT TTL natural (~60min) los pesque.

2. Login.js: clear password post-login (Agente #4 ALTO-FE-15).

   Pre-fix: tras `signInWithEmailAndPassword`, el password seguía en React
   state hasta unmount o next render. Si la app queda en background, otra
   app con memory dump capability puede leerlo. Best-effort en JS (no hay
   SecureString native) — `setPassword('')` después de éxito + después de
   "email no verificado + signOut".

3. UpdateModal: rechazar userinfo + custom port (Agente #4 ALTO-FE-11).

   Pre-fix: `new URL("https://attacker.com@miningtheblocks.com/evil.apk")`
   parsea hostname=miningtheblocks.com (passa el allowlist) pero algunos
   clientes Android Linking navegan al userinfo en lugar del host.
   Bypass de la última línea de defensa contra APK malicioso via
   downloadUrl.
   Fix: explícitamente reject `u.username`, `u.password`, `u.port !== 443`.

4. Registration.js: verifyBeforeUpdateEmail en lugar de updateEmail
   (Agente #6 HIGH-12).

   Pre-fix: `updateEmail(u, newEmail)` cambiaba el email inmediatamente +
   dejaba emailVerified=false silenciosamente → limbo (user cree email
   cambió; no puede recuperar password porque nuevo no está verified;
   viejo ya no funciona). Plus escalation vector en account takeover.

   Fix: `verifyBeforeUpdateEmail(u, newEmail)`. Manda email de
   verificación al NUEVO address; el email solo cambia cuando el user
   clickea el link. Hasta entonces puede seguir con el email viejo.
   API moderna de Firebase Auth v9+ que reemplaza updateEmail.

Tests: parse OK + lint OK + 24/24 helpers tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rofile uid, audio dedupe)

Cierra 3 hallazgos pendientes del Agente #4.

1. BuyCredits.js restore preserva amount + wallet (ALTO-FE-14).
   Pre-fix: cache guardaba solo {paymentId, expiresAt}; al restaurar tras
   crash/swipe, user veía timer corriendo pero amount=null y wallet=null
   → no sabía cuánto pagar ni a qué dirección. Fix: cachear también
   amount + wallet (devueltos por callCreateCryptoPayment).

2. Profile.js subscribe al uid actual via onAuthStateChanged (ALTO-FE-07).
   Pre-fix: useEffect deps=[] capturaba auth.currentUser al mount. Si el
   user cambiaba durante el componente lifetime (logout + login con otra
   cuenta), el listener servía data del uid VIEJO → cross-user data leak
   (mostraba wallet/email del primer user al segundo).
   Fix: subscribe a onAuthStateChanged; restart onSnapshot cuando uid
   cambia (incluye uid=null = signed out). Cleanup completo en unmount.

3. AudioManager double-unload warning (ALTO-FE-13).
   Pre-fix: cap eviction (line 165) llama oldest.unloadAsync(). Si el
   callback didJustFinish del oldest dispara después (race), llama
   unloadAsync de nuevo → "already unloaded" warning. Doble issue:
   también re-evalúa el indexOf que ya no existe.
   Fix: (a) clear setOnPlaybackStatusUpdate(null) ANTES del unload en
   eviction; (b) guard en el callback: solo unload si todavía está en
   activeSounds (defensive against race).

Tests: parse OK. helpers no afectados (cliente only).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… EN privacy disclosure

Cierra 4 hallazgos MED/LOW pendientes — todo cambios de texto/atributos
sin lógica nueva.

1. ChainHistoryScreen + ActivityScreen a11y (Agente #10 MED-10-43).
   Cada uno tenía 1-2 TouchableOpacity sin labels (back button, report
   button). accessibilityRole="button" + accessibilityLabel + hitSlop 8.
   Cierra el último gap a11y del cliente — todos los screens con
   interactividad ya están cubiertos. DynamicCube201 (cubo 3D) sigue
   sin a11y por naturaleza (rendering GL no es accesible vía SR).

2. verify.html default-DENY en errores no enumerados (Agente #7 LOW).
   Pre-fix (CRIT-24 documentado como "default-deny" pero código era
   default-success): el else de codes desconocidos llamaba showSuccess.
   Si Google agrega nuevo error code en el futuro (TOKEN_REVOKED,
   QUOTA_EXCEEDED, etc.) el user veía "verificado" cuando no lo estaba.
   Fix:
   - data.error.message no reconocido → showError genérico con el code
     truncado a 32 chars (para diagnóstico sin XSS).
   - Respuesta SIN .error pero sin shape esperada (email/localId/kind)
     → showError "respuesta inesperada".
   - Solo respuesta con shape correcta y SIN .error → showSuccess.

3. privacy.html EN section: agrega Cloudflare + GitHub + SCC disclosure
   (Agente #10 HIGH-10-36).
   Commit E agregó la sección ES pero la EN quedó con la lista vieja
   (solo Firebase + Polygon + Pinata). Ahora ambas secciones están
   alineadas con todos los sub-procesadores + SCC clause para UE→USA.
   Aplicado en docs/privacy.html Y public/privacy.html.

Tests: parse OK. helpers OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…VE_BOOT_COMPLETED, edgeToEdge dedup)

Tres fixes operacionales del Agente #9 — cambios de manifest sin impacto
funcional.

1. iOS bundleIdentifier (Agente #9 LOW-09-26).
   app.json pre-fix: `"ios": { "supportsTablet": true }` sin bundleId.
   Cuando subieran iOS, EAS auto-generaría un bundleId que probablemente
   NO matchearía el del proyecto Apple Developer → fricción TestFlight.
   Ahora declarado explícito: com.bissi.miningtheblocks (alineado con
   Android package name).

2. RECEIVE_BOOT_COMPLETED + MY_PACKAGE_REPLACED removed (Agente #9 HIGH-09-08).
   expo-notifications auto-inyecta estos perms + un receiver
   NotificationsService que se levanta al boot del device. La app NO usa
   scheduled notifications (App.js solo registra push remoto FCM, sin
   scheduleNotificationAsync). En sideload Android, "Esta app se inicia al
   encender" en Settings → asusta a tech-savvy users + reputación.
   Si en el futuro suman scheduled notifications locales, basta sacar
   los tools:node="remove" de estas líneas.

3. edgeToEdgeEnabled dedup (Agente #9 MED-09-24).
   gradle.properties tenía triplicado:
   - `edgeToEdgeEnabled=true` (línea 53) ← canónico.
   - `expo.edgeToEdgeEnabled=true` (línea 71) ← DEPRECATED por Expo SDK
     55, va a ser removido. Generaba warning en cada build.
   - `android.edgeToEdgeEnabled: true` en app.json ← canónico Expo plugin.
   Removida la legacy `expo.edgeToEdgeEnabled` para evitar deprecation
   warning + alinear con la dirección del framework.

NO HECHO (defer fundamentado):
- CAMERA permission removal: expo-image-picker SÍ se usa en
  Registration (line 318: launchCameraAsync para tomar foto avatar).
  Auditoría #9 HIGH-09-07 estaba mal en este punto.
- expo-autolinking consolidation (3 archivos): riesgo alto de romper
  exclusión de reanimated/gesture-handler. Mantener as-is hasta SDK 55
  bump (que va a forzar limpieza igual).
- App Links autoVerify: requiere /.well-known/assetlinks.json hosted en
  miningtheblocks.com. Pendiente operacional, documentado en RUNBOOK.
- ProGuard firebase reduction: solo INFO-39, riesgo de romper Firestore
  serialization. Mantener current.

Tests: XML + JSON parse OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…d dep, i18n hardcoded)

3 cleanups menores.

1. audioManager.cleanup() en signOut (Agente #4 MED-FE-21).
   Pre-fix: música seguía sonando después de signOut sobre la pantalla
   de Login. ~5MB residente + UX disonante.
   Fix: en App.js onAuthStateChanged, cuando u === null (signOut)
   llamamos audioManager.cleanup() antes de setUser(null). El cleanup
   es idempotente (resetea flags para que un re-init después funcione).
   Esto cubre TODOS los signOut paths (DrawerItem, ServerList session
   expired, cold-start con KEEP_SIGNED_IN=0, etc.) sin tener que
   modificar cada caller.

2. react-native-svg-transformer removido (Agente #9 MED-09-23).
   Verificado: 0 references en metro.config.js + 0 imports en src/.
   El transformer está instalado pero no se conecta — dead dep ~50KB.
   metro.config.js usa el default config sin transformer custom.
   Si en el futuro necesitan SVG como componentes (no como assets),
   re-instalar + agregar el transformer.

3. i18n key picksSuffix EN+ES en namespace profile (Agente #10 MED-10-25).
   GetPeaks.js:203 mostraba "picks" hardcoded en EN para todos los users.
   Usuarios en ES veían "picks" en lugar de "picos".
   Fix: key picksSuffix = "picks"/"picos" + GetPeaks usa t('profile.picksSuffix').

Tests: parse OK + JSON valid.

Próximo paso operacional: `npm install` para sacar react-native-svg-transformer
de node_modules + actualizar lockfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…store cap)

Cierra 2 leaks del Agente #5 sin tocar la lógica de rendering activo.

1. MinedCubesRewardStore cap a 20k entries con LRU eviction.

   Pre-fix: el Map de rewards crecía sin cota. En endgame (capa K minada
   completa, ~50k cells), residente ~10MB en RAM. Sesiones largas con
   múltiples servers progresados acumulaban sin liberar — ~50MB+ después
   de horas de juego.

   Fix: maxEntries=20000 + LRU eviction al exceder el cap. El delete+set
   pattern asegura que la entrada accedida queda al final del insertion
   order (Map preserva insertion order); eviction borra el head (más
   viejo). Trade-off: si el user re-visualiza una celda evicted, se pierde
   el reward indicator hasta el próximo snapshot. Acceptable porque las
   celdas evicted son las menos probables de re-aparecer en viewport.

   Bonus: método stats() agregado para debugging post-deploy.

2. Timer refs cleanup en unmount (DynamicCube201).

   Pre-fix: si user navega a otra screen DURANTE un mining flow activo
   (mid-watchdog, hud-toast visible, pre-hold counting), los setTimeout
   quedaban corriendo + sus callbacks disparaban contra árbol React
   desmontado → "Can't perform a React state update on an unmounted
   component" warning + retención de memoria por closures.

   Timer refs cleanedup ahora en el useEffect cleanup principal:
   - hudToastTimerRef (toast notifications)
   - gridExitTimerRef (transición grid → cube mode)
   - preHoldTimerRef (long-press indicator ~300ms)
   - miningWatchdogRef (modal congelado timeout)
   - miningProgressTimerRef (progress bar setInterval 120ms)

   longPressTimer y minedPollRef ya estaban en este cleanup pre-fix.

Tests: parse OK. helpers 24/24 OK.

NO HECHO (defer fundamentado):
- BoxGeometry dedup (6 → shared): el código de fragments + addDarkPatch
  es complejo (lifecycle de meshes, dispose patterns). Requiere browser
  test sobre Android real device. Defer.
- _intersectablesCache cleanup: requiere review del raycaster intersection
  pattern; refs a meshes disposed podrían causar GL crash si no se hace
  bien. Defer.
- LRU textureCache evict: similar — defer hasta poder testear.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra Agente #7 HIGH-2 (último gran refactor del web hardening).

Pre-fix: CSP del docs/index.html declaraba políticas exhaustivas pero
script-src incluía 'unsafe-inline' + el archivo tenía:
  - 1 bloque <script> inline de 258 LOC (toda la lógica de la página).
  - 7 onclick handlers en buttons (setLang × 2, copyRefCode, verifyCode,
    doLogin, submitClaim, acceptAge).
  - 2 onchange handlers en checkboxes (updateAgeBtn × 2).

Con 'unsafe-inline' la CSP es funcionalmente DECORATIVA contra XSS: la
declaración script-src 'self' https://www.gstatic.com no estaba siendo
enforced para nada inline. Para una página que tramita gem claims hasta
$100k, esto era un gap real.

Refactor:

1. docs/js/app.js (nuevo, ~280 LOC):
   - 100% del contenido del <script> inline (Firebase init, GEM_NAMES,
     GEM_PRIZES, ERRORS dicts, setLang/detectReferral/copyRefCode/verifyCode/
     doLogin/submitClaim/updateAgeBtn/acceptAge + helpers).
   - Sin cambios funcionales (lógica idéntica).
   - + wireEventDelegation() que registra:
     * Click handlers via [data-action="X"] + delegación al body.
     * setLang especial: [data-action="setLang"][data-lang="es|en"].
     * Change handlers directos sobre #check18 y #checkTC para updateAgeBtn.

2. docs/index.html cambios:
   - 9 atributos inline → declarativos:
     * onclick="setLang('es')" → data-action="setLang" data-lang="es"
     * onclick="setLang('en')" → data-action="setLang" data-lang="en"
     * onclick="copyRefCode()" → data-action="copyRefCode"
     * onclick="verifyCode()" → data-action="verifyCode"
     * onclick="doLogin()" → data-action="doLogin"
     * onclick="submitClaim()" → data-action="submitClaim"
     * onclick="acceptAge()" → data-action="acceptAge"
     * onchange="updateAgeBtn()" × 2 → (removido; listener en app.js by id).
   - <script>...258 LOC...</script> → <script src="js/app.js" defer></script>
   - CSP script-src: removido 'unsafe-inline'.
   - Mantenido style-src 'unsafe-inline' (hay style="..." attribute usage
     en algunos botones; refactor a classes queda como P3 follow-up; risk
     bajo porque style attrs no permiten JS execution).

Archivo total reducido de 952 → 699 líneas. CSP ahora hace su trabajo.

TODO post-deploy (operacional): probar en browser que el flow completo
funciona (verifyCode → doLogin → submitClaim → ageGate). Si algo se
rompe, el rollback es revertir este commit + el style-src cambio se
puede hacer en isolation después.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… package-lock

Cleanup post Commit Q (npm install ejecutado).

1. AndroidManifest.xml: 3 meta-data tags de Google Mobile Ads removidas.
   Pre-fix (post Commit Q): el SDK fue removido del package.json pero los
   meta-data tags quedaban residuales:
     com.google.android.gms.ads.APPLICATION_ID
     com.google.android.gms.ads.flag.OPTIMIZE_AD_LOADING
     com.google.android.gms.ads.flag.OPTIMIZE_INITIALIZATION
   Sin el SDK gradle dep, son inert (nadie los lee) pero ensucian el manifest
   merged. Removidos.

2. package-lock.json: regenerado por npm install.
   - Removed: 191 packages (las 3 deps removidas en L+Q+X + transitives).
   - Added: 1 (husky devDep de Commit I).
   - Conflicting peer warning (expo-three pidiendo RN 0.64 vs current 0.81):
     pre-existente, no relacionado con esta sesión.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nifest merger

Commit M usaba tools:node="merge" en CropImageActivity exported=false, pero
el manifest merger requiere tools:replace="android:exported" para sobrescribir
el exported=true que declara com.vanniktech:android-image-cropper:4.6.0.

Error pre-fix en gradle assembleDebug:
> Manifest merger failed : Attribute activity#com.canhub.cropper.CropImageActivity@exported
>   value=(false) from AndroidManifest.xml is also present at
>   [com.vanniktech:android-image-cropper:4.6.0] AndroidManifest.xml value=(true).
>   Suggestion: add 'tools:replace="android:exported"' to <activity> element.

Detectado al correr el gradlew assembleDebug por primera vez post-Commit M.
Sin esto, el APK no compila.

BUILD SUCCESSFUL en 4m12s con el fix → app-debug.apk 182MB producido.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/screens/BuyCredits.js:3 imports `expo-clipboard` para el copy de wallet
address en el flujo de buy credits con USDC, pero el paquete nunca estuvo
declarado en package.json. Pre-existing bug detectado en smoke test:
la app crasheaba al startup con "Cannot find native module 'ExpoClipboard'"
porque ExpoModulesPackageList no lo incluía.

Fix: `npx expo install expo-clipboard` → agrega `expo-clipboard: ~8.0.8`.
Rebuild de gradle requerido (módulo nativo); confirmado en emulator que
ExpoModulesPackageList ahora linka ClipboardModule y la app llega a Login.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
miningtheblocks and others added 2 commits July 2, 2026 21:51
El timer de la página web del anuncio era puramente cosmético — el
backend solo validaba un tope máximo de 12min desde que se creó la
sesión, nunca un mínimo. Con el sessionId+token en mano (se devuelven
apenas se crea la sesión) se podía reclamar el pico al instante, sin
esperar el anuncio para nada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Problem: el flujo de picos por anuncio (timer web de 60s + token de
sesión, createAdSession/claimAdSession) condicionaba la recompensa a
"esperar viendo el anuncio". Confirmado directamente con soporte de
Adsterra y por escrito en los términos de Coinzilla: eso es
"incentivized traffic", prohibido por ambas redes.

Fix:
- Nueva claimAdSlotPick reemplaza claimDailyPick + createAdSession +
  claimAdSession: el pico se entrega incondicional al tocar el botón
  (2 slots por cadena, cooldown 24h cada uno). Cualquier anuncio que
  se muestre en esa pantalla queda puramente pasivo, sin relación
  server-side con el claim.
- Banner pasivo (WebView aislado) en dos lugares: pantalla de picos
  (Social Bar) y carga inicial del cubo (banner 300x250, estirado a
  1.1s mínimo para contar como impresión viewable). Ninguno de los
  dos está atado a ningún pico.
- AD_VIEW_VALUE_USD recalibrado a $0,006 (piso real de CPM de Social
  Bar) desde el $0,01 anterior -- ajusta el desbloqueo de premios del
  server Free proporcionalmente.
- dailyAdSlots unificado en 2 para toda cadena (antes 5 en el Free).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El número de versión en la página de descarga y el cache-buster del
script no se actualizan solos con cada release -- quedaron en 1.3.19
desde hace 3 versiones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
miningtheblocks and others added 2 commits July 3, 2026 02:20
… de ads

Problem: la landing, terms.html y privacy.html seguían describiendo la
economía como si $15 fuera el único costo de entrada posible, y los
picos extra como condicionados a "ver un anuncio" -- ambas cosas
dejaron de ser ciertas esta sesión (server Free sin costo, servers a
medida con precio configurable, picos incondicionales).

- index.html: agrega explicación de server Free y servers a medida,
  corrige el texto de picos diarios y el de costo de entrada único.
- terms.html (documento legal, v2.0 → v2.1): reestructura §2 y §6 para
  cubrir los 3 tipos de cadena, corrige §7 (picos de anuncio → picos
  diarios incondicionales), aclara §8.3 sobre las diferencias
  estructurales del Free y los servers a medida.
- privacy.html: corrige la sección de Publicidad (ya no hay página
  externa ni condición de "ver" el anuncio), agrega a Adsterra como
  sub-procesador explícito.
- Borra docs/adpick.html, adpick.js, adpick-init.js, js/cookie-consent.js
  -- código muerto del flujo viejo, llamaban a un endpoint
  (claimAdSession) que ya no existe en el backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- AdMob → Adsterra en el stack (AdMob fue removido hace tiempo,
  README nunca se actualizó).
- Comando de checksum de ejemplo usaba v1.1.0 (hace ~20 releases);
  ahora usa el alias MTB-latest.apk.sha256, que no se desactualiza.
- Estructura: agrega freeServerConfig.js, serverConfig.js y
  CreateCustomServer.js, ausentes desde que se agregaron.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nuevo modo de juego opcional -- SE ENTREGA COMPLETO PERO INACTIVO,
gateado por config/app.blockchainModeEnabled (default false). Una sola
cadena global (sin episodios, a diferencia del resto del juego), con
capas que se AGREGAN a medida que se completan en vez de destruirse:

- placeCube: coloca un cubo en la capa frontera, aporta una tarifa en
  USD (según racha de días consecutivos, 5 tiers de $0,0025 a $0,0045)
  al pool compartido y al crédito propio de quien coloca -- el reparto
  final es proporcional a $ contribuido, no a cantidad de cubos, para
  que la racha de uno no le saque % a nadie más.
- claimChainPick: pico diario incondicional con captcha verificado
  server-side (hCaptcha) -- anti-bot puro, sin relación con anuncios.
- Al completar la capa 250, la cadena cierra: reparte el pool
  proporcional (ledger, sin movimiento real de plata todavía), archiva
  a blockchainHistory, y arranca la cadena siguiente.
- Terminología separada del resto del juego: la sección de servers
  estándar/Free/a medida pasa a llamarse "servers" (antes "cadenas"),
  reservando "cadena" para este modo nuevo específicamente.
- Captcha también agregado al registro de cuenta (fricción anti-bot
  client-side).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
hCaptcha rechazaba el sitekey ("invalid data") porque el WebView cargaba
el widget vía HTML inline, sin un dominio real registrable. Ahora se
sirve en https://miningtheblocks.com/captcha.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El anuncio (Adsterra Social Bar/Banner) se cargaba directo como
documento principal del WebView, con privilegios de navegación de nivel
superior -- dispara un redirect automático (~1s, sin tap) que
onShouldStartLoadWithRequest no siempre logra bloquear (limitación
conocida con navegaciones originadas en iframes anidados).

docs/ad-safe.html carga el anuncio real dentro de un iframe sandboxed
sin allow-popups/allow-top-navigation -- el motor del navegador bloquea
esos intentos de raíz, sin depender de que la app los detecte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bloqueo, notificaciones push

Chain (Cambio 9): mecánica invertida original descartada por bugs de
render 3D en capas chicas -- ahora reusa el mismo motor/mecánica que
servers (cubo completo en capa 250, mina hacia el centro), solo cambia
la economía (MTB coin por racha, no gemas).

Ads: fix del bug real "abre el navegador" -- el anuncio ahora carga
dentro de un iframe sandboxed (docs/ad-safe.html, sin allow-popups/
allow-top-navigation) en vez de como documento principal del WebView.
Ads pasivos (pointerEvents=none) y removidos de servers pagos
(estándar y a medida) -- solo Free y Chain los muestran.

Captcha: fix de "invalid data" (hosteado en dominio real,
docs/captcha.html) + nuevo gate de desbloqueo único (una vez, al
entrar por primera vez a Free/Chain) separado del captcha por-reclamo
de picos en Chain, que se mantiene sin cambios.

Picos: fix de un bug real en Firestore (claves con punto guardadas
como campos literales en vez de mapas anidados, rompía el cooldown
visible al reclamar cualquier slot). Chain: máximo 1 pico sin usar,
cooldown de 2h desde que se USA (no desde que se reclama). Free:
cooldown bajado a 6h (configurable por cadena).

Historial de Chain: actividad mine-by-mine (quién/cuándo/qué cubo),
mismo patrón que el historial de servers estándar, escrito desde el
backend (Admin SDK) para no depender de reglas de cliente.

Notificaciones push: detección de "pico listo" para Chain/Free/
servers pagos vía Cloud Scheduler, con mute independiente por
categoría en Config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
miningtheblocks and others added 2 commits July 9, 2026 21:19
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Agrega caja "Próximamente: Blockchain" (minar/contribuir con boost por
racha). Destaca Free como novedad + próximos servers gratis. Corrige
el número de versión visible que había quedado en v1.3.22 (2 releases
atrás) en el botón de descarga y el cache-busting del script -- el
link de descarga en sí ya era correcto (apunta a "latest" dinámico).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"App gratuita" seguido de "$15" en la misma frase leía como
contradictorio. Separa la idea de "descargar es gratis" de "elegís
cómo jugar" (3 opciones de costo distintas).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
miningtheblocks and others added 4 commits July 10, 2026 00:00
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ada en el $15

Intro con emojis cubriendo las 4 modalidades (Free/estándar/a medida/blockchain
próximamente), y sección de gemas con comparativa de los 3 pools reales en vez
de una nota aislada — el estándar queda como config de referencia ($650k) en
vez de único ejemplo. Fix de versión hardcodeada v1.3.22 -> v1.3.24.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…modo

- Stats grid: aclarar con "(estándar)" los 3 números que en realidad
  solo aplican al server estándar (cubos, premios, tipos de gema) y
  agregar nota con los números reales de Free (27.5M cubos, $35k) para
  no contradecir a la sección de gemas de más abajo.
- Agregar caja "🎯 Estándar" que faltaba en ¿Qué es? (las otras 3
  modalidades ya tenían la suya).
- Gemas y premios: reemplazar las 3 cajas de prosa (que repetían la
  explicación de modos ya dada en ¿Qué es?) por una tabla real de
  comparación Estándar vs Free, gema por gema y cantidad por cantidad,
  con fila de totales. A medida queda como nota aparte (no tiene
  cantidades fijas). Nota de picos también separada por modo
  (283,241 estándar / 400,000 Free).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reemplaza el stats-grid ambiguo y las cajas de prosa repetidas por
tarjetas de modo (Estándar/Free/A medida/Blockchain), cada una dueña
de su propio número — sin depender de aclaraciones "(estándar)" al
lado de cifras genéricas. La sección de gemas pasa de una tabla de
cantidades por server a una lista de las 9 gemas con su valor fijo en
USDC, marcando cuáles también da el Free. Pirámide, botones de
descarga, Discord, disclaimer y footer quedan sin cambios.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant