Skip to content

feat(meshcore): 2/3-byte path hash support in Define Path editor (#3670)#3686

Merged
Yeraze merged 1 commit into
mainfrom
feat/meshcore-multibyte-path-hash
Jun 23, 2026
Merged

feat(meshcore): 2/3-byte path hash support in Define Path editor (#3670)#3686
Yeraze merged 1 commit into
mainfrom
feat/meshcore-multibyte-path-hash

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #3670 — lets users author MeshCore forwarding paths with 2- and 3-byte per-hop routing hashes, not just the 1-byte default, in Direct Messages → Contact Details → Define Path.

Protocol verification

Before writing any device-facing code I traced this through the MeshCore firmware (ripplebiz/MeshCore, main) because the write path was ambiguous:

  • path_len is a single packed byte — top 2 bits = hash_size − 1 (1/2/3-byte hops; 4-byte rejected by isValidPathLen), bottom 6 bits = hop count. (Packet.h getPathHashSize/Count, setPathHashSizeAndCount.)
  • Contacts use the identical packed format as OTA packets (not a plain byte count, contrary to older notes). ContactInfo.out_path_len is stored raw and Mesh::sendDirect copies contact.out_path/out_path_len verbatim into the routed packet via copyPath. So a packed contact length round-trips and routes correctly.
  • 1-byte packed == plain (top bits 0), which is exactly why only 1-byte worked until now.
  • meshcore.js setContactPath() hardcodes outPathLen = path.length (plain) — correct only for 1-byte. For 2/3-byte we must bypass it.

Backend

  • meshcoreNativeBackend set_out_path: accepts hash_bytes (1/2/3). 1-byte keeps the proven setContactPath; 2/3-byte bypasses it and calls addOrUpdateContact directly with a hand-packed out_path_len = ((hash_bytes-1)<<6)|hop_count and the flat hop buffer.
  • meshcoreManager.setContactOutPath(pk, bytes, hashBytes): forwards hash_bytes; the optimistic mirror now groups the byte buffer into width-wide hop tokens ("a3f2,7f01") and stores pathLen as hop count.
  • parseHexPathChain(input, hashBytes): each token must be exactly hashBytes*2 hex chars.
  • PUT /contacts/:pk/out-path: validates hashBytes ∈ {1,2,3} and hop count ≤ 63.
  • traceContactPath: expands multi-byte hop tokens into their constituent bytes instead of truncating each token to one byte (silent corruption now that multi-byte paths can exist).

No DB migration — the per-hop width is encoded in the stored out_path hex string (token length), so it round-trips without a schema change.

Frontend

  • meshcorePath.ts utils are threaded with hashBytes; new pathHashBytesOf() infers width from an existing path.
  • The Define-Path editor gains a 1/2/3-byte hop-hash-width selector, pre-selected from the current path, with width-aware hop add/validation; it sends hashBytes in the PUT. Changing width clears the (width-specific) hop list.

Testing

  • Path-utils multi-byte parse/derive/resolve; native-backend packed-byte assertion (verifies 0x42 for a 2-byte/2-hop path via addOrUpdateContact, and that 1-byte still uses setContactPath); route multi-byte forwarding + hashBytes/width validation; component width-selector + width-inference tests.
  • Full Vitest suite: 7347 passed, 0 failed.

Follow-up (not in this PR)

Full multi-byte support for the trace-path diagnostic at the bridge/firmware layer (this PR only fixes the byte extraction so it no longer truncates).

🤖 Generated with Claude Code

MeshCore packs the per-hop routing-hash width into the top 2 bits of the
path_len byte (hash_size-1) for BOTH OTA packets and contact records, and
firmware routes a contact's out_path verbatim through that packed format
(verified against ripplebiz/MeshCore: Packet.h/.cpp, ContactInfo.h,
Mesh::sendDirect). 1-byte is the default and packed==plain there, which is
why only 1-byte worked before. This adds authoring of 2/3-byte paths.

Backend:
- meshcoreNativeBackend set_out_path: accept hash_bytes (1/2/3). For 1-byte
  keep meshcore.js's proven setContactPath; for 2/3-byte bypass it (it only
  ever writes a plain byte count) and call addOrUpdateContact directly with
  a hand-packed out_path_len = ((hash_bytes-1)<<6)|hop_count.
- meshcoreManager.setContactOutPath(publicKey, bytes, hashBytes): forward
  hash_bytes; group the mirrored outPath string into width-wide hop tokens
  ('a3f2,7f01') and store pathLen as hop COUNT not byte count.
- parseHexPathChain(input, hashBytes): width-aware token validation/parsing.
- PUT out-path: validate hashBytes (1/2/3) and hop count (<=63).
- traceContactPath: expand multi-byte hop tokens to constituent bytes
  instead of truncating each token to one byte via parseInt.

No DB migration: width is carried in the out_path hex string itself.

Frontend:
- meshcorePath utils threaded with hashBytes; new pathHashBytesOf() infers
  width from an existing path.
- Define-Path editor: 1/2/3-byte hop-hash-width selector (pre-selected from
  the current path), width-aware hop add/validation, sends hashBytes in PUT.

Tests: path-utils multi-byte, native-backend packed-byte assertion (0x42),
route multi-byte + hashBytes validation, component selector + width
inference. Full suite: 7347 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): 2/3-byte path hash support

