From 8c8e3608311e0491ac3d097cccefbfa727cffa1a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 12 May 2026 15:00:37 +0300 Subject: [PATCH 1/3] YNU-915: return success on absent-resource GET endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four single-resource lookups (get_latest_state, get_home_channel, get_escrow_channel, get_app_definition) used to respond with RespErr when the record was absent. That counted absence as a failure in rpc_requests_total{result="failed"}, skewing success-rate dashboards, and forced clients to parse error strings to distinguish "not found" from a real failure. Make absence a successful response: the relevant payload field is now optional/nullable. Handlers drop the nil→Fail branch. Go and TS SDK methods return nil/null on absence; callers explicitly check for nil instead of treating the error path as the "no channel yet" branch. - pkg/rpc/api.go: pointerize Channel/State/Definition fields - nitronode/api: drop nil→Fail, return success with empty payload - sdk/go, cerebro, examples: handle nil from lookup methods - sdk/ts, sdk/ts-compat: return T | null, drop redundant try/catch - docs/api.yaml: mark fields optional, remove *_not_found errors - tests: NotFound paths assert success + nil payload --- cerebro/commands.go | 12 ++ docs/api.yaml | 36 +++-- .../api/app_session_v1/get_app_definition.go | 6 +- .../app_session_v1/get_app_definition_test.go | 12 +- .../api/channel_v1/get_escrow_channel.go | 11 +- .../api/channel_v1/get_escrow_channel_test.go | 48 +++++++ nitronode/api/channel_v1/get_home_channel.go | 11 +- .../api/channel_v1/get_home_channel_test.go | 12 +- nitronode/api/channel_v1/get_latest_state.go | 14 +- .../api/channel_v1/get_latest_state_test.go | 37 +++++ pkg/rpc/api.go | 24 ++-- pkg/rpc/client_test.go | 8 +- sdk/go/app_session.go | 15 +- sdk/go/channel.go | 72 ++++++++-- sdk/go/client_test.go | 10 +- sdk/go/examples/challenge/main.go | 3 + sdk/ts-compat/src/client.ts | 19 ++- sdk/ts/src/client.ts | 134 +++++++++++------- sdk/ts/src/rpc/api.ts | 28 ++-- .../public-api-drift.test.ts.snap | 16 +-- sdk/ts/test/unit/client.test.ts | 13 +- 21 files changed, 373 insertions(+), 168 deletions(-) diff --git a/cerebro/commands.go b/cerebro/commands.go index 7dcb3051e..09d6c51ba 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -636,6 +636,10 @@ func (o *Operator) getHomeChannel(ctx context.Context, wallet, asset string) { fmt.Printf("ERROR: Failed to get home channel: %v\n", err) return } + if channel == nil { + fmt.Printf("No home channel found for %s (%s)\n", wallet, asset) + return + } typeStr := "unknown" switch channel.Type { @@ -675,6 +679,10 @@ func (o *Operator) getEscrowChannel(ctx context.Context, escrowChannelID string) fmt.Printf("ERROR: Failed to get escrow channel: %v\n", err) return } + if channel == nil { + fmt.Printf("No escrow channel found with ID %s\n", escrowChannelID) + return + } typeStr := "unknown" switch channel.Type { @@ -834,6 +842,10 @@ func (o *Operator) getLatestState(ctx context.Context, wallet, asset string) { fmt.Printf("ERROR: Failed to get state: %v\n", err) return } + if state == nil { + fmt.Printf("No state found for %s (%s)\n", wallet, asset) + return + } fmt.Printf("Latest State for %s (%s)\n", wallet, asset) fmt.Println("====================================") diff --git a/docs/api.yaml b/docs/api.yaml index db4eea051..358aa761b 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -510,7 +510,7 @@ api: - version: v1 methods: - name: get_home_channel - description: Retrieve current on-chain home channel information + description: Retrieve current on-chain home channel information. Returns a successful response with `channel` omitted when no home channel exists for the wallet/asset pair. request: - field_name: wallet type: string @@ -521,12 +521,11 @@ api: response: - field_name: channel type: channel - description: The on-chain channel information - errors: - - message: channel_not_found - description: The specified channel was not found + description: The on-chain channel information; omitted when no home channel exists for the wallet/asset pair + optional: true + errors: [] - name: get_escrow_channel - description: Retrieve current on-chain escrow channel information + description: Retrieve current on-chain escrow channel information. Returns a successful response with `channel` omitted when no escrow channel exists for the given ID. request: - field_name: escrow_channel_id type: string @@ -534,10 +533,9 @@ api: response: - field_name: channel type: channel - description: The on-chain channel information - errors: - - message: channel_not_found - description: The specified channel was not found + description: The on-chain channel information; omitted when no escrow channel exists for the given ID + optional: true + errors: [] - name: get_channels description: Retrieve all channels for a user with optional filtering request: @@ -570,7 +568,7 @@ api: - message: invalid_parameters description: The request parameters are invalid - name: get_latest_state - description: Retrieve the current state of the user stored on the Node + description: Retrieve the current state of the user stored on the Node. Returns a successful response with `state` omitted when no state is stored for the wallet/asset pair. request: - field_name: wallet type: string @@ -585,10 +583,9 @@ api: response: - field_name: state type: state - description: The current state of the user - errors: - - message: channel_not_found - description: The specified channel was not found + description: The current state of the user; omitted when no state is stored for the wallet/asset pair + optional: true + errors: [] - name: request_creation description: Request the creation of a channel from Node request: @@ -733,7 +730,7 @@ api: errors: [] - name: get_app_definition - description: Retrieve the application definition for a specific app session + description: Retrieve the application definition for a specific app session. Returns a successful response with `definition` omitted when no app session exists for the given ID. request: - field_name: app_session_id type: string @@ -741,10 +738,9 @@ api: response: - field_name: definition type: app_definition - description: The application definition - errors: - - message: app_session_not_found - description: The specified app session was not found + description: The application definition; omitted when no app session exists for the given ID + optional: true + errors: [] - name: get_app_sessions description: List all application sessions for a participant with optional filtering request: diff --git a/nitronode/api/app_session_v1/get_app_definition.go b/nitronode/api/app_session_v1/get_app_definition.go index 849b21a05..2d2cf81b8 100644 --- a/nitronode/api/app_session_v1/get_app_definition.go +++ b/nitronode/api/app_session_v1/get_app_definition.go @@ -14,7 +14,7 @@ func (h *Handler) GetAppDefinition(c *rpc.Context) { return } - var definition rpc.AppDefinitionV1 + var definition *rpc.AppDefinitionV1 err := h.useStoreInTx(func(store Store) error { session, err := store.GetAppSession(req.AppSessionID) @@ -23,7 +23,7 @@ func (h *Handler) GetAppDefinition(c *rpc.Context) { } if session == nil { - return rpc.Errorf("app_session_not_found") + return nil } // Convert participants @@ -35,7 +35,7 @@ func (h *Handler) GetAppDefinition(c *rpc.Context) { } } - definition = rpc.AppDefinitionV1{ + definition = &rpc.AppDefinitionV1{ Application: session.ApplicationID, Participants: participants, Quorum: session.Quorum, diff --git a/nitronode/api/app_session_v1/get_app_definition_test.go b/nitronode/api/app_session_v1/get_app_definition_test.go index bf690b6f0..4678f056c 100644 --- a/nitronode/api/app_session_v1/get_app_definition_test.go +++ b/nitronode/api/app_session_v1/get_app_definition_test.go @@ -84,6 +84,7 @@ func TestGetAppDefinition_Success(t *testing.T) { var response rpc.AppSessionsV1GetAppDefinitionResponse err = ctx.Response.Payload.Translate(&response) require.NoError(t, err) + require.NotNil(t, response.Definition) assert.Equal(t, "game", response.Definition.Application) assert.Len(t, response.Definition.Participants, 2) @@ -143,9 +144,14 @@ func TestGetAppDefinition_NotFound(t *testing.T) { // Execute handler.GetAppDefinition(ctx) - // Assert - assert.NotNil(t, ctx.Response.Error()) - assert.Contains(t, ctx.Response.Error().Error(), "app_session_not_found") + // Assert: absence is a successful response with definition == nil + assert.NotNil(t, ctx.Response.Payload) + assert.Nil(t, ctx.Response.Error()) + + var response rpc.AppSessionsV1GetAppDefinitionResponse + err = ctx.Response.Payload.Translate(&response) + require.NoError(t, err) + assert.Nil(t, response.Definition) // Verify all mock expectations mockStore.AssertExpectations(t) diff --git a/nitronode/api/channel_v1/get_escrow_channel.go b/nitronode/api/channel_v1/get_escrow_channel.go index 351e566c4..063a47e70 100644 --- a/nitronode/api/channel_v1/get_escrow_channel.go +++ b/nitronode/api/channel_v1/get_escrow_channel.go @@ -20,11 +20,6 @@ func (h *Handler) GetEscrowChannel(c *rpc.Context) { if err != nil { return rpc.Errorf("failed to get channel: %v", err) } - - if channel == nil { - return rpc.Errorf("channel_not_found") - } - return nil }) @@ -33,8 +28,10 @@ func (h *Handler) GetEscrowChannel(c *rpc.Context) { return } - response := rpc.ChannelsV1GetEscrowChannelResponse{ - Channel: coreChannelToRPC(*channel), + response := rpc.ChannelsV1GetEscrowChannelResponse{} + if channel != nil { + rpcChannel := coreChannelToRPC(*channel) + response.Channel = &rpcChannel } payload, err := rpc.NewPayload(response) diff --git a/nitronode/api/channel_v1/get_escrow_channel_test.go b/nitronode/api/channel_v1/get_escrow_channel_test.go index cc017aadb..ba63b01fb 100644 --- a/nitronode/api/channel_v1/get_escrow_channel_test.go +++ b/nitronode/api/channel_v1/get_escrow_channel_test.go @@ -87,6 +87,7 @@ func TestGetEscrowChannel_Success(t *testing.T) { var response rpc.ChannelsV1GetEscrowChannelResponse err = ctx.Response.Payload.Translate(&response) require.NoError(t, err) + require.NotNil(t, response.Channel) assert.Equal(t, escrowChannelID, response.Channel.ChannelID) assert.Equal(t, userWallet, response.Channel.UserWallet) @@ -97,3 +98,50 @@ func TestGetEscrowChannel_Success(t *testing.T) { // Verify all mock expectations mockTxStore.AssertExpectations(t) } + +func TestGetEscrowChannel_NotFound(t *testing.T) { + mockTxStore := new(MockStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(h StoreTxHandler) error { + return h(mockTxStore) + }, + nodeSigner: nodeSigner, + nodeAddress: mockSigner.PublicKey().Address().String(), + minChallenge: 3600, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + actionGateway: &MockActionGateway{}, + } + + escrowChannelID := "0xMissingEscrowChannel" + mockTxStore.On("GetChannelByID", escrowChannelID).Return(nil, nil) + + reqPayload := rpc.ChannelsV1GetEscrowChannelRequest{EscrowChannelID: escrowChannelID} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.get_escrow_channel", Payload: payload}, + } + + handler.GetEscrowChannel(ctx) + + // Absence is a successful response with channel == nil. + assert.NotNil(t, ctx.Response.Payload) + assert.Nil(t, ctx.Response.Error()) + + var response rpc.ChannelsV1GetEscrowChannelResponse + err = ctx.Response.Payload.Translate(&response) + require.NoError(t, err) + assert.Nil(t, response.Channel) + + mockTxStore.AssertExpectations(t) +} diff --git a/nitronode/api/channel_v1/get_home_channel.go b/nitronode/api/channel_v1/get_home_channel.go index bba432ec1..3bd06b3e3 100644 --- a/nitronode/api/channel_v1/get_home_channel.go +++ b/nitronode/api/channel_v1/get_home_channel.go @@ -27,11 +27,6 @@ func (h *Handler) GetHomeChannel(c *rpc.Context) { if err != nil { return rpc.Errorf("failed to get home channel: %v", err) } - - if channel == nil { - return rpc.Errorf("channel_not_found") - } - return nil }) @@ -40,8 +35,10 @@ func (h *Handler) GetHomeChannel(c *rpc.Context) { return } - response := rpc.ChannelsV1GetHomeChannelResponse{ - Channel: coreChannelToRPC(*channel), + response := rpc.ChannelsV1GetHomeChannelResponse{} + if channel != nil { + rpcChannel := coreChannelToRPC(*channel) + response.Channel = &rpcChannel } payload, err := rpc.NewPayload(response) diff --git a/nitronode/api/channel_v1/get_home_channel_test.go b/nitronode/api/channel_v1/get_home_channel_test.go index 17767e7e0..9196d9ab2 100644 --- a/nitronode/api/channel_v1/get_home_channel_test.go +++ b/nitronode/api/channel_v1/get_home_channel_test.go @@ -89,6 +89,7 @@ func TestGetHomeChannel_Success(t *testing.T) { var response rpc.ChannelsV1GetHomeChannelResponse err = ctx.Response.Payload.Translate(&response) require.NoError(t, err) + require.NotNil(t, response.Channel) assert.Equal(t, homeChannelID, response.Channel.ChannelID) assert.Equal(t, userWallet, response.Channel.UserWallet) @@ -156,9 +157,14 @@ func TestGetHomeChannel_NotFound(t *testing.T) { // Execute handler.GetHomeChannel(ctx) - // Assert - assert.NotNil(t, ctx.Response.Error()) - assert.Contains(t, ctx.Response.Error().Error(), "channel_not_found") + // Assert: absence is a successful response with channel == nil + assert.NotNil(t, ctx.Response.Payload) + assert.Nil(t, ctx.Response.Error()) + + var response rpc.ChannelsV1GetHomeChannelResponse + err = ctx.Response.Payload.Translate(&response) + require.NoError(t, err) + assert.Nil(t, response.Channel) // Verify all mock expectations mockTxStore.AssertExpectations(t) diff --git a/nitronode/api/channel_v1/get_latest_state.go b/nitronode/api/channel_v1/get_latest_state.go index 5d84e8e9b..ed1abcefc 100644 --- a/nitronode/api/channel_v1/get_latest_state.go +++ b/nitronode/api/channel_v1/get_latest_state.go @@ -20,18 +20,14 @@ func (h *Handler) GetLatestState(c *rpc.Context) { } req.Wallet = normalizedWallet - var state core.State + var state *core.State err = h.useStoreInTx(func(tx Store) error { lastState, err := tx.GetLastUserState(req.Wallet, req.Asset, req.OnlySigned) if err != nil { return rpc.Errorf("failed to get last user state: %v", err) } - if lastState == nil { - return rpc.Errorf("channel not found") - } - - state = *lastState + state = lastState return nil }) @@ -40,8 +36,10 @@ func (h *Handler) GetLatestState(c *rpc.Context) { return } - response := rpc.ChannelsV1GetLatestStateResponse{ - State: coreStateToRPC(state), + response := rpc.ChannelsV1GetLatestStateResponse{} + if state != nil { + rpcState := coreStateToRPC(*state) + response.State = &rpcState } payload, err := rpc.NewPayload(response) diff --git a/nitronode/api/channel_v1/get_latest_state_test.go b/nitronode/api/channel_v1/get_latest_state_test.go index 6f6c19619..9f4a06323 100644 --- a/nitronode/api/channel_v1/get_latest_state_test.go +++ b/nitronode/api/channel_v1/get_latest_state_test.go @@ -99,6 +99,7 @@ func TestGetLatestState_Success(t *testing.T) { var response rpc.ChannelsV1GetLatestStateResponse err = ctx.Response.Payload.Translate(&response) require.NoError(t, err) + require.NotNil(t, response.State) assert.Equal(t, state.ID, response.State.ID) assert.Equal(t, userWallet, response.State.UserWallet) @@ -198,6 +199,7 @@ func TestGetLatestState_OnlySigned(t *testing.T) { var response rpc.ChannelsV1GetLatestStateResponse err = ctx.Response.Payload.Translate(&response) require.NoError(t, err) + require.NotNil(t, response.State) assert.Equal(t, state.ID, response.State.ID) assert.Equal(t, "3", response.State.Version) @@ -236,3 +238,38 @@ func TestGetLatestState_NormalizesWallet(t *testing.T) { require.Nil(t, ctx.Response.Error()) mockTxStore.AssertExpectations(t) } + +func TestGetLatestState_NotFound(t *testing.T) { + mockTxStore := new(MockStore) + + handler := &Handler{ + useStoreInTx: func(h StoreTxHandler) error { return h(mockTxStore) }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + } + + userWallet := "0x1234567890123456789012345678901234567890" + asset := "USDC" + mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(nil, nil) + + reqPayload := rpc.ChannelsV1GetLatestStateRequest{Wallet: userWallet, Asset: asset} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.get_latest_state", Payload: payload}, + } + + handler.GetLatestState(ctx) + + // Absence is a successful response with state == nil. + assert.NotNil(t, ctx.Response.Payload) + assert.Nil(t, ctx.Response.Error()) + + var response rpc.ChannelsV1GetLatestStateResponse + err = ctx.Response.Payload.Translate(&response) + require.NoError(t, err) + assert.Nil(t, response.State) + + mockTxStore.AssertExpectations(t) +} diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index 1689aec08..4ec6e8135 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -22,9 +22,11 @@ type ChannelsV1GetHomeChannelRequest struct { } // ChannelsV1GetHomeChannelResponse returns the on-chain channel information. +// Channel is nil when no home channel exists for the given wallet/asset pair; +// this is a successful response, not an error. type ChannelsV1GetHomeChannelResponse struct { - // Channel is the on-chain channel information - Channel ChannelV1 `json:"channel"` + // Channel is the on-chain channel information, or nil if absent. + Channel *ChannelV1 `json:"channel,omitempty"` } // ChannelsV1GetEscrowChannelRequest retrieves current on-chain escrow channel information. @@ -34,9 +36,11 @@ type ChannelsV1GetEscrowChannelRequest struct { } // ChannelsV1GetEscrowChannelResponse returns the on-chain channel information. +// Channel is nil when no escrow channel exists for the given ID; this is a +// successful response, not an error. type ChannelsV1GetEscrowChannelResponse struct { - // Channel is the on-chain channel information - Channel ChannelV1 `json:"channel"` + // Channel is the on-chain channel information, or nil if absent. + Channel *ChannelV1 `json:"channel,omitempty"` } // ChannelsV1GetChannelsRequest retrieves all channels for a user with optional filtering. @@ -72,9 +76,11 @@ type ChannelsV1GetLatestStateRequest struct { } // ChannelsV1GetLatestStateResponse returns the current state of the user. +// State is nil when no state has been stored for the given wallet/asset pair; +// this is a successful response, not an error. type ChannelsV1GetLatestStateResponse struct { - // State is the current state of the user - State StateV1 `json:"state"` + // State is the current state of the user, or nil if absent. + State *StateV1 `json:"state,omitempty"` } // ChannelsV1RequestCreationRequest requests the creation of a channel from Node. @@ -192,9 +198,11 @@ type AppSessionsV1GetAppDefinitionRequest struct { } // AppSessionsV1GetAppDefinitionResponse returns the application definition. +// Definition is nil when no app session exists for the given ID; this is a +// successful response, not an error. type AppSessionsV1GetAppDefinitionResponse struct { - // Definition is the application definition - Definition AppDefinitionV1 `json:"definition"` + // Definition is the application definition, or nil if absent. + Definition *AppDefinitionV1 `json:"definition,omitempty"` } // AppSessionsV1GetAppSessionsRequest lists all application sessions for a participant with optional filtering. diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index 4dae33df6..82c227417 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -137,7 +137,7 @@ func TestClientV1_ChannelsV1GetHomeChannel(t *testing.T) { client, dialer := setupClient() channel := rpc.ChannelsV1GetHomeChannelResponse{ - Channel: rpc.ChannelV1{ + Channel: &rpc.ChannelV1{ ChannelID: testChannelID, UserWallet: testWalletV1, Type: "home", @@ -167,7 +167,7 @@ func TestClientV1_ChannelsV1GetEscrowChannel(t *testing.T) { client, dialer := setupClient() channel := rpc.ChannelsV1GetEscrowChannelResponse{ - Channel: rpc.ChannelV1{ + Channel: &rpc.ChannelV1{ ChannelID: testChannelID, Type: "escrow", BlockchainID: testChainIDV1, @@ -218,7 +218,7 @@ func TestClientV1_ChannelsV1GetLatestState(t *testing.T) { client, dialer := setupClient() state := rpc.ChannelsV1GetLatestStateResponse{ - State: rpc.StateV1{ + State: &rpc.StateV1{ ID: "state123", Asset: testAssetV1, UserWallet: testWalletV1, @@ -302,7 +302,7 @@ func TestClientV1_AppSessionsV1GetAppDefinition(t *testing.T) { client, dialer := setupClient() definition := rpc.AppSessionsV1GetAppDefinitionResponse{ - Definition: rpc.AppDefinitionV1{ + Definition: &rpc.AppDefinitionV1{ Application: "game", Participants: []rpc.AppParticipantV1{ {WalletAddress: testWalletV1, SignatureWeight: 1}, diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index cd91b852e..269b4d191 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -69,17 +69,22 @@ func (c *Client) GetAppSessions(ctx context.Context, opts *GetAppSessionsOptions // GetAppDefinition retrieves the definition for a specific app session. // +// Returns (nil, nil) when no app session exists for the given ID — absence is +// a successful response, not an error. +// // Parameters: // - appSessionID: The application session ID // // Returns: -// - app.AppDefinitionV1 with participants, quorum, and application info +// - app.AppDefinitionV1 with participants, quorum, and application info, or +// nil if absent // - Error if the request fails // // Example: // // def, err := client.GetAppDefinition(ctx, "session123") -// fmt.Printf("App: %s, Quorum: %d\n", def.Application, def.Quorum) +// if err != nil { return err } +// if def == nil { /* not found */ } func (c *Client) GetAppDefinition(ctx context.Context, appSessionID string) (*app.AppDefinitionV1, error) { if appSessionID == "" { return nil, fmt.Errorf("app session ID required") @@ -92,7 +97,11 @@ func (c *Client) GetAppDefinition(ctx context.Context, appSessionID string) (*ap return nil, fmt.Errorf("failed to get app definition: %w", err) } - def, err := transformAppDefinition(resp.Definition) + if resp.Definition == nil { + return nil, nil + } + + def, err := transformAppDefinition(*resp.Definition) if err != nil { return nil, fmt.Errorf("failed to transform app definition: %w", err) } diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 380150a51..37306f094 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -56,9 +56,12 @@ func (c *Client) Deposit(ctx context.Context, blockchainID uint64, asset string, // Try to get latest state to determine if channel exists state, err := c.GetLatestState(ctx, userWallet, asset, false) + if err != nil { + return nil, fmt.Errorf("failed to get latest state: %w", err) + } // Scenario A: Channel doesn't exist - create it - if err != nil || state.HomeChannelID == nil { + if state == nil || state.HomeChannelID == nil { // Get supported sig validators bitmap from node config bitmap, err := c.getSupportedSigValidatorsBitmap(ctx) if err != nil { @@ -164,9 +167,12 @@ func (c *Client) Withdraw(ctx context.Context, blockchainID uint64, asset string // Try to get latest state to determine if channel exists state, err := c.GetLatestState(ctx, userWallet, asset, false) + if err != nil { + return nil, fmt.Errorf("failed to get latest state: %w", err) + } // Channel doesn't exist - create it and withdraw - if err != nil || state.HomeChannelID == nil { + if state == nil || state.HomeChannelID == nil { // Get supported sig validators bitmap from node config bitmap, err := c.getSupportedSigValidatorsBitmap(ctx) if err != nil { @@ -260,7 +266,10 @@ func (c *Client) Transfer(ctx context.Context, recipientWallet string, asset str // Get sender's latest state senderWallet := c.GetUserAddress() state, err := c.GetLatestState(ctx, senderWallet, asset, false) - if err != nil || state.HomeChannelID == nil { + if err != nil { + return nil, fmt.Errorf("failed to get latest state: %w", err) + } + if state == nil || state.HomeChannelID == nil { // Get supported sig validators bitmap from node config bitmap, err := c.getSupportedSigValidatorsBitmap(ctx) if err != nil { @@ -380,7 +389,7 @@ func (c *Client) CloseHomeChannel(ctx context.Context, asset string) (*core.Stat return nil, err } - if state.HomeChannelID == nil { + if state == nil || state.HomeChannelID == nil { return nil, fmt.Errorf("no channel exists for asset %s", asset) } @@ -431,9 +440,12 @@ func (c *Client) Acknowledge(ctx context.Context, asset string) (*core.State, er // Try to get latest state to determine if channel exists state, err := c.GetLatestState(ctx, userWallet, asset, false) + if err != nil { + return nil, fmt.Errorf("failed to get latest state: %w", err) + } // No channel path - create channel with acknowledgement - if err != nil || state.HomeChannelID == nil { + if state == nil || state.HomeChannelID == nil { // Get supported sig validators bitmap from node config bitmap, err := c.getSupportedSigValidatorsBitmap(ctx) if err != nil { @@ -558,6 +570,10 @@ func (c *Client) Checkpoint(ctx context.Context, asset string) (string, error) { return "", fmt.Errorf("failed to get latest signed state: %w", err) } + if state == nil { + return "", fmt.Errorf("no signed state exists for asset %s", asset) + } + if state.HomeChannelID == nil { // NOTE: this should never happen, because signed state MUST have a channel ID return "", fmt.Errorf("no channel exists for asset %s", asset) @@ -576,6 +592,11 @@ func (c *Client) Checkpoint(ctx context.Context, asset string) (string, error) { if err != nil { return "", fmt.Errorf("failed to get home channel: %w", err) } + if channel == nil { + // Signed state existed but home channel record is missing — node is in + // an inconsistent state. + return "", fmt.Errorf("home channel missing for asset %s despite signed state", asset) + } switch state.Transition.Type { case core.TransitionTypeAcknowledgement, @@ -717,18 +738,22 @@ func (c *Client) Challenge(ctx context.Context, state core.State) (string, error // GetHomeChannel retrieves home channel information for a user's asset. // +// Returns (nil, nil) when no home channel exists for the wallet/asset pair — +// absence is a successful response, not an error. +// // Parameters: // - wallet: The user's wallet address // - asset: The asset symbol // // Returns: -// - Channel information for the home channel +// - Channel information for the home channel, or nil if absent // - Error if the request fails // // Example: // // channel, err := client.GetHomeChannel(ctx, "0x1234...", "usdc") -// fmt.Printf("Home Channel: %s (Version: %d)\n", channel.ChannelID, channel.StateVersion) +// if err != nil { return err } +// if channel == nil { /* no channel yet */ } func (c *Client) GetHomeChannel(ctx context.Context, wallet, asset string) (*core.Channel, error) { req := rpc.ChannelsV1GetHomeChannelRequest{ Wallet: wallet, @@ -739,7 +764,11 @@ func (c *Client) GetHomeChannel(ctx context.Context, wallet, asset string) (*cor return nil, fmt.Errorf("failed to get home channel: %w", err) } - channel, err := transformChannel(resp.Channel) + if resp.Channel == nil { + return nil, nil + } + + channel, err := transformChannel(*resp.Channel) if err != nil { return nil, fmt.Errorf("failed to transform channel: %w", err) } @@ -748,17 +777,21 @@ func (c *Client) GetHomeChannel(ctx context.Context, wallet, asset string) (*cor // GetEscrowChannel retrieves escrow channel information for a specific channel ID. // +// Returns (nil, nil) when no escrow channel exists for the given ID — absence +// is a successful response, not an error. +// // Parameters: // - escrowChannelID: The escrow channel ID to query // // Returns: -// - Channel information for the escrow channel +// - Channel information for the escrow channel, or nil if absent // - Error if the request fails // // Example: // // channel, err := client.GetEscrowChannel(ctx, "0x1234...") -// fmt.Printf("Escrow Channel: %s (Version: %d)\n", channel.ChannelID, channel.StateVersion) +// if err != nil { return err } +// if channel == nil { /* not found */ } func (c *Client) GetEscrowChannel(ctx context.Context, escrowChannelID string) (*core.Channel, error) { req := rpc.ChannelsV1GetEscrowChannelRequest{ EscrowChannelID: escrowChannelID, @@ -768,7 +801,11 @@ func (c *Client) GetEscrowChannel(ctx context.Context, escrowChannelID string) ( return nil, fmt.Errorf("failed to get escrow channel: %w", err) } - channel, err := transformChannel(resp.Channel) + if resp.Channel == nil { + return nil, nil + } + + channel, err := transformChannel(*resp.Channel) if err != nil { return nil, fmt.Errorf("failed to transform channel: %w", err) } @@ -781,19 +818,23 @@ func (c *Client) GetEscrowChannel(ctx context.Context, escrowChannelID string) ( // GetLatestState retrieves the latest state for a user's asset. // +// Returns (nil, nil) when the node has no stored state for the wallet/asset +// pair — absence is a successful response, not an error. +// // Parameters: // - wallet: The user's wallet address // - asset: The asset symbol (e.g., "usdc") // - onlySigned: If true, returns only the latest signed state // // Returns: -// - core.State containing all state information +// - core.State containing all state information, or nil if absent // - Error if the request fails // // Example: // // state, err := client.GetLatestState(ctx, "0x1234...", "usdc", false) -// fmt.Printf("State Version: %d, Balance: %s\n", state.Version, state.HomeLedger.UserBalance) +// if err != nil { return err } +// if state == nil { /* no state yet */ } func (c *Client) GetLatestState(ctx context.Context, wallet, asset string, onlySigned bool) (*core.State, error) { req := rpc.ChannelsV1GetLatestStateRequest{ Wallet: wallet, @@ -804,7 +845,10 @@ func (c *Client) GetLatestState(ctx context.Context, wallet, asset string, onlyS if err != nil { return nil, fmt.Errorf("failed to get latest state: %w", err) } - state, err := transformState(resp.State) + if resp.State == nil { + return nil, nil + } + state, err := transformState(*resp.State) if err != nil { return nil, fmt.Errorf("failed to transform state: %w", err) } diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index 489c2c3e1..f4b2babe5 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -23,7 +23,7 @@ func TestClient_GetHomeChannel(t *testing.T) { mockDialer.Dial(context.Background(), "", nil) mockResp := rpc.ChannelsV1GetHomeChannelResponse{ - Channel: rpc.ChannelV1{ + Channel: &rpc.ChannelV1{ ChannelID: "0xChannelID", UserWallet: "0xWallet", Type: "home", @@ -51,7 +51,7 @@ func TestClient_GetEscrowChannel(t *testing.T) { mockDialer.Dial(context.Background(), "", nil) mockResp := rpc.ChannelsV1GetEscrowChannelResponse{ - Channel: rpc.ChannelV1{ + Channel: &rpc.ChannelV1{ ChannelID: "0xEscrowID", UserWallet: "0xWallet", Type: "escrow", @@ -79,7 +79,7 @@ func TestClient_GetLatestState(t *testing.T) { mockDialer.Dial(context.Background(), "", nil) mockResp := rpc.ChannelsV1GetLatestStateResponse{ - State: rpc.StateV1{ + State: &rpc.StateV1{ ID: "0xStateID", Epoch: "1", Version: "1", @@ -198,7 +198,7 @@ func TestClient_GetAppDefinition(t *testing.T) { mockDialer.Dial(context.Background(), "", nil) mockResp := rpc.AppSessionsV1GetAppDefinitionResponse{ - Definition: rpc.AppDefinitionV1{ + Definition: &rpc.AppDefinitionV1{ Application: "0xApp", Participants: []rpc.AppParticipantV1{}, Nonce: "1", @@ -274,7 +274,7 @@ func TestClient_SubmitAppSessionDeposit(t *testing.T) { homeChannelID := "0xHomeChannel" // Mock latest state stateResp := rpc.ChannelsV1GetLatestStateResponse{ - State: rpc.StateV1{ + State: &rpc.StateV1{ ID: "0xStateID", Epoch: "1", Version: "1", diff --git a/sdk/go/examples/challenge/main.go b/sdk/go/examples/challenge/main.go index ba2b036f7..a9e829bd5 100644 --- a/sdk/go/examples/challenge/main.go +++ b/sdk/go/examples/challenge/main.go @@ -90,6 +90,9 @@ func main() { if err != nil { log.Fatalf("Failed to get latest state: %v", err) } + if preTransferState == nil { + log.Fatalf("No signed state available to save") + } fmt.Printf("Saved state at version %d (balance: %s USDC)\n\n", preTransferState.Version, preTransferState.HomeLedger.UserBalance) diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index f4ba1cfc5..d57468800 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -527,14 +527,14 @@ export class NitroliteClient { for (const [, info] of this.assetsBySymbol) { try { const ch = await this.innerClient.getHomeChannel(this.userAddress, info.symbol); - if (ch.channelId === _channelId) { + if (ch && ch.channelId === _channelId) { return { channel: ch, state: await this.innerClient.getLatestState(this.userAddress, info.symbol, false), }; } } catch { - // no channel for this asset + // transient RPC failure for this asset — skip } } throw new Error(`Channel ${_channelId} not found`); @@ -619,7 +619,7 @@ export class NitroliteClient { if (ch.status === 1) { try { const state = await this.innerClient.getLatestState(this.userAddress, ch.asset, false); - const raw = state.homeLedger?.userBalance; + const raw = state?.homeLedger?.userBalance; if (raw) { const dec = await this.getTokenDecimalsForChannel( @@ -661,12 +661,12 @@ export class NitroliteClient { try { const ch = await this.innerClient.getHomeChannel(this.userAddress, asset.symbol); - if (ch.channelId) { + if (ch && ch.channelId) { let userBalance = 0n; try { const state = await this.innerClient.getLatestState(this.userAddress, asset.symbol, false); - const raw = state.homeLedger?.userBalance; + const raw = state?.homeLedger?.userBalance; if (raw) { const dec = await this.getTokenDecimalsForChannel( @@ -967,6 +967,9 @@ export class NitroliteClient { async getAppDefinition(appSessionId: string): Promise { const def = await this.innerClient.getAppDefinition(appSessionId); + if (!def) { + throw new Error(`app session ${appSessionId} not found`); + } return { protocol: def.applicationId, participants: def.participants.map((p) => p.walletAddress), @@ -1188,7 +1191,11 @@ export class NitroliteClient { } async getEscrowChannel(escrowChannelId: string): Promise { - return this.innerClient.getEscrowChannel(escrowChannelId); + const channel = await this.innerClient.getEscrowChannel(escrowChannelId); + if (!channel) { + throw new Error(`escrow channel ${escrowChannelId} not found`); + } + return channel; } // ----------------------------------------------------------------------- diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 7c42baa77..15f6f7b0a 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -377,21 +377,15 @@ export class Client { throw new Error(`token address not found for asset ${asset} on blockchain ${blockchainId}`); } - // Try to get latest state to determine if channel exists - let state: core.State | null = null; + // Try to get latest state to determine if channel exists. Absence returns + // null (not an error); real RPC failures still throw. + let state = await this.getLatestState(userWallet, asset, false); let channelIsOpen = false; - try { - state = await this.getLatestState(userWallet, asset, false); - - // If state has a home channel ID, check if it's usable - if (state && state.homeChannelId) { - // Check if state has a finalize transition (channel is being closed) - const hasFinalize = state.transition.type === core.TransitionType.Finalize; - // If no finalize transition, channel is still open and usable - channelIsOpen = !hasFinalize; - } - } catch (err) { - // Channel doesn't exist, will create it + if (state && state.homeChannelId) { + // Check if state has a finalize transition (channel is being closed) + const hasFinalize = state.transition.type === core.TransitionType.Finalize; + // If no finalize transition, channel is still open and usable + channelIsOpen = !hasFinalize; } // Scenario A: Channel doesn't exist or is closed - create it @@ -475,21 +469,15 @@ export class Client { throw new Error(`token address not found for asset ${asset} on blockchain ${blockchainId}`); } - // Try to get latest state to determine if channel exists - let state: core.State | null = null; + // Try to get latest state to determine if channel exists. Absence returns + // null (not an error); real RPC failures still throw. + let state = await this.getLatestState(userWallet, asset, false); let channelIsOpen = false; - try { - state = await this.getLatestState(userWallet, asset, false); - - // If state has a home channel ID, check if it's usable - if (state && state.homeChannelId) { - // Check if state has a finalize transition (channel is being closed) - const hasFinalize = state.transition.type === core.TransitionType.Finalize; - // If no finalize transition, channel is still open and usable - channelIsOpen = !hasFinalize; - } - } catch (err) { - // Channel doesn't exist, will create it + if (state && state.homeChannelId) { + // Check if state has a finalize transition (channel is being closed) + const hasFinalize = state.transition.type === core.TransitionType.Finalize; + // If no finalize transition, channel is still open and usable + channelIsOpen = !hasFinalize; } // Channel doesn't exist or is closed - create it and withdraw @@ -562,13 +550,9 @@ export class Client { async transfer(recipientWallet: string, asset: string, amount: Decimal): Promise { const senderWallet = this.getUserAddress(); - // Get sender's latest state - let state: core.State | null = null; - try { - state = await this.getLatestState(senderWallet, asset, false); - } catch (err) { - // Channel doesn't exist - } + // Get sender's latest state. Absence returns null (not an error); real RPC + // failures still throw. + let state = await this.getLatestState(senderWallet, asset, false); // No open channel path - create channel with transfer. A non-null state // without a homeChannelId represents received off-chain funds before the @@ -656,13 +640,9 @@ export class Client { async acknowledge(asset: string): Promise { const userWallet = this.getUserAddress(); - // Try to get latest state to determine if channel exists - let state: core.State | null = null; - try { - state = await this.getLatestState(userWallet, asset, false); - } catch (err) { - // No state exists - } + // Try to get latest state to determine if channel exists. Absence returns + // null (not an error); real RPC failures still throw. + let state = await this.getLatestState(userWallet, asset, false); if (state?.userSig) { throw new Error('state already acknowledged by user'); @@ -748,7 +728,7 @@ export class Client { const state = await this.getLatestState(senderWallet, asset, false); - if (!state.homeChannelId) { + if (!state || !state.homeChannelId) { throw new Error(`no channel exists for asset ${asset}`); } @@ -793,6 +773,10 @@ export class Client { // Get latest signed state (both user and node signatures must be present) const state = await this.getLatestState(userWallet, asset, true); + if (!state) { + throw new Error(`no signed state exists for asset ${asset}`); + } + if (!state.homeChannelId) { // NOTE: this should never happen, because signed state MUST have a channel ID throw new Error(`no channel exists for asset ${asset}`); @@ -806,6 +790,11 @@ export class Client { // Get home channel info to determine on-chain status const channel = await this.getHomeChannel(userWallet, asset); + if (!channel) { + // Signed state existed but home channel record is missing — node is in + // an inconsistent state. + throw new Error(`home channel missing for asset ${asset} despite signed state`); + } switch (state.transition.type) { case core.TransitionType.Acknowledgement: @@ -1257,66 +1246,90 @@ export class Client { /** * GetHomeChannel retrieves home channel information for a user's asset. * + * Returns `null` when no home channel exists for the wallet/asset pair — + * absence is a successful response, not an error. + * * @param wallet - The user's wallet address * @param asset - The asset symbol - * @returns Channel information for the home channel + * @returns Channel information for the home channel, or `null` if absent * * @example * ```typescript * const channel = await client.getHomeChannel('0x1234...', 'usdc'); - * console.log(`Channel: ${channel.channelId} (Version: ${channel.stateVersion})`); + * if (channel === null) { + * // no channel yet + * } * ``` */ - async getHomeChannel(wallet: Address, asset: string): Promise { + async getHomeChannel(wallet: Address, asset: string): Promise { const req: API.ChannelsV1GetHomeChannelRequest = { wallet, asset, }; const resp = await this.rpcClient.channelsV1GetHomeChannel(req); + if (resp.channel == null) { + return null; + } return transformChannel(resp.channel); } /** * GetEscrowChannel retrieves escrow channel information for a specific channel ID. * + * Returns `null` when no escrow channel exists for the given ID — absence is + * a successful response, not an error. + * * @param escrowChannelId - The escrow channel ID to query - * @returns Channel information for the escrow channel + * @returns Channel information for the escrow channel, or `null` if absent * * @example * ```typescript * const channel = await client.getEscrowChannel('0x1234...'); - * console.log(`Channel: ${channel.channelId} (Version: ${channel.stateVersion})`); + * if (channel === null) { + * // not found + * } * ``` */ - async getEscrowChannel(escrowChannelId: string): Promise { + async getEscrowChannel(escrowChannelId: string): Promise { const req: API.ChannelsV1GetEscrowChannelRequest = { escrow_channel_id: escrowChannelId, }; const resp = await this.rpcClient.channelsV1GetEscrowChannel(req); + if (resp.channel == null) { + return null; + } return transformChannel(resp.channel); } /** * GetLatestState retrieves the latest state for a user's asset. * + * Returns `null` when the node has no stored state for the wallet/asset + * pair — absence is a successful response, not an error. + * * @param wallet - The user's wallet address * @param asset - The asset symbol (e.g., "usdc") * @param onlySigned - If true, returns only the latest signed state - * @returns State containing all state information + * @returns State containing all state information, or `null` if absent * * @example * ```typescript * const state = await client.getLatestState('0x1234...', 'usdc', false); - * console.log(`Version: ${state.version}, Balance: ${state.homeLedger.userBalance}`); + * if (state === null) { + * // no state yet + * } * ``` */ - async getLatestState(wallet: Address, asset: string, onlySigned: boolean): Promise { + async getLatestState(wallet: Address, asset: string, onlySigned: boolean): Promise { const req: API.ChannelsV1GetLatestStateRequest = { wallet, asset, only_signed: onlySigned, }; const resp = await this.rpcClient.channelsV1GetLatestState(req); + if (resp.state == null) { + return null; + } return transformState(resp.state); } @@ -1366,20 +1379,28 @@ export class Client { /** * GetAppDefinition retrieves the definition for a specific app session. * + * Returns `null` when no app session exists for the given ID — absence is a + * successful response, not an error. + * * @param appSessionId - The app session ID - * @returns App session definition + * @returns App session definition, or `null` if absent * * @example * ```typescript * const definition = await client.getAppDefinition('0x1234...'); - * console.log('Participants:', definition.participants); + * if (definition === null) { + * // not found + * } * ``` */ - async getAppDefinition(appSessionId: string): Promise { + async getAppDefinition(appSessionId: string): Promise { const req: API.AppSessionsV1GetAppDefinitionRequest = { app_session_id: appSessionId, }; const resp = await this.rpcClient.appSessionsV1GetAppDefinition(req); + if (resp.definition == null) { + return null; + } return transformAppDefinitionFromRPC(resp.definition); } @@ -1469,6 +1490,9 @@ export class Client { ): Promise { // Get current state const currentState = await this.getLatestState(this.getUserAddress(), asset, false); + if (!currentState) { + throw new Error('no channel state to advance for AppSession'); + } // Create next state with commit transition (use app session ID as account ID) const newState = nextState(currentState); diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 38ffbbe99..d97900b52 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -41,8 +41,11 @@ export interface ChannelsV1GetHomeChannelRequest { } export interface ChannelsV1GetHomeChannelResponse { - /** On-chain channel information */ - channel: ChannelV1; + /** + * On-chain channel information, or null/absent when no home channel exists + * for the requested wallet/asset pair. Absence is a successful response. + */ + channel?: ChannelV1 | null; } export interface ChannelsV1GetEscrowChannelRequest { @@ -51,8 +54,11 @@ export interface ChannelsV1GetEscrowChannelRequest { } export interface ChannelsV1GetEscrowChannelResponse { - /** On-chain channel information */ - channel: ChannelV1; + /** + * On-chain channel information, or null/absent when no escrow channel exists + * for the requested ID. Absence is a successful response. + */ + channel?: ChannelV1 | null; } export interface ChannelsV1GetChannelsRequest { @@ -85,8 +91,11 @@ export interface ChannelsV1GetLatestStateRequest { } export interface ChannelsV1GetLatestStateResponse { - /** Current state of the user */ - state: StateV1; + /** + * Current state of the user, or null/absent when no state is stored for the + * requested wallet/asset pair. Absence is a successful response. + */ + state?: StateV1 | null; } export interface ChannelsV1RequestCreationRequest { @@ -184,8 +193,11 @@ export interface AppSessionsV1GetAppDefinitionRequest { } export interface AppSessionsV1GetAppDefinitionResponse { - /** Application definition */ - definition: AppDefinitionV1; + /** + * Application definition, or null/absent when no app session exists for the + * requested ID. Absence is a successful response. + */ + definition?: AppDefinitionV1 | null; } export interface AppSessionsV1GetAppSessionsRequest { diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index ea7c38057..3165658af 100644 --- a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap +++ b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -283,7 +283,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "AppSessionsV1GetAppDefinitionResponse", "properties": [ - "definition: AppDefinitionV1", + "definition: AppDefinitionV1 | null", ], "signatures": [], }, @@ -848,7 +848,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "ChannelsV1GetEscrowChannelResponse", "properties": [ - "channel: ChannelV1", + "channel: ChannelV1 | null", ], "signatures": [], }, @@ -870,7 +870,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "ChannelsV1GetHomeChannelResponse", "properties": [ - "channel: ChannelV1", + "channel: ChannelV1 | null", ], "signatures": [], }, @@ -915,7 +915,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "ChannelsV1GetLatestStateResponse", "properties": [ - "state: StateV1", + "state: StateV1 | null", ], "signatures": [], }, @@ -1042,7 +1042,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "deposit: (blockchainId: bigint, asset: string, amount: Decimal): Promise", "escrowSecurityTokens: (targetWalletAddress: string, blockchainId: bigint, amount: Decimal): Promise", "getActionAllowances: (wallet: Address): Promise", - "getAppDefinition: (appSessionId: string): Promise", + "getAppDefinition: (appSessionId: string): Promise", "getAppSessions: (options?: { appSessionId?: string; wallet?: Address; status?: string; page?: number; pageSize?: number; }): Promise<{ sessions: app.AppSessionInfoV1[]; metadata: core.PaginationMetadata; }>", "getApps: (options?: { appId?: string; ownerWallet?: string; page?: number; pageSize?: number; }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata; }>", "getAssets: (blockchainId?: bigint): Promise", @@ -1050,11 +1050,11 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "getBlockchains: (): Promise", "getChannels: (wallet: Address, options?: { status?: string; asset?: string; channelType?: string; pagination?: core.PaginationParams; }): Promise<{ channels: core.Channel[]; metadata: core.PaginationMetadata; }>", "getConfig: (): Promise", - "getEscrowChannel: (escrowChannelId: string): Promise", - "getHomeChannel: (wallet: Address, asset: string): Promise", + "getEscrowChannel: (escrowChannelId: string): Promise", + "getHomeChannel: (wallet: Address, asset: string): Promise", "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", - "getLatestState: (wallet: Address, asset: string, onlySigned: boolean): Promise", + "getLatestState: (wallet: Address, asset: string, onlySigned: boolean): Promise", "getLockedBalance: (chainId: bigint, wallet: string): Promise", "getOnChainBalance: (chainId: bigint, asset: string, wallet: Address): Promise", "getTransactions: (wallet: Address, options?: { asset?: string; txType?: core.TransactionType; fromTime?: bigint; toTime?: bigint; page?: number; pageSize?: number; }): Promise<{ transactions: core.Transaction[]; metadata: core.PaginationMetadata; }>", diff --git a/sdk/ts/test/unit/client.test.ts b/sdk/ts/test/unit/client.test.ts index 86d89fa29..dc39e1788 100644 --- a/sdk/ts/test/unit/client.test.ts +++ b/sdk/ts/test/unit/client.test.ts @@ -11,12 +11,13 @@ const HOME_CHANNEL_ID = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const USER_SIGNATURE = '0x00'; const NODE_SIGNATURE = '0x01'; -function createHighLevelClient(latestState?: core.State, latestStateError?: Error) { +function createHighLevelClient(latestState?: core.State | null, latestStateError?: Error) { const getLatestState = jest.fn(); if (latestStateError) { getLatestState.mockRejectedValue(latestStateError); } else { - getLatestState.mockResolvedValue(latestState); + // Default to null (absence) when no state provided. + getLatestState.mockResolvedValue(latestState ?? null); } const client = Object.create(Client.prototype) as any; @@ -138,8 +139,8 @@ describe('Client.getOnChainBalance', () => { }); describe('Client.acknowledge', () => { - it('creates a channel with acknowledgement when latest state lookup fails', async () => { - const client = createHighLevelClient(undefined, new Error('state not found')); + it('creates a channel with acknowledgement when no latest state exists', async () => { + const client = createHighLevelClient(null); const state = await client.acknowledge('usdc'); @@ -206,8 +207,8 @@ describe('Client.acknowledge', () => { }); describe('Client.transfer', () => { - it('creates a channel with transfer when latest state lookup fails', async () => { - const client = createHighLevelClient(undefined, new Error('state not found')); + it('creates a channel with transfer when no latest state exists', async () => { + const client = createHighLevelClient(null); const amount = new Decimal(1); const state = await client.transfer(RECIPIENT_WALLET, 'usdc', amount); From 50f812eae05378ee62957cd26c1017df8efc337a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 19 May 2026 12:28:27 +0300 Subject: [PATCH 2/3] YNU-915: address PR 748 review feedback - sdk/ts-compat: fix getChannelData to surface lookup errors instead of reporting "not found", and reject matched channels with null latest state (would have leaked state=null to v0.5.3 callers). - nitronode/get_latest_state_test: use require before payload deref in the NotFound test; add doc comment. - pkg/rpc/client_test, sdk/go/client_test, sdk/ts/test/unit/client.test: add absence-semantics coverage for getHomeChannel, getEscrowChannel, getLatestState, getAppDefinition, and the nil-state guards in checkpoint, closeHomeChannel, and submitAppSessionDeposit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/channel_v1/get_latest_state_test.go | 5 +- pkg/rpc/client_test.go | 63 ++++++++++++++ sdk/go/client_test.go | 68 +++++++++++++++ sdk/ts-compat/src/client.ts | 30 +++++-- sdk/ts/test/unit/client.test.ts | 84 +++++++++++++++++++ 5 files changed, 239 insertions(+), 11 deletions(-) diff --git a/nitronode/api/channel_v1/get_latest_state_test.go b/nitronode/api/channel_v1/get_latest_state_test.go index 9f4a06323..5bebb38fd 100644 --- a/nitronode/api/channel_v1/get_latest_state_test.go +++ b/nitronode/api/channel_v1/get_latest_state_test.go @@ -239,6 +239,7 @@ func TestGetLatestState_NormalizesWallet(t *testing.T) { mockTxStore.AssertExpectations(t) } +// TestGetLatestState_NotFound verifies absent state returns a successful response with nil state payload. func TestGetLatestState_NotFound(t *testing.T) { mockTxStore := new(MockStore) @@ -263,8 +264,8 @@ func TestGetLatestState_NotFound(t *testing.T) { handler.GetLatestState(ctx) // Absence is a successful response with state == nil. - assert.NotNil(t, ctx.Response.Payload) - assert.Nil(t, ctx.Response.Error()) + require.NotNil(t, ctx.Response.Payload) + require.NoError(t, ctx.Response.Error()) var response rpc.ChannelsV1GetLatestStateResponse err = ctx.Response.Payload.Translate(&response) diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index 82c227417..d36242663 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -161,6 +161,22 @@ func TestClientV1_ChannelsV1GetHomeChannel(t *testing.T) { assert.Equal(t, "home", resp.Channel.Type) } +// TestClientV1_ChannelsV1GetHomeChannel_NilChannel verifies absent-channel responses decode to a nil pointer without error. +func TestClientV1_ChannelsV1GetHomeChannel_NilChannel(t *testing.T) { + t.Parallel() + + client, dialer := setupClient() + + registerSimpleHandlerV1(dialer, "channels.v1.get_home_channel", rpc.ChannelsV1GetHomeChannelResponse{}) + + resp, err := client.ChannelsV1GetHomeChannel(testCtxV1, rpc.ChannelsV1GetHomeChannelRequest{ + Wallet: testWalletV1, + Asset: testAssetV1, + }) + require.NoError(t, err) + assert.Nil(t, resp.Channel) +} + func TestClientV1_ChannelsV1GetEscrowChannel(t *testing.T) { t.Parallel() @@ -184,6 +200,21 @@ func TestClientV1_ChannelsV1GetEscrowChannel(t *testing.T) { assert.Equal(t, "escrow", resp.Channel.Type) } +// TestClientV1_ChannelsV1GetEscrowChannel_NilChannel verifies absent-channel responses decode to a nil pointer without error. +func TestClientV1_ChannelsV1GetEscrowChannel_NilChannel(t *testing.T) { + t.Parallel() + + client, dialer := setupClient() + + registerSimpleHandlerV1(dialer, "channels.v1.get_escrow_channel", rpc.ChannelsV1GetEscrowChannelResponse{}) + + resp, err := client.ChannelsV1GetEscrowChannel(testCtxV1, rpc.ChannelsV1GetEscrowChannelRequest{ + EscrowChannelID: testChannelID, + }) + require.NoError(t, err) + assert.Nil(t, resp.Channel) +} + func TestClientV1_ChannelsV1GetChannels(t *testing.T) { t.Parallel() @@ -245,6 +276,23 @@ func TestClientV1_ChannelsV1GetLatestState(t *testing.T) { assert.Equal(t, testAssetV1, resp.State.Asset) } +// TestClientV1_ChannelsV1GetLatestState_NilState verifies absent-state responses decode to a nil pointer without error. +func TestClientV1_ChannelsV1GetLatestState_NilState(t *testing.T) { + t.Parallel() + + client, dialer := setupClient() + + registerSimpleHandlerV1(dialer, "channels.v1.get_latest_state", rpc.ChannelsV1GetLatestStateResponse{}) + + resp, err := client.ChannelsV1GetLatestState(testCtxV1, rpc.ChannelsV1GetLatestStateRequest{ + Wallet: testWalletV1, + Asset: testAssetV1, + OnlySigned: false, + }) + require.NoError(t, err) + assert.Nil(t, resp.State) +} + func TestClientV1_ChannelsV1RequestCreation(t *testing.T) { t.Parallel() @@ -323,6 +371,21 @@ func TestClientV1_AppSessionsV1GetAppDefinition(t *testing.T) { assert.Len(t, resp.Definition.Participants, 2) } +// TestClientV1_AppSessionsV1GetAppDefinition_NilDefinition verifies absent-definition responses decode to a nil pointer without error. +func TestClientV1_AppSessionsV1GetAppDefinition_NilDefinition(t *testing.T) { + t.Parallel() + + client, dialer := setupClient() + + registerSimpleHandlerV1(dialer, "app_sessions.v1.get_app_definition", rpc.AppSessionsV1GetAppDefinitionResponse{}) + + resp, err := client.AppSessionsV1GetAppDefinition(testCtxV1, rpc.AppSessionsV1GetAppDefinitionRequest{ + AppSessionID: testAppSession, + }) + require.NoError(t, err) + assert.Nil(t, resp.Definition) +} + func TestClientV1_AppSessionsV1GetAppSessions(t *testing.T) { t.Parallel() diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index f4b2babe5..6929de7dc 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -45,6 +45,23 @@ func TestClient_GetHomeChannel(t *testing.T) { assert.Equal(t, core.ChannelTypeHome, ch.Type) } +// TestClient_GetHomeChannel_NilResponse verifies absent-channel responses surface as (nil, nil). +func TestClient_GetHomeChannel_NilResponse(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.ChannelsV1GetHomeChannelMethod.String(), rpc.ChannelsV1GetHomeChannelResponse{}) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + ch, err := client.GetHomeChannel(context.Background(), "0xWallet", "USDC") + require.NoError(t, err) + assert.Nil(t, ch) +} + func TestClient_GetEscrowChannel(t *testing.T) { t.Parallel() mockDialer := NewMockDialer() @@ -73,6 +90,23 @@ func TestClient_GetEscrowChannel(t *testing.T) { assert.Equal(t, core.ChannelTypeEscrow, ch.Type) } +// TestClient_GetEscrowChannel_NilResponse verifies absent-channel responses surface as (nil, nil). +func TestClient_GetEscrowChannel_NilResponse(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.ChannelsV1GetEscrowChannelMethod.String(), rpc.ChannelsV1GetEscrowChannelResponse{}) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + ch, err := client.GetEscrowChannel(context.Background(), "0xEscrowID") + require.NoError(t, err) + assert.Nil(t, ch) +} + func TestClient_GetLatestState(t *testing.T) { t.Parallel() mockDialer := NewMockDialer() @@ -110,6 +144,23 @@ func TestClient_GetLatestState(t *testing.T) { assert.Equal(t, uint64(1), state.Version) } +// TestClient_GetLatestState_NilResponse verifies absent-state responses surface as (nil, nil). +func TestClient_GetLatestState_NilResponse(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.ChannelsV1GetLatestStateMethod.String(), rpc.ChannelsV1GetLatestStateResponse{}) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + state, err := client.GetLatestState(context.Background(), "0xWallet", "USDC", false) + require.NoError(t, err) + assert.Nil(t, state) +} + func TestClient_GetBalances(t *testing.T) { t.Parallel() mockDialer := NewMockDialer() @@ -217,6 +268,23 @@ func TestClient_GetAppDefinition(t *testing.T) { assert.Equal(t, uint64(1), def.Nonce) } +// TestClient_GetAppDefinition_NilResponse verifies absent-definition responses surface as (nil, nil). +func TestClient_GetAppDefinition_NilResponse(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.AppSessionsV1GetAppDefinitionMethod.String(), rpc.AppSessionsV1GetAppDefinitionResponse{}) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + def, err := client.GetAppDefinition(context.Background(), "0xSessionID") + require.NoError(t, err) + assert.Nil(t, def) +} + func TestClient_CreateAppSession(t *testing.T) { t.Parallel() mockDialer := NewMockDialer() diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index 85de8bece..e484bd973 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -525,18 +525,30 @@ export class NitroliteClient { async getChannelData(_channelId: string): Promise { await this.ensureAssets(); + let lookupError: unknown = null; + let sawSuccessfulLookup = false; for (const [, info] of this.assetsBySymbol) { + let ch; try { - const ch = await this.innerClient.getHomeChannel(this.userAddress, info.symbol); - if (ch && ch.channelId === _channelId) { - return { - channel: ch, - state: await this.innerClient.getLatestState(this.userAddress, info.symbol, false), - }; - } - } catch { - // transient RPC failure for this asset — skip + ch = await this.innerClient.getHomeChannel(this.userAddress, info.symbol); + sawSuccessfulLookup = true; + } catch (err) { + lookupError = err; + continue; } + if (!ch || ch.channelId !== _channelId) { + continue; + } + const state = await this.innerClient.getLatestState(this.userAddress, info.symbol, false); + if (!state) { + throw new Error(`Channel ${_channelId} has no latest state`); + } + return { channel: ch, state }; + } + if (!sawSuccessfulLookup && lookupError) { + throw lookupError instanceof Error + ? lookupError + : new Error(`failed to query channels: ${String(lookupError)}`); } throw new Error(`Channel ${_channelId} not found`); } diff --git a/sdk/ts/test/unit/client.test.ts b/sdk/ts/test/unit/client.test.ts index dc39e1788..a4751898c 100644 --- a/sdk/ts/test/unit/client.test.ts +++ b/sdk/ts/test/unit/client.test.ts @@ -206,6 +206,90 @@ describe('Client.acknowledge', () => { }); }); +describe('Client absence semantics for GET methods', () => { + it('getHomeChannel returns null when the RPC response has no channel', async () => { + const client = Object.create(Client.prototype) as any; + client.rpcClient = { + channelsV1GetHomeChannel: jest.fn().mockResolvedValue({}), + }; + + const result = await client.getHomeChannel(USER_WALLET, 'usdc'); + + expect(result).toBeNull(); + expect(client.rpcClient.channelsV1GetHomeChannel).toHaveBeenCalledWith({ + wallet: USER_WALLET, + asset: 'usdc', + }); + }); + + it('getEscrowChannel returns null when the RPC response has no channel', async () => { + const client = Object.create(Client.prototype) as any; + client.rpcClient = { + channelsV1GetEscrowChannel: jest.fn().mockResolvedValue({}), + }; + + const result = await client.getEscrowChannel('0xEscrow'); + + expect(result).toBeNull(); + expect(client.rpcClient.channelsV1GetEscrowChannel).toHaveBeenCalledWith({ + escrow_channel_id: '0xEscrow', + }); + }); + + it('getAppDefinition returns null when the RPC response has no definition', async () => { + const client = Object.create(Client.prototype) as any; + client.rpcClient = { + appSessionsV1GetAppDefinition: jest.fn().mockResolvedValue({}), + }; + + const result = await client.getAppDefinition('0xSession'); + + expect(result).toBeNull(); + expect(client.rpcClient.appSessionsV1GetAppDefinition).toHaveBeenCalledWith({ + app_session_id: '0xSession', + }); + }); +}); + +describe('Client nil-state guards', () => { + it('closeHomeChannel throws when no latest state exists', async () => { + const client = Object.create(Client.prototype) as any; + client.getUserAddress = jest.fn(() => USER_WALLET); + client.getLatestState = jest.fn().mockResolvedValue(null); + + await expect(client.closeHomeChannel('usdc')).rejects.toThrow( + 'no channel exists for asset usdc' + ); + }); + + it('checkpoint throws when no signed state exists', async () => { + const client = Object.create(Client.prototype) as any; + client.getUserAddress = jest.fn(() => USER_WALLET); + client.getLatestState = jest.fn().mockResolvedValue(null); + + await expect(client.checkpoint('usdc')).rejects.toThrow( + 'no signed state exists for asset usdc' + ); + }); + + it('submitAppSessionDeposit throws when no current state exists', async () => { + const client = Object.create(Client.prototype) as any; + client.getUserAddress = jest.fn(() => USER_WALLET); + client.getLatestState = jest.fn().mockResolvedValue(null); + + const appStateUpdate = { + appSessionId: '0xSession', + intent: 'deposit', + version: 2n, + allocations: [], + } as any; + + await expect( + client.submitAppSessionDeposit(appStateUpdate, ['sig1'], 'usdc', new Decimal(10)) + ).rejects.toThrow('no channel state to advance for AppSession'); + }); +}); + describe('Client.transfer', () => { it('creates a channel with transfer when no latest state exists', async () => { const client = createHighLevelClient(null); From ea3dcb53af2dbb98910a29079cb0fb17353816b7 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 19 May 2026 12:58:01 +0300 Subject: [PATCH 3/3] YNU-915: rethrow uninspected-asset errors in getChannelData Per @ihsraham: a caught exception means that asset was not inspected, so a fall-through "not found" misreports state when the requested channel may have been behind a failed lookup. Drop the sawSuccessfulLookup gate and rethrow whenever lookupError is set. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/ts-compat/src/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index e484bd973..840276d8a 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -526,12 +526,10 @@ export class NitroliteClient { async getChannelData(_channelId: string): Promise { await this.ensureAssets(); let lookupError: unknown = null; - let sawSuccessfulLookup = false; for (const [, info] of this.assetsBySymbol) { let ch; try { ch = await this.innerClient.getHomeChannel(this.userAddress, info.symbol); - sawSuccessfulLookup = true; } catch (err) { lookupError = err; continue; @@ -545,7 +543,9 @@ export class NitroliteClient { } return { channel: ch, state }; } - if (!sawSuccessfulLookup && lookupError) { + // Any uninspected asset (caught exception) invalidates a not-found conclusion, + // since genuine absence comes back as null under the nullable contract. + if (lookupError) { throw lookupError instanceof Error ? lookupError : new Error(`failed to query channels: ${String(lookupError)}`);