Skip to content

fix(broadcast): normalize web3.js polling-fallback rejection#304

Merged
rz1989s merged 1 commit into
mainfrom
fix/issue-299-followup-normalize-polling-rejection
May 23, 2026
Merged

fix(broadcast): normalize web3.js polling-fallback rejection#304
rz1989s merged 1 commit into
mainfrom
fix/issue-299-followup-normalize-polling-rejection

Conversation

@rz1989s
Copy link
Copy Markdown
Member

@rz1989s rz1989s commented May 23, 2026

Summary

Diagnostic log from PR #303 revealed the real root cause of the prod 500 INTERNAL fallthrough that PR #302 was supposed to convert into 502 TX_FAILED_ON_CHAIN.

[tx/broadcast] caught error: {
  isError: false,
  isTFOCE: false,
  constructorName: 'Object',
  name: undefined,
  message: undefined,
  stringified: '[object Object]'
}

The thrown value is a plain Object, not a TransactionFailedOnChainError or any Error subclass — so every instanceof check in the route fails and we fall through to 500 INTERNAL "unknown error".

Root cause

In @solana/web3.js v1.98 (Connection#getTransactionConfirmationPromise), two paths can settle the confirmation:

  1. WS-subscription path (onSignature callback) resolves the promise with { __type: PROCESSED, response: { context, value: { err } } }, so confirmTransaction resolves with { value: { err } } and PR fix(broadcast): bail on confirmed-with-err to avoid CF 504 #302's result.value.err check fires correctly.
  2. getSignatureStatus polling fallback (~line 6601 of node_modules/@solana/web3.js/lib/index.cjs.js) calls reject(value.err) — it rejects with the bare TransactionError object (e.g. { InstructionError: [0, { Custom: 3012 }] }), not a thrown Error.

When the polling fallback wins the race (slow WS subscription on Helius devnet), confirmTransaction throws the bare plain object. PR #302 only handled the resolve path. Hence: 500 INTERNAL.

Fix

Wrap confirmTransaction in confirmInspected with a try/catch that normalizes any non-Error object rejection into a proper TransactionFailedOnChainError(signature, err). Real Error subclasses (TransactionExpiredBlockheightExceededError, SendTransactionError) propagate unchanged.

const confirmInspected = async (): Promise<string> => {
  let result
  try {
    result = await connection.confirmTransaction(...)
  } catch (err) {
    if (err !== null && typeof err === 'object' && !(err instanceof Error)) {
      throw new TransactionFailedOnChainError(signature, err)
    }
    throw err
  }
  if (result?.value?.err) {
    throw new TransactionFailedOnChainError(signature, result.value.err)
  }
  return signature
}

Also removes the diagnostic console.error from #303 (it served its purpose).

Test plan

  • pnpm typecheck clean across root + sdk + app + agent
  • pnpm vitest run tests/lib/sendWithRetry.test.ts tests/routes/tx-broadcast.test.ts — 23 passed (10 + 13)
  • Local diag2 against live devnet confirms TransactionFailedOnChainError is thrown correctly via the resolve path
  • Post-merge: re-run smoke against prod, expect 502 TX_FAILED_ON_CHAIN envelope

New tests

  • normalizes polling-fallback rejection (bare TransactionError) into TransactionFailedOnChainError — mocks confirmTransaction to Promise.reject(programErr) and asserts outer rejection is the wrapped class with matching signature/err.
  • preserves non-object rejection types (e.g. TransactionExpiredBlockheightExceededError) unchanged — guards against the normalization accidentally swallowing real Error instances.

The diagnostic console.error added in PR #303 revealed that the prod
broadcast endpoint catches a plain Object — not a TransactionFailedOnChainError
or any Error subclass — when the tx confirms on-chain with a program error:

  [tx/broadcast] caught error: {
    isError: false,
    isTFOCE: false,
    constructorName: 'Object',
    name: undefined,
    message: undefined,
    stringified: '[object Object]'
  }

Root cause is in @solana/web3.js (v1.98) Connection's
`getTransactionConfirmationPromise`. Two code paths can settle the
confirmation:

  - The WS-subscription `onSignature` callback resolves the promise with
    `{ __type: PROCESSED, response: { context, value: { err } } }` — so
    `confirmTransaction` then resolves with `{ value: { err } }` and our
    code reads `result.value.err`.
  - The `getSignatureStatus` polling fallback (fired in parallel) at
    line ~6601 of `node_modules/@solana/web3.js/lib/index.cjs.js` calls
    `reject(value.err)` directly with the bare `TransactionError` object
    when `value.err` is non-null. So `confirmTransaction` REJECTS with
    that bare plain object, and our `await` throws it as-is.

When the polling-fallback path wins the race (slow WS subscription), the
thrown value is a plain `{ InstructionError: [...] }` object, which fails
every `instanceof` check in our route handler — including the new
`TransactionFailedOnChainError` check — so we fall through to the
generic `500 INTERNAL "unknown error"` envelope.

This commit wraps `confirmTransaction` with a try/catch inside
`confirmInspected` that normalizes a non-Error object rejection into a
proper `TransactionFailedOnChainError(signature, err)`. Real Error
subclasses (`TransactionExpiredBlockheightExceededError`,
`SendTransactionError`, etc.) propagate unchanged so the route's existing
504/502 branches still fire correctly.

Tests:
- New test `normalizes polling-fallback rejection` mocks
  `confirmTransaction` to `Promise.reject(programErr)` and asserts the
  outer rejection is a `TransactionFailedOnChainError` with matching
  signature/err.
- New test `preserves non-object rejection types` asserts the
  blockheight-expired path still surfaces as
  `TransactionExpiredBlockheightExceededError`.
- Removed the diagnostic console.error from #303; it has served its
  purpose.

1652 agent / 10 sendWithRetry / 13 tx-broadcast tests pass.
Typecheck clean across root + sdk + app + agent.

Diag2 against live devnet confirms the local dist still throws
`TransactionFailedOnChainError` correctly via the resolve path. Prod
will be verified post-merge via the existing smoke script.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sipher Ready Ready Preview, Comment May 23, 2026 3:29pm

@rz1989s rz1989s merged commit f53f6bb into main May 23, 2026
8 checks passed
@rz1989s rz1989s deleted the fix/issue-299-followup-normalize-polling-rejection branch May 23, 2026 15:31
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