Tasks:

  • Gather context and understand the PR
  • Review src/utils/meshcorePath.ts and tests
  • Review src/server/meshcoreNativeBackend.ts and tests
  • Review src/server/meshcoreManager.ts
  • Review src/server/routes/meshcoreRoutes.ts and tests
  • Review src/components/MeshCore/MeshCoreContactDetailPanel.tsx and tests
  • Review src/components/MeshCore/hooks/useMeshCore.ts
  • Compile and post final review

Code Review

This is a well-implemented feature with solid protocol verification, good test coverage, and clean layering. Here are my observations:

Strengths

  • Protocol correctness: The packed path_len format (top 2 bits = hash_size−1, bottom 6 bits = hop count) is correctly implemented everywhere. The PR description's firmware trace is accurate and matches the code.
  • Backward compatibility: 1-byte paths continue to use setContactPath() (the proven library path), and multi-byte paths correctly bypass it. Callers that omit hashBytes still get 1-byte behavior.
  • No DB migration needed: Clever use of token-length encoding in the stored hex string — "a3,7f" vs "a3f2,7f01" encodes the width without a schema change.
  • Test coverage: The formatOutPath suite covers edge cases (padding, interior zeros, partial trailing hops). The set_out_path backend tests cover 1-byte/2-byte dispatch, oversize paths, and missing-contact errors.

Issues Found

1. parseHexPathChain redundant hex-range check (src/server/routes/meshcoreRoutes.ts:190)

const n = parseInt(tok.slice(j * 2, j * 2 + 2), 16);
if (!Number.isFinite(n) || n < 0 || n > 0xff) return null;

The tokenRe regex (^[0-9a-fA-F]{N}$) already guarantees tok is valid hex, so parseInt will always yield a finite integer in [0, 255]. The extra check is harmless but dead code.

2. Double getContacts() call in set_out_path (src/server/meshcoreNativeBackend.ts:1003)

// In resolvePublicKey():
const contacts: any[] = await this.connection.getContacts();

// Then immediately after:
const contacts: any[] = await c.getContacts();  // Line 1003

resolvePublicKey already calls getContacts() internally to resolve a prefix to a full key. Then set_out_path calls it again to find the full contact object. This means two serial round-trips to the device for every set_out_path call. The cost is acceptable for a low-frequency write operation, but it's worth noting. A future refactor could have resolvePublicKey return the full contact object.

