Skip to content

fix(evm-sdk): use dynamic transceiver indices instead of hardcoded 0#831

Open
evgeniko wants to merge 5 commits intomainfrom
fix/dynamic-transceiver-index-clean
Open

fix(evm-sdk): use dynamic transceiver indices instead of hardcoded 0#831
evgeniko wants to merge 5 commits intomainfrom
fix/dynamic-transceiver-index-clean

Conversation

@evgeniko
Copy link
Contributor

@evgeniko evgeniko commented Mar 2, 2026

Summary

Fixes a bug where the EVM TypeScript SDK hardcodes transceiver index 0 in encodeOptions(), quoteDeliveryPrice(), and verifyAddresses(). This breaks when the active transceiver has a non-zero on-chain registered index — which happens after removing the original transceiver and registering a new one, since TransceiverRegistry indices are monotonically increasing and never reused.

Root cause

There are actually two bugs that stack:

  1. SDK (fixed here): encodeOptions() hardcodes index: 0 instead of querying each transceiver's actual registered index via getTransceiverInfo().

  2. Contract (separate follow-up): ManagerBase.quoteDeliveryPrice() sizes the instructions array using enabledTransceivers.length instead of numRegisteredTransceivers (which _prepareForTransfer already does correctly). This causes an ARRAY_RANGE_ERROR(50) when the active transceiver's registered index >= number of enabled transceivers.

This PR fixes both issues from the SDK side:

  • Uses the real on-chain registered index for instruction encoding
  • Bypasses the buggy manager quoteDeliveryPrice by calling each transceiver's quoteDeliveryPrice directly

Reproduction scenario

  1. Deploy NttManager + WormholeTransceiver (transceiver gets index 0)
  2. Remove the transceiver via removeTransceiver()
  3. Deploy a new WormholeTransceiver and register it via setTransceiver() (gets index 1)
  4. Attempt any transfer → ARRAY_RANGE_ERROR(50) / Panic(0x32)

Changes

  • EvmNttWormholeTranceiver: add registeredIndex property (defaults to 0 for backwards compat)
  • EvmNtt.initTransceiverIndices(): new method that fetches on-chain indices via getTransceiverInfo() + getTransceivers() and caches them. Falls back gracefully for ABI versions that don't support getTransceiverInfo
  • EvmNtt.encodeOptions(): iterates all transceivers using their cached registeredIndex, sorts by index (contract requires strictly increasing order)
  • EvmNtt.quoteDeliveryPrice(): calls each transceiver's quoteDeliveryPrice directly instead of the manager's buggy implementation
  • EvmNtt.verifyAddresses(): matches the wormhole transceiver by address instead of assuming array position [0]

Follow-up (contract fix)

ManagerBase.quoteDeliveryPrice() should use _getRegisteredTransceiversStorage().length instead of enabledTransceivers.length to match _prepareForTransfer. One-line fix, but requires a contract upgrade.

Test plan

  • 14 unit tests added covering:
    • initTransceiverIndices: index fetching, case-insensitive address matching, fallback for old ABIs, idempotency, multiple transceivers
    • encodeOptions: dynamic index usage, sorted output, flag encoding
    • quoteDeliveryPrice: direct transceiver calls, correct instruction-to-transceiver pairing, price summation
    • End-to-end: transceiver at index 1 after remove+re-add
  • Manual test against Sepolia deployment with transceiver at index 1

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Transceiver indices now sync with on-chain registration during initialization; init is idempotent, tolerant of transient failures, and uses case-insensitive address matching.
    • Encoded instructions are ordered by transceiver index and respect per-transceiver skipRelay behavior.
    • Quote delivery pricing now sums per-transceiver quotes and preserves per-transceiver indices.
  • Tests

    • Added comprehensive tests covering index initialization, encoding, pricing, fallback behaviors, and multi-transceiver scenarios.

evgeniko and others added 2 commits March 2, 2026 16:00
The SDK hardcoded transceiver index 0 in encodeOptions(), quoteDeliveryPrice(),
and verifyAddresses(). This breaks when the active transceiver has a non-zero
registered index (e.g. after removing the original transceiver and registering
a new one, since on-chain indices are monotonically increasing and never reused).

Additionally, the manager's quoteDeliveryPrice() has a bug where it sizes the
instructions array using enabledTransceivers.length instead of
numRegisteredTransceivers, causing ARRAY_RANGE_ERROR(50) when the active
transceiver's index >= enabled count. The SDK now bypasses the manager's
quoteDeliveryPrice and calls each transceiver directly as a workaround.

