Skip to content

YNU-869: Add TS SDK protocol drift guards#705

Merged
ihsraham merged 11 commits into
mainfrom
protocol-ts-sdk-drift-guards
May 6, 2026
Merged

YNU-869: Add TS SDK protocol drift guards#705
ihsraham merged 11 commits into
mainfrom
protocol-ts-sdk-drift-guards

Conversation

@ihsraham
Copy link
Copy Markdown
Collaborator

@ihsraham ihsraham commented Apr 24, 2026

Summary

Adds protocol, TS SDK, and compat SDK drift guards on top of current main.

#699 and #701 are now merged, so this branch has been rebased directly on main and preserves current behavior for:

Change Areas

Drift Guards

  • Adds static guards for RPC method coverage, RPC DTO shape, public API snapshots, consumed ABI surface, app/session-key signing vectors, and response transforms.
  • Adds compat drift coverage for v1 app-session mapping, legacy flat fallback behavior, empty-session behavior, asset decimal conversion, config pass-through, and session-key public APIs.
  • Adds runtime smoke coverage that builds and starts an isolated local nitronode, then verifies live SDK and compat calls.

SDK Validation

  • Validates key-state states containers before mapping.
  • Validates app-session payloads, allocations, app definitions, participants, and quorum at the wire boundary.
  • Rejects arrays in session key-state record transforms.

Nitronode Database

  • Propagates SQLite migration errors from connectToSqlite.
  • Returns Postgres migration errors instead of panicking.
  • Keeps SQLite auto-migration aligned with current runtime tables, including state, transaction, app participants, and key-state tables.
  • Uses a parameterized schema existence check and quoted schema identifiers for Postgres schema setup.

Runtime Smoke And CI

  • Builds ./nitronode and uses NITRONODE_RUNTIME_SMOKE_* env names in new scripts/workflows/docs.
  • Requires NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY for external smoke mode.
  • Keeps the hardcoded Anvil key only for isolated local smoke.
  • Restricts runtime-smoke log directories to repo-local paths.
  • Passes an allowlisted child-process env instead of forwarding all parent env.
  • Captures compat logs for failure diagnostics.
  • Builds Foundry artifacts before TS SDK/static drift ABI checks in CI.

Review Notes

Addressed current actionable review feedback around SDK response validation, database migration error handling, runtime-smoke env/log safety, public API tautology tests, ABI drift-test stability, and stale Clearnode wording in PR-added files.

Deferred CodeRabbit docstring coverage and shared public API test utility extraction because they are broader cleanup items, not blockers for this PR.

Verification

Local checks run from a fresh worktree:

  • npm --prefix sdk/ts run typecheck
  • npm --prefix sdk/ts test
  • npm --prefix sdk/ts run lint
  • npm --prefix sdk/ts run build:ci
  • npm --prefix sdk/ts-compat run typecheck
  • npm --prefix sdk/ts-compat test
  • npm --prefix sdk/ts-compat run lint
  • npm --prefix sdk/ts-compat run build:ci
  • forge build for local ABI artifacts
  • ./scripts/check-protocol-drift.sh --static
  • ./scripts/check-protocol-drift.sh --runtime
  • go test ./nitronode/... ./pkg/rpc/...
  • git diff --check

GitHub checks have started on the rebased branch.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Warning

Rate limit exceeded

@ihsraham has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 43 minutes and 42 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72b2a87b-5434-43bd-9cf7-c319a77f36e9

📥 Commits

Reviewing files that changed from the base of the PR and between ea94a78 and 32ad8fc.

⛔ Files ignored due to path filters (3)
  • sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap is excluded by !**/*.snap
  • sdk/ts/package-lock.json is excluded by !**/package-lock.json
  • sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (16)
  • .github/workflows/main-pr.yml
  • .github/workflows/main-push.yml
  • .github/workflows/protocol-drift-external-smoke.yml
  • .github/workflows/test-sdk.yml
  • scripts/check-protocol-drift.sh
  • scripts/drift/runtime-smoke.mjs
  • sdk/PROTOCOL_DRIFT_GUARDS.md
  • sdk/ts-compat/test/unit/public-api-drift.test.ts
  • sdk/ts/package.json
  • sdk/ts/src/client.ts
  • sdk/ts/src/session_key_state_transforms.ts
  • sdk/ts/src/utils.ts
  • sdk/ts/test/unit/abi-drift.test.ts
  • sdk/ts/test/unit/public-api-drift.test.ts
  • sdk/ts/test/unit/rpc-drift.test.ts
  • sdk/ts/test/unit/transform-drift.test.ts
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive protocol drift guard system to detect unintended changes to the SDK public API, RPC contract, and cryptographic signing behavior. It adds static and runtime validation tests, CI workflows, and infrastructure scripts (shell, Go, Node.js) to enforce protocol stability across schema, ABI, transforms, and signing vectors.

Changes

Protocol Drift Guards System

