@novasamatech/statement-store 0.6.12 → 0.6.14

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.
package/README.md CHANGED
@@ -1,11 +1,272 @@
1
1
  # @novasamatech/statement-store
2
2
 
3
- Statement store integration layer.
3
+ Encrypted, signed messaging over a Polkadot statement store. Provides a session abstraction for sending and receiving typed request/response messages between two on-chain accounts.
4
4
 
5
5
  ## Overview
6
6
 
7
+ The library wraps the raw statement-store RPC into a typed session with:
8
+
9
+ - End-to-end AES-GCM encryption (ECDH shared secret via sr25519)
10
+ - sr25519 proof generation and verification on every statement
11
+ - Codec-based message serialization / deserialization
12
+ - Request/response correlation with automatic response acknowledgement
13
+
7
14
  ## Installation
8
15
 
9
16
  ```shell
10
17
  npm install @novasamatech/statement-store --save -E
11
18
  ```
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import {
24
+ createSession,
25
+ createLocalSessionAccount,
26
+ createRemoteSessionAccount,
27
+ createAccountId,
28
+ createSr25519Secret,
29
+ deriveSr25519PublicKey,
30
+ createSr25519Prover,
31
+ createEncryption,
32
+ createLazyClient,
33
+ createPapiStatementStoreAdapter,
34
+ } from '@novasamatech/statement-store';
35
+ import { str } from 'scale-ts';
36
+
37
+ // 1. Derive local key pair from entropy
38
+ const localSecret = createSr25519Secret(entropy, '//wallet');
39
+ const localPublicKey = deriveSr25519PublicKey(localSecret);
40
+
41
+ // 2. Build account descriptors
42
+ const localAccount = createLocalSessionAccount(createAccountId(localPublicKey));
43
+ const remoteAccount = createRemoteSessionAccount(
44
+ createAccountId(remotePublicKey),
45
+ remotePublicKey,
46
+ );
47
+
48
+ // 3. Wire up the chain adapter
49
+ const client = createLazyClient(provider);
50
+ const statementStore = createPapiStatementStoreAdapter(client);
51
+
52
+ // 4. Create session dependencies
53
+ const prover = createSr25519Prover(localSecret);
54
+ const encryption = createEncryption(remoteAccount.publicKey);
55
+
56
+ // 5. Open session
57
+ const session = createSession({ localAccount, remoteAccount, statementStore, encryption, prover });
58
+
59
+ // Send a typed request and wait for the remote acknowledgement
60
+ const result = await session.request(str, 'hello');
61
+
62
+ // Clean up
63
+ session.dispose();
64
+ client.disconnect();
65
+ ```
66
+
67
+ ## API
68
+
69
+ ### `createSession(params)`
70
+
71
+ Creates a `Session` for bidirectional typed messaging between two accounts.
72
+
73
+ ```ts
74
+ function createSession(params: SessionParams): Session
75
+ ```
76
+
77
+ **`SessionParams`**
78
+
79
+ | Property | Type | Description |
80
+ |---|---|---|
81
+ | `localAccount` | `LocalSessionAccount` | The local side of the session |
82
+ | `remoteAccount` | `RemoteSessionAccount` | The remote side of the session |
83
+ | `statementStore` | `StatementStoreAdapter` | Chain adapter for submitting/subscribing to statements |
84
+ | `encryption` | `Encryption` | Encryption instance (use `createEncryption`) |
85
+ | `prover` | `StatementProver` | Proof signer/verifier (use `createSr25519Prover`) |
86
+
87
+ **`Session` methods**
88
+
89
+ | Method | Signature | Description |
90
+ |---|---|---|
91
+ | `request` | `(codec, payload) → ResultAsync<void, Error>` | Submit a request and wait for the remote to acknowledge it. Resolves when the remote sends a success response; rejects on decoding/decryption failure or unknown error. |
92
+ | `submitRequestMessage` | `(codec, payload) → ResultAsync<{ requestId }, Error>` | Submit a request without waiting for a response. Returns the generated `requestId`. |
93
+ | `submitResponseMessage` | `(requestId, responseCode) → ResultAsync<void, Error>` | Send an explicit response to a request identified by `requestId`. |
94
+ | `waitForRequestMessage` | `(codec, filter) → ResultAsync<S, Error>` | Wait for the next incoming request whose decoded payload passes `filter`. Unsubscribes automatically once matched. |
95
+ | `waitForResponseMessage` | `(requestId) → ResultAsync<ResponseMessage, Error>` | Wait for the response to a specific outgoing request. |
96
+ | `subscribe` | `(codec, callback) → VoidFunction` | Subscribe to all incoming messages, decoded with `codec`. Returns an unsubscribe function. |
97
+ | `dispose` | `() → void` | Unsubscribe all active subscriptions created by this session. |
98
+
99
+ ---
100
+
101
+ ### Account factories
102
+
103
+ #### `createAccountId(value)`
104
+
105
+ Creates a 32-byte `AccountId` from a raw public key buffer.
106
+
107
+ ```ts
108
+ function createAccountId(value: Uint8Array): AccountId
109
+ ```
110
+
111
+ #### `createLocalSessionAccount(accountId, pin?)`
112
+
113
+ Creates a `LocalSessionAccount` representing the local participant.
114
+
115
+ ```ts
116
+ function createLocalSessionAccount(accountId: AccountId, pin?: string): LocalSessionAccount
117
+ ```
118
+
119
+ `pin` is an optional string used to namespace the session channel.
120
+
121
+ #### `createRemoteSessionAccount(accountId, publicKey, pin?)`
122
+
123
+ Creates a `RemoteSessionAccount` representing the remote participant. `publicKey` is used for shared-secret derivation and session ID computation.
124
+
125
+ ```ts
126
+ function createRemoteSessionAccount(
127
+ accountId: AccountId,
128
+ publicKey: Uint8Array,
129
+ pin?: string,
130
+ ): RemoteSessionAccount
131
+ ```
132
+
133
+ #### `createSessionId(sharedSecret, accountA, accountB)`
134
+
135
+ Derives a deterministic 32-byte session channel ID from a shared secret and two accounts.
136
+
137
+ ```ts
138
+ function createSessionId(
139
+ sharedSecret: Uint8Array,
140
+ accountA: SessionAccount,
141
+ accountB: SessionAccount,
142
+ ): SessionId
143
+ ```
144
+
145
+ ---
146
+
147
+ ### Encryption
148
+
149
+ #### `createEncryption(sharedSecret)`
150
+
151
+ Creates an `Encryption` instance that encrypts/decrypts payloads with AES-256-GCM. The shared secret is typically the remote account's public key (ECDH result).
152
+
153
+ ```ts
154
+ function createEncryption(sharedSecret: Uint8Array): Encryption
155
+ ```
156
+
157
+ `Encryption` interface:
158
+
159
+ | Method | Description |
160
+ |---|---|
161
+ | `encrypt(plaintext)` | Encrypts with a random 12-byte nonce prepended to the output |
162
+ | `decrypt(ciphertext)` | Strips the nonce prefix and decrypts |
163
+
164
+ ---
165
+
166
+ ### Proof generation
167
+
168
+ #### `createSr25519Prover(secret)`
169
+
170
+ Creates a `StatementProver` that signs and verifies statement proofs using sr25519.
171
+
172
+ ```ts
173
+ function createSr25519Prover(secret: Uint8Array): StatementProver
174
+ ```
175
+
176
+ `StatementProver` interface:
177
+
178
+ | Method | Description |
179
+ |---|---|
180
+ | `generateMessageProof(statement)` | Signs the statement and returns a `SignedStatement` |
181
+ | `verifyMessageProof(statement)` | Verifies the sr25519 signature on an incoming statement |
182
+
183
+ ---
184
+
185
+ ### Chain adapter
186
+
187
+ #### `createPapiStatementStoreAdapter(lazyClient)`
188
+
189
+ Creates a `StatementStoreAdapter` backed by the polkadot-api JSON-RPC client.
190
+
191
+ ```ts
192
+ function createPapiStatementStoreAdapter(lazyClient: LazyClient): StatementStoreAdapter
193
+ ```
194
+
195
+ `StatementStoreAdapter` interface:
196
+
197
+ | Method | Description |
198
+ |---|---|
199
+ | `queryStatements(topics, destination?)` | Fetch all current statements matching the given topics |
200
+ | `subscribeStatements(topics, callback)` | Subscribe to new statements on topics; returns unsubscribe function |
201
+ | `submitStatement(statement)` | Submit a signed statement; resolves on success, rejects with a typed error on failure |
202
+
203
+ #### `createLazyClient(provider)`
204
+
205
+ Creates a `LazyClient` that lazily initialises polkadot-api and substrate-client instances from a JSON-RPC provider.
206
+
207
+ ```ts
208
+ function createLazyClient(provider: JsonRpcProvider): LazyClient
209
+ ```
210
+
211
+ `LazyClient` methods:
212
+
213
+ | Method | Description |
214
+ |---|---|
215
+ | `getClient()` | Returns (or creates) a `PolkadotClient` |
216
+ | `getRequestFn()` | Returns a `RequestFn` for use with `sdk-statement` |
217
+ | `getSubscribeFn()` | Returns a `SubscribeFn` for use with `sdk-statement` |
218
+ | `disconnect()` | Destroys both underlying clients |
219
+
220
+ ---
221
+
222
+ ### Crypto utilities
223
+
224
+ Low-level sr25519 helpers used to produce keys and proofs.
225
+
226
+ #### `createSr25519Secret(entropy, derivation?)`
227
+
228
+ Derives an sr25519 secret key from raw entropy, optionally applying a derivation path (`//hard` or `/soft` segments).
229
+
230
+ ```ts
231
+ function createSr25519Secret(entropy: Uint8Array, derivation?: string): Uint8Array
232
+ ```
233
+
234
+ #### `createSr25519Derivation(secret, derivation)`
235
+
236
+ Applies a derivation path string to an existing sr25519 secret.
237
+
238
+ ```ts
239
+ function createSr25519Derivation(secret: Uint8Array, derivation: string): Uint8Array
240
+ ```
241
+
242
+ #### `deriveSr25519PublicKey(secret)`
243
+
244
+ Derives the sr25519 public key from a secret key.
245
+
246
+ ```ts
247
+ function deriveSr25519PublicKey(secret: Uint8Array): Uint8Array
248
+ ```
249
+
250
+ #### `signWithSr25519Secret(secret, message)`
251
+
252
+ Signs a message with an sr25519 secret key.
253
+
254
+ ```ts
255
+ function signWithSr25519Secret(secret: Uint8Array, message: Uint8Array): Uint8Array
256
+ ```
257
+
258
+ #### `verifySr25519Signature(message, signature, publicKey)`
259
+
260
+ Verifies an sr25519 signature. Returns `true` if valid.
261
+
262
+ ```ts
263
+ function verifySr25519Signature(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean
264
+ ```
265
+
266
+ #### `khash(secret, message)`
267
+
268
+ Computes a keyed blake2b-256 hash. Used internally to derive session channel IDs.
269
+
270
+ ```ts
271
+ function khash(secret: Uint8Array, message: Uint8Array): Uint8Array
272
+ ```
@@ -9,5 +9,7 @@ export type SessionParams = {
9
9
  statementStore: StatementStoreAdapter;
10
10
  encryption: Encryption;
11
11
  prover: StatementProver;
12
+ maxRequestSize?: number;
12
13
  };