Changes:
- Add registeredIndex property to EvmNttWormholeTranceiver
- Add initTransceiverIndices() that fetches real indices via getTransceiverInfo()
- Fix encodeOptions() to use cached registered indices for all transceivers
- Fix quoteDeliveryPrice() to call transceivers directly (bypasses contract bug)
- Fix verifyAddresses() to match transceiver by address instead of array position

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
14 tests covering:
- initTransceiverIndices: on-chain index fetching, case-insensitive address
  matching, graceful fallback for old ABIs, idempotency, multiple transceivers
- encodeOptions: uses registered index, sorted output, skipRelay flag encoding
- quoteDeliveryPrice: calls transceivers directly (not manager), correct
  instruction-to-transceiver pairing, price summation
- End-to-end: transceiver at index 1 after remove+re-add produces correct
  encoded bytes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

Fetches on-chain transceiver indices, maps them to SDK transceivers by address, caches per-transceiver registeredIndex, and updates encoding and pricing to use these indices with idempotent initialization and address-based matching.

Changes

Cohort / File(s) Summary
Tests
evm/ts/__tests__/transceiverIndex.test.ts
New comprehensive tests for initTransceiverIndices, case-insensitive address matching, fallback/retry behavior, idempotency, ordering of encoded instructions by registeredIndex, skipRelay encoding, and per-transceiver quoteDeliveryPrice including a real-world index-change scenario.
Core logic
evm/ts/src/ntt.ts
Adds registeredIndex: number to EvmNttWormholeTranceiver; adds _transceiverIndicesInitialized and public initTransceiverIndices() to EvmNtt; fromRpc now awaits index init. encodeOptions emits/sorts per-transceiver instructions by registeredIndex. quoteDeliveryPrice initializes indices then delegates to each transceiver and sums results. Remote transceiver lookup now matches by address rather than assuming index 0.
Manifest / metadata
package.json (manifest lines changed)
Minor manifest/metadata changes recorded in diff.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant EvmNtt
    participant OnChain
    participant Transceiver

    Client->>EvmNtt: fromRpc() / use instance
    EvmNtt->>EvmNtt: initTransceiverIndices()
    EvmNtt->>OnChain: getTransceivers()
    OnChain-->>EvmNtt: enabled addresses
    loop per address
        EvmNtt->>OnChain: getTransceiverInfo(address)
        OnChain-->>EvmNtt: info (includes index)
        EvmNtt->>Transceiver: set registeredIndex
    end
    EvmNtt-->>Client: ready

    Client->>EvmNtt: encodeOptions()
    loop per transceiver
        EvmNtt->>Transceiver: read registeredIndex / build instruction
    end
    EvmNtt->>EvmNtt: sort instructions by index
    EvmNtt-->>Client: encoded options

    Client->>EvmNtt: quoteDeliveryPrice()
    EvmNtt->>EvmNtt: initTransceiverIndices() (idempotent)
    loop per transceiver
        EvmNtt->>Transceiver: quoteDeliveryPrice(instruction with registeredIndex)
        Transceiver-->>EvmNtt: price
    end
    EvmNtt->>EvmNtt: sum quotes
    EvmNtt-->>Client: total price
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇
I hopped each address, one by one,
read indices in the warming sun.
I set the bytes and sorted right,
summed every fee before the night. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and precisely describes the main change: replacing hardcoded transceiver index 0 with dynamic indices based on on-chain registration, which is the core fix across encodeOptions, quoteDeliveryPrice, and verifyAddresses.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/dynamic-transceiver-index-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
evm/ts/src/ntt.ts (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Fix Prettier formatting issues.

The CI pipeline failed due to code style issues detected by Prettier. Run bun run prettier --write to fix formatting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@evm/ts/src/ntt.ts` at line 1, Run the Prettier formatter on the repository
and fix formatting in evm/ts/src/ntt.ts by executing the suggested command (bun
run prettier --write) or your project's Prettier invocation, then stage and
commit the updated file; ensure the file now passes CI formatting checks and
that any remaining style issues are resolved before pushing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@evm/ts/src/ntt.ts`:
- Line 1: Run the Prettier formatter on the repository and fix formatting in
evm/ts/src/ntt.ts by executing the suggested command (bun run prettier --write)
or your project's Prettier invocation, then stage and commit the updated file;
ensure the file now passes CI formatting checks and that any remaining style
issues are resolved before pushing.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fee796a and 70506ff.

📒 Files selected for processing (2)
  • evm/ts/__tests__/transceiverIndex.test.ts
  • evm/ts/src/ntt.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
evm/ts/__tests__/transceiverIndex.test.ts (1)

33-51: Add a constructor regression test for unsupported transceiver keys.

Please add a case where contracts.ntt.transceiver contains wormhole plus an extra unknown type, and assert constructor throws. This will catch the filtering regression around Line 262 in evm/ts/src/ntt.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@evm/ts/__tests__/transceiverIndex.test.ts` around lines 33 - 51, Add a
regression test to ensure the EvmNtt constructor rejects unsupported transceiver
keys by creating a test where contracts.ntt.transceiver includes "wormhole" and
an extra unknown key and asserting the constructor throws; locate the test suite
in transceiverIndex.test.ts and add a new it() case similar to the existing
constructor test that constructs EvmNtt (the EvmNtt constructor) with contracts
containing transceiver: { wormhole: {...}, unknown: {...} } and expect the
construction to throw to catch the filtering bug referenced around the
transceiver handling in evm/ts/src/ntt.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@evm/ts/src/ntt.ts`:
- Around line 262-264: The filter callback on
Object.keys(configuredTransceivers) doesn't return a boolean, so change the
callback in the expression that currently uses .filter((transceiverType) => {
transceiverType !== "wormhole"; }) to return the predicate (e.g.,
(transceiverType) => transceiverType !== "wormhole") so non-wormhole keys are
preserved and the subsequent unsupported-transceiver guard (the check around
configuredTransceivers usage) can run; locate the usage of
configuredTransceivers and the filter call in ntt.ts and replace the
block-bodied arrow with a returned expression or an explicit return statement.

---

Nitpick comments:
In `@evm/ts/__tests__/transceiverIndex.test.ts`:
- Around line 33-51: Add a regression test to ensure the EvmNtt constructor
rejects unsupported transceiver keys by creating a test where
contracts.ntt.transceiver includes "wormhole" and an extra unknown key and
asserting the constructor throws; locate the test suite in
transceiverIndex.test.ts and add a new it() case similar to the existing
constructor test that constructs EvmNtt (the EvmNtt constructor) with contracts
containing transceiver: { wormhole: {...}, unknown: {...} } and expect the
construction to throw to catch the filtering bug referenced around the
transceiver handling in evm/ts/src/ntt.ts.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70506ff and 50dcda6.

📒 Files selected for processing (2)
  • evm/ts/__tests__/transceiverIndex.test.ts
  • evm/ts/src/ntt.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
evm/ts/src/ntt.ts (1)

314-317: Consider logging the caught error for observability.

The empty catch block silently swallows all errors. While the retry-on-failure approach is reasonable for transient RPC issues, persistent errors (e.g., ABI mismatch, contract misconfiguration) will silently fall back to index 0, potentially causing the exact bug this PR aims to fix.

Proposed improvement
-    } catch {
+    } catch (e) {
+      // Log for debugging; do not mark initialized on failure so
+      // transient RPC errors can be retried on next call.
+      console.warn("Failed to fetch transceiver indices, will retry:", e);
       // Do not mark initialized on failure: retry on next call so transient
       // RPC/provider errors don't permanently pin indices to default 0.
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@evm/ts/src/ntt.ts` around lines 314 - 317, The empty catch in
evm/ts/src/ntt.ts is swallowing errors and should log them for observability;
update the catch to capture the thrown error (e.g., catch (err)) and call the
project's logger (or console.error) with a clear contextual message that
includes identifiers like the variable initialized, the contract/address or
index being resolved, and the original error so transient errors still retry but
persistent issues are visible for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@evm/ts/src/ntt.ts`:
- Around line 314-317: The empty catch in evm/ts/src/ntt.ts is swallowing errors
and should log them for observability; update the catch to capture the thrown
error (e.g., catch (err)) and call the project's logger (or console.error) with
a clear contextual message that includes identifiers like the variable
initialized, the contract/address or index being resolved, and the original
error so transient errors still retry but persistent issues are visible for
debugging.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 50dcda6 and 6bcda0a.

📒 Files selected for processing (2)
  • evm/ts/__tests__/transceiverIndex.test.ts
  • evm/ts/src/ntt.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • evm/ts/tests/transceiverIndex.test.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
evm/ts/src/ntt.ts (2)

537-548: Defensive fallback should ideally never trigger.

The instructions array is created from encodeOptions() which iterates over this.xcvrs, so there should always be a matching instruction for each transceiver. The fallback at lines 539-542 is good defensive coding, but if it ever executes, it likely indicates a logic error.

Consider adding a debug log or assertion to catch unexpected cases during development.

♻️ Optional: Add visibility when fallback is used
       const ix = instructions.find((i) => i.index === xcvr.registeredIndex);
+      if (!ix) {
+        console.debug(
+          `quoteDeliveryPrice: no instruction found for index ${xcvr.registeredIndex}, using empty payload`
+        );
+      }
       const instruction = ix ?? {
         index: xcvr.registeredIndex,
         payload: new Uint8Array(),
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@evm/ts/src/ntt.ts` around lines 537 - 548, The fallback creating a default
instruction when no match is found should never occur because instructions is
built from encodeOptions() iterating this.xcvrs; update the loop in the method
that calls xcvr.transceiver.quoteDeliveryPrice to surface unexpected cases by
adding a debug log and/or assertion when ix is undefined (i.e., when using the
payload: new Uint8Array() fallback). Specifically, reference this.xcvrs,
instructions, xcvr.registeredIndex, encodeOptions, and quoteDeliveryPrice: log
the xcvr.registeredIndex and any relevant context or throw/assert to fail fast
in development so you can detect and fix the upstream logic error if the
fallback is ever used.

314-317: Consider logging or capturing the error for observability.

The empty catch block silently swallows errors, making it difficult to diagnose transient RPC failures or unexpected issues during debugging. While the retry behavior is intentional, consider adding minimal logging at debug level.

♻️ Suggested improvement
-    } catch {
+    } catch (e) {
       // Do not mark initialized on failure: retry on next call so transient
       // RPC/provider errors don't permanently pin indices to default 0.
+      // Uncomment for debugging: console.debug("initTransceiverIndices failed, will retry:", e);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@evm/ts/src/ntt.ts` around lines 314 - 317, The empty catch block in
evm/ts/src/ntt.ts is swallowing errors and should record them for observability:
update the catch after the initialization logic (the block that currently
prevents marking initialized on failure) to log the caught error at debug/trace
level while preserving the current behavior of not setting initialized so
retries still occur; reference the same symbol(s) used there (e.g., the
initialized flag and the surrounding initialization function in ntt.ts) and use
the project’s logger if available (falling back to console.debug/console.error)
to emit a concise message like "ntt initialization transient error" with the
error object. Ensure you do not change the retry logic or rethrow the error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@evm/ts/src/ntt.ts`:
- Around line 537-548: The fallback creating a default instruction when no match
is found should never occur because instructions is built from encodeOptions()
iterating this.xcvrs; update the loop in the method that calls
xcvr.transceiver.quoteDeliveryPrice to surface unexpected cases by adding a
debug log and/or assertion when ix is undefined (i.e., when using the payload:
new Uint8Array() fallback). Specifically, reference this.xcvrs, instructions,
xcvr.registeredIndex, encodeOptions, and quoteDeliveryPrice: log the
xcvr.registeredIndex and any relevant context or throw/assert to fail fast in
development so you can detect and fix the upstream logic error if the fallback
is ever used.
- Around line 314-317: The empty catch block in evm/ts/src/ntt.ts is swallowing
errors and should record them for observability: update the catch after the
initialization logic (the block that currently prevents marking initialized on
failure) to log the caught error at debug/trace level while preserving the
current behavior of not setting initialized so retries still occur; reference
the same symbol(s) used there (e.g., the initialized flag and the surrounding
initialization function in ntt.ts) and use the project’s logger if available
(falling back to console.debug/console.error) to emit a concise message like
"ntt initialization transient error" with the error object. Ensure you do not
change the retry logic or rethrow the error.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6bcda0a and 91fc31f.

📒 Files selected for processing (2)
  • evm/ts/__tests__/transceiverIndex.test.ts
  • evm/ts/src/ntt.ts

@kev1n-peters
Copy link
Contributor

Is this change backwards compatible with existing deployments?

@evgeniko
Copy link
Contributor Author

evgeniko commented Mar 2, 2026

Is this change backwards compatible with existing deployments?

Yes, registeredIndex defaults to 0 (

registeredIndex: number = 0;
), and initTransceiverIndices() falls back to that default when getTransceiverInfo isn't on the ABI or the RPC call fails (ntt.ts#L294-L317).
Existing deployments with a single transceiver, that was never removed and have index 0 hit the default path and behave identically to before.

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.

2 participants