Skip to content

chore: promote to main for 1.118.8 release - #295

Open
TuCopFi wants to merge 48 commits into
mainfrom
release/1.118.8
Open

chore: promote to main for 1.118.8 release#295
TuCopFi wants to merge 48 commits into
mainfrom
release/1.118.8

Conversation

@TuCopFi

@TuCopFi TuCopFi commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Cut Development → main para la release 1.118.8. 45 commits queued desde 1.118.7, agrupados por tema abajo.

Version bump: 1.118.71.118.8 (patch: todos los cambios son fixes o telemetría interna, cero features de usuario final ni breaking).

  • package.json: 1.118.7 → 1.118.8
  • android/gradle.properties: VERSION_CODE 1021081776 → 1021081777 (+1)
  • android/app/build.gradle: versionName "1.118.7" → "1.118.8"
  • ios/MobileStack.xcodeproj/project.pbxproj: CURRENT_PROJECT_VERSION 257 → 258 (×4 build configs), MARKETING_VERSION 1.118.7 → 1.118.8 (×2 build configs). NotificationService extension MARKETING_VERSION 1.0 sin tocar.

Cambios incluidos (agrupados por tema)

Neeru vault integration (PRs #265-#283)

Cierre completo del roadmap de Neeru: zero-exposure runtime meta, tiered refactor de consumers, backend simulation-revert envelope, two-source revert cross-check, PoolCard con priceUsd real, catalogue en runtime, boot fetch, meta drift check, positions partialFailure honoring, fix Deposit topic0.

Sentry infra migration off Valora (PR #285, #289, #290, #292)

  • Off del GCP KMS de Valora hacia Keychain / env vars (populate_secrets.sh).
  • Android sourcemap upload activado (antes solo iOS).
  • CI secrets SENTRY_AUTH_TOKEN + SENTRY_CLIENT_URL expuestos al postinstall.
  • Telemetría gold_price_source (backend | backend_stale | dia_data | fallback_hardcoded) + circuit-breaker detection.
  • Consumo del header X-Stale que backend shippeó 2026-07-27.

op-reth mitigations (PR #287, #291)

Celo mainnet migró de op-geth a op-reth 2026-07-22. Requiere manejo distinto de errores CIP-64:

  • Nuevo estimateCip64FeesPerGas con 100% headroom en maxFee para no ser rechazado por op-reth por márgenes tight.
  • Widened isRecoverableEstimationError para aceptar cualquier InvalidInputRpcError (op-reth surfaces "Missing or invalid parameters" en vez del "gas required exceeds allowance" de op-geth).
  • Fallback de gas para ERC20 approve cuando la estimación falla (evita hang en flows con approve+swap).

Defense-in-depth transaction sagas (PR #286)

Empty guard + Sentry business error en swap / gold / jumpstart sagas: si preparedTransactions está vacío al dispatchear, falla con mensaje amigable en vez del críptico "Mismatch in number of prepared transactions and standby transaction creators". Padding para casos multi-hop donde Squid devuelve 3+ txs.

UX fixes

Neeru TVL fix (PR #288)

Los pools de Mento local-currency (COPm, EURm, BRLm, XOFm, GHSm, KESm) no deben pasar por useDollarsToLocalAmount. Nuevo helper isLocalCurrencyStable centraliza la classification. Bug fix visible: "Flexible TVL COP$321,865,000" (inflado 3218x) → "COP$110,000" (real).

Circuit breaker per-endpoint (PR #293)

Rekeyed el circuit breaker de ${host} a ${host}${pathname}. Bursts de 502s en /hooks-api ya no bloquean llamadas sanas a /api/prices/xaut u otros paths del mismo host.

Docs (PR #291)

README documenta el nuevo mecanismo de populate_secrets.sh (Keychain en dev, env vars en CI). Reemplaza la referencia stale a yarn keys:decrypt que dependía de la KMS de Valora.

Pre-flight verificado

Sin dependencies de release paralelas

  • Backend no requiere ninguna acción para esta release.
  • CI Fastlane upload sigue comentado (subida a stores manual post-merge, mismo flow que 1.118.7).

Test plan post-merge

  • CI build.yml verde en main.
  • AAB descargado de GH Actions artifacts, verified subir a Play Console Internal track sin error.
  • xcarchive descargado, signed local en Xcode Organizer, subido a TestFlight.
  • Smoke test wallet 0x8427...ece: Neeru TVLs correctos, Gold buy 2000 COPm completa (con fix approve + CIP-64 headroom), pesos con 2 decimales, badge stale (esperar upstream flake para verlo).
  • Verificar Sentry issues empezando a llegar del build 258 con tag release=org.tucop@1.118.8+258.

Follow-up conocido (NO en este release)

Target Android 16 / API 36 — deadline Google Play 2026-08-30. Roadmap interno: 2026-08-20. Descubierto durante audit de esta release, deliberadamente fuera de scope porque triggerea behaviour changes de Android 16 (edge-to-edge, predictive back, foreground services) que requieren regresión per-screen. Se abre branch dedicada chore/target-sdk-36 para 1.119.0 apenas 1.118.8 esté en producción.

TuCopFi added 30 commits July 12, 2026 00:01
…xposure) (#265)

* feat(earn/neeru): remove contract surface from tracked source (zero-exposure)

Backend flagged on 2026-07-12 that the wallet repo published the full
earn-vault contract layout in src/earn/neeru/abi.ts (positions struct
return with 8 named fields, Deposit event args, previewAccruedInterest
signature, three named custom errors) and types.ts mirrored the struct
1:1. Since the contract is intentionally unverified on Celoscan, that
made every field/event/error name recoverable from a public grep,
defeating the point of leaving the bytecode nameless.

This closes the exposure surface end to end.

- abi.ts: the named ABI object is replaced by DEPOSIT_EVENT_DATA_SCHEMA,
  a bare list of ABI parameter type descriptors (uint8, uint256, uint256)
  for decoding the Deposit event data slot. No function names, no event
  args, no error names in source.
- eventParsing.ts: parseDepositEvent now filters by topic0 (env-driven)
  and decodes non-indexed args with decodeAbiParameters against the
  type-only schema; the indexed positionId is read from topics[2] via
  hexToBigInt.
- constants.ts: contract address, Deposit topic0 and the three custom
  error selectors are read from react-native-config with hex defaults so
  local builds keep working; a repo-wide grep no longer surfaces the
  address as text. The tranche language is renamed to category
  end-to-end (NeeruCategoryId, NEERU_CATEGORY_IDS, NEERU_CATEGORY_LABEL_KEYS,
  categoryIdFromPositionId). The positionId regex still accepts both
  :tranche-<N> and :category-<N> so persisted state parses cleanly.
- saga.ts: matching the low-interest-pool revert now runs through
  NEERU_ERR_INTEREST_POOL_LOW_SELECTOR (env-driven) instead of a hardcoded
  'InterestPoolLow' string. The exported helper is renamed isLowPoolError
  and the wallet-internal signal constants NEERU_LOW_POOL_ACTION and
  NEERU_LOW_POOL_ERROR replace the contract-error-name mirrors.
- api.ts: adapter collapses to identity now that wire and internal names
  align on category / categoryLabel / amount / endTs / rateValue. The
  legacy fallback (amount ?? principal, etc.) is removed since backend
  confirmed 2026-07-12 that the pre-cutover names are no longer emitted.
- types.ts, rateConversion.ts, selectors.ts and all five UI components
  cascade the rename: principal -> amount, tranche -> category,
  trancheLabel -> categoryLabel, maturityTs -> endTs, dailyRateRay ->
  rateValue.
- Tests updated to match: new-wire assertions, selector-based low-pool
  detection, opaque wallet error signals.

Verification (backend's grep list):

  grep -rE 'FondoCOPmMVP|0xD05CDF2|0x988Af5977201|dailyRateRay|
  capitalizedInterest|lockSeconds|maturityTs|InterestPoolLow|
  principal_only|renewed_from_position_id' src/earn/neeru/

returns zero matches (was 252 hits before this change). Full test suite
(80 tests) passes, TypeScript and lint clean.

* chore(persist): bump v252 clearing stale neeru positions on upgrade

The zero-exposure refactor renames the persisted NeeruIndividualPosition
shape (principal -> amount, tranche -> category, trancheLabel -> categoryLabel,
maturityTs -> endTs, dailyRateRay -> rateValue). Old persisted state from
1.118.7 uses the pre-rename field names; loading it into the new reducer
would render the earn cards as undefined until the next backend fetch.

Migration 252 clears state.neeru.positions and state.neeru.optimisticPositions
on upgrade. The earn screen refetches on mount so the visible impact is a
single fetch cycle and no data loss (no client-computed state to preserve).

test/RootStateSchema.json regenerated to reflect the renamed fields.

* test: extend schemas to v252 and bump inline snapshot for the persist upgrade
…odes + smoke playbook (#266)

Wallet-consumer-spec 2026-07-12 review found three gaps against
tasks/specs/wallet-consumer-spec.md; this PR closes all three.

1. src/positions/saga.ts now surfaces the hooks-api meta envelope on the
   earn positions fetch. When getEarnPositions returns
   { data: [...], meta: { partialFailure: { neeru: true } } }, the saga
   preserves any prior positions from the affected app (neeru-vaults /
   allbridge) and merges them with the successful slice. Without this,
   the earn tab silently rendered empty cards for the failing app - the
   exact scenario that ran for 27h during the 2026-07-11 selector
   incident and only stayed invisible because the Statsig gate had
   suppressed exposure.

2. src/earn/neeru/errorMapping.ts drops INVALID_TRANCHE and
   TRANCHE_CAP_EXCEEDED. Backend confirmed the pre-cutover codes are no
   longer emitted anywhere; the spec (section 8) enumerates the eleven
   TRIGGER_USER_ERROR_CODES the backend still surfaces, and this file
   now matches that list one-to-one. errorMapping.test.ts is extended
   to assert the legacy codes fall through to the unknown i18n key.

3. scripts/pre-release-backend-smoke.sh is the executable form of the
   verification playbook (spec section 9). Six curl checks against
   BASE=tucop-backend-production.up.railway.app: health probes, neeru
   catalogue count + partialFailure absence, shortcut list contains the
   new id and omits the legacy one, asset URLs 200/404, neeru detail
   shape, feed indexer lag under 100 blocks. Halts on any regression.
   Dry-run against live backend passes 6/6.
…umer spec path (#267)

The wallet-consumer-spec.md doc lives in the backend clone at
/Users/.../TuCOPWallet-Backend/tasks/specs/, not in this repo. Two
comments pointed to a wallet-side path that never existed in tracked
source. Rephrase to name the spec by title without the file path so
future readers do not chase a broken breadcrumb.
…acked source (#268)

* chore(earn/neeru): drop remaining partner-contract vocabulary from tracked source

Backend sweep of HEAD c56e796 found residual tranche/principal/maturity
vocabulary that survived the #265 refactor across i18n keys, i18n values,
CSS class names, prop names, testIDs and test descriptions. This PR
closes the remaining surface end-to-end. Backend's two grep assertions
(tasks/specs/wallet-consumer-spec.md verification block) both return
zero on this HEAD.

Renames:

i18n keys (locales/base/translation.json + locales/es-419/translation.json):
- neeruVaults.tranches.* -> neeruVaults.categories.*
- neeruVaults.detail.descriptionByTranche.* -> descriptionByCategory.*
- neeruVaults.positionRow.principal -> positionRow.amount
- neeruVaults.positionRow.maturity -> positionRow.availableAt
- neeruVaults.closeSheet.principalLabel -> closeSheet.amountLabel
- neeruVaults.closeSheet.principalOnlyCta -> closeSheet.amountOnlyCta
- neeruVaults.closeSheet.principalOnlyHelp -> closeSheet.amountOnlyHelp
- neeruVaults.errors.invalidTranche -> errors.invalidCategory
- neeruVaults.errors.trancheCapExceeded -> errors.categoryCapExceeded

i18n interpolation vars (rename in values + all t() callsites):
- {{trancheLabel}} -> {{categoryLabel}}
- {{principal}} -> {{amount}}

i18n values (user-facing copy updated to avoid mirroring contract terms):
- 'Principal: {{amount}} Pesos' -> 'Deposited: {{amount}} Pesos' (es-419: 'Depositado')
- 'Principal' label -> 'Deposited' (es-419: 'Depositado')
- 'Exit with principal only' -> 'Withdraw deposit only' (es-419: 'Retirar solo el deposito')
- 'principal without interest' -> 'deposit without interest' (es-419: 'deposito sin intereses')
- 'never to your principal' -> 'never to your deposit' (es-419: 'nunca al deposito')
- Explainer bodies: 'principal + earned interest' -> 'deposit + earned interest'
- Emergency explanation: 'recover your principal of X Pesos' -> 'recover your deposit of X Pesos'
- 'Withdraw principal only' -> 'Withdraw deposit only'
- 'Invalid tranche selected.' -> 'Invalid category.' (es-419: 'Categoria invalida.')

Code:
- src/earn/neeru/constants.ts: categoryIdFromPositionId regex drops the
  legacy :tranche- fallback branch; only :category- matches now. Backend
  no longer emits the pre-cutover suffix, and the earn slice refetches
  on mount so persisted positionIds from before 1.118.7 get replaced on
  the next successful backend read.
- src/earn/neeru/errorMapping.ts: values point to invalidCategory /
  categoryCapExceeded (was still pointing at the old tranche i18n keys).
- src/earn/neeru/NeeruCloseSheet.tsx: PrincipalOnly prop / testID / CSS
  class renamed to AmountOnly. onPrincipalOnlyRequested ->
  onAmountOnlyRequested.
- src/earn/neeru/NeeruEmergencyCloseSheet.tsx: t() call passes 'amount:'
  instead of 'principal:' for the interpolation slot.
- src/earn/neeru/NeeruPositionRow.tsx: two t() key strings updated.
- src/earn/neeru/NeeruVaultDetailScreen.tsx: DESCRIPTION_KEY_BY_CATEGORY
  points at descriptionByCategory; local var trancheLabel renamed to
  categoryLabel; onPrincipalOnlyRequested callback renamed.
- Tests: describe/it strings and testID references updated; the constants
  test that used a :tranche- positionId is replaced with a generic
  :legacy- suffix (still validates the 'unknown suffix returns null'
  branch).

Release automation:
- .github/workflows/check.yml gains a backend-smoke job that runs
  scripts/pre-release-backend-smoke.sh. Guarded by the same 'PR to main
  OR manual OR schedule' condition as vulnerability and knip-regression,
  so day-to-day Development PRs are not gated on backend uptime.

* chore(earn): drop residual tranche vocabulary from PoolCard + EarnHome test

Two comments in src/earn/PoolCard.tsx and one positionId literal in
src/earn/EarnHome.test.tsx still spelled 'tranche' after the sweep in
the parent commit. The wider grep across src/earn/ (not just neeru/)
now returns zero for tranche|principal|maturity.
…ub (#269)

The two comments in src/redux/migrations.ts and test/schemas.ts spelled
out the pre-cutover field names (trancheLabel, maturityTs, dailyRateRay)
as documentation of what the migration replaces. That block survived the
zero-exposure sweep because grep scoped to src/earn/neeru/. Rewriting it
to 'five fields renamed to opaque wire names' before running the
history scrub keeps HEAD identical after --replace-text and avoids a
garbled comment ('categoryLabel -> categoryLabel') as collateral.
… shortcut gas (#270)

* fix(viem): buffer non-native shortcut gas by 25% to avoid CIP-64 OOG

When a shortcut (hooks-api trigger) pre-supplies rawTx.gas and the user pays
in a non-native fee currency, wallet was only adding STATIC_GAS_PADDING (50k)
on top. That was demonstrably too tight for complex shortcut calls: on
2026-07-14 a Neeru deposit reverted OOG at 306,385 / 310,000 (98.8%) because
the 260k hint + 50k padding left no room for real-world variance.

Add SHORTCUT_NON_NATIVE_BUFFER_BPS = 2500, applied ONLY when both conditions
hold (shortcut-provided gas AND CIP-64 fee currency). Wallet-estimated txs
already benefit from Forno's binary-search LIMIT and are unaffected. Buffer
scales with the shortcut's own gas so small pre-set gas barely moves (+3.75k
on a 15k tx) while realistic shortcut calls get a real safety margin
(+65k on a 260k tx).

* fix(feed): render failed state on EarnFeedItem so reverts stop reading as successful

When an Earn deposit / withdraw / claim reverts on-chain, TransactionFeedItemImage
swapped its avatar to the attention icon but EarnFeedItem never read
transaction.status for subtitle or amount styling. Result: a failed 20,000 COPm
Neeru deposit rendered "en la pool Neeru Vaults" with a full-color -COP$20,000
amount, indistinguishable from a successful move (trust-critical UX bug).

Mirror the TransferFeedItem pattern via transferFeedUtils.ts:151:
- If status === Failed, override the type-specific subtitle with feedItemFailedTransaction.
- Style the amount in Colors.gray3 instead of the accent color so it visually
  reads as inactive without hiding the number (retry decisions still need it).

* fix(feed): render failed state on SwapFeedItem to close the same gap in swap history

Same class of bug as EarnFeedItem: SwapFeedItem only passed status to the
avatar image and never overrode subtitle or amount styling. Any failed swap
(Squid, cross-chain, multi-leg 7702 Dolares batch) rendered with its full
route text ("Dolares > Pesos") and full-color incoming amount, reading as
successful. Confirmed live in 1.118.7.

Apply the same pattern as EarnFeedItem so a reverted swap:
- Replaces the route-based subtitle with feedItemFailedTransaction regardless
  of cross-chain / multi-dollar / multi-leg / single-leg branch.
- Grays the incoming amount (outgoing amount is already gray).

Left the icon path unchanged (TransactionFeedItemImage already handles Failed).

* test(swap): refresh SwapScreen gas math for the CIP-64 shortcut buffer

The SwapScreen fixture and analytics assertion baked in the pre-buffer gas
numbers. After a21e209 added SHORTCUT_NON_NATIVE_BUFFER_BPS = 2500 to
prepareTransactions, the swap tx now consumes:

  base 1_800_000 + STATIC_GAS_PADDING 50_000 + 25% * 1_800_000 = 2_300_000

Update the fixture (1_850_000 -> 2_300_000) and the swap_review_submit
analytics assertion (gas 1_871_000 -> 2_321_000, maxGasFee/estimatedGasFee
recomputed accordingly) so the four broken SwapScreen tests match the shipped
behavior. Comment expanded to spell out the buffer contribution.
…ncy fallback + UI polish (#271)

* feat(earn/neeru): consume backend simulation-revert envelope + emergency fallback stash

Backend now pre-simulates the Neeru withdraw and, when it would revert,
returns `transactions: []` plus `dataProps.simulationRevert.{selector, reason}`.
When the revert is INTEREST_POOL_LOW, the response also carries a pre-built
`dataProps.fallback.transactions` (the amount-only calldata, ready to sign),
so the wallet can present the emergency sheet without a second hooks-api
round-trip and without paying gas on a doomed tx.

Wallet-side changes:

- `slice.ts`: new transient `pendingEmergencyFallback` keyed by positionId,
  plus `setEmergencyFallback` / `clearEmergencyFallback` reducers. Cleared on
  rehydrate so stale calldata never survives an app restart.
- `selectors.ts`: `neeruEmergencyFallbackByIdSelector(state, positionId)`.
- `saga.ts`:
  - `TriggerShortcutResponseData` type for the enriched envelope.
  - `classifySimulationRevert(...)` returns an opaque tag
    (`low_pool` | `already_closed` | `not_owner` | `unknown` | null) matched
    against the raw 4-byte selectors in `constants.ts`. No contract error
    names in tracked source.
  - `closeNeeruPositionSaga`: when `response.transactions.length === 0`,
    routes on the classified tag. LOW_POOL stashes the pre-built fallback
    (if present) and dispatches the wallet-side action so the screen swaps
    to the emergency sheet. ALREADY_CLOSED / NOT_OWNER / UNKNOWN each map
    to their own opaque failure tag.
  - `emergencyCloseNeeruPositionSaga`: reads the stash first and consumes
    it in place, skipping triggerShortcut entirely. Falls back to a fresh
    trigger when reached via the wallet-side receipt-check safety net,
    which does not carry pre-built calldata.
- `test/schemas.ts`: mirror the new persisted field.
- Tests: 7 new cases (LOW_POOL with + without fallback, ALREADY_CLOSED,
  NOT_OWNER, UNKNOWN selector, empty response with no dataProps, emergency
  saga skip-trigger, emergency saga fall-through-to-trigger). Existing tests
  now bind `.withState({ neeru: initialState })` so selector calls resolve
  cleanly under expectSaga.

Receipt-check + on-chain replay path stays as safety net for races between
the backend simulation and the actual send.

* refactor(earn/neeru): sheets to BottomSheetModal + formatted amounts + proactive amount-only path

Neeru sheets migrate off the wrapper `BottomSheet` component to
`@gorhom/bottom-sheet`'s `BottomSheetModal` directly, so they can carry a
`BottomSheetBackdrop` (dim+dismiss on tap) and dynamic sizing. Amount
strings across the sheets, PositionRow, and PoolCard now go through
`formatValueToDisplay`, which renders thousands separators and two
decimals ("10,000.00" instead of the raw "10000"). Root testIDs
(`NeeruCloseSheet`, `NeeruEmergencyCloseSheet`) landed on the inner
`View` so the screen-level tests can assert sheet visibility around
`Manage` / dismiss transitions.

Two additions on top of the refactor:

- `NeeruCloseSheet.AmountOnly`: proactive "withdraw only my deposit"
  button below the primary Confirm CTA. When tapped, the detail screen
  dismisses the close sheet and presents the emergency sheet for the
  same position, so a user who already knows the interest pool is low
  can skip the doomed full-withdraw attempt and its gas cost. New
  `amountOnlyCta` translation key in en / es-419.
- `PoolCard` COPm balance fix: when the deposit token is COPm (mainnet)
  and its `priceUsd` is 0 (Mento stablecoins hydrate with a placeholder
  price), the USD -> local conversion collapses to 0. Skip the
  conversion in that case and render the token balance directly with
  the local currency symbol.

Sheet tests refreshed to match the formatted output (`/10,000/`,
`/82\.50/`) and the new proactive path.

* chore(test/schema): regenerate RootStateSchema.json for pendingEmergencyFallback

The new transient neeru state field lands in the generated Redux state
schema. No migration needed because the field is reset on rehydrate
(see the REHYDRATE reducer in earn/neeru/slice.ts).

* test(redux/store): refresh rehydration snapshot for pendingEmergencyFallback

The `validates against the RootState schema after rehydration` inline
snapshot mirrors the runtime state tree, so it needs the new transient
neeru field added under `state.neeru`. Same order as the initialState
in the slice reducer.
…hain layout (#272)

* fix(earn/neeru): correct Deposit topic0 default hex

The zero-exposure sweep hardcoded a topic0 default that did not match the
event actually emitted on-chain, so parseDepositEvent never found a
matching log and the wallet fell back to backend polling for every
deposit. Replaces the default with the value the backend meta endpoint
exposes (also verified by matching a real deposit receipt on Celo mainnet).

Env override via NEERU_DEPOSIT_TOPIC0 unchanged.

* fix(earn/neeru): align Deposit event data schema with 4-slot layout

Backend meta endpoint declares the Deposit event's non-indexed data as
four ABI slots (uint8 + three uint256), but the local schema only had
three. viem's decodeAbiParameters is strict about slot count, so the
decode threw and parseDepositEvent silently returned null on every real
receipt.

Extends the schema by the trailing uint256 slot (currently ignored by
the wallet) and refreshes the destructure with an explicit tuple cast.
Updates the fixture in eventParsing.test.ts to encode all four slots so
the happy-path assertion exercises the new layout.
…gue (#273)

* feat(earn/neeru): add /meta + /catalogue fetchers and shared types

Introduces NeeruMeta and NeeruCatalogue payload types plus their fetch
helpers. Both endpoints share a 5s timeout, shorter than the position
fetch because falling back to hardcoded defaults or a skeleton is quick
and always safe.

* feat(earn/neeru): neeruConfig slice + selectors + boot saga

Adds a Redux slice that tracks the cached backend meta payload and the
current-session catalogue payload separately. Meta persists across
sessions with a source tag (backend|cache|fallback) and a fetchedAt
timestamp; the resolver enforces a 24h TTL and falls back to a hardcoded
last-resort payload only in cold-boot-offline. Catalogue never persists
and has no fallback because on-chain rates fluctuate operationally, so
callers must render a skeleton while it retries.

The boot saga fires a meta fetch once at neeruSaga start; catalogue is
left for consumers to trigger on-demand so wallets that never open Earn
do not burn a request.

* feat(earn/neeru): wire neeruConfig reducer + spawn boot saga

Registers the new slice in reducersList and spawns neeruConfigSaga at
the top of neeruSaga so the meta fetch runs as soon as the app boots.
Order matters: config runs before positions fetch so downstream consumers
can start reading from neeruConfig instead of hardcoded defaults in
follow-up PRs.

* test(earn/neeru): cover neeruConfig slice, selectors and boot saga

Slice tests cover meta success/error, cache preservation on failure,
catalogue no-fallback behavior, and REHYDRATE downgrading source from
backend to cache. Selector tests exercise the four resolver branches
(hardcoded fallback, backend fresh, cache fresh within TTL, cache stale
past TTL) plus the exact TTL boundary. Saga tests dispatch success and
failure for both endpoints.

* chore(test/schema): register neeruConfig slice shape

Adds v252SchemaWithNeeruConfig sibling of v252Schema (no persist version
bump, autoMergeLevel2 folds the initialState for existing users at
rehydrate). Regenerates test/RootStateSchema.json and refreshes the
rehydration snapshot in src/redux/store.test.ts.
New test suite src/earn/neeru/__tests__/hardcoded-vs-live.test.ts that
fetches /api/meta/contracts/neeru and /api/earn/neeru/catalogue live from
the production backend and asserts the hardcoded NEERU_META_HARDCODED_FALLBACK
matches byte for byte (proxyAddress, Deposit topic0, dataSchema structural
shape, three error selectors, depositToken.address) plus structural
category IDs and lock-period seconds on the catalogue side. Rates are
excluded from the assertion because the operator retunes them without a
contract upgrade; retunes must not fail CI.

Suite is gated by NEERU_LIVE_META_CHECK=1 so local yarn test runs offline
stay green. A new blocking workflow job (Neeru meta drift check) sets the
env var and runs on every PR. Drift means merge blocked, closing the loop
on the PR #272 class of bug.
…dcoded constants (#276)

* refactor(earn/neeru): thread meta into eventParsing + saga helpers

parseDepositEvent now receives contractAddress, topic0 and dataSchema
as arguments instead of reading from static constants. Same for
classifySimulationRevert (errorSelectors) and isLowPoolError
(lowPoolSelector). Sagas that call them read the resolved meta from
neeruMetaSelector at the top and pass the fields down.

Runtime behavior: backend meta > cache (< 24h) > cache (> 24h,
degraded) > hardcoded fallback. Same fallback the CI drift check
enforces byte-for-byte.

* chore(earn/neeru): drop hardcoded contract state from constants.ts + abi.ts

Removes the five hex constants (NEERU_CONTRACT_ADDRESS, NEERU_DEPOSIT_TOPIC0,
three NEERU_ERR_*_SELECTOR values) and the deposit-token pair that were
sitting in constants.ts with build-time env overrides. Also deletes the
now-unused src/earn/neeru/abi.ts. All of these flow through
neeruMetaSelector at runtime and the last-resort fallback lives in
configSelectors.NEERU_META_HARDCODED_FALLBACK.

DEPOSIT_TOKEN_ADDRESS + DEPOSIT_TOKEN_ID had no external callers so they
are dropped without replacement; consumers use meta.depositToken.address
when needed.

* test(earn/neeru): update fixtures + provides to the meta-injected helpers

parseDepositEvent tests import topic0 and dataSchema from
NEERU_META_HARDCODED_FALLBACK and pass them explicitly on every call.
Saga tests provide neeruMetaSelector -> fallback so the wallet-side
flow exercises the same hex constants the CI drift check enforces.
isLowPoolError tests pass the INTEREST_POOL_LOW selector explicitly.
…logue at runtime (#280)

* feat(earn/neeru): expose per-category catalogue selector

New helper neeruCatalogueCategoryByIdSelector returns the {secs,
monthlyRatePercentage, annualEffectivePercentage} row for a given
category id, or null when the catalogue is missing the id. Consumers
must handle the null branch (skeleton / retry) instead of silently
using stale hardcoded rates. Backend-first policy for catalogue values.

* refactor(earn/neeru): optimistic UI consumes catalogue for lock + monthly rate

handleNeeruDepositOptimistic now reads the target category from the
catalogue and passes secs + monthlyRatePercentage to buildOptimisticPosition.
When the catalogue does not have the category (cold boot before the fetch
lands), the saga triggers a background catalogue fetch and falls back to
the normal backend-driven position fetch instead of using hardcoded
values. Removes CATEGORY_DURATION_SECONDS and the monthlyPercentFromRateValue
import; buildOptimisticPosition now takes secs and monthlyRatePercentage
as explicit arguments. Test provides feed CAT_ROWS through a helper.

* feat(earn): PoolCard reads Neeru rates from catalogue + refresh on EarnHome mount

PoolCard now consumes annualEffectivePercentage + monthlyRatePercentage
straight from neeruCatalogueCategoryByIdSelector when available, so
operational retunes on the backend show up on the card without the
wallet re-releasing. Falls back to the local monthly-to-annual
conversion when the catalogue is still loading (cold boot).

EarnHome dispatches fetchCatalogueStart on mount so the catalogue is
refreshed every time the user visits Earn instead of only at boot.

* chore(earn/neeru): drop monthlyPercentFromRateValue (backend catalogue covers it)

The wallet no longer computes the monthly rate from the on-chain RAY
value; the backend catalogue exposes monthlyRatePercentage directly.
Test suite renamed to exercise effectiveAnnualPercentFromMonthly
(kept as a pre-catalogue PoolCard fallback) plus the existing computePayout
coverage.
#281)

Adds fetchNeeruTxStatus API helper and refactors enforceReceiptsOrThrow
to cross-check its wallet-side eth_call replay against the backend's
replay at N-1. Confidence tag on the thrown error follows the 4-cell
matrix backend specified on 2026-07-25:

  wallet=X + backend=X  -> confirmed  (persistent revert)
  wallet=null + backend=X -> transient (already resolved on-chain)
  wallet=X + backend=null -> live-only (new reason after mining)
  wallet=null + backend=null -> unknown (surface "estado incierto")

Disagreement between the two selectors is logged and treated as live-only
so the user sees the current state, not a stale one. Backend outage
degrades to wallet-only (fail-soft, still throws so callers do not treat
the revert as success). cause.data still carries the raw selector so
isLowPoolError and classifySimulationRevert keep working.
…ta shape (#283)

* chore(earn/neeru): opaque event + error names in NeeruMeta, lowercase addresses

The 5-PR wire refactor exposed the earn-vault surface in tracked source
by using semantic property names (events.Deposit, errorSelectors.
INTEREST_POOL_LOW, etc) and a checksummed proxy address. The
zero-exposure policy from PR #265 forbids exactly this: a repo grep must
not surface the contract's event or error names.

Rewires the internal projection so:
- events.Deposit -> events.primary
- errorSelectors.INTEREST_POOL_LOW/ALREADY_CLOSED/NOT_OWNER -> e1/e2/e3
- proxyAddress and depositToken.address stored lowercase
- backend response adapted at the api.ts boundary (adaptNeeruMeta reads
  raw keys positionally, never references them as property paths)

The hardcoded fallback now agrees with the adapter output byte for byte,
and every downstream consumer sees only the opaque projection.

* refactor(earn/neeru): saga consumers use opaque errorSelectors + events.primary

classifySimulationRevert's third argument now takes {e1, e2, e3} instead
of the semantic names; the mapping to internal tags (low_pool, etc) is
stable via the enumeration order backend guarantees. handleNeeruDepositOptimistic
reads meta.events.primary.{topic0,dataSchema} and closeNeeruPositionSaga
passes meta.errorSelectors.e1 to isLowPoolError.

* test(earn/neeru): consume opaque projection, route live drift check through adapter

- eventParsing fixture: events.primary.{topic0,dataSchema}
- hardcoded-vs-live: routes the live /meta response through adaptNeeruMeta
  and asserts the opaque projection matches the local fallback byte for
  byte, so semantic backend names never appear in this file
- saga tests: errorSelectors.e1 and generic reason strings (E1/E2/E3) in
  the simulationRevert fixtures instead of the contract error names

* chore(test/schema): regen RootStateSchema for opaque NeeruMeta shape

Schema now uses events.primary and errorSelectors.{e1,e2,e3} matching the
zero-exposure projection landed in this PR.
…ine (#282)

Backend PR #152 shipped priceUsd for COPm on /hooks-api/getPositions,
so the wallet can now render the standard USD-to-local conversion for
COPm pools like any other token. The special-case that used the raw
balance directly is kept only as a defensive fallback for when the
token's priceUsd degrades to 0 (backend outage or fail-soft), so pool
cards never show a blank headline for COPm specifically.
…ror schema (#284)

* feat(sentry): PII scrub + opaque account id helpers

Every Sentry payload the wallet ships passes through scrubSensitiveStrings
before it leaves the device. EVM addresses (0x + 40 hex), transaction /
storage / block hashes (0x + 64 hex) and wei-scale amounts (>= 15 digit
runs) get replaced with opaque placeholders so an issue thread never
contains the identity or holdings of the reporting user.

opaqueAccountId maps a wallet address to a stable 16-hex id via FNV-1a
so Sentry can still count sessions per user without receiving the raw
address. Case-insensitive, deterministic, non-cryptographic (does not
need to be).

* feat(sentry): business-error helper with cross-team schema

captureBusinessError is the only entrypoint sagas use so the event
taxonomy (feature / provider / action / errorCode) is enforced by shape,
not by convention. Fingerprint is set explicitly so the same failure
mode groups into one Sentry issue across users regardless of the
underlying exception message, which is what we want for dashboards
(count / rate) rather than one issue per user.

BusinessFeature + BusinessProvider unions cover the current wallet
integrations (neeru, squid, allbridge, jumpstart, buckspay, marranitos,
internal). Extend the unions as new integrations land so the schema
review with backend stays sharp.

* feat(sentry): wire beforeSend + opaque user context in Sentry.init

Sentry.init now runs every event and every breadcrumb through
scrubSensitiveStrings before they leave the device, sends
sendDefaultPii=false so the SDK does not attach any identifying data
of its own, and reports the user as an opaque hashed id derived from
the wallet address instead of the raw address.

* feat(sentry): wire captureBusinessError in earn, positions, swap paths

Every catch block that already logs a business failure now also reports
it to Sentry with the shared taxonomy:

- earn/neeru/saga: fetch_positions, close_position, emergency_close
- earn/neeru/configSaga: fetch_meta (also sets neeru_meta_source tag,
  primed for backend's fallback-usage dashboard)
- positions/saga: trigger_shortcut, execute_shortcut
- swap/saga: execute (with the existing classifyError code as errorCode)

Existing Logger and AppAnalytics calls are preserved; Sentry is
additive so nothing about the local UX or Segment (still disabled)
paths changes.

* chore(env): enable Sentry on mainnet

Turns SENTRY_ENABLED on now that the PII scrub, business-error helper
and saga wires are in place. The DSN is sourced from secrets.json
(gitignored) at build time; the runtime SDK skips init when the DSN
is missing so dev builds without the secret are unaffected.

* chore(env): enable Sentry on mainnetdev too

Dev builds should exercise the same PII scrub + capture path as
production so smoke tests in the simulator surface any breakage
before it ships.

* feat(sentry): apply backend 2026-07-26 schema review

Backend approved #284 with 3 requested changes; applying them plus the
2 relevant observations before merge.

Requested changes:
- Add 'wri' to the BusinessProvider union so future delegate-relay and
  fee-adapter-bootstrap failures do not fall through to 'internal'.
- Introduce classifyHttpError (network_error | timeout | http_4xx |
  http_5xx | parse_error) and pass it as errorCode on every backend
  fetch path: fetch_meta, fetch_catalogue, fetch_positions,
  trigger_shortcut, execute_shortcut.
- Introduce classifyRevertConfidence (revert_confirmed | transient |
  live_only | unknown) and pass it as errorCode on close_position and
  emergency_close catches. When the error came from enforceReceiptsOrThrow
  the confidence tag wins; falls back to classifyHttpError for network
  failures on the same path.

Observations applied:
- neeru_meta_source gains a 'cache' value emitted at neeruConfigSaga
  boot when the persisted meta already exists. Dashboard now
  differentiates fresh backend / TTL cache hit / fallback_pending.
- beforeSend forces user.ip_address off (delete via destructure) so
  Sentry ingest never stores a connection IP even if the
  sendDefaultPii setting drifts on a future SDK upgrade.

Observation 3 (fingerprint recipe) is documented in the helper comment:
[feature, provider, action, errorCode ?? 'unclassified']. Favours
count/rate dashboards; per-user detail lives on individual events.
Adds scripts/populate_secrets.sh which reads the Sentry auth token
(SENTRY_ORG_TOKEN) and DSN (SENTRY_CLIENT_URL) from the developer's
macOS Keychain, or from SENTRY_AUTH_TOKEN / SENTRY_CLIENT_URL env
vars in CI, and writes ios/sentry.properties, android/sentry.properties,
and secrets.json. All three targets are gitignored.

Rewires postinstall to run the new script. Removes the yarn
keys:decrypt / keys:encrypt entry points; the encrypted-file
mechanism they wrap is deleted in the follow-up commit.

Templates now carry the real org (tucop-finance) and project
(tucopwallet) slugs so a dev without any token still gets a sensible
scaffold. Missing token or DSN emits a warning but never fails
postinstall; runtime error reporting simply stays disabled until
credentials are provided.
Deletes the four encrypted files that depended on Valora's GCP
project celo-mobile-testnet (keyring=github, key=wallet) and the
scripts/key_placer.sh helper that encrypted/decrypted them:

  ios/sentry.properties.enc
  android/sentry.properties.enc
  secrets.json.enc
  e2e/.env.enc
  scripts/key_placer.sh

The Sentry properties and secrets.json are now populated by
scripts/populate_secrets.sh from the TuCOP-owned Keychain entries
or CI env vars.

e2e/.env.enc had no on-disk template, no local decryptable copy,
and no TuCOP dev has access to Valora's KMS to open it. It is
removed here; when TuCOP defines its own e2e credentials, they
will land through the same Keychain/env mechanism.

README scripts+layout tree updated to reflect the new setup.
Uncomments the sentry.gradle apply in android/app/build.gradle. Without
it, Android release stacktraces reaching Sentry are minified and
practically unreadable (line 12345 of a giant bundle), while iOS
already had the equivalent Xcode phase active. Now both platforms
upload sourcemaps and dSYMs during release builds.

The plugin reads auth credentials from android/sentry.properties,
which populate_secrets.sh already generates from the Keychain / env
vars. Debug builds respect SENTRY_DISABLE_AUTO_UPLOAD=true and skip
the upload step.
@sentry/types was on 7.x while the active SDK @sentry/react-native is
on 6.22 which ships its own types. Grep across src/ confirms no
imports; it only lingered in the auto-generated LicenseDisclaimer.
Removing avoids the SDK-vs-types version drift and shrinks node_modules.
The Gold buy/sell confirm buttons only checked `!preparedTransactions`,
but the screen initialises that state as an empty array (`[]`) while
the on-screen quote is still being fetched and gas simulated. `![]`
is `false`, so the button stayed enabled during the "Tarifa de Red:
Estimando..." window. Tapping it navigated with `preparedTransactions:
[]` and `estimatedGasFee: "0"`, which the saga then blew up on with
"Mismatch in number of prepared transactions and standby transaction
creators" (0 txs vs 1 standby handler always pushed).

Gate now requires: preparedTransactions is an array with length > 0
AND estimatedGasFee is set and != "0" AND no quoteError AND toTokenId
resolved. Show the loading spinner while the quote hook is still
running so the user understands why the button is disabled.

Defence-in-depth in buyGoldSaga / sellGoldSaga: if the button is
somehow bypassed and the saga is entered with an empty tx list, fail
fast with a Spanish user-facing message and a Sentry business error
tagged `empty_prepared_txs` instead of surfacing the generic mismatch.

Also wire `captureBusinessError` in the buy + sell catch blocks so
gold-flow failures land in Sentry with the shared business schema
(feature=earn provider=squid action=buy_gold_execute / sell_gold_execute).
Previously they only hit the Logger and never reached the dashboard.
…ee currency

Previously useGoldQuote treated `prepareTransactions` returning
`not-enough-balance-for-gas` (or `need-decrease-spend-amount-for-gas`)
as success and returned a fake quote with `estimatedGasFee: '0'` and
`preparedTransactions: []`. With the confirm button now correctly
gated on those being populated, the user was left staring at
"Estimando..." forever with no way to know what went wrong.

Throw an actionable error instead. The confirm screen already catches
and displays it via the inline error notification and the button gate
stays disabled.

Message deliberately does not blame the user's balance: in practice
the same status code lands here for Forno rejecting eth_estimateGas
on every CIP-64 fee currency (a real Celo RPC flakiness pattern we
just observed live for 0x8427...ece which has 12,802 COPm plus 0.43
CELO of balance and yet hits "not-enough-balance-for-gas" because
maxFee/baseFee margin got tight for the currency being tried). The
copy invites a retry without misdiagnosing the state.
…en RPC rejects

Celo mainnet switched from op-geth to op-reth on 2026-07-22
(https://forum.celo.org/t/13594). Since then, Forno intermittently
rejects `eth_estimateGas` for CIP-64 non-native fee currency
transactions with "Missing or invalid parameters", even for simple
ERC20 approves that op-geth used to price cleanly.

The existing rescue path already substitutes a 65k-gas fallback for
`transfer(address,uint256)` (selector 0xa9059cbb) on the same class of
failure. Extend the same fallback to `approve(address,uint256)`
(selector 0x095ea7b3). Standard ERC20 approves have a well-known cost
in the same range (45-55k), so the fallback is safe and unblocks the
whole downstream batch — a swap flow whose approve fails estimation
would return null and cascade to "not-enough-balance-for-gas" even
when the user has ample balance in both fee currencies.

Observed live 2026-07-26 with wallet 0x8427...ece (12,802 COPm plus
0.43 CELO) attempting COPm -> XAUt0 via Squid: the Squid-returned
approve tx failed estimation for both COPm and CELO fee currencies
because of the op-reth regression, and useGoldQuote correctly
propagated "no fee currency worked" to the UI. With this fallback
the approve is priced, the swap that follows uses the existing
post-approve fallback, and the flow completes.

Also fix GoldBuyEnterAmount to surface the actual error message from
useGoldQuote instead of always showing the generic "Ruta de intercambio
no disponible" translation. That message was actively misleading when
the failure was a Forno flake and not a routing problem.
…w screens

`getInputDecimalsForToken` returns 6 across the board so users can TYPE
tiny Oro fractions without them rounding to zero. It was being called
in DISPLAY paths as well, producing "8,992.134348 Pesos" on the sell
confirmation review card when it should read "8,992.13 Pesos" per the
peso convention.

Splits the two concerns:

- `getInputDecimalsForToken` — unchanged, keeps returning 6 for input
  fields.
- `getDisplayDecimalsForToken` — new. Returns 2 for stablecoins
  (`isStableCoin`, plus an explicit id fallback covering Mento's COPm,
  USDm, EURm, BRLm and non-Mento USDT/USDC), 6 otherwise (Oro etc).

Applied to GoldSellConfirmation ("Recibes" amount and network fee)
and GoldBuyConfirmation (network fee). The "Vendes" and "Precio del
Oro" lines already used the right formatting.
…als for stablecoins

Follow-up to the confirmation-screen fix. The same
getInputDecimalsForToken misuse was rendering the "Recibes" preview
on GoldSellEnterAmount as "9,003.089097 Pesos" and the virtual-Dolares
aggregated output on SwapScreen with 6 trailing zeros. Route both
through getDisplayDecimalsForToken so stablecoin fields cap at 2.

Sweep verified: GoldBuyEnterAmount's output field renders Oro
(XAUT0_DECIMALS = 6, correct); other .toFormat(6) uses in
GoldHome / BalanceCard / TabWallet are on gold balances (also
correct).
…batches

The mismatch check in sendPreparedTransactions is the last line of
defence against dispatching a saga with mis-shaped tx state. Two
callers were still vulnerable to the same class of race that broke
gold buy last week:

1. `swap/saga.ts` (Squid swap): confirm screen gates on quote being
   populated, but stale-state races could still leave preparedTransactions
   as []. If it ever did, the saga always pushed one swap handler and
   blew up with "Mismatch in number of prepared transactions and
   standby transaction creators". Add the same early-return + Sentry
   business error we shipped for gold.

2. `jumpstart/saga.ts` (send-via-link): same shape. Add the empty guard
   before the handler pushes, wire captureBusinessError in the catch,
   and add a belt-and-suspenders check to the confirm button so it
   stays disabled if the route params are somehow empty.

Also handle the reverse edge case in all three sagas (swap + gold buy
+ gold sell): if Squid returns MORE txs than we built handlers for
(e.g. a multi-hop route with an intermediate step between approve and
swap, or a 2-tx batch whose first tx is not a standard approve), pad
the handlers array with null-returning entries before the trailing
swap handler. The intermediate txs still go on-chain, they just don't
generate standby entries the user sees.
…alora-kms

chore(sentry): migrate off Valora KMS + enable Android sourcemaps + drop @sentry/types
Celo mainnet migrated from op-geth to op-reth on 2026-07-22
(https://forum.celo.org/t/13594). op-reth validates that
`maxFeePerGas >= real baseFee + priority` at inclusion time more
strictly than op-geth, and rejects the request as "Missing or invalid
parameters" whenever the wallet's calculated max is too close to the
current price.

The previous shared Celo L2 path derived priority as
`eth_gasPrice(feeCurrency) - block.baseFeePerGas`. Those two numbers
are denominated in DIFFERENT units for CIP-64 non-native fee currency
transactions (block base is CELO wei, gas price is fee-currency
units), so the subtraction produced `priority ≈ gasPrice(fc)` and
left `maxFee` only 2-3% above the current price. Under op-geth this
usually worked; under op-reth it rejects the moment base fee moves
up between estimation and inclusion.

Split the CIP-64 case into `estimateCip64FeesPerGas` which treats
`eth_gasPrice(feeCurrency)` as authoritative (units agree, no
mixing) and builds a wide envelope:

  maxFeePerGas       = gasPrice(fc) * 2      (100% headroom)
  maxPriorityFeePerGas = gasPrice(fc) / 4    (25% of current price)
  baseFeePerGas      = gasPrice(fc) - priority (for UI display)

The 100% headroom is deliberately generous: op-reth rejects on any
borderline case, and the user is never actually charged above
`realBaseFee + priority` regardless of maxFee. Trading a slightly
overestimated ceiling for a lower rejection rate is the right call
until Celo Foundation stabilises the op-reth CIP-64 validation
tolerance.

The native (non-CIP-64) path is unchanged.

Test updated to reflect the new invariants — the block-header base
fee must NOT leak into the CIP-64 result, and maxFee must exceed
baseFee + priority in fee-currency units.
…and balance

Backend returns `dataProps.tvl` and computes `balance * priceUsd`
for a pool in whatever currency the deposit token represents. For
USD stablecoins (USDT, USDC, USDm, USAT) that value is in USD and
must be run through `useDollarsToLocalAmount` to display in the
user's local fiat. For local-currency Mento stablecoins (COPm,
EURm, BRLm, XOFm, GHSm, KESm), the value is ALREADY in the local
currency, so the same conversion multiplies by the USD->local rate
a second time - roughly 4000x for COP, producing "TVL COP$321,865,000"
where the real number is around COP$100,000 (321M / 4000 ≈ the 3218
COP/USD rate). Consumers had no way to distinguish the two, so every
COPm pool was inflated.

Approach:

- New helper `isLocalCurrencyStable(symbol)` in src/tokens/utils.ts
  centralises the classification (Mento peso/euro/real/etc. family
  plus legacy `cCOP`/`cEUR`/`cREAL` names still on historical
  records). USD stablecoins deliberately excluded.
- `getEarnPositionBalanceValues` in src/earn/utils.ts now also
  returns `isLocalCurrencyDenominated` derived from the deposit
  token's symbol. Old return shape preserved so existing callers
  keep compiling; new flag is optional to read.
- The 3 rendering call sites branch on the flag:
    * PoolCard.tsx: pool balance card + TVL row
    * poolInfoScreen/Cards.tsx: deposit balance card + TVL card
    * EarnEntrypoint.tsx: total supplied summary (converts per pool
      before summing so mixed portfolios stay honest)
- EarnConfirmationScreen already reads `poolBalanceInDepositToken`
  (not the USD variant) so it was unaffected.

Not affected: XAUt0 (gold) which has real USD-denominated pricing;
CELO (native, always USD-priced); Aave / other USD-only integrations.

Tests: 13 new cases on the helper cover the whole Mento family and
their opposites; 280 existing earn tests still pass.

Verification plan on mainnet (backend prod, wallet
0x8427e4409b73a31b9d4e0d210677c88877472ece):
- Flexible: COP$100,000 (was COP$321,865,000)
- 30d:     COP$0 (backend also fixed the empty aggregation)
- 60d:     COP$80,000 (was COP$257,492,000)
- 90d:     COP$20,000 (new tranche)
fix(tx): defense-in-depth for empty/oversized prepared tx batches (swap+gold+jumpstart)
TuCopFi added 18 commits July 26, 2026 21:48
…op-reth

fix(viem): dedicated CIP-64 fee estimator with 100% headroom for op-reth
…-denominated

fix(earn): stop double-converting TVL/balance for local-currency Mento pools (COPm et al)
… sentry.properties

The postinstall script `populate_secrets.sh` reads SENTRY_AUTH_TOKEN
and SENTRY_CLIENT_URL from env vars when there is no macOS Keychain
(i.e. Linux/macOS CI runners). Without these the script warns and
writes empty auth.token + empty DSN, which produces:

- Android sentry.gradle upload step tries to POST sourcemaps with an
  empty auth token and Sentry rejects.
- The shipped app has an empty SENTRY_CLIENT_URL baked in and reports
  nothing to Sentry at runtime.

Repo secrets already added to
https://github.com/TuCopFinance/TuCopWallet/settings/secrets/actions
so the ${{ secrets.X }} references resolve at build time.
…ction

Backend team asked (2026-07-27) why their Railway logs show ZERO requests
during outage windows while wallet users see the hardcoded $3050 fallback.
The three server-side hypotheses (tight timeout, wrong URL, upstream CMC
outage) do not match wallet code: FETCH_TIMEOUT_DURATION = 15s,
GET_XAUT_PRICE_URL points at the correct proxy.

The missing hypothesis is client-side. The wallet has a per-host circuit
breaker (src/lib/circuitBreaker: 5 failures within 60s opens the breaker
for 30s). While open, fetchWithTimeout returns a synthetic Response with
statusText "Service Unavailable (circuit open)" WITHOUT ever leaving the
device. If unrelated Railway calls (positions, tx/status, feed) burn the
five-failure budget, subsequent /api/prices/xaut requests short-circuit
locally and backend never sees them.

Instrument the price fetch so this is observable in Sentry going forward:

- captureBusinessError in the primary (TuCop backend) and fallback (DIA)
  catch blocks, with `feature: transactions, provider: internal,
  action: fetch_gold_price_backend|_dia`. errorCode uses classifyHttpError
  by default, but returns 'circuit_open' when the error message contains
  the CIRCUIT_OPEN_MARKER literal. Backend can dashboard the
  circuit_open bucket to know when the wallet aborted client-side vs
  when they should look at their own logs.

- Sentry.setTag('gold_price_source', 'backend' | 'dia_data' |
  'fallback_hardcoded') on the three success paths. Mirrors the existing
  neeru_meta_source pattern. The fallback_hardcoded rate becomes a direct
  operational health metric of the price feed.

No changes to timeout, URL, retry, or the circuit-breaker thresholds
themselves. The intent is observability first; if telemetry later shows
the breaker opening from unrelated calls, we can revisit whether the
gold-price fetch deserves its own bucket separate from the shared
host-level breaker.

Tests: 5 new cases cover each tag/errorCode branch, including the
circuit-open detection.
chore(ci): expose SENTRY_AUTH_TOKEN + SENTRY_CLIENT_URL to postinstall
feat(gold): Sentry telemetry + circuit-breaker detection on price fetch
…estimation error classification

Two small pieces of tech debt from the recent Sentry / op-reth work:

1. README: the postinstall now runs scripts/populate_secrets.sh
   (replacing the deleted yarn keys:decrypt), but the README's Quick
   Start still told newcomers to `cp secrets.json.template secrets.json`
   and edit by hand. Documented the new mechanism (Keychain on dev
   laptops, env vars in CI), the exact `security add-generic-password`
   commands, and pointed CI to the repo secrets link. This is the
   missing dev-onboarding half of PR #285.

2. viem/prepareTransactions: `isInsufficientFundsError` was checking
   `InvalidInputRpcError` only when `.details` matched
   `/gas required exceeds allowance/` (op-geth wording). op-reth (Celo
   mainnet from 2026-07-22) surfaces the same RPC error code -32602
   with `.details = "Missing or invalid parameters."`, so the old
   regex misses it and the fee-currency cascade throws instead of
   trying the next candidate. Widen to accept ANY
   InvalidInputRpcError as recoverable. Renamed the flag to
   `isRecoverableEstimationError` to reflect the actual semantics.
   Added a test case with the op-reth wording so future regressions
   fail loud.

   False-positive risk (a real unrecoverable error mis-classified as
   recoverable) is safe: it just retries with the next fee currency
   or uses the hardcoded ERC-20 fallback, and Logger.warn + the
   downstream captureBusinessError in the caller sagas keep the
   telemetry visible on Sentry.
Backend shipped (2026-07-27) a 24h stale-cache fallback on
GET /api/prices/xaut?vs=usd. Fresh path (TTL 60s) is unchanged. On
stale-serve the response body is identical but includes:

    X-Stale: true
    X-Stale-Age: <seconds>
    Cache-Control: max-age=0, must-revalidate

Wire the wallet to surface that signal:

- src/gold/types.ts: GoldPriceData gains `isStale?` and
  `staleAgeSeconds?`. Optional so DIA + hardcoded paths leave them
  undefined.
- src/gold/api.ts fetchFromTucopBackend: read the two headers after
  validating the body, propagate on the return.
- src/gold/api.ts fetchGoldPriceFromApi: on backend success branch on
  `priceData.isStale`:
    * fresh    -> tag `gold_price_source = backend`         (unchanged)
    * stale    -> tag `gold_price_source = backend_stale`
                  + tag `gold_price_stale_age_bucket = <bucket>`
- Buckets: <5min, 5-15min, 15-60min, >1h. Prevents per-second
  cardinality explosion on the Sentry side while still surfacing the
  operational shape (fresh-ish reads vs hours-old).

DIA / hardcoded / circuit-breaker paths are unchanged: their tags stay
`dia_data` / `fallback_hardcoded` / `errorCode:circuit_open`
respectively. UI consumers (Task 3 in the follow-up chain) will read
`isStale + staleAgeSeconds` off the GoldPriceData to show a "cotización
desactualizada" badge when age > 5min.

Tests: 11 new cases (3 header-propagation, 8 bucketize edges) on top
of the existing 10, all pass. Bucketize edges specifically pin the
5-15min boundary (299s <5min, 300s 5-15min) so future refactors do not
silently drift the thresholds.
… not contaminate

The circuit breaker in src/lib/circuitBreaker keyed state by URL host.
That contaminated shared-host deployments: a burst of 502s on
`/hooks-api` on tucop-backend-production.up.railway.app would open the
breaker for the WHOLE host, causing subsequent calls to
`/api/prices/xaut`, `/api/positions`, `/api/tx/status` and
`/api/earn/neeru/catalogue` on the same host to short-circuit with a
synthetic 503 for 30s even though those endpoints were healthy.

Squid upstream returns legitimate 502s by design when the user runs
multiple quick quotes or hits low-liquidity paths, so it is easy to
burn the 5-failure/60s budget just by browsing swap. Backend
independently flagged this behaviour in the same thread that shipped
the price-proxy stale cache.

Switch the key to `${host}${pathname}`. Query string is intentionally
excluded so different params on the same route do not fragment the
failure counter.

- circuitBreaker.ts: rename param `host` -> `key` throughout with
  updated docstrings. External API is unchanged (still
  `shouldShortCircuit(k)`, `recordFailure(k)`, `recordSuccess(k)`).
- fetchWithTimeout.ts: replace `extractHost` with `extractCircuitKey`
  that returns `${host}${pathname}`. Rename local `host` -> `circuitKey`
  and update the retry breadcrumb wording. No caller signature
  changes.
- 2 new tests pin the intended isolation: (a) `/hooks-api` opening does
  not affect `/api/prices/xaut` on the same host, (b) `recordSuccess`
  on one path does not clear the counter for another.

Existing 6 tests continue to pass unchanged because they used arbitrary
key strings, not literal hosts.

Note (not fixed here, on purpose): 4xx responses do not count against
the breaker. Backend's earlier note asking about 400/429 was based on
an outdated read of fetchWithTimeout; only response.status >= 500 and
caught network errors do. So this refactor is the only change needed
to fix the cross-endpoint contamination.
feat(gold): consume backend X-Stale header for stale-cache telemetry
Task 3 of the price-fix chain that started with #290 (Sentry telemetry)
and #292 (X-Stale header consumption in GoldPriceData).

When the TuCop backend serves the XAUt price from its 24h stale cache
(upstream provider degraded), the previous PR now carries that signal
through as `isStale + staleAgeSeconds` on GoldPriceData. This PR
persists the two fields into the gold Redux slice and renders a
"Cotización desactualizada" badge on the Gold buy amount screen so the
user knows the price they are seeing is real but a few minutes old.

Threshold: badge only shows when isStale && staleAgeSeconds > 300.
Sub-5min stale is common at the fresh-cache eviction boundary and
would flap the badge for essentially fresh data; the 5min bar matches
the backend fresh TTL headroom. In practice this triggers only when
the upstream provider is actually down long enough for the fresh cache
to expire, not on normal refresh churn.

Also fixes a small in-scope decimals bug: the "Disponible" balance line
was hardcoded to `.toFormat(4)` producing "9,591.5614 Pesos". Route it
through `getDisplayDecimalsForToken` so stablecoins render at 2 like
the rest of the app.

Slice additions (goldPriceIsStale, goldPriceStaleAgeSeconds): no
persist version bump per the pre-release convention. autoMergeLevel2
fills the new keys from initialState on rehydrate of older persisted
state, so existing users land on `false / 0` until the next successful
price fetch.

Screenshot approved by the user (2026-07-27, iOS iPhone 17 Pro sim)
before merge; the temporary render-force was reverted in this commit.

- src/gold/slice.ts: new state fields + reducer wiring
- src/gold/selectors.ts: goldPriceIsStale + goldPriceStaleAgeSeconds
- src/gold/GoldBuyEnterAmount.tsx: badge JSX + styles + `Disponible`
  decimals fix
- locales/base + es-419: goldFlow.stalePriceBadge
- test/RootStateSchema.json + test/schemas.ts + src/redux/store.test.ts:
  regen for the new fields
…fication

chore(viem): document populate_secrets.sh and widen recoverable estimation error classification
…r-path

refactor(circuit-breaker): key by host+path so unrelated endpoints do not contaminate
feat(gold): show stale-price badge when backend served from >5min cache
… baseline

Release CI runs `yarn audit --level high` on PRs to main and compares
the output to `yarn-audit-known-issues`. The file was empty, so the
check enforced zero vulnerabilities on release. Two things needed to
land the 1.118.8 promotion PR:

1. `ws@6.2.3` CVE-2026-48779 (HIGH, CVSS 7.5): memory-exhaustion DoS
   via tiny WebSocket fragments. Pulled transitively via
   `react-native>ws` and `react-native>@react-native/community-cli-plugin>
   @react-native/dev-middleware>ws`. Both paths are dev-server only
   (Metro debugging) and never shipped to production users, but the
   upgrade is trivial and cleaner than allowlisting. Add
   `"ws": "^6.2.4"` to resolutions to pull in the patched backport.

2. Regenerate `yarn-audit-known-issues` with the current 23 advisories
   from 5 modules (axios, brace-expansion, form-data, js-yaml,
   shell-quote). All are transitive from RN or twilio and require an
   attacker to already have code execution to matter. No user-facing
   runtime path. Verified locally with
   `bash scripts/ci_check_vulnerabilities.sh` -> "Ignoring known
   vulnerabilities".

Each remaining advisory sits behind a dep TuCop does not own directly.
Fixing them requires either upstream fixes in RN / twilio or forcing
resolutions that risk breaking the parent (e.g. axios has a resolution
already at ^1.15.2 but transitive paths through twilio still land on
an older version). Cleanest to accept as baseline for this release and
revisit when RN or twilio ship fixes.
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