13
- export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, }: SessionParams): Session;
14
+ export declare function nextExpiry(current: bigint): bigint;
15
+ export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, maxRequestSize, }: SessionParams): Session;
@@ -1,55 +1,246 @@
1
1
  import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
2
  import { toHex } from '@polkadot-api/utils';
3
3
  import { nanoid } from 'nanoid';
4
- import { ResultAsync, err, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
5
- import { Bytes } from 'scale-ts';
4
+ import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
6
5
  import { khash, stringToBytes } from '../crypto.js';
7
6
  import { nonNullable, toError } from '../helpers.js';
8
7
  import { createSessionId } from '../model/session.js';
9
8
  import { DecodingError, DecryptionError, UnknownError } from './error.js';
10
9
  import { toMessage } from './messageMapper.js';
11
10
  import { StatementData } from './scale/statementData.js';
12
- export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, }) {
13
- let subscriptions = [];
14
- function submit(sessionId, channel, data) {
11
+ const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
12
+ const DEFAULT_MAX_REQUEST_SIZE = 4096;
13
+ export function nextExpiry(current) {
14
+ const fresh = createExpiryFromDuration(DEFAULT_EXPIRY_DURATION_SECS);
15
+ return fresh > current ? fresh : current + 1n;
16
+ }
17
+ export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
18
+ const outgoingSessionId = createSessionId(remoteAccount.publicKey, localAccount, remoteAccount);
19
+ const incomingSessionId = createSessionId(remoteAccount.publicKey, remoteAccount, localAccount);
20
+ const state = {
21
+ phase: 'initialization',
22
+ expiry: 0n,
23
+ outgoingRequest: null,
24
+ incomingRequest: null,
25
+ respondedIncomingRequest: false,
26
+ messageQueue: [],
27
+ pendingDelivery: new Map(),
28
+ seenStatements: new Set(),
29
+ };
30
+ let subscribers = [];
31
+ const bufferedMessages = [];
32
+ let storeUnsub = null;
33
+ function submitStatementData(channel, topicSessionId, data) {
34
+ state.expiry = nextExpiry(state.expiry);
35
+ const expiry = state.expiry;
15
36
  return encryption
16
37
  .encrypt(data)
17
- .map(data => ({
18
- expiry: getExpiry(),
38
+ .map(encrypted => ({
39
+ expiry,
19
40
  channel: toHex(channel),
20
- topics: [toHex(sessionId)],
21
- data,
41
+ topics: [toHex(topicSessionId)],
42
+ data: encrypted,
22
43
  }))
23
44
  .asyncAndThen(prover.generateMessageProof)
24
45
  .andThen(statementStore.submitStatement);
25
46
  }
47
+ function encodeAndSubmitRequest(requestId, messages) {
48
+ const encode = fromThrowable(StatementData.enc, toError);
49
+ encode({ tag: 'request', value: { requestId, data: messages } })
50
+ .asyncAndThen(data => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data))
51
+ .mapErr(e => {
52
+ console.error('submitRequest failed:', e);
53
+ });
54
+ }
55
+ function deliverStatementData(statementData) {
56
+ if (subscribers.length === 0) {
57
+ if (state.phase === 'initialization') {
58
+ bufferedMessages.push(statementData);
59
+ }
60
+ return;
61
+ }
62
+ for (const sub of subscribers) {
63
+ const messages = toMessage(statementData, sub.codec);
64
+ if (messages.length > 0)
65
+ sub.callback(messages);
66
+ }
67
+ }
68
+ function tryDecodeStatement(statement) {
69
+ if (!statement.data)
70
+ return okAsync(null);
71
+ const data = statement.data;
72
+ return prover
73
+ .verifyMessageProof(statement)
74
+ .andThen(verified => (verified ? ok() : err(new Error('Invalid proof'))))
75
+ .andThen(() => encryption.decrypt(data))
76
+ .map(decrypted => StatementData.dec(decrypted))
77
+ .orElse(() => ok(null));
78
+ }
79
+ function processIncomingStatement(statement) {
80
+ if (!statement.data)
81
+ return;
82
+ const key = toHex(statement.data);
83
+ if (state.seenStatements.has(key))
84
+ return;
85
+ state.seenStatements.add(key);
86
+ tryDecodeStatement(statement).andTee(statementData => {
87
+ if (!statementData)
88
+ return;
89
+ if (statementData.tag === 'request') {
90
+ if (statementData.value.requestId === state.incomingRequest?.requestId)
91
+ return;
92
+ state.incomingRequest = { requestId: statementData.value.requestId };
93
+ state.respondedIncomingRequest = false;
94
+ deliverStatementData(statementData);
95
+ }
96
+ else if (statementData.tag === 'response') {
97
+ if (state.outgoingRequest?.requestId !== statementData.value.requestId)
98
+ return;
99
+ const responseMessage = {
100
+ type: 'response',
101
+ localId: statementData.value.requestId,
102
+ requestId: statementData.value.requestId,
103
+ responseCode: statementData.value.responseCode,
104
+ };
105
+ for (const token of state.outgoingRequest.tokens) {
106
+ const deferred = state.pendingDelivery.get(token);
107
+ if (deferred) {
108
+ deferred.resolve(responseMessage);
109
+ state.pendingDelivery.delete(token);
110
+ }
111
+ }
112
+ state.outgoingRequest = null;
113
+ deliverStatementData(statementData);
114
+ processMessageQueue();
115
+ }
116
+ });
117
+ }
118
+ function processNewMessage(encoded, token) {
119
+ if (state.outgoingRequest === null) {
120
+ const requestId = nanoid();
121
+ state.outgoingRequest = { requestId, messages: [encoded], tokens: [token] };
122
+ encodeAndSubmitRequest(requestId, state.outgoingRequest.messages);
123
+ }
124
+ else {
125
+ const currentTotal = state.outgoingRequest.messages.reduce((s, m) => s + m.length, 0);
126
+ if (currentTotal + encoded.length <= maxRequestSize) {
127
+ state.outgoingRequest.messages.push(encoded);
128
+ state.outgoingRequest.tokens.push(token);
129
+ state.outgoingRequest.requestId = nanoid();
130
+ encodeAndSubmitRequest(state.outgoingRequest.requestId, state.outgoingRequest.messages);
131
+ }
132
+ else {
133
+ state.messageQueue.push({ encoded, token });
134
+ }
135
+ }
136
+ }
137
+ function processMessageQueue() {
138
+ const currentTotal = state.outgoingRequest?.messages.reduce((s, m) => s + m.length, 0) ?? 0;
139
+ while (state.messageQueue.length > 0) {
140
+ const head = state.messageQueue[0];
141
+ if (state.outgoingRequest !== null && currentTotal + head.encoded.length > maxRequestSize)
142
+ break;
143
+ state.messageQueue.shift();
144
+ processNewMessage(head.encoded, head.token);
145
+ }
146
+ }
147
+ function ensureStoreSubscription() {
148
+ if (storeUnsub)
149
+ return;
150
+ storeUnsub = statementStore.subscribeStatements([incomingSessionId], statements => {
151
+ for (const statement of statements) {
152
+ processIncomingStatement(statement);
153
+ }
154
+ });
155
+ }
156
+ async function init() {
157
+ const [ownResult, peerResult] = await Promise.all([
158
+ statementStore.queryStatements([outgoingSessionId]),
159
+ statementStore.queryStatements([incomingSessionId]),
160
+ ]);
161
+ if (ownResult.isErr() || peerResult.isErr())
162
+ return;
163
+ const ownStatements = ownResult.value;
164
+ const peerStatements = peerResult.value;
165
+ let maxExpiry = 0n;
166
+ for (const s of ownStatements) {
167
+ if (s.expiry !== undefined && s.expiry > maxExpiry)
168
+ maxExpiry = s.expiry;
169
+ }
170
+ state.expiry = nextExpiry(maxExpiry);
171
+ for (const s of [...ownStatements, ...peerStatements]) {
172
+ if (s.data)
173
+ state.seenStatements.add(toHex(s.data));
174
+ }
175
+ const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).match(v => v, () => null))).then(r => r.filter(nonNullable));
176
+ const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
177
+ const ownRequest = ownDecoded.find(d => d.tag === 'request');
178
+ const ownResponse = ownDecoded.find(d => d.tag === 'response');
179
+ const peerRequest = peerDecoded.find(d => d.tag === 'request');
180
+ const peerResponse = peerDecoded.find(d => d.tag === 'response');
181
+ if (ownRequest?.tag === 'request') {
182
+ const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
183
+ if (!hasResponse) {
184
+ state.outgoingRequest = {
185
+ requestId: ownRequest.value.requestId,
186
+ messages: ownRequest.value.data,
187
+ tokens: [], // tokens from previous session cannot be restored
188
+ };
189
+ }
190
+ }
191
+ if (peerRequest?.tag === 'request') {
192
+ state.incomingRequest = { requestId: peerRequest.value.requestId };
193
+ state.respondedIncomingRequest =
194
+ ownResponse?.tag === 'response' && ownResponse.value.requestId === peerRequest.value.requestId;
195
+ }
196
+ // Notify app of any unresponded incoming request.
197
+ // Delivered while phase is still 'initialization' so that deliverStatementData
198
+ // buffers the message for replay if no subscriber is registered yet.
199
+ if (peerRequest && state.incomingRequest && !state.respondedIncomingRequest) {
200
+ deliverStatementData(peerRequest);
201
+ }
202
+ state.phase = 'active';
203
+ processMessageQueue();
204
+ }
26
205
  const session = {
27
206
  request(codec, data) {
28
- return session.submitRequestMessage(codec, data).andThen(({ requestId }) => {
29
- return session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode));
30
- });
207
+ return session
208
+ .submitRequestMessage(codec, data)
209
+ .andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
31
210
  },
