Skip to content

Enabling caching without enabledMethods crashes Coordinator and Realm read calls #6

Description

@Kewe63

Summary

Enabling the documented cache configuration without an explicit enabledMethods set makes both CoordinatorEdgeRpcProvider and RealmEdgeRpcProvider throw on their first read:

TypeError: Cannot read properties of undefined (reading 'has')

Basic reads without cache work. Explicitly supplying enabledMethods also works and caches the second identical read. This report covers only cache initialization, not other provider behavior.

Affected revision and environment

  • Default branch: mainnet-beta, rechecked before filing.
  • Commit: a34d5fc84580b8ef88d77c7d72fdcc515c467a3a.
  • Source package: @psy-protocol/psy-sdk, manifest version 2.0.4.
  • Node 22.23.1; pnpm 9.15.9; TypeScript 4.9.5; Jest 29.7.0 / ts-jest 29.4.6.
  • Linux x86_64 / WSL2, unchanged workspace lockfile.
  • Tests execute the exported provider classes from this checkout, not an independently downloaded registry build. Rust/WASM is not invoked by this reproducer.

Supported workflow and expected behavior

A developer enables caching using { cache: { ttl: 60000, maxSize: 1000 } }, then performs a normal read.

The cache example omits enabledMethods and says that the first read reaches the server and the second uses cache. The CacheConfig declaration explicitly makes the property optional, defaulting to all read methods.

Expected: both reads resolve successfully, with one transport request for two identical cached reads.

Actual behavior

The first read throws at provider.ts:414.

Provider Configuration Result
Coordinator No cache configuration Both reads pass; two transport calls
Coordinator Cache with explicit enabledMethods Both reads pass; one transport call
Coordinator Cache with enabledMethods omitted TypeError on first read
Realm No cache configuration Both reads pass; two transport calls
Realm Cache with explicit enabledMethods Both reads pass; one transport call
Realm Cache with enabledMethods omitted TypeError on first read

Root cause

The base constructor calls the overridden getReadOnlyMethods() while constructing cacheConfig.

The subclasses keep the read-only method sets in instance field initializers:

Those fields are initialized after super() returns. The base constructor therefore captures undefined as the default cacheConfig.enabledMethods. The later request calls the getter successfully, but still uses the earlier captured value for .has().

Minimal reproduction

In a disposable checkout of the pinned commit, use the project's locked dependencies:

cd psy-ts-sdk
pnpm install --frozen-lockfile
# Save the test below as packages/psy-sdk/src/provider/cache.issue.test.ts
unshare -Urn -- pnpm --filter @psy-protocol/psy-sdk exec jest --runInBand --no-cache --runTestsByPath src/provider/cache.issue.test.ts

unshare -Urn is the Linux network-isolation step used in verification. Packages must be installed before entering the isolated namespace. No compiler checkout, WASM module, live RPC, wallet, proving service or transaction is needed for this test.

The fixture implements the SDK's supported IHTTPClient injection interface and checks the real JSON-RPC request method/params. Responses are synthetic JSON-RPC 2.0 text envelopes. Neither the provider classes nor their cache logic are mocked.

Complete executed cache-only regression test
import { describe, expect, it } from "@jest/globals";
import { CoordinatorEdgeRpcProvider } from "../coord-edge-rpc/client";
import { CoordinatorEdgeRPCCommand } from "../coord-edge-rpc/types";
import { RealmEdgeRpcProvider } from "../realm-edge-rpc/client";
import { RealmEdgeRPCCommand } from "../realm-edge-rpc/types";
import type { IHTTPClient, ISimpleHTTPRequest, ISimpleHTTPResponse } from "../http/types";
import type { ClientConfig } from "./provider";

// Synthetic JSON-RPC transport implementing the SDK's public injectable interface.
// No external server, signing operation, proving operation, or WASM mock is involved.
class ReadTransport implements IHTTPClient {
    readonly requests: ISimpleHTTPRequest[] = [];
    constructor(
        private readonly method: string,
        private readonly result: number | string,
        private readonly failingUrl?: string
    ) {}
    async sendRequest(request: ISimpleHTTPRequest): Promise<ISimpleHTTPResponse> {
        this.requests.push(request);
        expect(request.method).toBe("POST");
        expect(request.responseType).toBe("text");
        expect(typeof request.body).toBe("string");
        const wire: { jsonrpc: string; id: string; method: string; params: unknown } = JSON.parse(String(request.body));
        expect(wire.jsonrpc).toBe("2.0");
        expect(wire.method).toBe(this.method);
        expect(wire.params).toEqual([]);
        if (request.url === this.failingUrl) throw new Error("ECONNREFUSED");
        return { statusCode: 200, body: JSON.stringify({ jsonrpc: "2.0", id: wire.id, result: this.result }) };
    }
}

type ReadClient = { read(): Promise<unknown>; destroy(): void };
type Scenario = {
    name: string;
    method: string;
    value: number | string;
    create(config: ClientConfig, transport: IHTTPClient): ReadClient;
};
const scenarios: Scenario[] = [
    {
        name: "coordinator",
        method: CoordinatorEdgeRPCCommand.GetLatestCheckpointId,
        value: 42,
        create(config, transport) {
            const client = new CoordinatorEdgeRpcProvider("http://127.0.0.1:18001", config, transport);
            return { read: () => client.getLatestCheckpointId(), destroy: () => client.destroy() };
        },
    },
    {
        name: "realm",
        method: RealmEdgeRPCCommand.GetLatestCheckpointTreeRoot,
        value: "11".repeat(32),
        create(config, transport) {
            const client = new RealmEdgeRpcProvider("http://127.0.0.1:18002", config, transport);
            return { read: () => client.getLatestCheckpointTreeRoot(), destroy: () => client.destroy() };
        },
    },
];

for (const scenario of scenarios) {
    describe(`quality cache initialization: ${scenario.name}`, () => {
        it("control: basic reads work without cache configuration", async () => {
            const transport = new ReadTransport(scenario.method, scenario.value);
            const client = scenario.create({}, transport);
            try {
                expect(await client.read()).toEqual(scenario.value);
                expect(await client.read()).toEqual(scenario.value);
                expect(transport.requests).toHaveLength(2);
            } finally {
                client.destroy();
            }
        });
        it("control: explicitly selected read methods cache successfully", async () => {
            const transport = new ReadTransport(scenario.method, scenario.value);
            const client = scenario.create({ cache: { enabledMethods: new Set([scenario.method]) } }, transport);
            try {
                expect(await client.read()).toEqual(scenario.value);
                expect(await client.read()).toEqual(scenario.value);
                expect(transport.requests).toHaveLength(1);
            } finally {
                client.destroy();
            }
        });
        it("omitted enabledMethods must default to the documented read methods", async () => {
            const transport = new ReadTransport(scenario.method, scenario.value);
            const client = scenario.create({ cache: { ttl: 60000, maxSize: 1000 } }, transport);
            try {
                expect(await client.read()).toEqual(scenario.value);
                expect(await client.read()).toEqual(scenario.value);
                expect(transport.requests).toHaveLength(1);
            } finally {
                client.destroy();
            }
        });
    });
}

Exact observed output

The cache-only test was rerun immediately before filing, from psy-ts-sdk/:

quality cache initialization: coordinator
  ✓ control: basic reads work without cache configuration
  ✓ control: explicitly selected read methods cache successfully
  ✕ omitted enabledMethods must default to the documented read methods
quality cache initialization: realm
  ✓ control: basic reads work without cache configuration
  ✓ control: explicitly selected read methods cache successfully
  ✕ omitted enabledMethods must default to the documented read methods

Tests: 2 failed, 4 passed, 6 total
Process exit: 1

TypeError: Cannot read properties of undefined (reading 'has')
  at CoordinatorEdgeRpcProvider.rpc_with_url (src/provider/provider.ts:414:85)
  at CoordinatorEdgeRpcProvider.getLatestCheckpointId (src/coord-edge-rpc/client.ts:146:21)

The Realm case fails at the same shared cache line through getLatestCheckpointTreeRoot. The same cache cases also reproduced in two earlier clean runs. Controls demonstrate that the transport responses and explicit cache path are functional, so this is not an unavailable RPC service or malformed fixture.

Impact and priority

Suggested priority: Medium / P2. A normal documented configuration prevents read calls from completing in both provider classes.

Demonstrated workarounds: disable caching or explicitly provide the enabled read-method set. No network-wide outage, transaction/fund impact, WASM failure or full release-package import claim is made. The introduction version was not established.

Duplicate research

The all-state tracker, accessible discussions, PR file lists and relevant history were reviewed. Immediately before filing there were no ordinary issues and five PRs: #1 open, #2#5 merged. That empty issue count was not treated as novelty proof by itself.

  • feat: support contract ABI deployment flows #1 targets feat/shield-poseidon-bridge, not the tested default branch. Its ABI deployment/WASM work does not change this base Provider cache initialization.
  • The other PR patches did not identify or fix this default-cache mechanism.
  • Fresh all-state searches for enabledMethods, cache and getReadOnlyMethods returned no matching issue/PR.
  • Relevant Provider path history was inspected. The latest changes add cancellation propagation and health-check error handling, not a correction to constructor initialization order.

No matching report or fix was found in accessible history. Local history is shallow, so path history was also read through the commit API; private/external histories and unavailable oversized lockfile patches were not exhaustively reviewed.

Suggested fix direction

Resolve default read methods after derived initialization, or avoid reading derived instance fields through an overridden method in the base constructor. Preserve explicit enabledMethods overrides. Keep regression coverage for omitted/explicit configuration on both subclasses.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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