Layer / File(s) Summary
Data Shape & Types
sdk/ts/src/blockchain/evm/channel_hub_abi.ts, sdk/ts/src/session_key_state_transforms.ts
ChannelHubAbi is updated with corrected stateMutability values and a new IncorrectMsgSender error. New transform module exports transformChannelSessionKeyState and transformAppSessionKeyState validators for session-key RPC responses.
Core SDK Logic
sdk/ts/src/client.ts, sdk/ts/src/utils.ts
Client methods getLastChannelKeyStates and getLastKeyStates now apply per-element transforms to RPC responses. Utils gains allocation validation and per-field type checks in transformAppSessionInfo and transformAppDefinitionFromRPC with context-rich error messages.
Drift Detection Tests
sdk/ts/test/unit/rpc-drift.test.ts, sdk/ts/test/unit/rpc-dto-drift.test.ts, sdk/ts/test/unit/abi-drift.test.ts, sdk/ts/test/unit/app-signing-drift.test.ts, sdk/ts/test/unit/public-api-drift.test.ts, sdk/ts/test/unit/transform-drift.test.ts, sdk/ts-compat/test/unit/...
Six new test suites validate RPC method coverage, DTO shape consistency, ABI signatures, cryptographic hashes against precomputed vectors, public API surface snapshots, and response transform correctness. Compat-layer tests ensure backwards-compatibility mapping.
Test & Package Config
sdk/ts/package.json, sdk/ts-compat/package.json
New drift:check scripts run focused Jest test suites with experimental VM modules flag; test suites enumerate specific drift test files by path.
Runtime Smoke Harness
scripts/drift/runtime-smoke.mjs
Full-lifecycle Node.js script that spawns/connects to Nitronode, initializes TS SDK Client with test signers, executes API smoke calls (ping, getConfig, getAssets, getAppSessions, getLastChannelKeyStates, getLastKeyStates), validates response shapes, tests compat-layer mapping, and logs failures with optional external log directory support.
Vector Generation & Check Script
scripts/drift/generate-app-signing-vectors.go, scripts/check-protocol-drift.sh
Go script generates canonical cryptographic drift vectors for app session/state/key operations with boundary cases. Bash CLI wraps static and runtime drift checks with environment and package validation.
CI Workflows
.github/workflows/main-pr.yml, .github/workflows/main-push.yml, .github/workflows/protocol-drift-external-smoke.yml
Three workflow jobs (static and runtime on PR/push, external smoke on manual dispatch) set up Node and Go, install SDK dependencies, build TS SDK and compat package, run protocol drift checks, and upload logs on failure.
Documentation
sdk/PROTOCOL_DRIFT_GUARDS.md
Comprehensive guide defining drift guard layers (RPC, DTO, Public API, ABI, Signing, Transform, Compat, Runtime), intentional update workflows, adversarial testing requirements, troubleshooting, and CI policy.

Database Schema Robustness

Layer / File(s) Summary
PostgreSQL Schema & Migration Safety
nitronode/store/database/database.go
Schema existence check refactored to use parameterized queries instead of string matching. Search path and identifier quoting now use quotePostgresIdentifier. Migration errors wrapped with context. SQLite migration errors propagated in connectToSqlite.

Sequence Diagram

sequenceDiagram
    actor CI as CI / Script
    participant Script as runtime-smoke.mjs
    participant NK as Nitronode<br/>(spawned)
    participant WS as WebSocket
    participant Client as TS SDK<br/>Client
    participant Compat as NitroliteClient<br/>(compat layer)

    CI->>Script: Invoke with env vars
    Script->>Script: Write config dir<br/>(temp or custom)
    Script->>NK: Spawn nitronode process
    NK->>NK: Initialize
    NK->>WS: Listen on port 7824
    
    Script->>WS: Attempt connect with timeout
    WS-->>Script: Connection open
    Script->>Script: Wait for readiness<br/>(with backoff)
    
    Script->>Client: Create with test signers
    
    Script->>Client: ping()
    Client->>NK: RPC: ping
    NK-->>Client: pong
    Client-->>Script: ✓
    
    Script->>Client: getConfig()
    Client->>NK: RPC: getConfig
    NK-->>Client: config (camelCase)
    Client-->>Script: ✓ validated shape
    
    Script->>Client: getAssets()
    Client->>NK: RPC: getAssets
    NK-->>Client: assets array
    Client-->>Script: ✓ bigint conversions
    
    Script->>Client: getLastChannelKeyStates()
    Client->>NK: RPC: getLastChannelKeyStates
    NK-->>Client: states array (raw)
    Client->>Client: Transform each state
    Client-->>Script: ✓ typed states
    
    Script->>Compat: Instantiate with client
    Script->>Compat: getAppSessionsList()
    Compat->>Client: getAppSessions()
    Client->>NK: RPC
    NK-->>Client: response
    Client-->>Compat: sessions
    Compat->>Compat: Map to compat shape
    Compat-->>Script: ✓ compat works
    
    Script->>Script: Write logs & summary
    Script->>Client: Close
    Script->>NK: Terminate process
    Script->>CI: Exit(0) on success<br/>or Exit(1) + logs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • nksazonov
  • dimast-x

🐰 Drift guards stand tall and proud,
Static checks call out loud,
Runtime smoke dances free,
Protocol safe as can be! ✨🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'YNU-869: Add TS SDK protocol drift guards' directly and concisely describes the main change: adding protocol drift guards to the TypeScript SDK.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch protocol-ts-sdk-drift-guards

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@ihsraham ihsraham marked this pull request as draft April 24, 2026 12:58
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