32
211
  submitRequestMessage(codec, message) {
33
- const requestId = nanoid();
34
- const sessionId = createSessionId(remoteAccount.publicKey, localAccount, remoteAccount);
35
- const encode = fromThrowable(StatementData.enc, toError);
36
- const encoded = codec.enc(message);
37
- const rawData = encode({
38
- tag: 'request',
39
- value: { requestId, data: [encoded] },
212
+ const encode = fromThrowable(codec.enc, toError);
213
+ const encodedResult = encode(message);
214
+ if (encodedResult.isErr())
215
+ return errAsync(encodedResult.error);
216
+ const encoded = encodedResult.value;
217
+ if (encoded.length > maxRequestSize)
218
+ return errAsync(new Error('message too big'));
219
+ const token = nanoid();
220
+ let resolveFn;
221
+ let rejectFn;
222
+ const promise = new Promise((res, rej) => {
223
+ resolveFn = res;
224
+ rejectFn = rej;
40
225
  });
41
- return rawData
42
- .asyncAndThen(data => submit(sessionId, createRequestChannel(sessionId), data))
43
- .map(() => ({ requestId }));
226
+ state.pendingDelivery.set(token, { resolve: resolveFn, reject: rejectFn, promise });
227
+ if (state.phase === 'initialization') {
228
+ state.messageQueue.push({ encoded, token });
229
+ }
230
+ else {
231
+ processNewMessage(encoded, token);
232
+ }
233
+ return okAsync({ requestId: token });
44
234
  },
45
235
  submitResponseMessage(requestId, responseCode) {
46
- const sessionId = createSessionId(remoteAccount.publicKey, localAccount, remoteAccount);
236
+ if (state.respondedIncomingRequest)
237
+ return okAsync(undefined);
238
+ if (state.incomingRequest?.requestId !== requestId) {
239
+ return errAsync(new Error(`No incoming request with id ${requestId}`));
240
+ }
241
+ state.respondedIncomingRequest = true;
47
242
  const encode = fromThrowable(StatementData.enc, toError);
48
- const rawData = encode({
49
- tag: 'response',
50
- value: { requestId, responseCode },
51
- });
52
- return rawData.asyncAndThen(data => submit(sessionId, createResponseChannel(sessionId), data));
243
+ return encode({ tag: 'response', value: { requestId, responseCode } }).asyncAndThen(data => submitStatementData(createResponseChannel(incomingSessionId), incomingSessionId, data));
53
244
  },
54
245
  waitForRequestMessage(codec, filter) {
55
246
  const promise = new Promise(resolve => {
@@ -71,56 +262,47 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
71
262
  });
72
263
  return fromPromise(promise, toError);
73
264
  },
74
- waitForResponseMessage(requestId) {
75
- const promise = new Promise(resolve => {
76
- const unsub = session.subscribe(Bytes(), messages => {
77
- const response = messages.filter(m => m.type === 'response').find(m => m.requestId === requestId);
78
- if (response) {
79
- unsub();
80
- resolve(response);
81
- }
82
- });
83
- });
84
- return fromPromise(promise, toError);
265
+ waitForResponseMessage(token) {
266
+ const deferred = state.pendingDelivery.get(token);
267
+ if (!deferred)
268
+ return errAsync(new Error(`No pending delivery for token ${token}`));
269
+ return fromPromise(deferred.promise, toError);
85
270
  },
86
271
  subscribe(codec, callback) {
87
- const sessionId = createSessionId(remoteAccount.publicKey, remoteAccount, localAccount);
88
- function processStatement(statement) {
89
- if (!statement.data)
90
- return okAsync(null);
91
- const data = statement.data;
92
- return prover
93
- .verifyMessageProof(statement)
94
- .andThen(verified => (verified ? ok() : err(new Error('Statement proof is not valid'))))
95
- .andThen(() => encryption.decrypt(data))
96
- .map(StatementData.dec)
97
- .orElse(() => ok(null));
272
+ const sub = {
273
+ codec: codec,
274
+ callback: callback,
275
+ };
276
+ subscribers.push(sub);
277
+ ensureStoreSubscription();
278
+ // Deliver buffered init messages to this subscriber
279
+ if (bufferedMessages.length > 0) {
280
+ const messages = bufferedMessages.flatMap(sd => toMessage(sd, codec));
281
+ if (messages.length > 0)
282
+ callback(messages);
98
283
  }
99
- return statementStore.subscribeStatements([sessionId], statements => {
100
- ResultAsync.combine(statements.map(processStatement))
101
- .map(messages => messages.filter(nonNullable).flatMap(x => toMessage(x, codec)))
102
- .andTee(messages => {
103
- if (messages.length > 0) {
104
- callback(messages);
105
- }
106
- })
107
- // TODO rework
108
- .andTee(messages => {
109
- const requests = messages.filter(m => m.type === 'request').map(m => m.requestId);
110
- const responses = requests.map(requestId => session.submitResponseMessage(requestId, 'success'));
111
- return ResultAsync.combine(responses);
112
- });
113
- });
284
+ return () => {
285
+ subscribers = subscribers.filter(s => s !== sub);
286
+ if (subscribers.length === 0 && storeUnsub) {
287
+ storeUnsub();
288
+ storeUnsub = null;
289
+ }
290
+ };
114
291
  },
115
292
  dispose() {
116
- for (const unsub of subscriptions) {
117
- unsub();
293
+ storeUnsub?.();
294
+ storeUnsub = null;
295
+ subscribers = [];
296
+ for (const [, deferred] of state.pendingDelivery) {
297
+ deferred.reject(new Error('Session disposed'));
118
298
  }
119
- subscriptions = [];
299
+ state.pendingDelivery.clear();
120
300
  },
121
301
  };
302
+ void init();
122
303
  return session;
123
304
  }
305
+ // ── module-level helpers ──────────────────────────────────────────────────────
124
306
  function mapResponseCode(responseCode) {
125
307
  switch (responseCode) {
126
308
  case 'success':
@@ -133,10 +315,6 @@ function mapResponseCode(responseCode) {
133
315
  return err(new UnknownError());
134
316
  }
135
317
  }
136
- const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
137
- function getExpiry() {
138
- return createExpiryFromDuration(DEFAULT_EXPIRY_DURATION_SECS);
139
- }
140
318
  function createRequestChannel(sessionId) {
141
319
  return khash(sessionId, stringToBytes('request'));
142
320
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,440 @@
1
+ import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
+ import { ok, okAsync } from 'neverthrow';
3
+ import { Bytes, str } from 'scale-ts';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
6
+ import { StatementData } from './scale/statementData.js';
7
+ import { createSession, nextExpiry } from './session.js';
8
+ // ── test helpers ──────────────────────────────────────────────────────────────
9
+ function makeAccounts() {
10
+ const localAccount = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(1)));
11
+ const remoteAccount = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(2)), new Uint8Array(32).fill(3));
12
+ return { localAccount, remoteAccount };
13
+ }
14
+ function makeEncryption() {
15
+ return {
16
+ encrypt: (data) => ok(data),
17
+ decrypt: (data) => ok(data),
18
+ };
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
+ function makeAdapter() {
36
+ const unsub = vi.fn();
37
+ return {
38
+ queryStatements: vi.fn().mockReturnValue(okAsync([])),
39
+ subscribeStatements: vi.fn().mockReturnValue(unsub),
40
+ submitStatement: vi.fn().mockReturnValue(okAsync(undefined)),
41
+ };
42
+ }
43
+ function makeStatement(statementData, expiry) {
44
+ return {
45
+ expiry: expiry ?? createExpiryFromDuration(7 * 24 * 60 * 60),
46
+ data: StatementData.enc(statementData),
47
+ topics: [],
48
+ channel: `0x${'00'.repeat(32)}`,
49
+ };
50
+ }
51
+ function makeSession(overrides) {
52
+ const { localAccount, remoteAccount } = makeAccounts();
53
+ const { maxRequestSize, ...adapterOverrides } = overrides ?? {};
54
+ const adapter = { ...makeAdapter(), ...adapterOverrides };
55
+ const session = createSession({
56
+ localAccount,
57
+ remoteAccount,
58
+ statementStore: adapter,
59
+ encryption: makeEncryption(),
60
+ prover: makeProver(),
61
+ maxRequestSize,
62
+ });
63
+ return { session, adapter };
64
+ }
65
+ async function flushPromises() {
66
+ await new Promise(resolve => setTimeout(resolve, 0));
67
+ }
68
+ describe('session', () => {
69
+ describe('nextExpiry', () => {
70
+ it('returns fresh expiry when current is 0', () => {
71
+ const result = nextExpiry(0n);
72
+ expect(result).toBeGreaterThan(0n);
73
+ });
74
+ it('returns fresh expiry when current is less than fresh', () => {
75
+ const stale = createExpiryFromDuration(1); // 1 second from now, will be smaller than 7-day
76
+ const result = nextExpiry(stale);
77
+ const fresh = createExpiryFromDuration(7 * 24 * 60 * 60);
78
+ expect(result).toBeGreaterThanOrEqual(fresh);
79
+ });
80
+ it('returns current + 1n when current is already at or above fresh', () => {
81
+ const high = createExpiryFromDuration(7 * 24 * 60 * 60 + 999999);
82
+ const result = nextExpiry(high);
83
+ expect(result).toBe(high + 1n);
84
+ });
85
+ it('is monotonically increasing across repeated calls', () => {
86
+ let expiry = 0n;
87
+ for (let i = 0; i < 5; i++) {
88
+ const next = nextExpiry(expiry);
89
+ expect(next).toBeGreaterThan(expiry);
90
+ expiry = next;
91
+ }
92
+ });
93
+ });
94
+ describe('createSession initialization', () => {
95
+ it('queries own and peer statements on creation', async () => {
96
+ const { adapter } = makeSession();
97
+ await flushPromises();
98
+ expect(adapter.queryStatements).toHaveBeenCalledTimes(2);
99
+ });
100
+ it('expiry is initialized from max own statement expiry', async () => {
101
+ const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
102
+ const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'r1', data: [] } }, highExpiry);
103
+ const adapter = makeAdapter();
104
+ let firstCall = true;
105
+ adapter.queryStatements.mockImplementation(() => {
106
+ if (firstCall) {
107
+ firstCall = false;
108
+ return okAsync([ownRequest]);
109
+ }
110
+ return okAsync([]);
111
+ });
112
+ const { session } = makeSession(adapter);
113
+ await flushPromises();
114
+ // Submit a message to trigger a statement — its expiry must be greater than highExpiry
115
+ const rawCodec = Bytes();
116
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
117
+ await flushPromises();
118
+ const submittedStatement = adapter.submitStatement.mock.calls[0]?.[0];
119
+ if (submittedStatement) {
120
+ expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
121
+ }
122
+ });
123
+ it('marks own and peer statement data as seen during init', async () => {
124
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'r2', data: [new Uint8Array([1])] } });
125
+ const adapter = makeAdapter();
126
+ let callCount = 0;
127
+ adapter.queryStatements.mockImplementation(() => {
128
+ callCount++;
129
+ if (callCount === 2)
130
+ return okAsync([peerRequest]);
131
+ return okAsync([]);
132
+ });
133
+ const { session } = makeSession(adapter);
134
+ await flushPromises();
135
+ // Register subscriber AFTER init — buffered incoming request should be delivered
136
+ const callback = vi.fn();
137
+ session.subscribe(Bytes(), callback);
138
+ expect(callback).toHaveBeenCalled();
139
+ });
140
+ it('transitions to active phase after queries complete', async () => {
141
+ const { session, adapter } = makeSession();
142
+ // Before init completes, submitRequestMessage queues the message
143
+ const rawCodec = str;
144
+ void session.submitRequestMessage(rawCodec, 'hello');
145
+ // Statement should NOT be submitted yet (still initializing)
146
+ expect(adapter.submitStatement).not.toHaveBeenCalled();
147
+ await flushPromises();
148
+ // After init, queued messages are processed → submitStatement called
149
+ expect(adapter.submitStatement).toHaveBeenCalled();
150
+ });
151
+ });
152
+ describe('session state restoration', () => {
153
+ it('restores outgoingRequest when own has request with no peer response', async () => {
154
+ const requestId = 'saved-request-id';
155
+ const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
156
+ const adapter = makeAdapter();
157
+ let callCount = 0;
158
+ adapter.queryStatements.mockImplementation(() => {
159
+ callCount++;
160
+ if (callCount === 1)
161
+ return okAsync([ownRequest]);
162
+ return okAsync([]); // no peer response
163
+ });
164
+ const { session } = makeSession(adapter);
165
+ await flushPromises();
166
+ // If outgoingRequest was restored, a new message appends to it
167
+ const codec = str;
168
+ void session.submitRequestMessage(codec, 'hello');
169
+ await flushPromises();
170
+ expect(adapter.submitStatement).toHaveBeenCalled();
171
+ });
172
+ it('clears outgoingRequest when peer has a matching response', async () => {
173
+ const requestId = 'acked-request';
174
+ const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
175
+ const peerResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
176
+ const adapter = makeAdapter();
177
+ let callCount = 0;
178
+ adapter.queryStatements.mockImplementation(() => {
179
+ callCount++;
180
+ if (callCount === 1)
181
+ return okAsync([ownRequest]);
182
+ return okAsync([peerResponse]);
183
+ });
184
+ const { session } = makeSession(adapter);
185
+ await flushPromises();
186
+ // No pending outgoing request — new message creates a brand new request
187
+ const codec = str;
188
+ void session.submitRequestMessage(codec, 'hi');
189
+ await flushPromises();
190
+ // submitStatement called exactly once (for the new message only)
191
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
192
+ });
193
+ it('restores incomingRequest from peer statements', async () => {
194
+ const requestId = 'peer-request-id';
195
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
196
+ const adapter = makeAdapter();
197
+ let callCount = 0;
198
+ adapter.queryStatements.mockImplementation(() => {
199
+ callCount++;
200
+ if (callCount === 2)
201
+ return okAsync([peerRequest]);
202
+ return okAsync([]);
203
+ });
204
+ const { session } = makeSession(adapter);
205
+ await flushPromises();
206
+ // Calling submitResponseMessage with restored requestId should succeed
207
+ const result = await session.submitResponseMessage(requestId, 'success');
208
+ expect(result.isOk()).toBe(true);
209
+ });
210
+ it('sets respondedIncomingRequest=true when own has a response for the peer request', async () => {
211
+ const requestId = 'peer-request-id';
212
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
213
+ const ownResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
214
+ const adapter = makeAdapter();
215
+ let callCount = 0;
216
+ adapter.queryStatements.mockImplementation(() => {
217
+ callCount++;
218
+ if (callCount === 1)
219
+ return okAsync([ownResponse]);
220
+ return okAsync([peerRequest]);
221
+ });
222
+ const { session } = makeSession(adapter);
223
+ await flushPromises();
224
+ // Already responded — submitResponseMessage should return ok without submitting again
225
+ const submitsBefore = adapter.submitStatement.mock.calls.length;
226
+ const result = await session.submitResponseMessage(requestId, 'success');
227
+ expect(result.isOk()).toBe(true);
228
+ expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // no new submit
229
+ });
230
+ });
231
+ describe('subscribe', () => {
232
+ const rawCodec = Bytes();
233
+ it('delivers buffered init messages when subscriber registers after init', async () => {
234
+ const requestId = 'incoming-req';
235
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1, 2, 3])] } });
236
+ const adapter = makeAdapter();
237
+ let callCount = 0;
238
+ adapter.queryStatements.mockImplementation(() => {
239
+ callCount++;
240
+ if (callCount === 2)
241
+ return okAsync([peerRequest]);
242
+ return okAsync([]);
243
+ });
244
+ const { session } = makeSession(adapter);
245
+ await flushPromises(); // init completes
246
+ const callback = vi.fn();
247
+ session.subscribe(rawCodec, callback);
248
+ expect(callback).toHaveBeenCalledTimes(1);
249
+ const messages = callback.mock.calls[0][0];
250
+ expect(messages[0]?.type).toBe('request');
251
+ expect(messages[0]?.requestId).toBe(requestId);
252
+ });
253
+ it('delivers init messages via subscribe when subscriber is registered before init completes', async () => {
254
+ const requestId = 'early-subscribe';
255
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
256
+ const adapter = makeAdapter();
257
+ let callCount = 0;
258
+ adapter.queryStatements.mockImplementation(() => {
259
+ callCount++;
260
+ if (callCount === 2)
261
+ return okAsync([peerRequest]);
262
+ return okAsync([]);
263
+ });
264
+ const { session } = makeSession(adapter);
265
+ const callback = vi.fn();
266
+ session.subscribe(rawCodec, callback); // before init completes
267
+ await flushPromises();
268
+ expect(callback).toHaveBeenCalledTimes(1);
269
+ const messages2 = callback.mock.calls[0][0];
270
+ expect(messages2[0]?.requestId).toBe(requestId);
271
+ });
272
+ it('does NOT deliver already-seen statements from subscription', async () => {
273
+ const requestId = 'seen-req';
274
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
275
+ const adapter = makeAdapter();
276
+ let queryCallCount = 0;
277
+ adapter.queryStatements.mockImplementation(() => {
278
+ queryCallCount++;
279
+ if (queryCallCount === 2)
280
+ return okAsync([peerRequest]);
281
+ return okAsync([]);
282
+ });
283
+ let subscribeCallback;
284
+ adapter.subscribeStatements.mockImplementation((_topics, cb) => {
285
+ subscribeCallback = cb;
286
+ return vi.fn();
287
+ });
288
+ const { session } = makeSession(adapter);
289
+ await flushPromises(); // init sees peerRequest, adds to seenStatements
290
+ const appCallback = vi.fn();
291
+ session.subscribe(rawCodec, appCallback);
292
+ // Simulate subscription delivering the same statement again
293
+ subscribeCallback([peerRequest]);
294
+ await flushPromises();
295
+ // Should only be called once (from buffered init message), not again from subscription
296
+ expect(appCallback).toHaveBeenCalledTimes(1);
297
+ });
298
+ it('does NOT auto-send a response when an incoming request arrives', async () => {
299
+ const requestId = 'no-auto-resp';
300
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
301
+ const adapter = makeAdapter();
302
+ let subscribeCallback;
303
+ adapter.subscribeStatements.mockImplementation((_topics, cb) => {
304
+ subscribeCallback = cb;
305
+ return vi.fn();
306
+ });
307
+ adapter.queryStatements.mockReturnValue(okAsync([]));
308
+ const { session } = makeSession(adapter);
309
+ await flushPromises();
310
+ const callback = vi.fn();
311
+ session.subscribe(rawCodec, callback);
312
+ adapter.submitStatement.mockClear();
313
+ subscribeCallback([peerRequest]);
314
+ await flushPromises();
315
+ // Message delivered to app callback but no automatic response submitted
316
+ expect(callback).toHaveBeenCalled();
317
+ expect(adapter.submitStatement).not.toHaveBeenCalled();
318
+ });
319
+ it('unsubscribing last subscriber tears down the store subscription', () => {
320
+ const { session, adapter } = makeSession();
321
+ const unsub = session.subscribe(rawCodec, vi.fn());
322
+ expect(adapter.subscribeStatements).toHaveBeenCalledTimes(1);
323
+ unsub();
324
+ // subscribeStatements returns a mock unsubscribe fn — verify it was called
325
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
326
+ const storeMockUnsub = adapter.subscribeStatements.mock.results[0].value;
327
+ expect(storeMockUnsub).toHaveBeenCalled();
328
+ });
329
+ });
330
+ describe('submitResponseMessage', () => {
331
+ it('is idempotent — second call does not submit again', async () => {
332
+ const requestId = 'req-to-respond';
333
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
334
+ const adapter = makeAdapter();
335
+ let callCount = 0;
336
+ adapter.queryStatements.mockImplementation(() => {
337
+ callCount++;
338
+ if (callCount === 2)
339
+ return okAsync([peerRequest]);
340
+ return okAsync([]);
341
+ });
342
+ const { session } = makeSession(adapter);
343
+ await flushPromises();
344
+ await session.submitResponseMessage(requestId, 'success');
345
+ const submitsAfterFirst = adapter.submitStatement.mock.calls.length;
346
+ await session.submitResponseMessage(requestId, 'success'); // second call
347
+ expect(adapter.submitStatement.mock.calls.length).toBe(submitsAfterFirst);
348
+ });
349
+ it('returns error when requestId does not match incomingRequest', async () => {
350
+ const { session } = makeSession();
351
+ await flushPromises();
352
+ const result = await session.submitResponseMessage('wrong-id', 'success');
353
+ expect(result.isErr()).toBe(true);
354
+ });
355
+ });
356
+ describe('message batching', () => {
357
+ const rawCodec = Bytes();
358
+ it('sends a single statement for the first message', async () => {
359
+ const { session, adapter } = makeSession();
360
+ await flushPromises();
361
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
362
+ await flushPromises();
363
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
364
+ });
365
+ it('appends second message to existing request (resubmits with new requestId)', async () => {
366
+ const { session, adapter } = makeSession();
367
+ await flushPromises();
368
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
369
+ void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
370
+ await flushPromises();
371
+ // Two submits: first for msg1, second for msg1+msg2 batched
372
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
373
+ });
374
+ it('queues message that exceeds maxRequestSize', async () => {
375
+ const { session, adapter } = makeSession({ maxRequestSize: 5 });
376
+ await flushPromises();
377
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // 3 bytes — fits
378
+ void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7])); // 4 bytes — doesn't fit with existing
379
+ await flushPromises();
380
+ // Only first message sent; second is queued
381
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
382
+ });
383
+ it('drains message queue after response received', async () => {
384
+ let subscribeCallback;
385
+ const subscribeStatements = vi
386
+ .fn()
387
+ .mockImplementation((_topics, cb) => {
388
+ subscribeCallback = cb;
389
+ return vi.fn();
390
+ });
391
+ const { session, adapter } = makeSession({ maxRequestSize: 5, subscribeStatements });
392
+ await flushPromises();
393
+ session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
394
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
395
+ void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
396
+ await flushPromises();
397
+ const submitCountBefore = adapter.submitStatement.mock.calls.length;
398
+ // Simulate peer responding to the first request
399
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
400
+ const lastSubmittedStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
401
+ const decoded = StatementData.dec(lastSubmittedStatement.data);
402
+ const respondingRequestId = decoded.tag === 'request' ? decoded.value.requestId : '';
403
+ const responseStatement = makeStatement({
404
+ tag: 'response',
405
+ value: { requestId: respondingRequestId, responseCode: 'success' },
406
+ });
407
+ subscribeCallback([responseStatement]);
408
+ await flushPromises();
409
+ // Queued message should now be submitted
410
+ expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
411
+ });
412
+ it('waitForResponseMessage resolves when response arrives for batch', async () => {
413
+ let subscribeCallback;
414
+ const subscribeStatements = vi
415
+ .fn()
416
+ .mockImplementation((_topics, cb) => {
417
+ subscribeCallback = cb;
418
+ return vi.fn();
419
+ });
420
+ const { session, adapter } = makeSession({ subscribeStatements });
421
+ await flushPromises();
422
+ session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
423
+ const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
424
+ const token = submitResult.unwrapOr({ requestId: '' }).requestId;
425
+ await flushPromises();
426
+ const responsePromise = session.waitForResponseMessage(token);
427
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
428
+ const lastStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
429
+ const decoded = StatementData.dec(lastStatement.data);
430
+ const respondingId = decoded.tag === 'request' ? decoded.value.requestId : '';
431
+ subscribeCallback([
432
+ makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } }),
433
+ ]);
434
+ await flushPromises();
435
+ const result = await responsePromise;
436
+ expect(result.isOk()).toBe(true);
437
+ expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
438
+ });
439
+ });
440
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/statement-store",
3
3
  "type": "module",
4
- "version": "0.6.12",
4
+ "version": "0.6.14",
5
5
  "description": "Statement store integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {