Audit Round 2 + operacional pre-launch review#24
Open
miningtheblocks wants to merge 123 commits into
Open
Conversation
… 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>
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>
… 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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review pre-launch v1.1.1