@novasamatech/statement-store 0.8.11 → 0.9.0
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/dist/crypto.js +2 -1
- package/dist/model/session.d.ts +1 -1
- package/dist/model/session.js +1 -1
- package/dist/model/sessionAccount.js +2 -1
- package/dist/session/encyption.js +7 -7
- package/dist/session/encyption.spec.d.ts +1 -0
- package/dist/session/encyption.spec.js +43 -0
- package/dist/session/scale/statementData.js +2 -1
- package/dist/session/session.spec.js +46 -46
- package/dist/session/statementProver.spec.js +4 -4
- package/dist/submit/retry.spec.js +10 -10
- package/dist/submit/submitStatement.spec.js +5 -5
- package/package.json +5 -5
package/dist/crypto.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { blake2b } from '@noble/hashes/blake2.js';
|
|
2
|
+
import { Bytes } from '@novasamatech/scale';
|
|
2
3
|
import { deriveSlotAccountPublicKey as deriveSlotPublicKey, ensureSubstrateSlotSr25519Ready, signSlotAccountSecret as signSlotSecret, verifySlotAccountSignature as verifySlotSignature, } from '@novasamatech/substrate-slot-sr25519-wasm';
|
|
3
4
|
import { entropyToMiniSecret } from '@polkadot-labs/hdkd-helpers';
|
|
4
5
|
import { HDKD as sr25519HDKD, secretFromSeed as sr25519SecretFromSeed } from '@scure/sr25519';
|
|
5
|
-
import {
|
|
6
|
+
import { str, u64 } from 'scale-ts';
|
|
6
7
|
import { substrateSr25519PublicKey, substrateSr25519Sign, substrateSr25519Verify } from './substrateSr25519.js';
|
|
7
8
|
export { ensureSubstrateSlotSr25519Ready };
|
|
8
9
|
export { ensureSubstrateSr25519Ready } from './substrateSr25519.js';
|
package/dist/model/session.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Branded } from '../types.js';
|
|
2
2
|
import type { SessionAccount } from './sessionAccount.js';
|
|
3
3
|
export type SessionId = Branded<Uint8Array, 'SessionId'>;
|
|
4
|
-
export declare const SessionIdCodec: import("scale
|
|
4
|
+
export declare const SessionIdCodec: import("@novasamatech/scale").BytesCodec<number>;
|
|
5
5
|
export declare function createSessionId(sharedSecret: Uint8Array, accountA: SessionAccount, accountB: SessionAccount): SessionId;
|
package/dist/model/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { Bytes } from '@novasamatech/scale';
|
|
1
2
|
import { mergeUint8 } from 'polkadot-api/utils';
|
|
2
|
-
import { Bytes } from 'scale-ts';
|
|
3
3
|
import { khash, stringToBytes } from '../crypto.js';
|
|
4
4
|
export const SessionIdCodec = Bytes(32);
|
|
5
5
|
export function createSessionId(sharedSecret, accountA, accountB) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Bytes
|
|
1
|
+
import { Bytes } from '@novasamatech/scale';
|
|
2
|
+
import { Option, Struct, str } from 'scale-ts';
|
|
2
3
|
import { BrandedBytesCodec } from '../crypto.js';
|
|
3
4
|
export const AccountIdCodec = BrandedBytesCodec(32);
|
|
4
5
|
export function createAccountId(value) {
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha.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
5
|
import { Result, fromThrowable } from 'neverthrow';
|
|
6
6
|
import { mergeUint8 } from 'polkadot-api/utils';
|
|
7
7
|
export function createEncryption(sharedSecret) {
|
|
8
|
-
const salt = new Uint8Array(); // secure enough since
|
|
8
|
+
const salt = new Uint8Array(); // secure enough since the X25519 shared secret provides full entropy
|
|
9
9
|
const info = new Uint8Array(); // no need to introduce any context
|
|
10
|
-
const
|
|
10
|
+
const aeadKey = hkdf(sha256, sharedSecret, salt, info, 32);
|
|
11
11
|
return {
|
|
12
12
|
encrypt: fromThrowable(cipherText => {
|
|
13
13
|
const nonce = randomBytes(12);
|
|
14
|
-
const
|
|
15
|
-
return mergeUint8([nonce,
|
|
14
|
+
const aead = chacha20poly1305(aeadKey, nonce);
|
|
15
|
+
return mergeUint8([nonce, aead.encrypt(cipherText)]);
|
|
16
16
|
}),
|
|
17
17
|
decrypt: fromThrowable(encryptedMessage => {
|
|
18
18
|
const nonce = encryptedMessage.slice(0, 12);
|
|
19
19
|
const cipherText = encryptedMessage.slice(12);
|
|
20
|
-
const
|
|
21
|
-
return
|
|
20
|
+
const aead = chacha20poly1305(aeadKey, nonce);
|
|
21
|
+
return aead.decrypt(cipherText);
|
|
22
22
|
}),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
2
|
+
import { hkdf } from '@noble/hashes/hkdf.js';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { createEncryption } from './encyption.js';
|
|
7
|
+
const deriveAeadKey = (sharedSecret) => hkdf(sha256, sharedSecret, new Uint8Array(), new Uint8Array(), 32);
|
|
8
|
+
describe('statement-store message encryption', () => {
|
|
9
|
+
it('round-trips a message', () => {
|
|
10
|
+
const sharedSecret = randomBytes(32);
|
|
11
|
+
const encryption = createEncryption(sharedSecret);
|
|
12
|
+
const plaintext = new TextEncoder().encode('hello statement');
|
|
13
|
+
const encrypted = encryption.encrypt(plaintext)._unsafeUnwrap();
|
|
14
|
+
const decrypted = encryption.decrypt(encrypted)._unsafeUnwrap();
|
|
15
|
+
expect(decrypted).toEqual(plaintext);
|
|
16
|
+
});
|
|
17
|
+
it('produces ChaCha20-Poly1305 ciphertext decryptable by an external ChaCha20-Poly1305 reader', () => {
|
|
18
|
+
const sharedSecret = randomBytes(32);
|
|
19
|
+
const plaintext = new TextEncoder().encode('cross-decrypt me');
|
|
20
|
+
const encrypted = createEncryption(sharedSecret).encrypt(plaintext)._unsafeUnwrap();
|
|
21
|
+
const nonce = encrypted.slice(0, 12);
|
|
22
|
+
const body = encrypted.slice(12);
|
|
23
|
+
const aeadKey = deriveAeadKey(sharedSecret);
|
|
24
|
+
const decrypted = chacha20poly1305(aeadKey, nonce).decrypt(body);
|
|
25
|
+
expect(decrypted).toEqual(plaintext);
|
|
26
|
+
});
|
|
27
|
+
it('decrypts a ChaCha20-Poly1305 ciphertext built externally (nonce || ct || tag framing)', () => {
|
|
28
|
+
const sharedSecret = randomBytes(32);
|
|
29
|
+
const plaintext = new TextEncoder().encode('external chacha');
|
|
30
|
+
const aeadKey = deriveAeadKey(sharedSecret);
|
|
31
|
+
const nonce = randomBytes(12);
|
|
32
|
+
const body = chacha20poly1305(aeadKey, nonce).encrypt(plaintext);
|
|
33
|
+
const wire = new Uint8Array([...nonce, ...body]);
|
|
34
|
+
const decrypted = createEncryption(sharedSecret).decrypt(wire)._unsafeUnwrap();
|
|
35
|
+
expect(decrypted).toEqual(plaintext);
|
|
36
|
+
});
|
|
37
|
+
it('fails to decrypt with a different shared secret', () => {
|
|
38
|
+
const plaintext = new TextEncoder().encode('secret');
|
|
39
|
+
const encrypted = createEncryption(randomBytes(32)).encrypt(plaintext)._unsafeUnwrap();
|
|
40
|
+
const result = createEncryption(randomBytes(32)).decrypt(encrypted);
|
|
41
|
+
expect(result.isErr()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Bytes
|
|
1
|
+
import { Bytes } from '@novasamatech/scale';
|
|
2
|
+
import { Enum, Struct, Vector, enhanceCodec, str, u8 } from 'scale-ts';
|
|
2
3
|
export const ResponseCode = enhanceCodec(u8, error => {
|
|
3
4
|
switch (error) {
|
|
4
5
|
case 'success':
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { Bytes } from '@novasamatech/scale';
|
|
1
2
|
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
3
|
import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
|
|
3
|
-
import {
|
|
4
|
+
import { Struct, str } from 'scale-ts';
|
|
4
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
6
|
import { createInMemoryStatementStore } from '../adapter/inMemory.js';
|
|
6
7
|
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
@@ -263,7 +264,7 @@ describe('session', () => {
|
|
|
263
264
|
const { session } = makeSession({ peer: [peerRequest] });
|
|
264
265
|
await delay();
|
|
265
266
|
const result = await session.submitResponseMessage(requestId, 'success');
|
|
266
|
-
expect(result
|
|
267
|
+
await expect(result).toBeOk();
|
|
267
268
|
});
|
|
268
269
|
it('treats an incoming request as already answered when our response is present (no resubmit)', async () => {
|
|
269
270
|
const requestId = 'peer-request-id';
|
|
@@ -274,7 +275,7 @@ describe('session', () => {
|
|
|
274
275
|
await delay();
|
|
275
276
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
276
277
|
const result = await session.submitResponseMessage(requestId, 'success');
|
|
277
|
-
expect(result
|
|
278
|
+
await expect(result).toBeOk();
|
|
278
279
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // no new submit
|
|
279
280
|
});
|
|
280
281
|
it('does not re-deliver an incoming request we already answered', async () => {
|
|
@@ -365,8 +366,7 @@ describe('session', () => {
|
|
|
365
366
|
});
|
|
366
367
|
await delay();
|
|
367
368
|
const result = await responsePromise;
|
|
368
|
-
expect(result.
|
|
369
|
-
expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
|
|
369
|
+
await expect(result).toBeOkWith(expect.objectContaining({ responseCode: 'success' }));
|
|
370
370
|
});
|
|
371
371
|
it('does not resend a message that is already in flight (dedup)', async () => {
|
|
372
372
|
const store = createInMemoryStatementStore();
|
|
@@ -401,8 +401,8 @@ describe('session', () => {
|
|
|
401
401
|
isComplete: true,
|
|
402
402
|
});
|
|
403
403
|
await delay();
|
|
404
|
-
expect(
|
|
405
|
-
expect(
|
|
404
|
+
await expect(w1).toBeOk();
|
|
405
|
+
await expect(w2).toBeOk();
|
|
406
406
|
});
|
|
407
407
|
it('preserves FIFO order: a later fitting message does not overtake queued ones', async () => {
|
|
408
408
|
const m1 = rawCodec.enc(new Uint8Array([1, 2, 3])); // first → in-flight batch
|
|
@@ -426,7 +426,7 @@ describe('session', () => {
|
|
|
426
426
|
await delay();
|
|
427
427
|
adapter.submitStatement.mockClear();
|
|
428
428
|
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3, 4]));
|
|
429
|
-
expect(result
|
|
429
|
+
await expect(result).toBeErr();
|
|
430
430
|
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
431
431
|
});
|
|
432
432
|
});
|
|
@@ -524,7 +524,7 @@ describe('session', () => {
|
|
|
524
524
|
const { session } = makeSession({ peer: [peerRequest] });
|
|
525
525
|
await delay(); // init buffers the peer request
|
|
526
526
|
const result = await session.waitForRequestMessage(rawCodec, () => 'matched');
|
|
527
|
-
expect(result
|
|
527
|
+
await expect(result).toBeOkWith('matched');
|
|
528
528
|
}, 2000);
|
|
529
529
|
it('opens a single subscription on the incoming topic', () => {
|
|
530
530
|
const store = createInMemoryStatementStore();
|
|
@@ -591,7 +591,7 @@ describe('session', () => {
|
|
|
591
591
|
const { session } = makeSession();
|
|
592
592
|
await delay();
|
|
593
593
|
const result = await session.submitResponseMessage('wrong-id', 'success');
|
|
594
|
-
expect(result
|
|
594
|
+
await expect(result).toBeErr();
|
|
595
595
|
});
|
|
596
596
|
it('NACKs an undecodable incoming request with decodingFailed', async () => {
|
|
597
597
|
// A request (enum tag 0) whose requestId decodes but whose data vector claims more
|
|
@@ -650,7 +650,7 @@ describe('session', () => {
|
|
|
650
650
|
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no premature NACK
|
|
651
651
|
// The legitimate response still goes through.
|
|
652
652
|
const res = await session.submitResponseMessage(reqId, 'success');
|
|
653
|
-
expect(res
|
|
653
|
+
await expect(res).toBeOk();
|
|
654
654
|
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
655
655
|
expect(decoded.tag === 'response' && decoded.value.responseCode).toBe('success');
|
|
656
656
|
});
|
|
@@ -676,7 +676,7 @@ describe('session', () => {
|
|
|
676
676
|
host.respondToRequests(RemoteMsg, () => 'success');
|
|
677
677
|
await settle();
|
|
678
678
|
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
679
|
-
expect(ack
|
|
679
|
+
await expect(ack).toBeOk();
|
|
680
680
|
host.dispose();
|
|
681
681
|
peer.dispose();
|
|
682
682
|
});
|
|
@@ -688,7 +688,7 @@ describe('session', () => {
|
|
|
688
688
|
host.respondToRequests(RemoteMsg, () => okAsync('decodingFailed'));
|
|
689
689
|
await settle();
|
|
690
690
|
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
691
|
-
expect(ack
|
|
691
|
+
await expect(ack).toBeErr();
|
|
692
692
|
expect(ack._unsafeUnwrapErr()).toBeInstanceOf(DecodingError);
|
|
693
693
|
host.dispose();
|
|
694
694
|
peer.dispose();
|
|
@@ -701,7 +701,7 @@ describe('session', () => {
|
|
|
701
701
|
host.respondToRequests(RemoteMsg, () => errAsync(new Error('handler boom')));
|
|
702
702
|
await settle();
|
|
703
703
|
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
704
|
-
expect(ack
|
|
704
|
+
await expect(ack).toBeErr();
|
|
705
705
|
expect(ack._unsafeUnwrapErr()).toBeInstanceOf(UnknownError);
|
|
706
706
|
host.dispose();
|
|
707
707
|
peer.dispose();
|
|
@@ -731,8 +731,8 @@ describe('session', () => {
|
|
|
731
731
|
await delay();
|
|
732
732
|
const resA = await session.submitResponseMessage('A', 'success');
|
|
733
733
|
const resB = await session.submitResponseMessage('B', 'success');
|
|
734
|
-
expect(resA
|
|
735
|
-
expect(resB
|
|
734
|
+
await expect(resA).toBeOk();
|
|
735
|
+
await expect(resB).toBeOk();
|
|
736
736
|
});
|
|
737
737
|
it('remains answerable after response submission retries are exhausted', async () => {
|
|
738
738
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'rid', data: [] } });
|
|
@@ -740,11 +740,11 @@ describe('session', () => {
|
|
|
740
740
|
await delay();
|
|
741
741
|
adapter.submitStatement.mockReturnValue(errAsync(new Error('store rejected')));
|
|
742
742
|
const first = await session.submitResponseMessage('rid', 'success'); // all retries fail → err + rollback
|
|
743
|
-
expect(first
|
|
743
|
+
await expect(first).toBeErr();
|
|
744
744
|
adapter.submitStatement.mockReturnValue(okAsync(undefined)); // store recovers
|
|
745
745
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
746
746
|
const second = await session.submitResponseMessage('rid', 'success'); // retryable → submits and succeeds
|
|
747
|
-
expect(second
|
|
747
|
+
await expect(second).toBeOk();
|
|
748
748
|
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
|
|
749
749
|
}, 3000);
|
|
750
750
|
it.each([
|
|
@@ -774,13 +774,13 @@ describe('session', () => {
|
|
|
774
774
|
pendings.find(p => p.requestId === 'A').settle(err(new PriorityError(0n, 0n))); // A lands late, rejected
|
|
775
775
|
const resA = await resAPromise;
|
|
776
776
|
const resB = await resBPromise;
|
|
777
|
-
expect(resB
|
|
778
|
-
expect(resA
|
|
777
|
+
await expect(resB).toBeOk();
|
|
778
|
+
await expect(resA).toBeOk(); // superseded rejection absorbed, not surfaced as an error
|
|
779
779
|
// A stays answered: re-answering it must NOT submit again (which would clobber B's response).
|
|
780
780
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
781
781
|
const reAnswer = await session.submitResponseMessage('A', 'success');
|
|
782
782
|
await delay();
|
|
783
|
-
expect(reAnswer
|
|
783
|
+
await expect(reAnswer).toBeOk();
|
|
784
784
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // deduped → no resubmit
|
|
785
785
|
session.dispose();
|
|
786
786
|
}, 3000);
|
|
@@ -793,7 +793,7 @@ describe('session', () => {
|
|
|
793
793
|
await delay();
|
|
794
794
|
const before = adapter.submitStatement.mock.calls.length;
|
|
795
795
|
const result = await session.clearOutgoingStatement();
|
|
796
|
-
expect(result
|
|
796
|
+
await expect(result).toBeOk();
|
|
797
797
|
expect(adapter.submitStatement.mock.calls.length).toBe(before);
|
|
798
798
|
});
|
|
799
799
|
it('absorbs an ExpiryTooLow on the superseding empty batch as success', async () => {
|
|
@@ -809,7 +809,7 @@ describe('session', () => {
|
|
|
809
809
|
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
810
810
|
await delay();
|
|
811
811
|
const cleared = await session.clearOutgoingStatement();
|
|
812
|
-
expect(cleared
|
|
812
|
+
await expect(cleared).toBeOk(); // ExpiryTooLow suppressed
|
|
813
813
|
session.dispose();
|
|
814
814
|
}, 3000);
|
|
815
815
|
it('submits an empty batch on the same channel at a higher expiry and clears local state', async () => {
|
|
@@ -823,7 +823,7 @@ describe('session', () => {
|
|
|
823
823
|
if (liveDecoded.tag === 'request')
|
|
824
824
|
expect(liveDecoded.value.data.length).toBe(1);
|
|
825
825
|
const result = await session.clearOutgoingStatement();
|
|
826
|
-
expect(result
|
|
826
|
+
await expect(result).toBeOk();
|
|
827
827
|
const clearCall = adapter.submitStatement.mock.calls.at(-1)?.[0];
|
|
828
828
|
const clearDecoded = StatementData.dec(clearCall.data);
|
|
829
829
|
expect(clearDecoded.tag).toBe('request');
|
|
@@ -848,7 +848,7 @@ describe('session', () => {
|
|
|
848
848
|
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
849
849
|
await settle();
|
|
850
850
|
const result = await session.clearOutgoingStatement();
|
|
851
|
-
expect(result
|
|
851
|
+
await expect(result).toBeOk();
|
|
852
852
|
await settle();
|
|
853
853
|
const requests = store
|
|
854
854
|
.currentStatements()
|
|
@@ -862,12 +862,12 @@ describe('session', () => {
|
|
|
862
862
|
const { session } = makeSession();
|
|
863
863
|
await delay();
|
|
864
864
|
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([9]));
|
|
865
|
-
expect(submit
|
|
865
|
+
await expect(submit).toBeOk();
|
|
866
866
|
const requestId = submit._unsafeUnwrap().requestId;
|
|
867
867
|
const waiter = session.waitForResponseMessage(requestId);
|
|
868
868
|
await session.clearOutgoingStatement();
|
|
869
869
|
const waited = await waiter;
|
|
870
|
-
expect(waited
|
|
870
|
+
await expect(waited).toBeErr();
|
|
871
871
|
});
|
|
872
872
|
it('clears local state and rejects waiters even when the supersede submission fails', async () => {
|
|
873
873
|
const { session, adapter } = makeSession();
|
|
@@ -877,10 +877,10 @@ describe('session', () => {
|
|
|
877
877
|
const waiter = session.waitForResponseMessage(requestId);
|
|
878
878
|
adapter.submitStatement.mockReturnValueOnce(errAsync(new Error('store rejected')));
|
|
879
879
|
const result = await session.clearOutgoingStatement();
|
|
880
|
-
expect(result
|
|
880
|
+
await expect(result).toBeErr();
|
|
881
881
|
// The pending waiter is rejected despite the failed submission.
|
|
882
882
|
const waited = await waiter;
|
|
883
|
-
expect(waited
|
|
883
|
+
await expect(waited).toBeErr();
|
|
884
884
|
// State is cleared: the next message starts a brand-new batch (data length 1, not 2).
|
|
885
885
|
adapter.submitStatement.mockReturnValue(okAsync(undefined));
|
|
886
886
|
void session.submitRequestMessage(rawCodec, new Uint8Array([4]));
|
|
@@ -902,10 +902,10 @@ describe('session', () => {
|
|
|
902
902
|
const waiter = session.waitForResponseMessage(requestId);
|
|
903
903
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
904
904
|
const result = await session.clearOutgoingStatement();
|
|
905
|
-
expect(result
|
|
905
|
+
await expect(result).toBeOk();
|
|
906
906
|
// The queued waiter is rejected rather than left to be submitted after init.
|
|
907
907
|
const waited = await waiter;
|
|
908
|
-
expect(waited
|
|
908
|
+
await expect(waited).toBeErr();
|
|
909
909
|
// No empty batch is submitted since there was no live on-chain request yet.
|
|
910
910
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore);
|
|
911
911
|
});
|
|
@@ -1011,7 +1011,7 @@ describe('session', () => {
|
|
|
1011
1011
|
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1012
1012
|
const requestId = submit._unsafeUnwrap().requestId;
|
|
1013
1013
|
const waited = await session.waitForResponseMessage(requestId);
|
|
1014
|
-
expect(waited
|
|
1014
|
+
await expect(waited).toBeErr();
|
|
1015
1015
|
}, 2000);
|
|
1016
1016
|
it('absorbs a superseded older submission rejected as ExpiryTooLow without surfacing an error', async () => {
|
|
1017
1017
|
// Two messages batch onto one outgoing request: the first submission (requestId A) is in
|
|
@@ -1055,7 +1055,7 @@ describe('session', () => {
|
|
|
1055
1055
|
const waiter = session.waitForRequestMessage(rawCodec, () => 'x');
|
|
1056
1056
|
session.dispose();
|
|
1057
1057
|
const result = await waiter;
|
|
1058
|
-
expect(result
|
|
1058
|
+
await expect(result).toBeErr();
|
|
1059
1059
|
}, 2000);
|
|
1060
1060
|
it('cancels a pending init retry (no further queries)', async () => {
|
|
1061
1061
|
const queryStatements = vi.fn(() => errAsync(new Error('store down'))); // init always fails → schedules retry
|
|
@@ -1071,7 +1071,7 @@ describe('session', () => {
|
|
|
1071
1071
|
await delay();
|
|
1072
1072
|
session.dispose();
|
|
1073
1073
|
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1074
|
-
expect(result
|
|
1074
|
+
await expect(result).toBeErr(); // surfaced immediately, not a token left pending forever
|
|
1075
1075
|
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
1076
1076
|
});
|
|
1077
1077
|
it('rejects submitResponseMessage after dispose', async () => {
|
|
@@ -1079,7 +1079,7 @@ describe('session', () => {
|
|
|
1079
1079
|
await delay();
|
|
1080
1080
|
session.dispose();
|
|
1081
1081
|
const result = await session.submitResponseMessage('any-id', 'success');
|
|
1082
|
-
expect(result
|
|
1082
|
+
await expect(result).toBeErr();
|
|
1083
1083
|
});
|
|
1084
1084
|
it('does not re-activate when disposed while init is in flight', async () => {
|
|
1085
1085
|
// dispose() lands during init's query await; init must bail before restoring state / flipping
|
|
@@ -1089,13 +1089,13 @@ describe('session', () => {
|
|
|
1089
1089
|
const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(gate));
|
|
1090
1090
|
const { session, adapter } = makeSession({ queryStatements });
|
|
1091
1091
|
const queued = await session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
|
|
1092
|
-
expect(queued
|
|
1092
|
+
await expect(queued).toBeOk();
|
|
1093
1093
|
session.dispose(); // dispose mid-init
|
|
1094
1094
|
resolveQueries([]); // init resumes — must bail before draining the queue / activating
|
|
1095
1095
|
await settle();
|
|
1096
1096
|
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no resurrection-driven submit
|
|
1097
1097
|
const after = await session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
1098
|
-
expect(after
|
|
1098
|
+
await expect(after).toBeErr(); // session stays disposed
|
|
1099
1099
|
}, 3000);
|
|
1100
1100
|
});
|
|
1101
1101
|
// The in-memory adapter replicates the store's observable contract; `fidelity` pins the double's
|
|
@@ -1123,7 +1123,7 @@ describe('session', () => {
|
|
|
1123
1123
|
const store = createInMemoryStatementStore();
|
|
1124
1124
|
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1125
1125
|
const higher = await store.submitStatement(makeSignedStatement(hex(0xaa), 11n, hex(0x01), new Uint8Array([2])));
|
|
1126
|
-
expect(higher
|
|
1126
|
+
await expect(higher).toBeOk();
|
|
1127
1127
|
expect(store.currentStatements()).toHaveLength(1);
|
|
1128
1128
|
expect(store.currentStatements()[0]?.expiry).toBe(11n);
|
|
1129
1129
|
});
|
|
@@ -1132,9 +1132,9 @@ describe('session', () => {
|
|
|
1132
1132
|
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1133
1133
|
const equal = await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([9])));
|
|
1134
1134
|
const lower = await store.submitStatement(makeSignedStatement(hex(0xaa), 5n, hex(0x01), new Uint8Array([9])));
|
|
1135
|
-
expect(equal
|
|
1135
|
+
await expect(equal).toBeErr();
|
|
1136
1136
|
expect(equal._unsafeUnwrapErr()).toBeInstanceOf(ExpiryTooLowError);
|
|
1137
|
-
expect(lower
|
|
1137
|
+
await expect(lower).toBeErr();
|
|
1138
1138
|
// The original statement is untouched.
|
|
1139
1139
|
expect(store.currentStatements()[0]?.data).toEqual(new Uint8Array([1]));
|
|
1140
1140
|
});
|
|
@@ -1143,7 +1143,7 @@ describe('session', () => {
|
|
|
1143
1143
|
const stmt = makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1]));
|
|
1144
1144
|
await store.submitStatement(stmt);
|
|
1145
1145
|
const again = await store.submitStatement(stmt);
|
|
1146
|
-
expect(again
|
|
1146
|
+
await expect(again).toBeOk();
|
|
1147
1147
|
expect(store.currentStatements()).toHaveLength(1);
|
|
1148
1148
|
});
|
|
1149
1149
|
it('coexists statements on different channels sharing a topic', async () => {
|
|
@@ -1184,9 +1184,9 @@ describe('session', () => {
|
|
|
1184
1184
|
const mobileReplyAck = mobileGotRequest.andThen(req => mobile.request(RemoteMsg, { id: 'm1', kind: 'reply', respondingTo: req.id, body: 'signature' }));
|
|
1185
1185
|
const hostReply = host.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'reply' && msg.respondingTo === 'h1' ? msg.body : undefined);
|
|
1186
1186
|
await settle();
|
|
1187
|
-
expect(
|
|
1188
|
-
expect(
|
|
1189
|
-
expect(
|
|
1187
|
+
await expect(hostAck).toBeOk(); // mobile ACKed the host request
|
|
1188
|
+
await expect(mobileReplyAck).toBeOk(); // host ACKed the mobile reply
|
|
1189
|
+
await expect(hostReply).toBeOkWith('signature'); // host received the reply
|
|
1190
1190
|
host.dispose();
|
|
1191
1191
|
mobile.dispose();
|
|
1192
1192
|
});
|
|
@@ -1212,7 +1212,7 @@ describe('session', () => {
|
|
|
1212
1212
|
mobile = makeMobile(store);
|
|
1213
1213
|
mobile.respondToRequests(RemoteMsg, () => 'success');
|
|
1214
1214
|
await settle();
|
|
1215
|
-
expect(
|
|
1215
|
+
await expect(hostAck).toBeOk();
|
|
1216
1216
|
host.dispose();
|
|
1217
1217
|
mobile.dispose();
|
|
1218
1218
|
});
|
|
@@ -1233,7 +1233,7 @@ describe('session', () => {
|
|
|
1233
1233
|
const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
|
|
1234
1234
|
await settle();
|
|
1235
1235
|
// The reply is delivered…
|
|
1236
|
-
expect(
|
|
1236
|
+
await expect(hostReply).toBeOkWith('signature');
|
|
1237
1237
|
// …while the transport ACK is still outstanding (mobile never sent it).
|
|
1238
1238
|
const ackState = await Promise.race([
|
|
1239
1239
|
Promise.resolve(hostAck).then(() => 'resolved'),
|
|
@@ -32,7 +32,7 @@ describe('statementProver', () => {
|
|
|
32
32
|
const signed = (await prover.generateMessageProof(makeStatement(new Uint8Array([1, 2, 3]))))._unsafeUnwrap();
|
|
33
33
|
expect(proofSigner(signed)).toBe(toHex(deriveSr25519PublicKey(secret)));
|
|
34
34
|
const verified = await prover.verifyMessageProof(signed);
|
|
35
|
-
expect(verified
|
|
35
|
+
await expect(verified).toBeOkWith(true);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
38
|
describe('createSlotAccountProver (mobile slot secrets)', () => {
|
|
@@ -44,7 +44,7 @@ describe('statementProver', () => {
|
|
|
44
44
|
const signed = (await prover.generateMessageProof(makeStatement(new Uint8Array([4, 5, 6]))))._unsafeUnwrap();
|
|
45
45
|
expect(proofSigner(signed)).toBe(toHex(deriveSlotAccountPublicKey(slotSecret)));
|
|
46
46
|
const verified = await prover.verifyMessageProof(signed);
|
|
47
|
-
expect(verified
|
|
47
|
+
await expect(verified).toBeOkWith(true);
|
|
48
48
|
});
|
|
49
49
|
it('signs under a different public key than the scure prover would for the same secret', () => {
|
|
50
50
|
// Regression guard: a slot secret pushed through the scure scheme derives the WRONG
|
|
@@ -58,14 +58,14 @@ describe('statementProver', () => {
|
|
|
58
58
|
const signed = (await prover.generateMessageProof(makeStatement(new Uint8Array([7, 8, 9]))))._unsafeUnwrap();
|
|
59
59
|
const tampered = { ...signed, data: new Uint8Array([9, 9, 9]) };
|
|
60
60
|
const verified = await prover.verifyMessageProof(tampered);
|
|
61
|
-
expect(verified
|
|
61
|
+
await expect(verified).toBeOkWith(false);
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
describe('verifyMessageProof', () => {
|
|
65
65
|
it('errors when the statement carries no proof', async () => {
|
|
66
66
|
const prover = createSr25519Prover(createSr25519Secret(mnemonicToEntropy(DEV_MNEMONIC)));
|
|
67
67
|
const verified = await prover.verifyMessageProof(makeStatement(new Uint8Array([1])));
|
|
68
|
-
expect(verified
|
|
68
|
+
await expect(verified).toBeErr();
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
});
|
|
@@ -15,7 +15,7 @@ describe('submitWithRetry', () => {
|
|
|
15
15
|
let calls = 0;
|
|
16
16
|
const submit = vi.fn(() => ++calls <= 6 ? errAsync(new AccountFullError(0n, 1n)) : okAsync(undefined));
|
|
17
17
|
const result = await submitWithRetry(submit, { ...FAST, attempts: 3, priorityAttempts: 'unbounded' });
|
|
18
|
-
expect(result
|
|
18
|
+
await expect(result).toBeOk();
|
|
19
19
|
expect(calls).toBe(7);
|
|
20
20
|
});
|
|
21
21
|
it("priorityAttempts 'unbounded': a no-longer-live priority rejection settles as success", async () => {
|
|
@@ -26,13 +26,13 @@ describe('submitWithRetry', () => {
|
|
|
26
26
|
priorityAttempts: 'unbounded',
|
|
27
27
|
shouldRetry: () => false,
|
|
28
28
|
});
|
|
29
|
-
expect(result
|
|
29
|
+
await expect(result).toBeOk(); // lost the channel race — benign
|
|
30
30
|
expect(submit).toHaveBeenCalledTimes(1);
|
|
31
31
|
});
|
|
32
32
|
it('priorityAttempts budgeted: priority errors consume their budget then propagate', async () => {
|
|
33
33
|
const submit = vi.fn(() => errAsync(new AccountFullError(0n, 1n)));
|
|
34
34
|
const result = await submitWithRetry(submit, { ...FAST, attempts: 0, priorityAttempts: 3 });
|
|
35
|
-
expect(result
|
|
35
|
+
await expect(result).toBeErr();
|
|
36
36
|
expect(result._unsafeUnwrapErr()).toBeInstanceOf(AccountFullError);
|
|
37
37
|
expect(submit).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
|
38
38
|
});
|
|
@@ -46,7 +46,7 @@ describe('submitWithRetry', () => {
|
|
|
46
46
|
priorityAttempts: 2,
|
|
47
47
|
onPriorityError: error => seen.push(error.min),
|
|
48
48
|
});
|
|
49
|
-
expect(result
|
|
49
|
+
await expect(result).toBeErr();
|
|
50
50
|
expect(submit).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
|
51
51
|
expect(seen).toEqual([1n, 2n, 3n]); // adopted the floor on all three, including the terminal rejection
|
|
52
52
|
});
|
|
@@ -59,13 +59,13 @@ describe('submitWithRetry', () => {
|
|
|
59
59
|
it('attempts 0: a non-priority error propagates immediately', async () => {
|
|
60
60
|
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
61
61
|
const result = await submitWithRetry(submit, { ...FAST, attempts: 0, priorityAttempts: 3 });
|
|
62
|
-
expect(result
|
|
62
|
+
await expect(result).toBeErr();
|
|
63
63
|
expect(submit).toHaveBeenCalledTimes(1);
|
|
64
64
|
});
|
|
65
65
|
it('non-priority errors consume the attempts budget then propagate', async () => {
|
|
66
66
|
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
67
67
|
const result = await submitWithRetry(submit, { ...FAST, attempts: 2, priorityAttempts: 'unbounded' });
|
|
68
|
-
expect(result
|
|
68
|
+
await expect(result).toBeErr();
|
|
69
69
|
expect(submit).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
|
70
70
|
});
|
|
71
71
|
it('per-attempt delay schedule is honored and reported via onRetry', async () => {
|
|
@@ -78,7 +78,7 @@ describe('submitWithRetry', () => {
|
|
|
78
78
|
delaysMs: [1, 2, 3],
|
|
79
79
|
onRetry: ({ attempt, delayMs }) => seen.push({ attempt, delayMs }),
|
|
80
80
|
});
|
|
81
|
-
expect(result
|
|
81
|
+
await expect(result).toBeOk();
|
|
82
82
|
expect(seen).toEqual([
|
|
83
83
|
{ attempt: 0, delayMs: 1 },
|
|
84
84
|
{ attempt: 1, delayMs: 2 },
|
|
@@ -87,7 +87,7 @@ describe('submitWithRetry', () => {
|
|
|
87
87
|
it('a negative budget propagates immediately instead of looping', async () => {
|
|
88
88
|
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
89
89
|
const result = await submitWithRetry(submit, { ...FAST, attempts: -1, priorityAttempts: 3 });
|
|
90
|
-
expect(result
|
|
90
|
+
await expect(result).toBeErr();
|
|
91
91
|
expect(submit).toHaveBeenCalledTimes(1);
|
|
92
92
|
});
|
|
93
93
|
it('a shouldRetry flip during the backoff settles a priority rejection as success', async () => {
|
|
@@ -104,7 +104,7 @@ describe('submitWithRetry', () => {
|
|
|
104
104
|
priorityAttempts: 'unbounded',
|
|
105
105
|
shouldRetry: () => live,
|
|
106
106
|
});
|
|
107
|
-
expect(result
|
|
107
|
+
await expect(result).toBeOk(); // settled after the delay, no second attempt
|
|
108
108
|
expect(submit).toHaveBeenCalledTimes(1);
|
|
109
109
|
});
|
|
110
110
|
it('shouldRetry is re-checked before each retry and stops the loop', async () => {
|
|
@@ -119,7 +119,7 @@ describe('submitWithRetry', () => {
|
|
|
119
119
|
priorityAttempts: 'unbounded',
|
|
120
120
|
shouldRetry: () => live,
|
|
121
121
|
});
|
|
122
|
-
expect(result
|
|
122
|
+
await expect(result).toBeErr(); // non-priority + not live → propagate, no settle
|
|
123
123
|
expect(submit).toHaveBeenCalledTimes(1);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
@@ -41,7 +41,7 @@ describe('submitStatementOnce', () => {
|
|
|
41
41
|
it('submits with the pinned-high expiry layout', async () => {
|
|
42
42
|
const { adapter, submitted } = makeStore();
|
|
43
43
|
const result = await submitStatementOnce(baseParams(adapter));
|
|
44
|
-
expect(result
|
|
44
|
+
await expect(result).toBeOk();
|
|
45
45
|
expect(submitted).toHaveLength(1);
|
|
46
46
|
expect((submitted[0].expiry ?? 0n) >> 32n).toBe(0xffffffffn);
|
|
47
47
|
});
|
|
@@ -51,8 +51,8 @@ describe('submitStatementOnce', () => {
|
|
|
51
51
|
const params = baseParams(adapter);
|
|
52
52
|
const first = await submitStatementOnce(params);
|
|
53
53
|
const second = await submitStatementOnce(params);
|
|
54
|
-
expect(first
|
|
55
|
-
expect(second
|
|
54
|
+
await expect(first).toBeErr();
|
|
55
|
+
await expect(second).toBeOk();
|
|
56
56
|
expect(submitted[1].expiry ?? 0n).toBeGreaterThan(chainMin); // adopted min, not wall clock
|
|
57
57
|
});
|
|
58
58
|
});
|
|
@@ -73,7 +73,7 @@ describe('signAndSubmitStatement', () => {
|
|
|
73
73
|
});
|
|
74
74
|
await vi.advanceTimersByTimeAsync(600); // cover the 500ms first backoff
|
|
75
75
|
const result = await promise;
|
|
76
|
-
expect(result
|
|
76
|
+
await expect(result).toBeOk();
|
|
77
77
|
expect(submitted).toHaveLength(2);
|
|
78
78
|
expect(submitted[1].expiry ?? 0n).toBeGreaterThan(chainMin);
|
|
79
79
|
});
|
|
@@ -92,7 +92,7 @@ describe('signAndSubmitStatement', () => {
|
|
|
92
92
|
});
|
|
93
93
|
await vi.advanceTimersByTimeAsync(50);
|
|
94
94
|
const result = await promise;
|
|
95
|
-
expect(result
|
|
95
|
+
await expect(result).toBeErr();
|
|
96
96
|
expect(result._unsafeUnwrapErr()).toBeInstanceOf(AccountFullError);
|
|
97
97
|
expect(submitted).toHaveLength(4); // 1 initial + 3 priority retries
|
|
98
98
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/statement-store",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Statement store integration",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/scale": "0.
|
|
28
|
+
"@novasamatech/scale": "0.9.0",
|
|
29
29
|
"@novasamatech/sdk-statement": "^0.6.0",
|
|
30
|
-
"@novasamatech/substrate-slot-sr25519-wasm": "0.
|
|
30
|
+
"@novasamatech/substrate-slot-sr25519-wasm": "0.9.0",
|
|
31
31
|
"@polkadot-api/substrate-bindings": "^0.20.3",
|
|
32
32
|
"@polkadot-api/substrate-client": "^0.7.0",
|
|
33
|
-
"@polkadot-labs/hdkd-helpers": "^0.0.
|
|
33
|
+
"@polkadot-labs/hdkd-helpers": "^0.0.31",
|
|
34
34
|
"@polkadot-labs/schnorrkel-wasm": "0.0.9",
|
|
35
35
|
"@noble/hashes": "2.2.0",
|
|
36
36
|
"@noble/ciphers": "2.2.0",
|
|
37
37
|
"@scure/sr25519": "2.2.0",
|
|
38
38
|
"polkadot-api": ">=2",
|
|
39
|
-
"nanoid": "
|
|
39
|
+
"nanoid": "6.0.0",
|
|
40
40
|
"neverthrow": "^8.2.0",
|
|
41
41
|
"scale-ts": "1.6.1"
|
|
42
42
|
},
|