Note

Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

✅ Created PR with unit tests: #706

Copy link
Copy Markdown
Contributor

@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: 2

🧹 Nitpick comments (10)
sdk/PROTOCOL_DRIFT_GUARDS.md (1)

14-18: Minor: Sequential cd commands won't work as written.

The commands shown run in subshells, so the second cd sdk/ts-compat would fail if run sequentially in a single shell from repo root (first cd doesn't persist).

📝 Clarify as separate commands
 Run package checks directly:
 
 ```bash
-cd sdk/ts && npm run drift:check
-cd sdk/ts-compat && npm run drift:check
+(cd sdk/ts && npm run drift:check)
+(cd sdk/ts-compat && npm run drift:check)
 ```

Or separate into two distinct code blocks.

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

In `@sdk/PROTOCOL_DRIFT_GUARDS.md` around lines 14 - 18, Update the snippet so the
two directory changes run independently instead of relying on a persistent shell
state: replace the two lines "cd sdk/ts && npm run drift:check" and "cd
sdk/ts-compat && npm run drift:check" with either subshell invocations like "(cd
sdk/ts && npm run drift:check)" and "(cd sdk/ts-compat && npm run drift:check)"
or split them into two separate code blocks so each command runs from the repo
root without depending on the previous cd.
sdk/ts/src/session_key_state_transforms.ts (1)

4-9: Consider handling array edge case in asRecord.

Arrays pass the typeof raw !== 'object' check since typeof [] === 'object'. If a caller accidentally passes an array, it would be cast to a record and subsequent field lookups would return array indices.

🛡️ Optional defensive check
 function asRecord(raw: unknown, context: string): Record<string, unknown> {
-  if (!raw || typeof raw !== 'object') {
+  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
     throw new Error(`Invalid ${context}: expected object`);
   }
   return raw as Record<string, unknown>;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/ts/src/session_key_state_transforms.ts` around lines 4 - 9, The asRecord
helper currently treats arrays as objects because typeof [] === 'object'; update
asRecord to reject arrays by checking Array.isArray(raw) and throwing an Error
(including context) when raw is null/not an object or is an array so callers
cannot accidentally get array indices from a cast; ensure you adjust the error
message and keep the function signature and return type (asRecord(raw, context):
Record<string, unknown>).
sdk/ts/test/unit/public-api-drift.test.ts (1)

65-68: Redundant cast on Line 66.

The declaration parameter is already typed as ts.Declaration, so casting it again is unnecessary.

🔧 Remove redundant cast
 function isPrivateOrProtected(declaration: ts.Declaration): boolean {
-    const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration);
+    const flags = ts.getCombinedModifierFlags(declaration);
     return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/ts/test/unit/public-api-drift.test.ts` around lines 65 - 68, The function
isPrivateOrProtected unnecessarily casts its parameter when calling
ts.getCombinedModifierFlags; update the call in isPrivateOrProtected to pass
declaration directly (i.e., remove the redundant cast) so it becomes
ts.getCombinedModifierFlags(declaration) while leaving the rest of the logic
(flags check against ts.ModifierFlags.Private | ts.ModifierFlags.Protected)
intact.
sdk/ts/test/unit/abi-drift.test.ts (2)

103-111: Map comparison relies on insertion order.

The comparison [...sdkSigs].toEqual([...artifactSigs]) depends on Map iteration order being consistent between SDK and artifact ABIs. Since both are populated by iterating arrays and entries are sorted by name at insertion, this works, but it's fragile.

Consider comparing sorted arrays explicitly or using a dedicated matcher:

🔧 Explicit sorted comparison
     it('keeps checked-in ChannelHub ABI aligned with Foundry artifact for every artifact function', () => {
         const artifactSigs = functionSignatures(
             loadArtifact('contracts/out/ChannelHub.sol/ChannelHub.json')
         );
         const sdkSigs = functionSignatures(ChannelHubAbi as readonly AbiEntry[]);

-        expect([...sdkSigs]).toEqual([...artifactSigs]);
+        const sortedSdk = [...sdkSigs].sort(([a], [b]) => a.localeCompare(b));
+        const sortedArtifact = [...artifactSigs].sort(([a], [b]) => a.localeCompare(b));
+        expect(sortedSdk).toEqual(sortedArtifact);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/ts/test/unit/abi-drift.test.ts` around lines 103 - 111, The test compares
Maps via [...sdkSigs] and [...artifactSigs], which relies on insertion order and
is fragile; update the test that builds artifactSigs and sdkSigs (from
functionSignatures, loadArtifact, ChannelHubAbi) to compare deterministic sorted
arrays instead — extract the signatures into arrays, sort them (e.g., by
signature string/name) and assert equality of the sorted arrays so order
differences won't cause spurious failures.

37-41: Trailing space when stateMutability is undefined.

The signature format produces a trailing space when stateMutability is falsy (e.g., "foo() -> () "). This could cause spurious mismatches.

🔧 Trim or conditionally append stateMutability
 function signature(entry: AbiEntry): string {
     const inputs = (entry.inputs ?? []).map(canonicalType).join(',');
     const outputs = (entry.outputs ?? []).map(canonicalType).join(',');
-    return `${entry.name}(${inputs}) -> (${outputs}) ${entry.stateMutability ?? ''}`;
+    const mutability = entry.stateMutability ? ` ${entry.stateMutability}` : '';
+    return `${entry.name}(${inputs}) -> (${outputs})${mutability}`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/ts/test/unit/abi-drift.test.ts` around lines 37 - 41, The signature
function produces a trailing space when stateMutability is falsy; update the
function signature(entry: AbiEntry) to conditionally append stateMutability (or
trim the final string) so you only include a leading space and the
stateMutability when entry.stateMutability is truthy; locate the function named
signature that calls canonicalType on entry.inputs/outputs and modify the return
construction to avoid emitting " ... () " when stateMutability is undefined.
scripts/drift/runtime-smoke.mjs (3)

297-325: Fragile NitroliteClient instantiation via Object.create.

Manually setting internal properties (assetsBySymbol, _lastAppSessionsListError, etc.) creates tight coupling to NitroliteClient internals. If the class adds required initialization or renames private fields, this will silently break.

Consider using a factory or exposing a test helper in the compat package.

💡 Alternative approach

If NitroliteClient cannot be instantiated without a full WebSocket setup, consider adding a static factory method or test helper:

// In sdk/ts-compat, add a test helper:
// static createForTesting(innerClient: Client, userAddress: Address): NitroliteClient

// Then in smoke test:
const compatClient = NitroliteClient.createForTesting(client, wallet);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/drift/runtime-smoke.mjs` around lines 297 - 325, The test constructs
a NitroliteClient via Object.create and manually sets internals (assetsBySymbol,
_lastAppSessionsListError, etc.), which is fragile; add a safe test factory on
NitroliteClient (e.g., static createForTesting(innerClient, userAddress)) or a
compat test helper and use it in the smoke test to instantiate compatClient
instead of mutating internals; update the test to call
NitroliteClient.createForTesting(client, wallet) (or the new helper) and keep
assertions around getAppSessionsList and getLastAppSessionsListError unchanged.

305-318: Console suppression may hide diagnostic errors.

Blanket suppression of console.info and console.warn during the compat test could mask useful diagnostic information if something goes wrong. Consider capturing the output instead of discarding it.

🔧 Capture instead of discard
     const originalInfo = console.info;
     const originalWarn = console.warn;
+    const capturedLogs = [];
     let compatSessions;
     try {
-      console.info = () => {};
-      console.warn = () => {};
+      console.info = (...args) => capturedLogs.push(['info', ...args]);
+      console.warn = (...args) => capturedLogs.push(['warn', ...args]);
       compatSessions = await withTimeout(
         'compat.getAppSessionsList',
         compatClient.getAppSessionsList()
       );
     } finally {
       console.info = originalInfo;
       console.warn = originalWarn;
     }
+    // capturedLogs available for debugging if assertions fail
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/drift/runtime-smoke.mjs` around lines 305 - 318, The current
try/finally entirely silences console.info and console.warn
(originalInfo/originalWarn) while awaiting compatClient.getAppSessionsList() via
withTimeout, which can hide diagnostics; change this to capture calls instead of
discarding them by replacing console.info and console.warn with wrappers that
push messages into local arrays (e.g., infoLog/warnLog) while the await runs,
then restore originals in the finally block and optionally log or attach the
captured info/warn if compatSessions or the timeout fails; keep the same
withTimeout call and variable names (compatSessions, withTimeout,
compatClient.getAppSessionsList) so the behavior and sequencing remain
unchanged.

22-24: Hardcoded private key is acceptable for smoke testing.

This is the well-known Anvil/Hardhat test account #2 private key, safe for local testing purposes. Consider adding a brief comment noting this.

📝 Add clarifying comment
+// Well-known Anvil/Hardhat test account `#2` - safe for local testing only
 const privateKey =
   '0x59c6995e998f97a5a0044966f094538f0d0921e301baca6a9ae52cd7834c90b9';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/drift/runtime-smoke.mjs` around lines 22 - 24, Hardcoded privateKey
constant currently contains a known Anvil/Hardhat test account key; add a
concise comment next to the privateKey declaration explaining that this is the
well-known Anvil/Hardhat test account `#2` private key used only for local/smoke
testing and not for production, to clarify intent and safety when reviewing the
runtime-smoke.mjs script and the privateKey symbol.
sdk/ts-compat/test/unit/public-api-drift.test.ts (2)

65-68: Redundant cast on Line 66 (same as sdk/ts version).

The declaration parameter is already typed as ts.Declaration.

🔧 Remove redundant cast
 function isPrivateOrProtected(declaration: ts.Declaration): boolean {
-    const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration);
+    const flags = ts.getCombinedModifierFlags(declaration);
     return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/ts-compat/test/unit/public-api-drift.test.ts` around lines 65 - 68, The
cast is redundant in isPrivateOrProtected — remove the unnecessary cast in the
call to ts.getCombinedModifierFlags so it uses the declaration parameter
directly (i.e., call ts.getCombinedModifierFlags(declaration)), keeping the rest
of the function (flags & (ts.ModifierFlags.Private |
ts.ModifierFlags.Protected)) unchanged to preserve behavior.

27-179: Consider extracting shared test utilities.

The serializePublicApi, normalizeText, declarationKind, and related helper functions are nearly identical between sdk/ts and sdk/ts-compat public-api-drift tests. Consider extracting to a shared test utility module to reduce duplication.

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

In `@sdk/ts-compat/test/unit/public-api-drift.test.ts` around lines 27 - 179,
Extract the duplicated public-api-drift test helpers into a shared test utility
module and update both test suites to import from it: move normalizeText,
createPackageProgram, declarationKind, signaturesForType, isPrivateOrProtected,
propertiesForType, enumMembers, and serializePublicApi (and any FORMAT_FLAGS or
type aliases they depend on) into a new shared file (e.g., a test-utils module),
ensure it exports those functions, remove the duplicate implementations from
sdk/ts and sdk/ts-compat tests, and change the tests to import the helpers from
the new shared module so both suites reuse the single implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sdk/ts/src/client.ts`:
- Around line 1707-1709: Validate that resp.states is an array before calling
map: replace the direct mapping with an Array.isArray(resp.states) check and
throw a clear drift-guard error (or return an empty list) if it’s not an array;
apply this guard at both places where resp.states is mapped (the callsite that
invokes transformChannelSessionKeyState with the `channel session key
state[...]` label and the other mapping around lines 1759-1761 that uses the
same transform), so malformed responses yield a descriptive error instead of a
generic .map crash.

In `@sdk/ts/src/utils.ts`:
- Around line 358-361: The current guard reads raw.allocations and uses `|| []`,
which hides malformed or non-object top-level payloads; update the code in
utils.ts to first validate that `raw` is a non-null object (not a primitive),
then assert that `raw` has an `allocations` property and that
`Array.isArray(raw.allocations)` is true, and throw a clear Error if those
checks fail instead of defaulting to an empty array; locate the
`raw`/`allocations` extraction in the function surrounding the shown diff and
replace the `const allocations = raw.allocations || [];` pattern with explicit
validations and a thrown error on invalid payloads.

---

Nitpick comments:
In `@scripts/drift/runtime-smoke.mjs`:
- Around line 297-325: The test constructs a NitroliteClient via Object.create
and manually sets internals (assetsBySymbol, _lastAppSessionsListError, etc.),
which is fragile; add a safe test factory on NitroliteClient (e.g., static
createForTesting(innerClient, userAddress)) or a compat test helper and use it
in the smoke test to instantiate compatClient instead of mutating internals;
update the test to call NitroliteClient.createForTesting(client, wallet) (or the
new helper) and keep assertions around getAppSessionsList and
getLastAppSessionsListError unchanged.
- Around line 305-318: The current try/finally entirely silences console.info
and console.warn (originalInfo/originalWarn) while awaiting
compatClient.getAppSessionsList() via withTimeout, which can hide diagnostics;
change this to capture calls instead of discarding them by replacing
console.info and console.warn with wrappers that push messages into local arrays
(e.g., infoLog/warnLog) while the await runs, then restore originals in the
finally block and optionally log or attach the captured info/warn if
compatSessions or the timeout fails; keep the same withTimeout call and variable
names (compatSessions, withTimeout, compatClient.getAppSessionsList) so the
behavior and sequencing remain unchanged.
- Around line 22-24: Hardcoded privateKey constant currently contains a known
Anvil/Hardhat test account key; add a concise comment next to the privateKey
declaration explaining that this is the well-known Anvil/Hardhat test account `#2`
private key used only for local/smoke testing and not for production, to clarify
intent and safety when reviewing the runtime-smoke.mjs script and the privateKey
symbol.

In `@sdk/PROTOCOL_DRIFT_GUARDS.md`:
- Around line 14-18: Update the snippet so the two directory changes run
independently instead of relying on a persistent shell state: replace the two
lines "cd sdk/ts && npm run drift:check" and "cd sdk/ts-compat && npm run
drift:check" with either subshell invocations like "(cd sdk/ts && npm run
drift:check)" and "(cd sdk/ts-compat && npm run drift:check)" or split them into
two separate code blocks so each command runs from the repo root without
depending on the previous cd.

In `@sdk/ts-compat/test/unit/public-api-drift.test.ts`:
- Around line 65-68: The cast is redundant in isPrivateOrProtected — remove the
unnecessary cast in the call to ts.getCombinedModifierFlags so it uses the
declaration parameter directly (i.e., call
ts.getCombinedModifierFlags(declaration)), keeping the rest of the function
(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) unchanged to
preserve behavior.
- Around line 27-179: Extract the duplicated public-api-drift test helpers into
a shared test utility module and update both test suites to import from it: move
normalizeText, createPackageProgram, declarationKind, signaturesForType,
isPrivateOrProtected, propertiesForType, enumMembers, and serializePublicApi
(and any FORMAT_FLAGS or type aliases they depend on) into a new shared file
(e.g., a test-utils module), ensure it exports those functions, remove the
duplicate implementations from sdk/ts and sdk/ts-compat tests, and change the
tests to import the helpers from the new shared module so both suites reuse the
single implementation.

In `@sdk/ts/src/session_key_state_transforms.ts`:
- Around line 4-9: The asRecord helper currently treats arrays as objects
because typeof [] === 'object'; update asRecord to reject arrays by checking
Array.isArray(raw) and throwing an Error (including context) when raw is
null/not an object or is an array so callers cannot accidentally get array
indices from a cast; ensure you adjust the error message and keep the function
signature and return type (asRecord(raw, context): Record<string, unknown>).

In `@sdk/ts/test/unit/abi-drift.test.ts`:
- Around line 103-111: The test compares Maps via [...sdkSigs] and
[...artifactSigs], which relies on insertion order and is fragile; update the
test that builds artifactSigs and sdkSigs (from functionSignatures,
loadArtifact, ChannelHubAbi) to compare deterministic sorted arrays instead —
extract the signatures into arrays, sort them (e.g., by signature string/name)
and assert equality of the sorted arrays so order differences won't cause
spurious failures.
- Around line 37-41: The signature function produces a trailing space when
stateMutability is falsy; update the function signature(entry: AbiEntry) to
conditionally append stateMutability (or trim the final string) so you only
include a leading space and the stateMutability when entry.stateMutability is
truthy; locate the function named signature that calls canonicalType on
entry.inputs/outputs and modify the return construction to avoid emitting " ...
() " when stateMutability is undefined.

In `@sdk/ts/test/unit/public-api-drift.test.ts`:
- Around line 65-68: The function isPrivateOrProtected unnecessarily casts its
parameter when calling ts.getCombinedModifierFlags; update the call in
isPrivateOrProtected to pass declaration directly (i.e., remove the redundant
cast) so it becomes ts.getCombinedModifierFlags(declaration) while leaving the
rest of the logic (flags check against ts.ModifierFlags.Private |
ts.ModifierFlags.Protected) intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1c12dd8-3da9-450b-bd07-0a787f87b109

📥 Commits

Reviewing files that changed from the base of the PR and between 0ec5683 and 70cf1c9.

⛔ Files ignored due to path filters (2)
  • sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap is excluded by !**/*.snap
  • sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (22)
  • .github/workflows/main-pr.yml
  • .github/workflows/main-push.yml
  • .github/workflows/protocol-drift-external-smoke.yml
  • clearnode/store/database/database.go
  • scripts/check-protocol-drift.sh
  • scripts/drift/generate-app-signing-vectors.go
  • scripts/drift/runtime-smoke.mjs
  • sdk/PROTOCOL_DRIFT_GUARDS.md
  • sdk/ts-compat/package.json
  • sdk/ts-compat/test/unit/client.test.ts
  • sdk/ts-compat/test/unit/public-api-drift.test.ts
  • sdk/ts/package.json
  • sdk/ts/src/blockchain/evm/channel_hub_abi.ts
  • sdk/ts/src/client.ts
  • sdk/ts/src/session_key_state_transforms.ts
  • sdk/ts/src/utils.ts
  • sdk/ts/test/unit/abi-drift.test.ts
  • sdk/ts/test/unit/app-signing-drift.test.ts
  • sdk/ts/test/unit/public-api-drift.test.ts
  • sdk/ts/test/unit/rpc-drift.test.ts
  • sdk/ts/test/unit/rpc-dto-drift.test.ts
  • sdk/ts/test/unit/transform-drift.test.ts

Comment thread sdk/ts/src/client.ts
Comment thread sdk/ts/src/utils.ts Outdated
Base automatically changed from ts-sdk-drift-guards to main May 5, 2026 07:50
@ihsraham ihsraham changed the title Add TS SDK protocol drift guards YNU-869: Add TS SDK protocol drift guards May 5, 2026
Comment thread nitronode/store/database/database.go
Comment thread nitronode/store/database/database.go
Comment thread nitronode/store/database/database.go
Comment thread scripts/drift/runtime-smoke.mjs Outdated
Comment thread scripts/drift/runtime-smoke.mjs Outdated
Comment thread sdk/ts-compat/test/unit/public-api-drift.test.ts Outdated
Comment thread sdk/ts/test/unit/rpc-drift.test.ts
Comment thread sdk/ts/test/unit/abi-drift.test.ts Outdated
Comment thread sdk/ts/src/utils.ts Outdated
Comment thread sdk/ts-compat/src/client.ts
@ihsraham ihsraham force-pushed the protocol-ts-sdk-drift-guards branch from 70cf1c9 to ea94a78 Compare May 6, 2026 08:11
@ihsraham ihsraham marked this pull request as ready for review May 6, 2026 08:13
@ihsraham ihsraham force-pushed the protocol-ts-sdk-drift-guards branch from ea94a78 to b757a98 Compare May 6, 2026 08:18
Copy link
Copy Markdown
Contributor

@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 (4)
nitronode/store/database/database.go (2)

173-175: 💤 Low value

Prefer errors.Is(err, sql.ErrNoRows) over direct comparison.

Direct equality with sql.ErrNoRows works today but breaks the moment a driver or middleware (e.g., a wrapped sqlx error, retry layer) returns a wrapped error. Using errors.Is is the idiomatic Go form and is robust to wrapping.

♻️ Proposed change
 	var exists int
-	if err := db.QueryRow("SELECT 1 FROM information_schema.schemata WHERE schema_name=$1", cnf.Schema).Scan(&exists); err != nil && err != sql.ErrNoRows {
+	if err := db.QueryRow("SELECT 1 FROM information_schema.schemata WHERE schema_name=$1", cnf.Schema).Scan(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
 		return fmt.Errorf("error while checking schema existance: %s", err.Error())
 	} else if err == nil {

Add "errors" to the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nitronode/store/database/database.go` around lines 173 - 175, Replace the
direct comparison to sql.ErrNoRows with errors.Is to handle wrapped errors: add
"errors" to the import block and change the check in the QueryRow result
handling to use errors.Is(err, sql.ErrNoRows) (e.g. if err != nil &&
!errors.Is(err, sql.ErrNoRows) { return fmt.Errorf(...) } else if err == nil {
... }) so wrapped driver/middleware errors are handled correctly; update the
conditional around db.QueryRow(...).Scan(&exists) accordingly.

180-180: 💤 Low value

Consider using pq.QuoteIdentifier instead of the hand-rolled helper.

github.com/lib/pq is already available as a transitive dependency (via gorm.io/driver/postgres), and it ships pq.QuoteIdentifier with identical semantics to the current implementation. This approach additionally guards against embedded NUL bytes and removes security-sensitive code that future contributors must keep correct.

Replace the quotePostgresIdentifier calls at lines 180 and 202, and remove the function definition at lines 244-246:

♻️ Optional refactor using pq.QuoteIdentifier
 import (
 	"database/sql"
 	"embed"
 	"fmt"
 	"log"
-	"strings"
 	"time"

 	"github.com/jmoiron/sqlx"
+	"github.com/lib/pq"
 	"github.com/pressly/goose/v3"
 	...
 )
-	if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quotePostgresIdentifier(cnf.Schema))); err != nil {
+	if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", pq.QuoteIdentifier(cnf.Schema))); err != nil {
-			if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", quotePostgresIdentifier(cnf.Schema))); err != nil {
+			if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(cnf.Schema))); err != nil {
-func quotePostgresIdentifier(identifier string) string {
-	return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
-}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nitronode/store/database/database.go` at line 180, Replace the hand-rolled
quotePostgresIdentifier usage with pq.QuoteIdentifier from github.com/lib/pq:
call pq.QuoteIdentifier(cnf.Schema) where the code currently builds the CREATE
SCHEMA SQL and where it sets the search_path (i.e., the two places using
quotePostgresIdentifier), import the pq package, and then remove the
quotePostgresIdentifier function definition entirely; keep existing error
handling and string formatting but pass the pq-quoted identifier so NUL-byte
checks and correct quoting are delegated to pq.
scripts/drift/runtime-smoke.mjs (1)

336-341: 💤 Low value

Object.create bypass on NitroliteClient is brittle to internal refactors.

Constructing the compat client via Object.create(NitroliteClient.prototype) and hand-injecting userAddress, innerClient, assetsBySymbol, and the two _lastAppSessionsListError* private fields skips whatever the real constructor does and silently breaks if it ever initializes additional state used by getAppSessionsList. Consider either invoking the real constructor with stub args or extracting the mapping as a separate exported helper that the smoke can call without faking instance state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/drift/runtime-smoke.mjs` around lines 336 - 341, The compat client is
being faked via Object.create(NitroliteClient.prototype) and manually setting
userAddress, innerClient, assetsBySymbol, _lastAppSessionsListError and
_lastAppSessionsListErrorLogged which bypasses NitroliteClient constructor logic
used by getAppSessionsList; fix by either creating a real instance of
NitroliteClient (call new NitroliteClient(...) with minimal/stubbed args so the
constructor runs and then set innerClient/userAddress) or refactor the mapping
logic into an exported helper (e.g., export a buildCompatMapping or
populateAssetsBySymbol helper) and call that from the smoke test instead of
faking instance state; update the runtime-smoke.mjs code to use the real
constructor or the new helper and remove the manual injection of private fields
(_lastAppSessionsListError/_lastAppSessionsListErrorLogged).
.github/workflows/main-pr.yml (1)

34-114: ⚡ Quick win

Consider extracting the drift jobs into a reusable workflow.

The two jobs added here are duplicated almost verbatim in .github/workflows/main-push.yml (lines 34-114 of that file), which risks the two paths drifting (e.g., a future Node version bump applied to one but not the other). The repo already follows the reusable-workflow pattern (test-go.yml, test-forge.yml, test-sdk.yml at lines 13, 17, 21 of this file), so extracting these into ./.github/workflows/test-protocol-drift.yml with static/runtime modes would fit existing conventions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/main-pr.yml around lines 34 - 114, Duplicate jobs
test-protocol-drift-static and test-protocol-drift-runtime should be extracted
into a reusable workflow file (e.g., .github/workflows/test-protocol-drift.yml)
that accepts an input like mode (static|runtime) and reproduces the same steps
(checkout, setup-node/setup-go where used, npm ci/build steps, running
./scripts/check-protocol-drift.sh with --static or --runtime, and the runtime
artifact upload on failure). Replace the two jobs in this workflow and the
duplicate ones in main-push.yml with calls to the reusable workflow using uses:
./.github/workflows/test-protocol-drift.yml and pass with: mode: static (or
runtime), preserving env (NITRONODE_RUNTIME_SMOKE_LOG_DIR), timeout-minutes,
permissions, caching inputs (node-version-file, cache-dependency-path), and the
conditional upload-artifact behavior so runtime logs are still uploaded on
failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/drift/runtime-smoke.mjs`:
- Around line 91-114: The openWebSocket function uses the global WebSocket which
is only present in Node ≥22; update the script to detect availability and
fallback to a polyfill: inside openWebSocket (or before its first use) check if
globalThis.WebSocket is undefined via process.versions.node (or simply if
(!globalThis.WebSocket)), and if missing require and assign a compatible
implementation (e.g., const WebSocket = require('ws')) before constructing new
WebSocket(url); alternatively, gate the function to throw a clear error
suggesting to run on Node ≥22 or update package engines if you prefer to mandate
Node 22+. Ensure the symbol WebSocket used in openWebSocket and the timeoutMs
logic remains unchanged.

---

Nitpick comments:
In @.github/workflows/main-pr.yml:
- Around line 34-114: Duplicate jobs test-protocol-drift-static and
test-protocol-drift-runtime should be extracted into a reusable workflow file
(e.g., .github/workflows/test-protocol-drift.yml) that accepts an input like
mode (static|runtime) and reproduces the same steps (checkout,
setup-node/setup-go where used, npm ci/build steps, running
./scripts/check-protocol-drift.sh with --static or --runtime, and the runtime
artifact upload on failure). Replace the two jobs in this workflow and the
duplicate ones in main-push.yml with calls to the reusable workflow using uses:
./.github/workflows/test-protocol-drift.yml and pass with: mode: static (or
runtime), preserving env (NITRONODE_RUNTIME_SMOKE_LOG_DIR), timeout-minutes,
permissions, caching inputs (node-version-file, cache-dependency-path), and the
conditional upload-artifact behavior so runtime logs are still uploaded on
failure.

In `@nitronode/store/database/database.go`:
- Around line 173-175: Replace the direct comparison to sql.ErrNoRows with
errors.Is to handle wrapped errors: add "errors" to the import block and change
the check in the QueryRow result handling to use errors.Is(err, sql.ErrNoRows)
(e.g. if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf(...) }
else if err == nil { ... }) so wrapped driver/middleware errors are handled
correctly; update the conditional around db.QueryRow(...).Scan(&exists)
accordingly.
- Line 180: Replace the hand-rolled quotePostgresIdentifier usage with
pq.QuoteIdentifier from github.com/lib/pq: call pq.QuoteIdentifier(cnf.Schema)
where the code currently builds the CREATE SCHEMA SQL and where it sets the
search_path (i.e., the two places using quotePostgresIdentifier), import the pq
package, and then remove the quotePostgresIdentifier function definition
entirely; keep existing error handling and string formatting but pass the
pq-quoted identifier so NUL-byte checks and correct quoting are delegated to pq.

In `@scripts/drift/runtime-smoke.mjs`:
- Around line 336-341: The compat client is being faked via
Object.create(NitroliteClient.prototype) and manually setting userAddress,
innerClient, assetsBySymbol, _lastAppSessionsListError and
_lastAppSessionsListErrorLogged which bypasses NitroliteClient constructor logic
used by getAppSessionsList; fix by either creating a real instance of
NitroliteClient (call new NitroliteClient(...) with minimal/stubbed args so the
constructor runs and then set innerClient/userAddress) or refactor the mapping
logic into an exported helper (e.g., export a buildCompatMapping or
populateAssetsBySymbol helper) and call that from the smoke test instead of
faking instance state; update the runtime-smoke.mjs code to use the real
constructor or the new helper and remove the manual injection of private fields
(_lastAppSessionsListError/_lastAppSessionsListErrorLogged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 336c0e31-7b9c-4de5-bf1f-0621e744c78e

📥 Commits

Reviewing files that changed from the base of the PR and between 70cf1c9 and ea94a78.

📒 Files selected for processing (9)
  • .github/workflows/main-pr.yml
  • .github/workflows/main-push.yml
  • .github/workflows/protocol-drift-external-smoke.yml
  • nitronode/store/database/database.go
  • scripts/check-protocol-drift.sh
  • scripts/drift/generate-app-signing-vectors.go
  • scripts/drift/runtime-smoke.mjs
  • sdk/PROTOCOL_DRIFT_GUARDS.md
  • sdk/ts-compat/package.json

Comment thread scripts/drift/runtime-smoke.mjs
@ihsraham ihsraham force-pushed the protocol-ts-sdk-drift-guards branch from b757a98 to 32ad8fc Compare May 6, 2026 08:29
Copy link
Copy Markdown
Contributor

@nksazonov nksazonov left a comment

Choose a reason for hiding this comment

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

Good job!

@ihsraham ihsraham merged commit 53fe65f into main May 6, 2026
12 checks passed
@ihsraham ihsraham deleted the protocol-ts-sdk-drift-guards branch May 6, 2026 11:43
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.

3 participants