@novasamatech/statement-store 0.7.0-0 → 0.7.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
1
1
  import type { RequestFn, SubscribeFn } from '@novasamatech/sdk-statement';
2
- import type { JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
3
- import type { PolkadotClient } from 'polkadot-api';
2
+ import type { JsonRpcProvider, PolkadotClient } from 'polkadot-api';
4
3
  export type LazyClient = ReturnType<typeof createLazyClient>;
5
4
  export declare const createLazyClient: (provider: JsonRpcProvider) => {
6
5
  getClient(): PolkadotClient;
@@ -1,5 +1,7 @@
1
1
  import { createClient as createSubstrateClient } from '@polkadot-api/substrate-client';
2
2
  import { createClient as createPolkadotClient } from 'polkadot-api';
3
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
4
+ const noop = () => { };
3
5
  export const createLazyClient = (provider) => {
4
6
  let polkadotClient = null;
5
7
  let substrateClient = null;
@@ -28,12 +30,31 @@ export const createLazyClient = (provider) => {
28
30
  getSubscribeFn() {
29
31
  const c = getSubstrateClient();
30
32
  return (method, params, onMessage, onError) => {
31
- return c._request(method, params, {
32
- onSuccess: (subscriptionId, followSubscription) => {
33
- followSubscription(subscriptionId, { next: onMessage, error: onError });
33
+ let subscriptionId = null;
34
+ let unsubscribeLocal = null;
35
+ const cancelRequest = c._request(method, params, {
36
+ onSuccess: (subId, followSubscription) => {
37
+ subscriptionId = subId;
38
+ unsubscribeLocal = followSubscription(subId, { next: onMessage, error: onError });
34
39
  },
35
40
  onError,
36
41
  });
42
+ // Derive the unsubscribe RPC method from the subscribe method name
43
+ // e.g. statement_subscribeStatement -> statement_unsubscribeStatement
44
+ const unsubscribeMethod = method.replace('subscribe', 'unsubscribe');
45
+ return () => {
46
+ if (unsubscribeLocal) {
47
+ unsubscribeLocal();
48
+ // Send the server-side unsubscribe RPC call
49
+ c._request(unsubscribeMethod, [subscriptionId], {
50
+ onSuccess: noop,
51
+ onError: noop,
52
+ });
53
+ }
54
+ else {
55
+ cancelRequest();
56
+ }
57
+ };
37
58
  };
38
59
  },
39
60
  disconnect() {
@@ -1,15 +1,19 @@
1
+ import { toHex } from '@novasamatech/scale';
1
2
  import { createStatementSdk } from '@novasamatech/sdk-statement';
2
- import { toHex } from '@polkadot-api/utils';
3
3
  import { errAsync, fromPromise, okAsync } from 'neverthrow';
4
4
  import { toError } from '../helpers.js';
5
5
  import { AccountFullError, AlreadyExpiredError, BadProofError, DataTooLargeError, EncodingTooLargeError, ExpiryTooLowError, InternalStoreError, KnownExpiredError, NoAllowanceError, NoProofError, StorageFullError, } from './types.js';
6
- function createKey(topics) {
7
- return topics.map(toHex).sort().join('');
6
+ function toSdkTopicFilter(filter) {
7
+ if ('matchAll' in filter) {
8
+ return { matchAll: filter.matchAll.map(toHex) };
9
+ }
10
+ return { matchAny: filter.matchAny.map(toHex) };
8
11
  }
9
- function toTopicFilter(topics) {
10
- if (topics.length === 0)
11
- return 'any';
12
- return { matchAll: topics.map(t => toHex(t)) };
12
+ function createKey(filter) {
13
+ if ('matchAll' in filter) {
14
+ return `matchAll:${filter.matchAll.map(toHex).sort().join(',')}`;
15
+ }
16
+ return `matchAny:${filter.matchAny.map(toHex).sort().join(',')}`;
13
17
  }
14
18
  export function createPapiStatementStoreAdapter(lazyClient) {
15
19
  const sdk = createStatementSdk(lazyClient.getRequestFn(), lazyClient.getSubscribeFn());
@@ -25,28 +29,24 @@ export function createPapiStatementStoreAdapter(lazyClient) {
25
29
  return list;
26
30
  }
27
31
  function removeCallback(key, callback) {
28
- let list = callbacks.get(key);
32
+ const list = callbacks.get(key);
29
33
  if (!list)
30
34
  return [];
31
- list = list.filter(x => x !== callback);
32
- if (list.length === 0) {
35
+ const idx = list.indexOf(callback);
36
+ if (idx !== -1)
37
+ list.splice(idx, 1);
38
+ if (list.length === 0)
33
39
  callbacks.delete(key);
34
- }
35
- else {
36
- callbacks.set(key, list);
37
- }
38
40
  return list;
39
41
  }
40
42
  const adapter = {
41
- queryStatements(topics) {
42
- const filter = toTopicFilter(topics);
43
- return fromPromise(sdk.getStatements(filter), toError);
43
+ queryStatements(filter) {
44
+ return fromPromise(sdk.getStatements(toSdkTopicFilter(filter)), toError);
44
45
  },
45
- subscribeStatements(topics, callback) {
46
- const key = createKey(topics);
46
+ subscribeStatements(filter, callback) {
47
+ const key = createKey(filter);
47
48
  const list = addCallback(key, callback);
48
49
  if (list.length === 1) {
49
- const filter = toTopicFilter(topics);
50
50
  let batch = [];
51
51
  let flushScheduled = false;
52
52
  const flush = () => {
@@ -58,11 +58,11 @@ export function createPapiStatementStoreAdapter(lazyClient) {
58
58
  const currentCallbacks = callbacks.get(key);
59
59
  if (currentCallbacks) {
60
60
  for (const fn of currentCallbacks) {
61
- fn(statements);
61
+ fn({ statements, isComplete: true });
62
62
  }
63
63
  }
64
64
  };
65
- const unsub = sdk.subscribeStatements(filter, statement => {
65
+ const unsub = sdk.subscribeStatements(toSdkTopicFilter(filter), statement => {
66
66
  batch.push(statement);
67
67
  if (!flushScheduled) {
68
68
  flushScheduled = true;
@@ -1,8 +1,17 @@
1
1
  import type { SignedStatement, Statement } from '@novasamatech/sdk-statement';
2
2
  import type { ResultAsync } from 'neverthrow';
3
+ export type TopicFilter = {
4
+ matchAll: Uint8Array[];
5
+ } | {
6
+ matchAny: Uint8Array[];
7
+ };
8
+ export type StatementsPage = {
9
+ statements: Statement[];
10
+ isComplete: boolean;
11
+ };
3
12
  export type StatementStoreAdapter = {
4
- queryStatements(topics: Uint8Array[], destination?: Uint8Array): ResultAsync<Statement[], Error>;
5
- subscribeStatements(topics: Uint8Array[], callback: (statements: Statement[]) => unknown): VoidFunction;
13
+ queryStatements(filter: TopicFilter, destination?: Uint8Array): ResultAsync<Statement[], Error>;
14
+ subscribeStatements(filter: TopicFilter, callback: (page: StatementsPage) => unknown): VoidFunction;
6
15
  submitStatement(statement: SignedStatement): ResultAsync<void, DataTooLargeError | ExpiryTooLowError | AccountFullError | StorageFullError | NoProofError | BadProofError | EncodingTooLargeError | NoAllowanceError | AlreadyExpiredError | KnownExpiredError | InternalStoreError | Error>;
7
16
  };
8
17
  export declare class DataTooLargeError extends Error {
package/dist/crypto.d.ts CHANGED
@@ -4,7 +4,7 @@ export declare function stringToBytes(str: string): Uint8Array<ArrayBuffer>;
4
4
  /**
5
5
  * blake2b_256 with key
6
6
  */
7
- export declare function khash(secret: Uint8Array, message: Uint8Array): Uint8Array<ArrayBufferLike>;
7
+ export declare function khash(secret: Uint8Array, message: Uint8Array): Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
8
8
  export declare function createSr25519Secret(entropy: Uint8Array, derivation?: string): Uint8Array<ArrayBufferLike>;
9
9
  export declare function createSr25519Derivation(secret: Uint8Array, derivation: string): Uint8Array<ArrayBufferLike>;
10
10
  export declare function deriveSr25519PublicKey(secret: Uint8Array): Uint8Array;
@@ -1,4 +1,4 @@
1
- import { mergeUint8 } from '@polkadot-api/utils';
1
+ import { mergeUint8 } from 'polkadot-api/utils';
2
2
  import { Bytes } from 'scale-ts';
3
3
  import { khash, stringToBytes } from '../crypto.js';
4
4
  export const SessionIdCodec = Bytes(32);
@@ -2,8 +2,8 @@ import { gcm } from '@noble/ciphers/aes.js';
2
2
  import { hkdf } from '@noble/hashes/hkdf.js';
3
3
  import { sha256 } from '@noble/hashes/sha2.js';
4
4
  import { randomBytes } from '@noble/hashes/utils.js';
5
- import { mergeUint8 } from '@polkadot-api/utils';
6
5
  import { Result, fromThrowable } from 'neverthrow';
6
+ import { mergeUint8 } from 'polkadot-api/utils';
7
7
  export function createEncryption(sharedSecret) {
8
8
  const salt = new Uint8Array(); // secure enough since P256 random keys provide enough entropy
9
9
  const info = new Uint8Array(); // no need to introduce any context
@@ -1,20 +1,20 @@
1
+ function decode(payload, codec) {
2
+ try {
3
+ return { status: 'parsed', value: codec.dec(payload) };
4
+ }
5
+ catch {
6
+ return { status: 'failed', value: payload };
7
+ }
8
+ }
1
9
  export function toMessage(statementData, codec) {
2
10
  switch (statementData.tag) {
3
11
  case 'request': {
4
- const decode = (payload) => {
5
- try {
6
- return { status: 'parsed', value: codec.dec(payload) };
7
- }
8
- catch {
9
- return { status: 'failed', value: payload };
10
- }
11
- };
12
12
  return statementData.value.data.map((payload, index) => {
13
13
  return {
14
14
  type: 'request',
15
15
  localId: `${statementData.value.requestId}-${index.toString()}`,
16
16
  requestId: statementData.value.requestId,
17
- payload: decode(payload),
17
+ payload: decode(payload, codec),
18
18
  };
19
19
  });
20
20
  }
@@ -1,7 +1,7 @@
1
1
  import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
- import { toHex } from '@polkadot-api/utils';
3
2
  import { nanoid } from 'nanoid';
4
3
  import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
4
+ import { toHex } from 'polkadot-api/utils';
5
5
  import { khash, stringToBytes } from '../crypto.js';
6
6
  import { nonNullable, toError } from '../helpers.js';
7
7
  import { createSessionId } from '../model/session.js';
@@ -30,6 +30,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
30
30
  let subscribers = [];
31
31
  const bufferedMessages = [];
32
32
  let storeUnsub = null;
33
+ let responseStoreUnsub = null;
33
34
  function submitStatementData(channel, topicSessionId, data) {
34
35
  state.expiry = nextExpiry(state.expiry);
35
36
  const expiry = state.expiry;
@@ -53,12 +54,14 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
53
54
  });
54
55
  }
55
56
  function deliverStatementData(statementData) {
56
- if (subscribers.length === 0) {
57
- if (state.phase === 'initialization') {
58
- bufferedMessages.push(statementData);
59
- }
60
- return;
57
+ // Buffer 'request' statements unconditionally so that waitForRequestMessage
58
+ // registered after delivery (race condition) still receives them via subscribe() replay.
59
+ // Buffer everything else during initialization when there are no subscribers yet.
60
+ if (statementData.tag === 'request' || (subscribers.length === 0 && state.phase === 'initialization')) {
61
+ bufferedMessages.push(statementData);
61
62
  }
63
+ if (subscribers.length === 0)
64
+ return;
62
65
  for (const sub of subscribers) {
63
66
  const messages = toMessage(statementData, sub.codec);
64
67
  if (messages.length > 0)
@@ -76,7 +79,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
76
79
  .map(decrypted => StatementData.dec(decrypted))
77
80
  .orElse(() => ok(null));
78
81
  }
79
- function processIncomingStatement(statement) {
82
+ function processIncomingStatement(statement, responsesOnly = false) {
80
83
  if (!statement.data)
81
84
  return;
82
85
  const key = toHex(statement.data);
@@ -87,6 +90,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
87
90
  if (!statementData)
88
91
  return;
89
92
  if (statementData.tag === 'request') {
93
+ if (responsesOnly)
94
+ return;
90
95
  if (statementData.value.requestId === state.incomingRequest?.requestId)
91
96
  return;
92
97
  state.incomingRequest = { requestId: statementData.value.requestId };
@@ -147,16 +152,24 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
147
152
  function ensureStoreSubscription() {
148
153
  if (storeUnsub)
149
154
  return;
150
- storeUnsub = statementStore.subscribeStatements([incomingSessionId], statements => {
151
- for (const statement of statements) {
155
+ storeUnsub = statementStore.subscribeStatements({ matchAll: [incomingSessionId] }, page => {
156
+ for (const statement of page.statements) {
152
157
  processIncomingStatement(statement);
153
158
  }
154
159
  });
160
+ // Subscribe to outgoing topic to receive peer ACK responses.
161
+ // Only process response-type statements — request-type statements on this topic
162
+ // are our own submissions echoed back and must be ignored.
163
+ responseStoreUnsub = statementStore.subscribeStatements({ matchAll: [outgoingSessionId] }, ({ statements }) => {
164
+ for (const statement of statements) {
165
+ processIncomingStatement(statement, true);
166
+ }
167
+ });
155
168
  }
156
169
  async function init() {
157
170
  const [ownResult, peerResult] = await Promise.all([
158
- statementStore.queryStatements([outgoingSessionId]),
159
- statementStore.queryStatements([incomingSessionId]),
171
+ statementStore.queryStatements({ matchAll: [outgoingSessionId] }),
172
+ statementStore.queryStatements({ matchAll: [incomingSessionId] }),
160
173
  ]);
161
174
  if (ownResult.isErr() || peerResult.isErr())
162
175
  return;
@@ -179,7 +192,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
179
192
  const peerRequest = peerDecoded.find(d => d.tag === 'request');
180
193
  const peerResponse = peerDecoded.find(d => d.tag === 'response');
181
194
  if (ownRequest?.tag === 'request') {
182
- const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
195
+ const hasResponse = ownResponse?.tag === 'response' && ownResponse.value.requestId === ownRequest.value.requestId;
183
196
  if (!hasResponse) {
184
197
  state.outgoingRequest = {
185
198
  requestId: ownRequest.value.requestId,
@@ -191,7 +204,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
191
204
  if (peerRequest?.tag === 'request') {
192
205
  state.incomingRequest = { requestId: peerRequest.value.requestId };
193
206
  state.respondedIncomingRequest =
194
- ownResponse?.tag === 'response' && ownResponse.value.requestId === peerRequest.value.requestId;
207
+ peerResponse?.tag === 'response' && peerResponse.value.requestId === peerRequest.value.requestId;
195
208
  }
196
209
  // Notify app of any unresponded incoming request.
197
210
  // Delivered while phase is still 'initialization' so that deliverStatementData
@@ -283,15 +296,23 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
283
296
  }
284
297
  return () => {
285
298
  subscribers = subscribers.filter(s => s !== sub);
286
- if (subscribers.length === 0 && storeUnsub) {
287
- storeUnsub();
288
- storeUnsub = null;
299
+ if (subscribers.length === 0) {
300
+ if (storeUnsub) {
301
+ storeUnsub();
302
+ storeUnsub = null;
303
+ }
304
+ if (responseStoreUnsub) {
305
+ responseStoreUnsub();
306
+ responseStoreUnsub = null;
307
+ }
289
308
  }
290
309
  };
291
310
  },
292
311
  dispose() {
293
312
  storeUnsub?.();
294
313
  storeUnsub = null;
314
+ responseStoreUnsub?.();
315
+ responseStoreUnsub = null;
295
316
  subscribers = [];
296
317
  for (const [, deferred] of state.pendingDelivery) {
297
318
  deferred.reject(new Error('Session disposed'));
@@ -302,7 +323,6 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
302
323
  void init();
303
324
  return session;
304
325
  }
305
- // ── module-level helpers ──────────────────────────────────────────────────────
306
326
  function mapResponseCode(responseCode) {
307
327
  switch (responseCode) {
308
328
  case 'success':
@@ -5,33 +5,18 @@ import { describe, expect, it, vi } from 'vitest';
5
5
  import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
6
6
  import { StatementData } from './scale/statementData.js';
7
7
  import { createSession, nextExpiry } from './session.js';
8
- // ── test helpers ──────────────────────────────────────────────────────────────
8
+ import { createSr25519Prover } from './statementProver.js';
9
9
  function makeAccounts() {
10
10
  const localAccount = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(1)));
11
11
  const remoteAccount = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(2)), new Uint8Array(32).fill(3));
12
12
  return { localAccount, remoteAccount };
13
13
  }
14
- function makeEncryption() {
14
+ function mockEncryption() {
15
15
  return {
16
16
  encrypt: (data) => ok(data),
17
17
  decrypt: (data) => ok(data),
18
18
  };
19
19
  }
20
- function makeProver() {
21
- return {
22
- generateMessageProof: s => okAsync({
23
- ...s,
24
- proof: {
25
- type: 'sr25519',
26
- value: {
27
- signature: `0x${'00'.repeat(64)}`,
28
- signer: `0x${'00'.repeat(32)}`,
29
- },
30
- },
31
- }),
32
- verifyMessageProof: () => okAsync(true),
33
- };
34
- }
35
20
  function makeAdapter() {
36
21
  const unsub = vi.fn();
37
22
  return {
@@ -56,14 +41,14 @@ function makeSession(overrides) {
56
41
  localAccount,
57
42
  remoteAccount,
58
43
  statementStore: adapter,
59
- encryption: makeEncryption(),
60
- prover: makeProver(),
44
+ encryption: mockEncryption(),
45
+ prover: createSr25519Prover(new Uint8Array(64).fill(1)),
61
46
  maxRequestSize,
62
47
  });
63
48
  return { session, adapter };
64
49
  }
65
- async function flushPromises() {
66
- await new Promise(resolve => setTimeout(resolve, 0));
50
+ async function delay(ttl = 0) {
51
+ await new Promise(resolve => setTimeout(resolve, ttl));
67
52
  }
68
53
  describe('session', () => {
69
54
  describe('nextExpiry', () => {
@@ -94,7 +79,7 @@ describe('session', () => {
94
79
  describe('createSession initialization', () => {
95
80
  it('queries own and peer statements on creation', async () => {
96
81
  const { adapter } = makeSession();
97
- await flushPromises();
82
+ await delay();
98
83
  expect(adapter.queryStatements).toHaveBeenCalledTimes(2);
99
84
  });
100
85
  it('expiry is initialized from max own statement expiry', async () => {
@@ -110,11 +95,11 @@ describe('session', () => {
110
95
  return okAsync([]);
111
96
  });
112
97
  const { session } = makeSession(adapter);
113
- await flushPromises();
98
+ await delay();
114
99
  // Submit a message to trigger a statement — its expiry must be greater than highExpiry
115
100
  const rawCodec = Bytes();
116
101
  void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
117
- await flushPromises();
102
+ await delay();
118
103
  const submittedStatement = adapter.submitStatement.mock.calls[0]?.[0];
119
104
  if (submittedStatement) {
120
105
  expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
@@ -131,7 +116,7 @@ describe('session', () => {
131
116
  return okAsync([]);
132
117
  });
133
118
  const { session } = makeSession(adapter);
134
- await flushPromises();
119
+ await delay();
135
120
  // Register subscriber AFTER init — buffered incoming request should be delivered
136
121
  const callback = vi.fn();
137
122
  session.subscribe(Bytes(), callback);
@@ -140,11 +125,10 @@ describe('session', () => {
140
125
  it('transitions to active phase after queries complete', async () => {
141
126
  const { session, adapter } = makeSession();
142
127
  // Before init completes, submitRequestMessage queues the message
143
- const rawCodec = str;
144
- void session.submitRequestMessage(rawCodec, 'hello');
128
+ void session.submitRequestMessage(str, 'hello');
145
129
  // Statement should NOT be submitted yet (still initializing)
146
130
  expect(adapter.submitStatement).not.toHaveBeenCalled();
147
- await flushPromises();
131
+ await delay();
148
132
  // After init, queued messages are processed → submitStatement called
149
133
  expect(adapter.submitStatement).toHaveBeenCalled();
150
134
  });
@@ -162,11 +146,11 @@ describe('session', () => {
162
146
  return okAsync([]); // no peer response
163
147
  });
164
148
  const { session } = makeSession(adapter);
165
- await flushPromises();
149
+ await delay();
166
150
  // If outgoingRequest was restored, a new message appends to it
167
151
  const codec = str;
168
152
  void session.submitRequestMessage(codec, 'hello');
169
- await flushPromises();
153
+ await delay();
170
154
  expect(adapter.submitStatement).toHaveBeenCalled();
171
155
  });
172
156
  it('clears outgoingRequest when peer has a matching response', async () => {
@@ -177,16 +161,17 @@ describe('session', () => {
177
161
  let callCount = 0;
178
162
  adapter.queryStatements.mockImplementation(() => {
179
163
  callCount++;
164
+ // Outgoing topic contains both our request AND the peer's response
180
165
  if (callCount === 1)
181
- return okAsync([ownRequest]);
182
- return okAsync([peerResponse]);
166
+ return okAsync([ownRequest, peerResponse]);
167
+ return okAsync([]);
183
168
  });
184
169
  const { session } = makeSession(adapter);
185
- await flushPromises();
170
+ await delay();
186
171
  // No pending outgoing request — new message creates a brand new request
187
172
  const codec = str;
188
173
  void session.submitRequestMessage(codec, 'hi');
189
- await flushPromises();
174
+ await delay();
190
175
  // submitStatement called exactly once (for the new message only)
191
176
  expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
192
177
  });
@@ -202,7 +187,7 @@ describe('session', () => {
202
187
  return okAsync([]);
203
188
  });
204
189
  const { session } = makeSession(adapter);
205
- await flushPromises();
190
+ await delay();
206
191
  // Calling submitResponseMessage with restored requestId should succeed
207
192
  const result = await session.submitResponseMessage(requestId, 'success');
208
193
  expect(result.isOk()).toBe(true);
@@ -216,11 +201,12 @@ describe('session', () => {
216
201
  adapter.queryStatements.mockImplementation(() => {
217
202
  callCount++;
218
203
  if (callCount === 1)
219
- return okAsync([ownResponse]);
220
- return okAsync([peerRequest]);
204
+ return okAsync([]);
205
+ // Incoming topic contains both the peer's request AND our response
206
+ return okAsync([peerRequest, ownResponse]);
221
207
  });
222
208
  const { session } = makeSession(adapter);
223
- await flushPromises();
209
+ await delay();
224
210
  // Already responded — submitResponseMessage should return ok without submitting again
225
211
  const submitsBefore = adapter.submitStatement.mock.calls.length;
226
212
  const result = await session.submitResponseMessage(requestId, 'success');
@@ -242,7 +228,7 @@ describe('session', () => {
242
228
  return okAsync([]);
243
229
  });
244
230
  const { session } = makeSession(adapter);
245
- await flushPromises(); // init completes
231
+ await delay(); // init completes
246
232
  const callback = vi.fn();
247
233
  session.subscribe(rawCodec, callback);
248
234
  expect(callback).toHaveBeenCalledTimes(1);
@@ -264,7 +250,7 @@ describe('session', () => {
264
250
  const { session } = makeSession(adapter);
265
251
  const callback = vi.fn();
266
252
  session.subscribe(rawCodec, callback); // before init completes
267
- await flushPromises();
253
+ await delay();
268
254
  expect(callback).toHaveBeenCalledTimes(1);
269
255
  const messages2 = callback.mock.calls[0][0];
270
256
  expect(messages2[0]?.requestId).toBe(requestId);
@@ -281,17 +267,17 @@ describe('session', () => {
281
267
  return okAsync([]);
282
268
  });
283
269
  let subscribeCallback;
284
- adapter.subscribeStatements.mockImplementation((_topics, cb) => {
270
+ adapter.subscribeStatements.mockImplementation((_filter, cb) => {
285
271
  subscribeCallback = cb;
286
272
  return vi.fn();
287
273
  });
288
274
  const { session } = makeSession(adapter);
289
- await flushPromises(); // init sees peerRequest, adds to seenStatements
275
+ await delay(); // init sees peerRequest, adds to seenStatements
290
276
  const appCallback = vi.fn();
291
277
  session.subscribe(rawCodec, appCallback);
292
278
  // Simulate subscription delivering the same statement again
293
- subscribeCallback([peerRequest]);
294
- await flushPromises();
279
+ subscribeCallback({ statements: [peerRequest], isComplete: true });
280
+ await delay();
295
281
  // Should only be called once (from buffered init message), not again from subscription
296
282
  expect(appCallback).toHaveBeenCalledTimes(1);
297
283
  });
@@ -299,33 +285,139 @@ describe('session', () => {
299
285
  const requestId = 'no-auto-resp';
300
286
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
301
287
  const adapter = makeAdapter();
302
- let subscribeCallback;
303
- adapter.subscribeStatements.mockImplementation((_topics, cb) => {
304
- subscribeCallback = cb;
288
+ const subscribeCallbacks = [];
289
+ adapter.subscribeStatements.mockImplementation((_filter, cb) => {
290
+ subscribeCallbacks.push(cb);
305
291
  return vi.fn();
306
292
  });
307
293
  adapter.queryStatements.mockReturnValue(okAsync([]));
308
294
  const { session } = makeSession(adapter);
309
- await flushPromises();
295
+ await delay();
310
296
  const callback = vi.fn();
311
297
  session.subscribe(rawCodec, callback);
312
298
  adapter.submitStatement.mockClear();
313
- subscribeCallback([peerRequest]);
314
- await flushPromises();
299
+ // Fire on the incoming topic callback (first subscription)
300
+ subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
301
+ await delay();
315
302
  // Message delivered to app callback but no automatic response submitted
316
303
  expect(callback).toHaveBeenCalled();
317
304
  expect(adapter.submitStatement).not.toHaveBeenCalled();
318
305
  });
306
+ it('delivers peer request to a subscriber that registers after the batch notification (race condition)', async () => {
307
+ // Regression test for PB-439: when peer's request and the ACK response arrive in the
308
+ // same subscribeStatements batch, the request is processed before waitForRequestMessage
309
+ // has a chance to register its subscriber. The fix ensures request statements are always
310
+ // buffered so late subscribers (simulating waitForRequestMessage called in .andThen()
311
+ // after waitForResponseMessage resolves) still receive them.
312
+ const subscribeCallbacks = [];
313
+ const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
314
+ subscribeCallbacks.push(cb);
315
+ return vi.fn();
316
+ });
317
+ const { session } = makeSession({ subscribeStatements });
318
+ await delay();
319
+ // Register a dummy subscriber to activate the store subscription (simulates
320
+ // any pre-existing subscriber in the session, e.g. the app listening for messages).
321
+ const dummyUnsub = session.subscribe(rawCodec, vi.fn());
322
+ const peerRequestId = 'race-condition-request';
323
+ const peerRequest = makeStatement({
324
+ tag: 'request',
325
+ value: { requestId: peerRequestId, data: [new Uint8Array([42])] },
326
+ });
327
+ // Peer request arrives on the incoming topic (first subscription) while the
328
+ // dummy subscriber is active but waitForRequestMessage hasn't registered its
329
+ // subscriber yet (the race condition scenario).
330
+ subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
331
+ await delay();
332
+ // Now the late subscriber registers (simulates waitForRequestMessage being called
333
+ // in the .andThen() chain after waitForResponseMessage resolves).
334
+ const lateCallback = vi.fn();
335
+ session.subscribe(rawCodec, lateCallback);
336
+ // The late subscriber must receive the buffered peer request, otherwise
337
+ // waitForRequestMessage would hang indefinitely.
338
+ expect(lateCallback).toHaveBeenCalledTimes(1);
339
+ const messages = lateCallback.mock.calls[0][0];
340
+ expect(messages[0]?.type).toBe('request');
341
+ expect(messages[0]?.requestId).toBe(peerRequestId);
342
+ dummyUnsub();
343
+ });
319
344
  it('unsubscribing last subscriber tears down the store subscription', () => {
320
345
  const { session, adapter } = makeSession();
321
346
  const unsub = session.subscribe(rawCodec, vi.fn());
322
- expect(adapter.subscribeStatements).toHaveBeenCalledTimes(1);
347
+ expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
323
348
  unsub();
324
349
  // subscribeStatements returns a mock unsubscribe fn — verify it was called
325
350
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
326
351
  const storeMockUnsub = adapter.subscribeStatements.mock.results[0].value;
327
352
  expect(storeMockUnsub).toHaveBeenCalled();
328
353
  });
354
+ it('subscribes to outgoing topic for peer responses alongside incoming topic', () => {
355
+ const { session, adapter } = makeSession();
356
+ session.subscribe(rawCodec, vi.fn());
357
+ // Two subscriptions: one for incoming (peer requests), one for outgoing (peer responses)
358
+ expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
359
+ });
360
+ it('tears down outgoing subscription when last subscriber leaves', () => {
361
+ const { session, adapter } = makeSession();
362
+ const unsub = session.subscribe(rawCodec, vi.fn());
363
+ unsub();
364
+ // Both unsubscribe functions should be called
365
+ for (const result of adapter.subscribeStatements.mock.results) {
366
+ const mockUnsub = result.value;
367
+ expect(mockUnsub).toHaveBeenCalled();
368
+ }
369
+ });
370
+ it('delivers peer response from outgoing topic subscription to subscribers', async () => {
371
+ const subscribeCallbacks = [];
372
+ const subscribeStatements = vi.fn().mockImplementation((_topics, cb) => {
373
+ subscribeCallbacks.push(cb);
374
+ return vi.fn();
375
+ });
376
+ const { session, adapter } = makeSession({ subscribeStatements });
377
+ await delay();
378
+ // Submit a request so the session has an outgoingRequest
379
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
380
+ await delay();
381
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
382
+ const submitted = adapter.submitStatement.mock.calls[0][0];
383
+ const decoded = StatementData.dec(submitted.data);
384
+ const requestId = decoded.tag === 'request' ? decoded.value.requestId : '';
385
+ const callback = vi.fn();
386
+ session.subscribe(rawCodec, callback);
387
+ // Deliver the response via the SECOND subscription callback (outgoing topic)
388
+ // subscribeCallbacks[0] = incoming topic, subscribeCallbacks[1] = outgoing topic
389
+ const responseStatement = makeStatement({
390
+ tag: 'response',
391
+ value: { requestId, responseCode: 'success' },
392
+ });
393
+ subscribeCallbacks[1]({ statements: [responseStatement], isComplete: true });
394
+ await delay();
395
+ // Subscriber should receive the response
396
+ const allCalls = callback.mock.calls.flat();
397
+ const responseMessages = allCalls.flat().filter((m) => m.type === 'response');
398
+ expect(responseMessages.length).toBeGreaterThan(0);
399
+ });
400
+ it('ignores request-type statements from outgoing topic subscription', async () => {
401
+ const subscribeCallbacks = [];
402
+ const subscribeStatements = vi.fn().mockImplementation((_topics, cb) => {
403
+ subscribeCallbacks.push(cb);
404
+ return vi.fn();
405
+ });
406
+ const { session } = makeSession({ subscribeStatements });
407
+ await delay();
408
+ const callback = vi.fn();
409
+ session.subscribe(rawCodec, callback);
410
+ callback.mockClear(); // clear any buffered init messages
411
+ // Deliver a request via the outgoing topic subscription (would be our own echoed back)
412
+ const ownRequest = makeStatement({
413
+ tag: 'request',
414
+ value: { requestId: 'own-req', data: [new Uint8Array([1])] },
415
+ });
416
+ subscribeCallbacks[1]({ statements: [ownRequest], isComplete: true });
417
+ await delay();
418
+ // Should NOT be delivered to subscriber (filtered by responsesOnly flag)
419
+ expect(callback).not.toHaveBeenCalled();
420
+ });
329
421
  });
330
422
  describe('submitResponseMessage', () => {
331
423
  it('is idempotent — second call does not submit again', async () => {
@@ -340,7 +432,7 @@ describe('session', () => {
340
432
  return okAsync([]);
341
433
  });
342
434
  const { session } = makeSession(adapter);
343
- await flushPromises();
435
+ await delay();
344
436
  await session.submitResponseMessage(requestId, 'success');
345
437
  const submitsAfterFirst = adapter.submitStatement.mock.calls.length;
346
438
  await session.submitResponseMessage(requestId, 'success'); // second call
@@ -348,7 +440,7 @@ describe('session', () => {
348
440
  });
349
441
  it('returns error when requestId does not match incomingRequest', async () => {
350
442
  const { session } = makeSession();
351
- await flushPromises();
443
+ await delay();
352
444
  const result = await session.submitResponseMessage('wrong-id', 'success');
353
445
  expect(result.isErr()).toBe(true);
354
446
  });
@@ -357,43 +449,41 @@ describe('session', () => {
357
449
  const rawCodec = Bytes();
358
450
  it('sends a single statement for the first message', async () => {
359
451
  const { session, adapter } = makeSession();
360
- await flushPromises();
452
+ await delay();
361
453
  void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
362
- await flushPromises();
454
+ await delay();
363
455
  expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
364
456
  });
365
457
  it('appends second message to existing request (resubmits with new requestId)', async () => {
366
458
  const { session, adapter } = makeSession();
367
- await flushPromises();
459
+ await delay();
368
460
  void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
369
461
  void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
370
- await flushPromises();
462
+ await delay();
371
463
  // Two submits: first for msg1, second for msg1+msg2 batched
372
464
  expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
373
465
  });
374
466
  it('queues message that exceeds maxRequestSize', async () => {
375
467
  const { session, adapter } = makeSession({ maxRequestSize: 5 });
376
- await flushPromises();
468
+ await delay();
377
469
  void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // 3 bytes — fits
378
470
  void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7])); // 4 bytes — doesn't fit with existing
379
- await flushPromises();
471
+ await delay();
380
472
  // Only first message sent; second is queued
381
473
  expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
382
474
  });
383
475
  it('drains message queue after response received', async () => {
384
476
  let subscribeCallback;
385
- const subscribeStatements = vi
386
- .fn()
387
- .mockImplementation((_topics, cb) => {
477
+ const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
388
478
  subscribeCallback = cb;
389
479
  return vi.fn();
390
480
  });
391
481
  const { session, adapter } = makeSession({ maxRequestSize: 5, subscribeStatements });
392
- await flushPromises();
482
+ await delay();
393
483
  session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
394
484
  void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
395
485
  void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
396
- await flushPromises();
486
+ await delay();
397
487
  const submitCountBefore = adapter.submitStatement.mock.calls.length;
398
488
  // Simulate peer responding to the first request
399
489
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -404,34 +494,33 @@ describe('session', () => {
404
494
  tag: 'response',
405
495
  value: { requestId: respondingRequestId, responseCode: 'success' },
406
496
  });
407
- subscribeCallback([responseStatement]);
408
- await flushPromises();
497
+ subscribeCallback({ statements: [responseStatement], isComplete: true });
498
+ await delay();
409
499
  // Queued message should now be submitted
410
500
  expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
411
501
  });
412
502
  it('waitForResponseMessage resolves when response arrives for batch', async () => {
413
503
  let subscribeCallback;
414
- const subscribeStatements = vi
415
- .fn()
416
- .mockImplementation((_topics, cb) => {
504
+ const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
417
505
  subscribeCallback = cb;
418
506
  return vi.fn();
419
507
  });
420
508
  const { session, adapter } = makeSession({ subscribeStatements });
421
- await flushPromises();
509
+ await delay();
422
510
  session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
423
511
  const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
424
512
  const token = submitResult.unwrapOr({ requestId: '' }).requestId;
425
- await flushPromises();
513
+ await delay();
426
514
  const responsePromise = session.waitForResponseMessage(token);
427
515
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
428
516
  const lastStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
429
517
  const decoded = StatementData.dec(lastStatement.data);
430
518
  const respondingId = decoded.tag === 'request' ? decoded.value.requestId : '';
431
- subscribeCallback([
432
- makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } }),
433
- ]);
434
- await flushPromises();
519
+ subscribeCallback({
520
+ statements: [makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } })],
521
+ isComplete: true,
522
+ });
523
+ await delay();
435
524
  const result = await responsePromise;
436
525
  expect(result.isOk()).toBe(true);
437
526
  expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
@@ -1,7 +1,7 @@
1
1
  import { getStatementSigner, statementCodec } from '@novasamatech/sdk-statement';
2
2
  import { compact } from '@polkadot-api/substrate-bindings';
3
- import { fromHex } from '@polkadot-api/utils';
4
3
  import { errAsync, fromPromise, fromThrowable, okAsync } from 'neverthrow';
4
+ import { fromHex } from 'polkadot-api/utils';
5
5
  import { deriveSr25519PublicKey, signWithSr25519Secret, verifySr25519Signature } from '../crypto.js';
6
6
  import { toError } from '../helpers.js';
7
7
  export function createSr25519Prover(secret) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/statement-store",
3
3
  "type": "module",
4
- "version": "0.7.0-0",
4
+ "version": "0.7.0-1",
5
5
  "description": "Statement store integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -25,15 +25,16 @@
25
25
  "README.md"
26
26
  ],
27
27
  "dependencies": {
28
+ "@novasamatech/scale": "0.7.0-1",
28
29
  "@novasamatech/sdk-statement": "^0.5.0",
29
- "@polkadot-api/substrate-bindings": "^0.17.0",
30
- "@polkadot-api/substrate-client": "^0.5.0",
31
- "@polkadot-api/utils": "0.2.0",
32
- "@polkadot-labs/hdkd-helpers": "^0.0.28",
33
- "@noble/hashes": "2.0.1",
34
- "@noble/ciphers": "2.1.1",
30
+ "@polkadot-api/substrate-bindings": "^0.20.0",
31
+ "@polkadot-api/substrate-client": "^0.7.0",
32
+ "@polkadot-labs/hdkd-helpers": "^0.0.29",
33
+ "@noble/hashes": "2.2.0",
34
+ "@noble/ciphers": "2.2.0",
35
35
  "@scure/sr25519": "1.0.0",
36
- "polkadot-api": "^1.23.3",
36
+ "polkadot-api": ">=2",
37
+ "nanoid": "5.1.9",
37
38
  "neverthrow": "^8.2.0",
38
39
  "scale-ts": "1.6.1"
39
40
  },