3. traceContactPath passes raw 1-byte hashes to firmware for multi-byte paths (src/server/meshcoreManager.ts:2328-2336)

const pathBytes = Uint8Array.from(
  contact.outPath.split(',').flatMap((h) => {
    const tok = h.trim();
    const bytes: number[] = [];
    for (let i = 0; i + 2 <= tok.length; i += 2) {
      bytes.push(parseInt(tok.slice(i, i + 2), 16));
    }
    return bytes;
  }),
);

This correctly expands "a3f2"[0xa3, 0xf2]. However, the trace_path command then calls c.tracePath(path, ...) — and tracePath() in meshcore.js presumably expects a packed path_len byte (same wire format as out_path_len). The PR description says "this PR only fixes the byte extraction so it no longer truncates" and defers full multi-byte trace support. The comment in the PR body makes this explicit. Just confirming the known limitation: tracing a 2/3-byte path will send the raw expanded bytes but without the correct packed out_path_len — behavior may be undefined at the firmware level until the follow-up. A code comment in traceContactPath calling this out would help future contributors.

4. hopOptions recalculates on every render (src/components/MeshCore/MeshCoreContactDetailPanel.tsx:229-232)

const hopOptions = React.useMemo(
  () => repeaterHopOptions(repeaters ?? [], editorHashBytes),
  [repeaters, editorHashBytes],
);

This is correctly memoized. ✅

5. parsePathHops silently drops invalid tokens (src/utils/meshcorePath.ts:31)

.filter((s) => /^[0-9a-f]+$/.test(s) && s.length % 2 === 0 && s.length >= 2 && s.length <= 6);

This filters out bad tokens rather than returning an error. This is fine for display/parsing of stored paths (where the caller just wants the valid hops), but it means a malformed stored path is displayed silently truncated. The behavior is consistent with existing code, just worth being aware of.

6. UI width selector clears hops even on same-width selection (src/components/MeshCore/MeshCoreContactDetailPanel.tsx:316-322)

const changeHashBytes = (next: 1 | 2 | 3) => {
  if (next === editorHashBytes) return;   // ← early exit on same value ✅
  setEditorHashBytes(next);
  setEditorHops([]);
  ...
};

Actually handles this correctly — the early return prevents clearing when the user clicks the already-selected option. ✅

7. Missing test: set_out_path with hash_bytes=3 (src/server/meshcoreNativeBackend.test.ts)

Tests cover 1-byte (setContactPath) and 2-byte (addOrUpdateContact with 0x42), but there's no test for 3-byte paths (packed ((3-1)<<6)|hop_count = 0x80|count). Given 3 is a valid user-facing option, a test covering the 3-byte packed length would close this gap.

8. Route error message uses hardcoded example (src/server/routes/meshcoreRoutes.ts:733)

error: `Invalid outPath — expected a comma-separated hex chain of ${hashBytes}-byte hops (${hashBytes * 2} hex chars each), e.g. "${'a3f27f01'.slice(0, hashBytes * 2)}"`,

'a3f27f01'.slice(0, hashBytes * 2) yields "a3" (1-byte), "a3f2" (2-byte), "a3f27f" (3-byte but only one token is shown, no comma). For 3 bytes the example would be more readable as "a3f27f,0102ab" to show the comma-separated format. Minor UX nit.

Summary

The core logic is correct and safe. The protocol-level packed byte math is right, backward compatibility is preserved, and the test suite covers the critical dispatch paths. The issues above are minor — a missing 3-byte test case is the most meaningful gap. The deferred trace_path limitation is clearly acknowledged.

No blocking issues. The feature is ready to merge pending any nits the team wants to address.

@Yeraze Yeraze merged commit 680733b into main Jun 23, 2026
19 checks passed
@Yeraze Yeraze deleted the feat/meshcore-multibyte-path-hash branch June 23, 2026 22: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.

[FEAT] 2 and 3 byte support

1 participant