diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index 96d4b3ab3..bb1e1b73e 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -16,12 +16,20 @@ jobs: name: Test (Foundry) uses: ./.github/workflows/test-forge.yml - # test-sdk: - # name: Test (SDK) - # uses: ./.github/workflows/test-sdk.yml - # with: - # project-path: 'sdk' - # project-name: 'SDK' + test-sdk-ts: + name: Test (TS SDK) + uses: ./.github/workflows/test-sdk.yml + with: + project-path: 'sdk/ts' + project-name: 'TS SDK' + + test-sdk-compat: + name: Test (TS SDK Compat) + uses: ./.github/workflows/test-sdk.yml + with: + project-path: 'sdk/ts-compat' + project-name: 'TS SDK Compat' + bootstrap-project-path: 'sdk/ts' build-and-publish-clearnode: name: Build and Publish (Clearnode) @@ -56,4 +64,3 @@ jobs: cache-from: type=gha build-args: | VERSION=${{ steps.sha.outputs.short_sha }} - diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index 901393272..12932661e 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -16,15 +16,23 @@ jobs: name: Test (Foundry) uses: ./.github/workflows/test-forge.yml - # test-sdk: - # name: Test (SDK) - # uses: ./.github/workflows/test-sdk.yml - # with: - # project-path: 'sdk' - # project-name: 'SDK' + test-sdk-ts: + name: Test (TS SDK) + uses: ./.github/workflows/test-sdk.yml + with: + project-path: 'sdk/ts' + project-name: 'TS SDK' + + test-sdk-compat: + name: Test (TS SDK Compat) + uses: ./.github/workflows/test-sdk.yml + with: + project-path: 'sdk/ts-compat' + project-name: 'TS SDK Compat' + bootstrap-project-path: 'sdk/ts' # build-and-publish-sdk: - # needs: test-sdk + # needs: [test-sdk-ts, test-sdk-compat] # name: Build and Publish (SDK) # uses: ./.github/workflows/publish-sdk.yml # secrets: @@ -93,7 +101,7 @@ jobs: # notify-slack: # name: Notify Slack # runs-on: ubuntu-latest - # needs: [deploy-prod, deploy-sandbox, deploy-uat, test-forge, test-sdk] + # needs: [deploy-prod, deploy-sandbox, deploy-uat, test-forge, test-sdk-ts, test-sdk-compat] # if: always() # steps: @@ -116,4 +124,3 @@ jobs: # ⚠️ RC build or deployment was cancelled! # ${{github.event.head_commit.message}} # SLACK_FOOTER: 'Nitrolite CI/CD Pipeline' - diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index f9afed2a0..a69729f9e 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -5,18 +5,21 @@ on: inputs: project-path: description: 'Path to the SDK project directory' - required: false + required: true type: string - default: 'sdk' project-name: description: 'Human-readable name for the project' + required: true + type: string + bootstrap-project-path: + description: 'Optional SDK project that must be built before validating the target project' required: false type: string - default: 'SDK' + default: '' jobs: test: - name: Test ${{ inputs.project-name }} + name: Validate ${{ inputs.project-name }} runs-on: ubuntu-latest permissions: contents: read @@ -28,12 +31,32 @@ jobs: with: node-version-file: '${{ inputs.project-path }}/package.json' cache: 'npm' - cache-dependency-path: '${{ inputs.project-path }}/package-lock.json' + cache-dependency-path: | + ${{ inputs.project-path }}/package-lock.json + ${{ inputs.bootstrap-project-path != '' && format('{0}/package-lock.json', inputs.bootstrap-project-path) || '' }} + + - name: Install bootstrap dependencies + if: ${{ inputs.bootstrap-project-path != '' }} + run: npm ci + working-directory: ${{ inputs.bootstrap-project-path }} + + - name: Build bootstrap project + if: ${{ inputs.bootstrap-project-path != '' }} + run: npm run build:ci + working-directory: ${{ inputs.bootstrap-project-path }} - name: Install dependencies run: npm ci working-directory: ${{ inputs.project-path }} - - name: Run tests + - name: Test run: npm test working-directory: ${{ inputs.project-path }} + + - name: Lint + run: npm run lint + working-directory: ${{ inputs.project-path }} + + - name: Build + run: npm run build:ci + working-directory: ${{ inputs.project-path }} diff --git a/.github/workflows/v1-push.yml b/.github/workflows/v1-push.yml index 93af694f7..965922a4d 100644 --- a/.github/workflows/v1-push.yml +++ b/.github/workflows/v1-push.yml @@ -16,15 +16,23 @@ jobs: name: Test (Foundry) uses: ./.github/workflows/test-forge.yml - # test-sdk: - # name: Test (SDK) + # test-sdk-ts: + # name: Test (TS SDK) # uses: ./.github/workflows/test-sdk.yml # with: - # project-path: 'sdk' - # project-name: 'SDK' + # project-path: 'sdk/ts' + # project-name: 'TS SDK' + + # test-sdk-compat: + # name: Test (TS SDK Compat) + # uses: ./.github/workflows/test-sdk.yml + # with: + # project-path: 'sdk/ts-compat' + # project-name: 'TS SDK Compat' + # bootstrap-project-path: 'sdk/ts' # build-and-publish-sdk: - # needs: test-sdk + # needs: [test-sdk-ts, test-sdk-compat] # name: Build and Publish (SDK) # uses: ./.github/workflows/publish-sdk.yml # secrets: @@ -137,7 +145,7 @@ jobs: # notify-slack: # name: Notify Slack # runs-on: ubuntu-latest - # needs: [deploy-prod, deploy-sandbox, deploy-uat, test-forge, test-sdk] + # needs: [deploy-prod, deploy-sandbox, deploy-uat, test-forge, test-sdk-ts, test-sdk-compat] # if: always() # steps: diff --git a/docs/api.yaml b/docs/api.yaml index f49873945..db4eea051 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -589,43 +589,6 @@ api: errors: - message: channel_not_found description: The specified channel was not found - - name: get_states - description: Retrieve state history for a user with optional filtering - request: - - field_name: wallet - type: string - description: The user's wallet address - - field_name: asset - type: string - description: Filter by asset symbol - - field_name: epoch - type: string - description: Filter by user epoch index - optional: true - - field_name: channel_id - type: string - description: Filter by Home/Escrow Channel ID - optional: true - - field_name: only_signed - type: boolean - description: Return only signed states - - field_name: pagination - type: pagination_params - description: Pagination parameters (offset, limit, sort) - optional: true - response: - - field_name: states - type: array - items: - type: state - description: List of states - - field_name: metadata - type: pagination_metadata - description: Pagination information - optional: true - errors: - - message: invalid_parameters - description: The request parameters are invalid - name: request_creation description: Request the creation of a channel from Node request: diff --git a/pkg/rpc/README.md b/pkg/rpc/README.md index 642b29ac6..957eac73b 100644 --- a/pkg/rpc/README.md +++ b/pkg/rpc/README.md @@ -350,12 +350,6 @@ state, err := client.ChannelsV1GetLatestState(ctx, rpc.ChannelsV1GetLatestStateR Asset: "usdc", }) -// Get states with filters -states, err := client.ChannelsV1GetStates(ctx, rpc.ChannelsV1GetStatesRequest{ - Wallet: walletAddress, - Asset: &asset, -}) - // Request channel creation creation, err := client.ChannelsV1RequestCreation(ctx, rpc.ChannelsV1RequestCreationRequest{ Wallet: walletAddress, @@ -385,11 +379,6 @@ session, err := client.AppSessionsV1CreateAppSession(ctx, rpc.AppSessionsV1Creat Definition: definition, }) -// Close app session -closeResp, err := client.AppSessionsV1CloseAppSession(ctx, rpc.AppSessionsV1CloseAppSessionRequest{ - AppSessionID: sessionID, -}) - // Submit deposit state depositResp, err := client.AppSessionsV1SubmitDepositState(ctx, rpc.AppSessionsV1SubmitDepositStateRequest{ AppSessionID: sessionID, diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index 72b364191..1689aec08 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -77,30 +77,6 @@ type ChannelsV1GetLatestStateResponse struct { State StateV1 `json:"state"` } -// ChannelsV1GetStatesRequest retrieves state history for a user with optional filtering. -type ChannelsV1GetStatesRequest struct { - // Wallet is the user's wallet address - Wallet string `json:"wallet"` - // Asset filters by asset symbol - Asset string `json:"asset"` - // Epoch filters by user epoch index - Epoch *string `json:"epoch,omitempty"` - // ChannelID filters by Home/Escrow Channel ID - ChannelID *string `json:"channel_id,omitempty"` - // OnlySigned returns only signed states - OnlySigned bool `json:"only_signed"` - // Pagination contains pagination parameters (offset, limit, sort) - Pagination *PaginationParamsV1 `json:"pagination,omitempty"` -} - -// ChannelsV1GetStatesResponse returns the list of states. -type ChannelsV1GetStatesResponse struct { - // States is the list of states - States []StateV1 `json:"states"` - // Metadata contains pagination information - Metadata PaginationMetadataV1 `json:"metadata"` -} - // ChannelsV1RequestCreationRequest requests the creation of a channel from Node. type ChannelsV1RequestCreationRequest struct { // State is the state to be submitted diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index f479d0da6..b633ed8a2 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -96,15 +96,6 @@ func (c *Client) ChannelsV1GetLatestState(ctx context.Context, req ChannelsV1Get return resp, nil } -// ChannelsV1GetStates retrieves state history for a user with optional filtering. -func (c *Client) ChannelsV1GetStates(ctx context.Context, req ChannelsV1GetStatesRequest) (ChannelsV1GetStatesResponse, error) { - var resp ChannelsV1GetStatesResponse - if err := c.call(ctx, ChannelsV1GetStatesMethod, req, &resp); err != nil { - return resp, err - } - return resp, nil -} - // ChannelsV1RequestCreation requests the creation of a channel from Node. func (c *Client) ChannelsV1RequestCreation(ctx context.Context, req ChannelsV1RequestCreationRequest) (ChannelsV1RequestCreationResponse, error) { var resp ChannelsV1RequestCreationResponse diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index 658e35e1e..4dae33df6 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -245,35 +245,6 @@ func TestClientV1_ChannelsV1GetLatestState(t *testing.T) { assert.Equal(t, testAssetV1, resp.State.Asset) } -func TestClientV1_ChannelsV1GetStates(t *testing.T) { - t.Parallel() - - client, dialer := setupClient() - - states := rpc.ChannelsV1GetStatesResponse{ - States: []rpc.StateV1{ - {ID: "state1", Version: "1", Asset: testAssetV1}, - {ID: "state2", Version: "2", Asset: testAssetV1}, - }, - Metadata: rpc.PaginationMetadataV1{ - Page: 1, - PerPage: 10, - TotalCount: 2, - PageCount: 1, - }, - } - - registerSimpleHandlerV1(dialer, "channels.v1.get_states", states) - - resp, err := client.ChannelsV1GetStates(testCtxV1, rpc.ChannelsV1GetStatesRequest{ - Wallet: testWalletV1, - Asset: testAssetV1, - OnlySigned: false, - }) - require.NoError(t, err) - assert.Len(t, resp.States, 2) -} - func TestClientV1_ChannelsV1RequestCreation(t *testing.T) { t.Parallel() diff --git a/pkg/rpc/methods.go b/pkg/rpc/methods.go index de8a37df6..1d108e024 100644 --- a/pkg/rpc/methods.go +++ b/pkg/rpc/methods.go @@ -12,7 +12,6 @@ const ( ChannelsV1GetEscrowChannelMethod Method = "channels.v1.get_escrow_channel" ChannelsV1GetChannelsMethod Method = "channels.v1.get_channels" ChannelsV1GetLatestStateMethod Method = "channels.v1.get_latest_state" - ChannelsV1GetStatesMethod Method = "channels.v1.get_states" ChannelsV1RequestCreationMethod Method = "channels.v1.request_creation" ChannelsV1SubmitStateMethod Method = "channels.v1.submit_state" ChannelsV1SubmitSessionKeyStateMethod Method = "channels.v1.submit_session_key_state" diff --git a/sdk/ts-compat/package.json b/sdk/ts-compat/package.json index 9090c9be4..a757d5970 100644 --- a/sdk/ts-compat/package.json +++ b/sdk/ts-compat/package.json @@ -12,6 +12,7 @@ ], "scripts": { "build": "tsc", + "build:ci": "tsc", "build:prod": "tsc -p tsconfig.prod.json", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", "lint": "eslint src test", diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index 7b1c35137..3f9fc4f1b 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -551,29 +551,38 @@ export class NitroliteClient { } async getAppSessionsList(wallet?: Address, status?: string): Promise { - const mapSessions = (sessions: any[]) => sessions.map((s) => ({ - app_session_id: s.appSessionId, - nonce: Number(s.nonce ?? 0), - participants: s.participants.map((p: any) => p.walletAddress), - protocol: '', - quorum: s.quorum, - status: s.isClosed ? 'closed' : 'open', - version: Number(s.version ?? 0), - weights: s.participants.map((p: any) => p.signatureWeight), - allocations: s.allocations?.map((a: any) => { - const info = this.assetsBySymbol.get(a.asset?.toLowerCase?.() ?? ''); - const dec = info?.decimals ?? 6; - const rawAmount = a.amount - ? a.amount.mul(new Decimal(10).pow(dec)).toFixed(0) - : '0'; - return { - participant: a.participant as Address, - asset: a.asset, - amount: rawAmount, - }; - }) ?? [], - sessionData: s.sessionData ?? '', - })); + const mapSessions = (sessions: any[]) => sessions.map((s) => { + const definition = s.appDefinition ?? { + participants: s.participants ?? [], + quorum: s.quorum, + nonce: s.nonce, + }; + const participants = definition.participants ?? []; + + return { + app_session_id: s.appSessionId, + nonce: Number(definition.nonce ?? 0), + participants: participants.map((p: any) => p.walletAddress), + protocol: '', + quorum: definition.quorum ?? 0, + status: s.isClosed ? 'closed' : 'open', + version: Number(s.version ?? 0), + weights: participants.map((p: any) => p.signatureWeight), + allocations: s.allocations?.map((a: any) => { + const info = this.assetsBySymbol.get(a.asset?.toLowerCase?.() ?? ''); + const dec = info?.decimals ?? 6; + const rawAmount = a.amount + ? a.amount.mul(new Decimal(10).pow(dec)).toFixed(0) + : '0'; + return { + participant: a.participant as Address, + asset: a.asset, + amount: rawAmount, + }; + }) ?? [], + sessionData: s.sessionData ?? '', + }; + }); const participant = (wallet ?? this.userAddress).toLowerCase() as Address; const normalizedStatus = status?.toLowerCase(); diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts new file mode 100644 index 000000000..6c338683b --- /dev/null +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -0,0 +1,122 @@ +import { Decimal } from 'decimal.js'; +import { jest } from '@jest/globals'; + +import { NitroliteClient } from '../../src/client.js'; + +const wallet = '0x1111111111111111111111111111111111111111'; +const friend = '0x2222222222222222222222222222222222222222'; + +function makeClient(sessions: any[]) { + const client = Object.create(NitroliteClient.prototype) as any; + client.userAddress = wallet; + client.innerClient = { + getAppSessions: jest.fn().mockResolvedValue({ sessions }), + }; + client.assetsBySymbol = new Map([ + ['yusd', { decimals: 6 }], + ['yellow', { decimals: 18 }], + ]); + client._lastAppSessionsListError = null; + client._lastAppSessionsListErrorLogged = null; + + return client; +} + +describe('NitroliteClient getAppSessionsList compat mapping', () => { + let infoSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('maps the current v1 appDefinition session shape', async () => { + const client = makeClient([ + { + appSessionId: '0xsession', + appDefinition: { + participants: [ + { walletAddress: wallet, signatureWeight: 1 }, + { walletAddress: friend, signatureWeight: 2 }, + ], + quorum: 3, + nonce: 42n, + }, + isClosed: false, + version: 7n, + allocations: [ + { participant: wallet, asset: 'YUSD', amount: new Decimal('1.25') }, + { participant: friend, asset: 'YELLOW', amount: new Decimal('2') }, + ], + sessionData: '{"intent":"purchase"}', + }, + ]); + + const sessions = await client.getAppSessionsList(); + + expect(client.innerClient.getAppSessions).toHaveBeenCalledWith({ + wallet: wallet.toLowerCase(), + }); + expect(sessions).toEqual([ + { + app_session_id: '0xsession', + nonce: 42, + participants: [wallet, friend], + protocol: '', + quorum: 3, + status: 'open', + version: 7, + weights: [1, 2], + allocations: [ + { participant: wallet, asset: 'YUSD', amount: '1250000' }, + { participant: friend, asset: 'YELLOW', amount: '2000000000000000000' }, + ], + sessionData: '{"intent":"purchase"}', + }, + ]); + }); + + it('keeps the legacy flat session shape fallback', async () => { + const client = makeClient([ + { + appSessionId: '0xlegacy', + participants: [ + { walletAddress: wallet, signatureWeight: 50 }, + { walletAddress: friend, signatureWeight: 50 }, + ], + quorum: 100, + nonce: 99n, + isClosed: true, + version: 4n, + allocations: [], + sessionData: '{"legacy":true}', + }, + ]); + + const sessions = await client.getAppSessionsList(wallet, 'any'); + + expect(client.innerClient.getAppSessions).toHaveBeenCalledWith({ + wallet: wallet.toLowerCase(), + }); + expect(sessions).toEqual([ + { + app_session_id: '0xlegacy', + nonce: 99, + participants: [wallet, friend], + protocol: '', + quorum: 100, + status: 'closed', + version: 4, + weights: [50, 50], + allocations: [], + sessionData: '{"legacy":true}', + }, + ]); + }); +}); diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 993d9f2d4..a6c98f529 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -11,6 +11,7 @@ ], "scripts": { "build": "npm run test && tsc", + "build:ci": "tsc", "codegen-abi": "npx tsx scripts/codegen-abi.ts", "build:prod": "npm run test && tsc -p tsconfig.prod.json", "build:full": "npm run build", diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 5c4b265ca..38ffbbe99 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -89,28 +89,6 @@ export interface ChannelsV1GetLatestStateResponse { state: StateV1; } -export interface ChannelsV1GetStatesRequest { - /** User's wallet address */ - wallet: Address; - /** Asset symbol */ - asset: string; - /** User epoch index filter */ - epoch?: bigint; // uint64 - /** Home/Escrow Channel ID filter */ - channel_id?: string; - /** Return only signed states */ - only_signed: boolean; - /** Pagination parameters */ - pagination?: PaginationParamsV1; -} - -export interface ChannelsV1GetStatesResponse { - /** List of states */ - states: StateV1[]; - /** Pagination information */ - metadata: PaginationMetadataV1; -} - export interface ChannelsV1RequestCreationRequest { /** State to be submitted */ state: StateV1; diff --git a/sdk/ts/src/rpc/client.ts b/sdk/ts/src/rpc/client.ts index 4b7e39956..9badbb54a 100644 --- a/sdk/ts/src/rpc/client.ts +++ b/sdk/ts/src/rpc/client.ts @@ -88,13 +88,6 @@ export class RPCClient { return this.call(Methods.ChannelsV1GetLatestStateMethod, req, signal); } - async channelsV1GetStates( - req: API.ChannelsV1GetStatesRequest, - signal?: AbortSignal - ): Promise { - return this.call(Methods.ChannelsV1GetStatesMethod, req, signal); - } - async channelsV1RequestCreation( req: API.ChannelsV1RequestCreationRequest, signal?: AbortSignal diff --git a/sdk/ts/src/rpc/methods.ts b/sdk/ts/src/rpc/methods.ts index 1f36191db..53f5553df 100644 --- a/sdk/ts/src/rpc/methods.ts +++ b/sdk/ts/src/rpc/methods.ts @@ -18,7 +18,6 @@ export const ChannelsV1GetHomeChannelMethod: Method = 'channels.v1.get_home_chan export const ChannelsV1GetEscrowChannelMethod: Method = 'channels.v1.get_escrow_channel'; export const ChannelsV1GetChannelsMethod: Method = 'channels.v1.get_channels'; export const ChannelsV1GetLatestStateMethod: Method = 'channels.v1.get_latest_state'; -export const ChannelsV1GetStatesMethod: Method = 'channels.v1.get_states'; export const ChannelsV1RequestCreationMethod: Method = 'channels.v1.request_creation'; export const ChannelsV1SubmitStateMethod: Method = 'channels.v1.submit_state'; diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts new file mode 100644 index 000000000..d5fe98182 --- /dev/null +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../../..'); + +// This guard intentionally parses rpc_router.go with regex. If router registration +// is refactored away from direct Handle(rpc.XxxMethod.String(), ...) calls, update +// extractRouterHandlers rather than treating the resulting failure as protocol drift. +function extractGoMethodLiterals(source: string): Set { + const matches = source.matchAll(/^\s*[A-Za-z0-9]+Method\s+Method\s*=\s*"([^"]+)"$/gm); + return new Set(Array.from(matches, ([, method]) => method)); +} + +function extractTsMethodLiterals(source: string): Set { + const matches = source.matchAll( + /^\s*export const [A-Za-z0-9]+Method:\s*Method\s*=\s*'([^']+)';$/gm + ); + return new Set(Array.from(matches, ([, method]) => method)); +} + +function extractRouterHandlers(source: string): Set { + const matches = source.matchAll(/Handle\(rpc\.[A-Za-z0-9]+Method\.String\(\),/g); + const methodNames = Array.from(matches, ([match]) => + match.match(/rpc\.([A-Za-z0-9]+Method)\.String/)?.[1] + ).filter((methodName): methodName is string => Boolean(methodName)); + + const methodsSource = fs.readFileSync(path.join(repoRoot, 'pkg/rpc/methods.go'), 'utf8'); + const namedLiterals = new Map( + Array.from( + methodsSource.matchAll(/^\s*([A-Za-z0-9]+Method)\s+Method\s*=\s*"([^"]+)"$/gm), + ([, name, literal]) => [name, literal] + ) + ); + + const unresolvedMethodNames = methodNames.filter((name) => !namedLiterals.has(name)); + if (unresolvedMethodNames.length > 0) { + throw new Error( + `rpc_router.go references unresolved rpc method constants: ${unresolvedMethodNames.join( + ', ' + )}` + ); + } + + return new Set(methodNames.map((name) => namedLiterals.get(name) as string)); +} + +function sorted(values: Set): string[] { + return Array.from(values).sort(); +} + +function diff(left: Set, right: Set): { missing: string[]; extra: string[] } { + return { + missing: sorted(new Set(Array.from(right).filter((value) => !left.has(value)))), + extra: sorted(new Set(Array.from(left).filter((value) => !right.has(value)))), + }; +} + +describe('TS RPC drift guards', () => { + it('keeps the TS raw RPC method surface aligned with pkg/rpc', () => { + const goMethods = extractGoMethodLiterals( + fs.readFileSync(path.join(repoRoot, 'pkg/rpc/methods.go'), 'utf8') + ); + const tsMethods = extractTsMethodLiterals( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/rpc/methods.ts'), 'utf8') + ); + + const { missing, extra } = diff(tsMethods, goMethods); + + expect({ missing, extra }).toEqual({ missing: [], extra: [] }); + }); + + it('keeps the TS raw RPC method surface aligned with live router registrations', () => { + const routerMethods = extractRouterHandlers( + fs.readFileSync(path.join(repoRoot, 'clearnode/api/rpc_router.go'), 'utf8') + ); + const tsMethods = extractTsMethodLiterals( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/rpc/methods.ts'), 'utf8') + ); + + const { missing, extra } = diff(tsMethods, routerMethods); + + expect({ missing, extra }).toEqual({ missing: [], extra: [] }); + }); +});