Skip to content

Error 463 permanent on cold sends for instances paired before v0.7.2 — NCT salt never backfilled #124

Description

@starctt

Summary

Instances that were paired before v0.7.2 (when the project dropped the custom whatsmeow-lib fork and switched to official upstream go.mau.fi/whatsmeow) never receive the NCT salt, and therefore can never generate a <cstoken> for first-contact (cold) 1:1 sends. This makes error 463 (NackCallerReachoutTimelocked) permanent for any pre-v0.7.2 instance trying to message a number it has never talked to before — it never self-heals, no matter how many times the instance reconnects.

Root cause

  • generateCsToken (whatsmeow cstoken.go) requires cli.Store.NCTSalt.GetNCTSalt(ctx) to return a non-empty salt. We confirmed via direct Postgres inspection that whatsmeow_nct_salt is completely empty (0 rows) for an instance that has been connected and working for months.
  • The salt is only delivered through two whatsmeow-internal paths: (1) the one-time History Sync payload sent right after a fresh QR pairing (message.go, historySync.GetNctSalt()), or (2) an App State Sync patch (regular_high category, nct_salt_sync mutation index) fetched via FetchAppState.
  • Instances paired before v0.7.2 went through their one-time History Sync using the old custom fork, which had no concept of NCT salt at all — that one-time opportunity is gone forever for those instances.
  • Going forward, regular_high should self-heal via handleAppStateSyncKeyShare, but that function calls FetchAppState(ctx, name, false, onlyResyncIfNotSynced=true) — and onlyIfNotSynced skips categories that already have some stored version, even if that version predates cstoken support and never actually captured the salt mutation. So already-synced-but-stale categories are never re-fetched, and the instance is stuck permanently.

How Baileys solved the identical problem

Baileys hit the exact same gap and fixed it in WhiskeySockets/Baileys#2438 with a dedicated ensureNctSaltSynced() (in chats.ts): on every connection.updateopen, if no salt is stored yet, it force-fetches regular_high on its own — independent of the normal "already synced" gating, guarded on the persisted regular_high version so it bootstraps at most once and never loops for accounts that legitimately have no salt yet.

Suggested fix

Port the equivalent of ensureNctSaltSynced() into evolution-go (or ideally into go.mau.fi/whatsmeow itself, so all consumers benefit): on events.Connected, if cli.Store.NCTSalt.GetNCTSalt(ctx) is empty, call cli.FetchAppState(ctx, appstate.WAPatchRegularHigh, true, false) once (forcing fullSync=true, onlyIfNotSynced=false specifically for this bootstrap case), guarded so it only runs once per process/instance to avoid loops for accounts that genuinely have no salt provisioned server-side yet.

Impact

Affects any instance paired before the org's whatsmeow upgrade (in our case, before 2026-07-03 / v0.7.2). For anyone doing bulk migrations of older instances (e.g. from a different provider) into evolution-go, this silently breaks first-contact/cold outreach sends indefinitely, with no error message pointing at the actual cause (surfaces only as server returned error 463 on /send/text, identical to the generic reach-out time-lock).

Environment

  • evolution-go v0.7.2 (go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b)
  • Instance paired 2026-02-18 (pre-v0.7.2, custom whatsmeow-lib fork era)
  • Confirmed via direct query: whatsmeow_nct_salt = 0 rows, whatsmeow_privacy_tokens = 0 rows for the affected contact, whatsmeow_lid_map correctly populated (so LID resolution is not the issue)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions