@novasamatech/statement-store 0.8.7-1 → 0.8.7-3
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/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/session/priority.d.ts +9 -0
- package/dist/session/priority.js +22 -0
- package/dist/session/priority.spec.d.ts +1 -0
- package/dist/session/priority.spec.js +43 -0
- package/dist/session/session.d.ts +9 -2
- package/dist/session/session.js +83 -81
- package/dist/session/session.spec.js +171 -31
- package/dist/session/statementProver.js +2 -2
- package/dist/submit/allocator.d.ts +30 -0
- package/dist/submit/allocator.js +35 -0
- package/dist/submit/allocator.spec.d.ts +1 -0
- package/dist/submit/allocator.spec.js +42 -0
- package/dist/submit/retry.d.ts +32 -0
- package/dist/submit/retry.js +52 -0
- package/dist/submit/retry.spec.d.ts +1 -0
- package/dist/submit/retry.spec.js +105 -0
- package/dist/submit/submitStatement.d.ts +28 -0
- package/dist/submit/submitStatement.js +28 -0
- package/dist/submit/submitStatement.spec.d.ts +1 -0
- package/dist/submit/submitStatement.spec.js +99 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -19,5 +19,11 @@ export { createInMemoryStatementStore } from './adapter/inMemory.js';
|
|
|
19
19
|
export type { StatementStoreAdapter } from './adapter/types.js';
|
|
20
20
|
export { AccountFullError, AlreadyExpiredError, BadProofError, DataTooLargeError, EncodingTooLargeError, ExpiryTooLowError, InternalStoreError, KnownExpiredError, NoAllowanceError, NoProofError, StorageFullError, } from './adapter/types.js';
|
|
21
21
|
export { createPapiStatementStoreAdapter } from './adapter/rpc.js';
|
|
22
|
+
export type { ExpiryAllocator } from './submit/allocator.js';
|
|
23
|
+
export { PRIORITY_EPOCH_OFFSET, createExpiryAllocator } from './submit/allocator.js';
|
|
24
|
+
export type { SubmitRetryOptions } from './submit/retry.js';
|
|
25
|
+
export { isPriorityTooLow, submitWithRetry } from './submit/retry.js';
|
|
26
|
+
export type { SubmitStatementParams } from './submit/submitStatement.js';
|
|
27
|
+
export { signAndSubmitStatement, submitStatementOnce } from './submit/submitStatement.js';
|
|
22
28
|
export { createSr25519Derivation, createSr25519Secret, deriveSlotAccountPublicKey, deriveSr25519PublicKey, ensureSubstrateSlotSr25519Ready, ensureSubstrateSr25519Ready, khash, signSlotAccountSecret, signWithSr25519Secret, verifySlotAccountSignature, verifySr25519Signature, } from './crypto.js';
|
|
23
29
|
export { substrateSr25519PublicKey } from './substrateSr25519.js';
|
package/dist/index.js
CHANGED
|
@@ -9,5 +9,8 @@ export { createLazyClient } from './adapter/lazyClient.js';
|
|
|
9
9
|
export { createInMemoryStatementStore } from './adapter/inMemory.js';
|
|
10
10
|
export { AccountFullError, AlreadyExpiredError, BadProofError, DataTooLargeError, EncodingTooLargeError, ExpiryTooLowError, InternalStoreError, KnownExpiredError, NoAllowanceError, NoProofError, StorageFullError, } from './adapter/types.js';
|
|
11
11
|
export { createPapiStatementStoreAdapter } from './adapter/rpc.js';
|
|
12
|
+
export { PRIORITY_EPOCH_OFFSET, createExpiryAllocator } from './submit/allocator.js';
|
|
13
|
+
export { isPriorityTooLow, submitWithRetry } from './submit/retry.js';
|
|
14
|
+
export { signAndSubmitStatement, submitStatementOnce } from './submit/submitStatement.js';
|
|
12
15
|
export { createSr25519Derivation, createSr25519Secret, deriveSlotAccountPublicKey, deriveSr25519PublicKey, ensureSubstrateSlotSr25519Ready, ensureSubstrateSr25519Ready, khash, signSlotAccountSecret, signWithSr25519Secret, verifySlotAccountSignature, verifySr25519Signature, } from './crypto.js';
|
|
13
16
|
export { substrateSr25519PublicKey } from './substrateSr25519.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Priority epoch base: seconds at 2025-11-15T00:00:00Z. The low word is a u32 priority counted FROM
|
|
3
|
+
* this epoch (spec §1), not the raw Unix timestamp. iOS (StatementPriorityFactory.unixOffset) and
|
|
4
|
+
* Android subtract the same offset; omitting it makes the TS low word ~1.76e9 larger than every
|
|
5
|
+
* mobile client's, so any cross-client or shared-channel priority comparison would always rank a
|
|
6
|
+
* TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
9
|
+
export declare function nextExpiry(current: bigint): bigint;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement expiry/priority, u64 = (expiration_epoch << 32) | priority (spec layout).
|
|
3
|
+
* We pin the high word to 0xFFFFFFFF (max → effectively non-expiring, matching iOS &
|
|
4
|
+
* Android) and use the low word as a wall-clock-floored monotonic priority, so channel
|
|
5
|
+
* supersession is driven by the priority regardless of how the store compares the field.
|
|
6
|
+
* Returns a value strictly greater than `current` (i.e. `max(current + 1, now-priority)`).
|
|
7
|
+
*/
|
|
8
|
+
const NEVER_EXPIRE_HIGH = 0xffffffffn;
|
|
9
|
+
/**
|
|
10
|
+
* Priority epoch base: seconds at 2025-11-15T00:00:00Z. The low word is a u32 priority counted FROM
|
|
11
|
+
* this epoch (spec §1), not the raw Unix timestamp. iOS (StatementPriorityFactory.unixOffset) and
|
|
12
|
+
* Android subtract the same offset; omitting it makes the TS low word ~1.76e9 larger than every
|
|
13
|
+
* mobile client's, so any cross-client or shared-channel priority comparison would always rank a
|
|
14
|
+
* TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
|
|
15
|
+
*/
|
|
16
|
+
export const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
17
|
+
export function nextExpiry(current) {
|
|
18
|
+
const nowSecs = BigInt(Math.floor(Date.now() / 1000));
|
|
19
|
+
const priority = nowSecs > PRIORITY_EPOCH_OFFSET ? nowSecs - PRIORITY_EPOCH_OFFSET : 0n;
|
|
20
|
+
const timestampPriority = (NEVER_EXPIRE_HIGH << 32n) | priority;
|
|
21
|
+
return timestampPriority > current ? timestampPriority : current + 1n;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { nextExpiry } from './priority.js';
|
|
3
|
+
// Statement expiry/priority: u64 = (expiration_epoch << 32) | priority. The high word is pinned
|
|
4
|
+
// to 0xFFFFFFFF (non-expiring) and the low word is a wall-clock-floored monotonic priority that
|
|
5
|
+
// drives channel supersession. (Spec §1; matches iOS/Android.)
|
|
6
|
+
describe('expiry priority', () => {
|
|
7
|
+
it('encodes a non-expiring statement (high word pinned to 0xFFFFFFFF)', () => {
|
|
8
|
+
expect(nextExpiry(0n) >> 32n).toBe(0xffffffffn);
|
|
9
|
+
});
|
|
10
|
+
it('carries a wall-clock priority in the low word', () => {
|
|
11
|
+
const result = nextExpiry(0n);
|
|
12
|
+
expect(result & 0xffffffffn).toBeGreaterThan(0n);
|
|
13
|
+
});
|
|
14
|
+
it('counts the low word from the 2025-11-15 priority epoch (matches iOS/Android)', () => {
|
|
15
|
+
// iOS StatementPriorityFactory: priority = unixSeconds - 1_763_164_800 (the 2025-11-15 base,
|
|
16
|
+
// spec §1). The TS SDK must use the SAME base; otherwise its low word is ~1.76e9 larger than
|
|
17
|
+
// every mobile client's, so any cross-client/shared-channel priority comparison would rank a
|
|
18
|
+
// TS-written statement above a mobile-written one regardless of real time.
|
|
19
|
+
const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
try {
|
|
22
|
+
const fixedMs = 1_780_000_000_000;
|
|
23
|
+
vi.setSystemTime(fixedMs);
|
|
24
|
+
const expected = BigInt(Math.floor(fixedMs / 1000)) - PRIORITY_EPOCH_OFFSET;
|
|
25
|
+
expect(nextExpiry(0n) & 0xffffffffn).toBe(expected);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it('increments by one when the current value already exceeds the wall-clock priority', () => {
|
|
32
|
+
const high = (0xffffffffn << 32n) | 0xffffffffn; // max u64
|
|
33
|
+
expect(nextExpiry(high)).toBe(high + 1n);
|
|
34
|
+
});
|
|
35
|
+
it('is strictly monotonic across repeated calls', () => {
|
|
36
|
+
let expiry = 0n;
|
|
37
|
+
for (let i = 0; i < 5; i++) {
|
|
38
|
+
const next = nextExpiry(expiry);
|
|
39
|
+
expect(next).toBeGreaterThan(expiry);
|
|
40
|
+
expiry = next;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StatementStoreAdapter } from '../adapter/types.js';
|
|
2
2
|
import type { LocalSessionAccount, RemoteSessionAccount } from '../model/sessionAccount.js';
|
|
3
|
+
import type { ExpiryAllocator } from '../submit/allocator.js';
|
|
3
4
|
import type { Encryption } from './encyption.js';
|
|
4
5
|
import type { StatementProver } from './statementProver.js';
|
|
5
6
|
import type { Session } from './types.js';
|
|
@@ -23,6 +24,13 @@ export type SessionParams = {
|
|
|
23
24
|
* throw.
|
|
24
25
|
*/
|
|
25
26
|
sessionKey: Uint8Array;
|
|
27
|
+
/**
|
|
28
|
+
* Expiry source for this session's submits. Inject ONE shared allocator when
|
|
29
|
+
* several writers (sessions, raw submits) sign with the same account, so
|
|
30
|
+
* same-second submits cannot tie. Defaults to a private allocator —
|
|
31
|
+
* identical to the previous per-session behavior.
|
|
32
|
+
*/
|
|
33
|
+
allocator?: ExpiryAllocator;
|
|
26
34
|
maxRequestSize?: number;
|
|
27
35
|
};
|
|
28
36
|
/**
|
|
@@ -32,5 +40,4 @@ export type SessionParams = {
|
|
|
32
40
|
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
33
41
|
*/
|
|
34
42
|
export declare const STATEMENT_OVERHEAD: number;
|
|
35
|
-
export declare function
|
|
36
|
-
export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize, }: SessionParams): Session;
|
|
43
|
+
export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, allocator, maxRequestSize, }: SessionParams): Session;
|
package/dist/session/session.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
import { toHex } from '@novasamatech/scale';
|
|
1
2
|
import { nanoid } from 'nanoid';
|
|
2
3
|
import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
|
|
3
|
-
import { toHex } from 'polkadot-api/utils';
|
|
4
4
|
import { Struct, str } from 'scale-ts';
|
|
5
|
-
import { ExpiryTooLowError } from '../adapter/types.js';
|
|
6
5
|
import { khash, stringToBytes } from '../crypto.js';
|
|
7
6
|
import { nonNullable, toError } from '../helpers.js';
|
|
8
7
|
import { createSessionId } from '../model/session.js';
|
|
8
|
+
import { createExpiryAllocator } from '../submit/allocator.js';
|
|
9
|
+
import { isPriorityTooLow, submitWithRetry } from '../submit/retry.js';
|
|
10
|
+
import { submitStatementOnce } from '../submit/submitStatement.js';
|
|
9
11
|
import { DecodingError, DecryptionError, UnknownError } from './error.js';
|
|
10
12
|
import { toMessage } from './messageMapper.js';
|
|
11
13
|
import { StatementData } from './scale/statementData.js';
|
|
12
14
|
const DEFAULT_MAX_REQUEST_SIZE = 4096;
|
|
15
|
+
// Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
|
|
16
|
+
// always fails new and in-flight work the same way.
|
|
17
|
+
const SESSION_DISPOSED = 'Session disposed';
|
|
13
18
|
// Bounded retry for transient transport failures (the spec mandates retrying queries
|
|
14
19
|
// and submit_statement on connection failure). The TS adapter doesn't expose connection
|
|
15
20
|
// state, so we approximate with a short fixed backoff and an attempt cap.
|
|
@@ -23,19 +28,6 @@ const RETRY_DELAY_MS = 25;
|
|
|
23
28
|
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
24
29
|
*/
|
|
25
30
|
export const STATEMENT_OVERHEAD = 32 + 32 + 8 + 64 + 32; // 168 bytes
|
|
26
|
-
/**
|
|
27
|
-
* Statement expiry/priority, u64 = (expiration_epoch << 32) | priority (spec layout).
|
|
28
|
-
* We pin the high word to 0xFFFFFFFF (max → effectively non-expiring, matching iOS &
|
|
29
|
-
* Android) and use the low word as a wall-clock-floored monotonic priority, so channel
|
|
30
|
-
* supersession is driven by the priority regardless of how the store compares the field.
|
|
31
|
-
* Returns a value strictly greater than `current` (i.e. `max(current + 1, now-priority)`).
|
|
32
|
-
*/
|
|
33
|
-
const NEVER_EXPIRE_HIGH = 0xffffffffn;
|
|
34
|
-
export function nextExpiry(current) {
|
|
35
|
-
const nowSecs = BigInt(Math.floor(Date.now() / 1000));
|
|
36
|
-
const timestampPriority = (NEVER_EXPIRE_HIGH << 32n) | nowSecs;
|
|
37
|
-
return timestampPriority > current ? timestampPriority : current + 1n;
|
|
38
|
-
}
|
|
39
31
|
// Encode/decode a StatementData envelope, surfacing scale-ts throws as a Result.
|
|
40
32
|
const encodeStatementData = fromThrowable(StatementData.enc, toError);
|
|
41
33
|
const decodeStatementData = fromThrowable(StatementData.dec, toError);
|
|
@@ -77,26 +69,17 @@ function makeDeferred() {
|
|
|
77
69
|
promise.catch(() => undefined);
|
|
78
70
|
return { resolve, reject, promise };
|
|
79
71
|
}
|
|
80
|
-
|
|
81
|
-
// A successful submit returns immediately (no delay on the happy path). `shouldRetry` is
|
|
82
|
-
// re-checked before each retry: once the submission is superseded, aborted, or the session
|
|
83
|
-
// is disposed it returns false, so a stale retry can never resurrect an old statement.
|
|
84
|
-
function submitWithRetry(submit, attemptsLeft, shouldRetry) {
|
|
85
|
-
return submit().orElse(error => {
|
|
86
|
-
if (attemptsLeft <= 0 || !shouldRetry())
|
|
87
|
-
return errAsync(error);
|
|
88
|
-
return ResultAsync.fromSafePromise(new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))).andThen(() => !shouldRetry() ? errAsync(error) : submitWithRetry(submit, attemptsLeft - 1, shouldRetry));
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
72
|
+
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, allocator = createExpiryAllocator(), maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
92
73
|
const outgoingSessionId = createSessionId(sessionKey, localAccount, remoteAccount);
|
|
93
74
|
const incomingSessionId = createSessionId(sessionKey, remoteAccount, localAccount);
|
|
75
|
+
// Session-constant channel hashes — derived once so retries don't re-hash them per attempt.
|
|
76
|
+
const requestChannel = createRequestChannel(outgoingSessionId);
|
|
77
|
+
const responseChannel = createResponseChannel(outgoingSessionId);
|
|
94
78
|
// Message bytes must fit within the statement limit minus the fixed wire overhead.
|
|
95
79
|
const maxPayloadSize = Math.max(0, maxRequestSize - STATEMENT_OVERHEAD);
|
|
96
80
|
const state = {
|
|
97
81
|
phase: 'initialization',
|
|
98
82
|
initError: null,
|
|
99
|
-
expiry: 0n,
|
|
100
83
|
outgoingRequest: null,
|
|
101
84
|
incomingRequests: new Map(),
|
|
102
85
|
messageQueue: [],
|
|
@@ -115,28 +98,13 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
115
98
|
// Id of the most recent response we initiated (responses share one channel, so only the
|
|
116
99
|
// latest is live — a retry for an older one must not resurrect it).
|
|
117
100
|
let lastResponseRequestId = null;
|
|
118
|
-
//
|
|
101
|
+
// Encrypt, then submit on `channel`/`topicSessionId` at the allocator's next (strictly
|
|
102
|
+
// increasing) expiry. On a priority rejection submitStatementOnce resyncs the allocator to the
|
|
103
|
+
// chain-reported minimum before propagating, so the retry — and every later submit — clears it.
|
|
119
104
|
function submitStatementData(channel, topicSessionId, data) {
|
|
120
|
-
state.expiry = nextExpiry(state.expiry);
|
|
121
|
-
const expiry = state.expiry;
|
|
122
105
|
return encryption
|
|
123
106
|
.encrypt(data)
|
|
124
|
-
.
|
|
125
|
-
expiry,
|
|
126
|
-
channel: toHex(channel),
|
|
127
|
-
topics: [toHex(topicSessionId)],
|
|
128
|
-
data: encrypted,
|
|
129
|
-
}))
|
|
130
|
-
.asyncAndThen(prover.generateMessageProof)
|
|
131
|
-
.andThen(statementStore.submitStatement)
|
|
132
|
-
.orElse(error => {
|
|
133
|
-
// The chain is the source of truth for a channel's priority. If our in-memory counter
|
|
134
|
-
// drifted behind it (a prior run, another writer, or propagation lag), resync to the
|
|
135
|
-
// reported minimum so the retry — and every later submit — clears it.
|
|
136
|
-
if (error instanceof ExpiryTooLowError && error.min > state.expiry)
|
|
137
|
-
state.expiry = error.min;
|
|
138
|
-
return errAsync(error);
|
|
139
|
-
});
|
|
107
|
+
.asyncAndThen(encrypted => submitStatementOnce({ statementStore, prover, allocator, channel, topics: [topicSessionId], data: encrypted }));
|
|
140
108
|
}
|
|
141
109
|
// Settle and remove the pending-delivery entries for the given tokens.
|
|
142
110
|
function settleTokens(tokens, settle) {
|
|
@@ -148,26 +116,39 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
148
116
|
}
|
|
149
117
|
}
|
|
150
118
|
}
|
|
119
|
+
// Session retry policy (this and every submitWithRetry call below): priority errors
|
|
120
|
+
// (ExpiryTooLow / AccountFull) are retried with `priorityAttempts: 'unbounded'` — they never
|
|
121
|
+
// consume the transient-failure budget, because submitStatementData has already resynced the
|
|
122
|
+
// allocator above the chain-reported minimum, so the next attempt submits higher. We keep at it
|
|
123
|
+
// until the statement lands or the submission is superseded; once superseded, a priority
|
|
124
|
+
// rejection is swallowed as success (it merely lost the channel race to a newer, higher-priority
|
|
125
|
+
// statement). The upshot: priority errors never surface to session callers. Other errors keep
|
|
126
|
+
// the bounded retry and propagate when exhausted. `shouldRetry` is re-checked before each retry:
|
|
127
|
+
// once the submission is superseded, aborted, or the session is disposed it returns false, so a
|
|
128
|
+
// stale retry can never resurrect an old statement.
|
|
151
129
|
function encodeAndSubmitRequest(requestId, messages) {
|
|
152
130
|
encodeStatementData({ tag: 'request', value: { requestId, data: messages } })
|
|
153
|
-
.asyncAndThen(data => submitWithRetry(() => submitStatementData(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
131
|
+
.asyncAndThen(data => submitWithRetry(() => submitStatementData(requestChannel, outgoingSessionId, data), {
|
|
132
|
+
attempts: MAX_SUBMIT_RETRIES,
|
|
133
|
+
priorityAttempts: 'unbounded',
|
|
134
|
+
delaysMs: RETRY_DELAY_MS,
|
|
135
|
+
// Only keep retrying while this is still the live submission (not superseded by a
|
|
136
|
+
// newer retransmit, aborted via clearOutgoingStatement, or disposed).
|
|
137
|
+
shouldRetry: () => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId,
|
|
138
|
+
}))
|
|
157
139
|
.mapErr(e => {
|
|
158
|
-
|
|
140
|
+
// Priority errors never reach here (see the policy note above), so this is a genuine
|
|
141
|
+
// failure. If this submission was already superseded by a newer retransmit (same tokens)
|
|
142
|
+
// it is not the live request's concern — drop it silently; the newer one carries the
|
|
143
|
+
// waiters. Otherwise the bounded retries are exhausted on the LIVE submission: the
|
|
144
|
+
// request never landed, so fail its waiters rather than let them hang.
|
|
145
|
+
const outgoing = state.outgoingRequest;
|
|
146
|
+
if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
|
|
159
147
|
return;
|
|
160
148
|
console.error('submitRequest failed:', e);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// is expected and must NOT reject, since the newer one carries the same tokens.
|
|
165
|
-
const outgoing = state.outgoingRequest;
|
|
166
|
-
if (outgoing && outgoing.requestIds[outgoing.requestIds.length - 1] === requestId) {
|
|
167
|
-
settleTokens(outgoing.tokens, deferred => deferred.reject(e));
|
|
168
|
-
state.outgoingRequest = null;
|
|
169
|
-
processMessageQueue();
|
|
170
|
-
}
|
|
149
|
+
settleTokens(outgoing.tokens, deferred => deferred.reject(e));
|
|
150
|
+
state.outgoingRequest = null;
|
|
151
|
+
processMessageQueue();
|
|
171
152
|
});
|
|
172
153
|
}
|
|
173
154
|
function deliverStatementData(statementData) {
|
|
@@ -365,20 +346,20 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
365
346
|
if (s.expiry !== undefined && s.expiry > maxExpiry)
|
|
366
347
|
maxExpiry = s.expiry;
|
|
367
348
|
}
|
|
368
|
-
//
|
|
369
|
-
// submitted while init was in flight (e.g. an auto-ACK for a
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
if (seeded > state.expiry)
|
|
375
|
-
state.expiry = seeded;
|
|
349
|
+
// Adopt the snapshot's maximum as the allocator floor. raiseFloor is monotonic — the floor
|
|
350
|
+
// never regresses — so a statement submitted while init was in flight (e.g. an auto-ACK for a
|
|
351
|
+
// peer request that arrived during the query) keeps the counter ahead of this snapshot, the
|
|
352
|
+
// same guarantee the old conditional seeding gave. The next submit then draws strictly above
|
|
353
|
+
// the seen on-chain maximum and at least the wall-clock priority, so it cannot collide at an equal expiry.
|
|
354
|
+
allocator.raiseFloor(maxExpiry);
|
|
376
355
|
for (const s of [...ownStatements, ...peerStatements]) {
|
|
377
356
|
if (s.data)
|
|
378
357
|
state.seenStatements.add(toHex(s.data));
|
|
379
358
|
}
|
|
380
359
|
const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).unwrapOr({ kind: 'undecodable', requestId: null }))).then(outcomes => outcomes.map(o => (o.kind === 'decoded' ? o.data : null)).filter(nonNullable));
|
|
381
360
|
const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
|
|
361
|
+
if (disposed)
|
|
362
|
+
return;
|
|
382
363
|
// Both parties publish on their own outgoing topic, so the OUTGOING query returns our
|
|
383
364
|
// requests + OUR responses, and the INCOMING query returns the peer's requests + the
|
|
384
365
|
// PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
|
|
@@ -421,6 +402,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
421
402
|
.andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
|
|
422
403
|
},
|
|
423
404
|
submitRequestMessage(codec, message) {
|
|
405
|
+
if (disposed)
|
|
406
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
424
407
|
const encode = fromThrowable(codec.enc, toError);
|
|
425
408
|
const encodedResult = encode(message);
|
|
426
409
|
if (encodedResult.isErr())
|
|
@@ -448,6 +431,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
448
431
|
return okAsync({ requestId: token });
|
|
449
432
|
},
|
|
450
433
|
submitResponseMessage(requestId, responseCode) {
|
|
434
|
+
if (disposed)
|
|
435
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
451
436
|
const incoming = state.incomingRequests.get(requestId);
|
|
452
437
|
if (!incoming)
|
|
453
438
|
return errAsync(new Error(`No incoming request with id ${requestId}`));
|
|
@@ -465,15 +450,29 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
465
450
|
lastResponseRequestId = requestId;
|
|
466
451
|
// Responses go on OUR outgoing topic/response-channel (per spec: the responder
|
|
467
452
|
// publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
|
|
468
|
-
return (submitWithRetry(() => submitStatementData(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
453
|
+
return (submitWithRetry(() => submitStatementData(responseChannel, outgoingSessionId, encoded.value), {
|
|
454
|
+
attempts: MAX_SUBMIT_RETRIES,
|
|
455
|
+
priorityAttempts: 'unbounded',
|
|
456
|
+
delaysMs: RETRY_DELAY_MS,
|
|
457
|
+
// Stop retrying once a newer response supersedes this one (shared response channel) or disposed.
|
|
458
|
+
shouldRetry: () => !disposed && lastResponseRequestId === requestId,
|
|
459
|
+
})
|
|
473
460
|
.orElse(error => {
|
|
461
|
+
// Priority errors never reach here (see the policy note above), so this is a genuine
|
|
462
|
+
// failure. If this is no longer the latest response (superseded) or the session is
|
|
463
|
+
// disposed, keep the request marked answered — re-answering would only clobber the
|
|
464
|
+
// newer response — and absorb the error. NOTE: the shared response channel still only
|
|
465
|
+
// exposes the latest response to the peer, so reliably ACKing several outstanding
|
|
466
|
+
// requests needs the protocol-level fix tracked separately.
|
|
467
|
+
if (disposed || lastResponseRequestId !== requestId)
|
|
468
|
+
return okAsync(undefined);
|
|
469
|
+
// The live response genuinely failed after exhausting retries — roll back so a later
|
|
470
|
+
// peer retransmit can still be answered, and surface the error.
|
|
474
471
|
incoming.responded = false;
|
|
475
472
|
return errAsync(error);
|
|
476
|
-
})
|
|
473
|
+
})
|
|
474
|
+
// Answered (or absorbed as such): it no longer needs replaying to future subscribers.
|
|
475
|
+
.andTee(() => pruneBufferedRequest(requestId)));
|
|
477
476
|
},
|
|
478
477
|
waitForRequestMessage(codec, filter) {
|
|
479
478
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -573,9 +572,12 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
573
572
|
return errAsync(encoded.error);
|
|
574
573
|
// Supersede the live batch with an empty one. Use submitStatementData so the
|
|
575
574
|
// empty statement goes out at a STRICTLY higher expiry — the store rejects an
|
|
576
|
-
// equal-or-lower expiry on the same channel, so reusing
|
|
577
|
-
// leave the original request live on-chain.
|
|
578
|
-
|
|
575
|
+
// equal-or-lower expiry on the same channel, so reusing the last allocated expiry
|
|
576
|
+
// would leave the original request live on-chain. One shot, no retry (clearing is a
|
|
577
|
+
// supersede, not a request that must land); a priority rejection (ExpiryTooLow /
|
|
578
|
+
// AccountFull) means the channel already advanced past us, so the clear already
|
|
579
|
+
// happened → absorb it as success.
|
|
580
|
+
return submitStatementData(requestChannel, outgoingSessionId, encoded.value).orElse(error => isPriorityTooLow(error) ? okAsync(undefined) : errAsync(error));
|
|
579
581
|
},
|
|
580
582
|
dispose() {
|
|
581
583
|
disposed = true;
|
|
@@ -592,9 +594,9 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
592
594
|
// Settle any waitForRequestMessage() promises so callers unwind instead of
|
|
593
595
|
// hanging forever. Snapshot first — rejecting mutates the set.
|
|
594
596
|
for (const rejectWaiter of [...requestWaiters])
|
|
595
|
-
rejectWaiter(new Error(
|
|
597
|
+
rejectWaiter(new Error(SESSION_DISPOSED));
|
|
596
598
|
requestWaiters.clear();
|
|
597
|
-
rejectAllPending(new Error(
|
|
599
|
+
rejectAllPending(new Error(SESSION_DISPOSED));
|
|
598
600
|
},
|
|
599
601
|
};
|
|
600
602
|
void init();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
|
-
import { ResultAsync, errAsync, ok, okAsync } from 'neverthrow';
|
|
2
|
+
import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
|
|
3
3
|
import { Bytes, Struct, str } from 'scale-ts';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { createInMemoryStatementStore } from '../adapter/inMemory.js';
|
|
6
|
-
import { ExpiryTooLowError } from '../adapter/types.js';
|
|
6
|
+
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
7
7
|
import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
|
|
8
8
|
import { DecodingError, UnknownError } from './error.js';
|
|
9
9
|
import { StatementData } from './scale/statementData.js';
|
|
10
|
-
import { STATEMENT_OVERHEAD, createSession
|
|
10
|
+
import { STATEMENT_OVERHEAD, createSession } from './session.js';
|
|
11
11
|
// Real signature work belongs in statementProver tests; this stub stamps a
|
|
12
12
|
// non-empty proof so submitted statements are well-formed.
|
|
13
13
|
const mockProver = {
|
|
@@ -91,6 +91,20 @@ function lastSubmittedRequestId(adapter) {
|
|
|
91
91
|
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
92
92
|
return decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
93
93
|
}
|
|
94
|
+
// A submitStatement mock that defers every submission instead of resolving: each call records a
|
|
95
|
+
// `{ requestId, settle }` entry in `pendings`, letting a test land or reject submissions in a chosen
|
|
96
|
+
// order (used to drive shared-channel supersession races). Works for request and response payloads.
|
|
97
|
+
function deferredSubmit() {
|
|
98
|
+
const pendings = [];
|
|
99
|
+
const submitStatement = vi.fn((stmt) => {
|
|
100
|
+
const decoded = StatementData.dec(stmt.data);
|
|
101
|
+
const requestId = decoded.tag === 'request' || decoded.tag === 'response' ? decoded.value.requestId : '';
|
|
102
|
+
return ResultAsync.fromPromise(new Promise((resolve, reject) => {
|
|
103
|
+
pendings.push({ requestId, settle: r => (r.isOk() ? resolve() : reject(r.error)) });
|
|
104
|
+
}), e => e);
|
|
105
|
+
});
|
|
106
|
+
return { submitStatement, pendings };
|
|
107
|
+
}
|
|
94
108
|
// Encoded size of the request payload (the statement `data` field) for these messages —
|
|
95
109
|
// what the session sizes batches against. Includes the requestId (a fixed-length nanoid)
|
|
96
110
|
// and the SCALE vector framing, so it is larger than the raw message bytes alone.
|
|
@@ -139,30 +153,6 @@ function makeMobile(adapter) {
|
|
|
139
153
|
}
|
|
140
154
|
describe('session', () => {
|
|
141
155
|
const rawCodec = Bytes();
|
|
142
|
-
// Statement expiry/priority: u64 = (expiration_epoch << 32) | priority. The high word is pinned
|
|
143
|
-
// to 0xFFFFFFFF (non-expiring) and the low word is a wall-clock-floored monotonic priority that
|
|
144
|
-
// drives channel supersession. (Spec §1; matches iOS/Android.)
|
|
145
|
-
describe('expiry priority', () => {
|
|
146
|
-
it('encodes a non-expiring statement (high word pinned to 0xFFFFFFFF)', () => {
|
|
147
|
-
expect(nextExpiry(0n) >> 32n).toBe(0xffffffffn);
|
|
148
|
-
});
|
|
149
|
-
it('carries a wall-clock priority in the low word', () => {
|
|
150
|
-
const result = nextExpiry(0n);
|
|
151
|
-
expect(result & 0xffffffffn).toBeGreaterThan(0n);
|
|
152
|
-
});
|
|
153
|
-
it('increments by one when the current value already exceeds the wall-clock priority', () => {
|
|
154
|
-
const high = (0xffffffffn << 32n) | 0xffffffffn; // max u64
|
|
155
|
-
expect(nextExpiry(high)).toBe(high + 1n);
|
|
156
|
-
});
|
|
157
|
-
it('is strictly monotonic across repeated calls', () => {
|
|
158
|
-
let expiry = 0n;
|
|
159
|
-
for (let i = 0; i < 5; i++) {
|
|
160
|
-
const next = nextExpiry(expiry);
|
|
161
|
-
expect(next).toBeGreaterThan(expiry);
|
|
162
|
-
expiry = next;
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
156
|
// On creation a session queries both of its topics, derives the starting expiry, and buffers
|
|
167
157
|
// anything it finds until it goes active. (Spec §5 initialization.)
|
|
168
158
|
describe('initialization', () => {
|
|
@@ -757,6 +747,43 @@ describe('session', () => {
|
|
|
757
747
|
expect(second.isOk()).toBe(true);
|
|
758
748
|
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
|
|
759
749
|
}, 3000);
|
|
750
|
+
it.each([
|
|
751
|
+
['ExpiryTooLow', ExpiryTooLowError],
|
|
752
|
+
['AccountFull', AccountFullError],
|
|
753
|
+
])('absorbs a superseded response rejected as %s and keeps the request answered', async (_name, PriorityError) => {
|
|
754
|
+
// Two incoming requests are answered on the SHARED response channel. The response to A is in
|
|
755
|
+
// flight when the response to B takes over the channel; A then lands at a now-lower expiry and
|
|
756
|
+
// the store rejects it with a priority error. That supersession is expected: B's response owns
|
|
757
|
+
// the channel, so A's rejection must be absorbed (not surfaced) and A must stay marked
|
|
758
|
+
// answered — re-answering would only clobber B. (Returning ok here is also what stops
|
|
759
|
+
// respondToRequests from logging it as a failed response.)
|
|
760
|
+
const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
|
|
761
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
762
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
763
|
+
const { session, adapter } = makeSession({ peer: [reqA], subscribeStatements, submitStatement });
|
|
764
|
+
await delay();
|
|
765
|
+
session.subscribe(rawCodec, vi.fn()); // activate the store subscription
|
|
766
|
+
const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
|
|
767
|
+
callbacks[0]({ statements: [reqB], isComplete: true });
|
|
768
|
+
await delay();
|
|
769
|
+
const resAPromise = session.submitResponseMessage('A', 'success'); // in flight on the shared channel
|
|
770
|
+
const resBPromise = session.submitResponseMessage('B', 'success'); // supersedes A
|
|
771
|
+
await delay(); // both reach submitStatement
|
|
772
|
+
expect(pendings).toHaveLength(2);
|
|
773
|
+
pendings.find(p => p.requestId === 'B').settle(ok(undefined)); // B lands, owns the channel
|
|
774
|
+
pendings.find(p => p.requestId === 'A').settle(err(new PriorityError(0n, 0n))); // A lands late, rejected
|
|
775
|
+
const resA = await resAPromise;
|
|
776
|
+
const resB = await resBPromise;
|
|
777
|
+
expect(resB.isOk()).toBe(true);
|
|
778
|
+
expect(resA.isOk()).toBe(true); // superseded rejection absorbed, not surfaced as an error
|
|
779
|
+
// A stays answered: re-answering it must NOT submit again (which would clobber B's response).
|
|
780
|
+
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
781
|
+
const reAnswer = await session.submitResponseMessage('A', 'success');
|
|
782
|
+
await delay();
|
|
783
|
+
expect(reAnswer.isOk()).toBe(true);
|
|
784
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // deduped → no resubmit
|
|
785
|
+
session.dispose();
|
|
786
|
+
}, 3000);
|
|
760
787
|
});
|
|
761
788
|
// clearOutgoingStatement aborts the in-flight request: it drops local state, rejects waiters, and
|
|
762
789
|
// supersedes the on-chain request with an empty batch at a strictly higher expiry.
|
|
@@ -769,6 +796,22 @@ describe('session', () => {
|
|
|
769
796
|
expect(result.isOk()).toBe(true);
|
|
770
797
|
expect(adapter.submitStatement.mock.calls.length).toBe(before);
|
|
771
798
|
});
|
|
799
|
+
it('absorbs an ExpiryTooLow on the superseding empty batch as success', async () => {
|
|
800
|
+
// clearOutgoingStatement runs a single direct submit (no submitWithRetry). If the empty batch
|
|
801
|
+
// is rejected ExpiryTooLow, the channel already advanced past us — the request is already gone
|
|
802
|
+
// — so the clear has effectively happened. The caller must see success, not the sync artifact.
|
|
803
|
+
let calls = 0;
|
|
804
|
+
const submitStatement = vi.fn((stmt) => ++calls === 1
|
|
805
|
+
? okAsync(undefined) // the request itself lands
|
|
806
|
+
: errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, (0xffffffffn << 32n) | 9000000000n)));
|
|
807
|
+
const { session } = makeSession({ submitStatement });
|
|
808
|
+
await delay();
|
|
809
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
810
|
+
await delay();
|
|
811
|
+
const cleared = await session.clearOutgoingStatement();
|
|
812
|
+
expect(cleared.isOk()).toBe(true); // ExpiryTooLow suppressed
|
|
813
|
+
session.dispose();
|
|
814
|
+
}, 3000);
|
|
772
815
|
it('submits an empty batch on the same channel at a higher expiry and clears local state', async () => {
|
|
773
816
|
const { session, adapter } = makeSession();
|
|
774
817
|
await delay();
|
|
@@ -910,14 +953,18 @@ describe('session', () => {
|
|
|
910
953
|
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2); // 1 failure + ≥1 retry
|
|
911
954
|
session.dispose();
|
|
912
955
|
}, 3000);
|
|
913
|
-
it(
|
|
914
|
-
|
|
915
|
-
|
|
956
|
+
it.each([
|
|
957
|
+
['ExpiryTooLow', ExpiryTooLowError],
|
|
958
|
+
['AccountFull', AccountFullError],
|
|
959
|
+
])('resyncs its expiry above the chain minimum after an %s rejection', async (_name, PriorityError) => {
|
|
960
|
+
// The in-memory expiry counter has drifted behind the chain's real priority floor (prior run /
|
|
961
|
+
// other writer / propagation lag / account full of higher-priority statements). The chain
|
|
962
|
+
// reports the minimum; the retry must clear it.
|
|
916
963
|
const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n; // well above the wall-clock priority
|
|
917
964
|
let calls = 0;
|
|
918
965
|
const submitStatement = vi.fn((stmt) => {
|
|
919
966
|
calls++;
|
|
920
|
-
return calls === 1 ? errAsync(new
|
|
967
|
+
return calls === 1 ? errAsync(new PriorityError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined);
|
|
921
968
|
});
|
|
922
969
|
const { session, adapter } = makeSession({ submitStatement });
|
|
923
970
|
await delay();
|
|
@@ -928,6 +975,34 @@ describe('session', () => {
|
|
|
928
975
|
expect(retried.expiry ?? 0n).toBeGreaterThan(CHAIN_MIN); // healed past the chain minimum
|
|
929
976
|
session.dispose();
|
|
930
977
|
}, 3000);
|
|
978
|
+
it.each([
|
|
979
|
+
['ExpiryTooLow', ExpiryTooLowError],
|
|
980
|
+
['AccountFull', AccountFullError],
|
|
981
|
+
])('keeps retrying a live %s past the transient-retry cap until it lands', async (_name, PriorityError) => {
|
|
982
|
+
// Priority errors are sync artifacts, not chain failures: while the submission is still live
|
|
983
|
+
// the session keeps retrying (resyncing each time) BEYOND MAX_SUBMIT_RETRIES until it lands,
|
|
984
|
+
// and never surfaces the error to the caller. (A non-priority error gives up at the cap —
|
|
985
|
+
// see the test below.)
|
|
986
|
+
const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n;
|
|
987
|
+
let calls = 0;
|
|
988
|
+
const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new PriorityError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
|
|
989
|
+
const { session } = makeSession({ submitStatement });
|
|
990
|
+
await delay();
|
|
991
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
992
|
+
try {
|
|
993
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
994
|
+
let waiterRejected = false;
|
|
995
|
+
void session.waitForResponseMessage(submit._unsafeUnwrap().requestId).mapErr(() => (waiterRejected = true));
|
|
996
|
+
await new Promise(resolve => setTimeout(resolve, 300)); // 6 retries × 25ms backoff + slack
|
|
997
|
+
expect(calls).toBeGreaterThanOrEqual(7); // retried well past the 3-attempt cap, then landed
|
|
998
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
999
|
+
expect(waiterRejected).toBe(false); // the priority error never surfaced to the caller
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
errorSpy.mockRestore();
|
|
1003
|
+
session.dispose();
|
|
1004
|
+
}
|
|
1005
|
+
}, 3000);
|
|
931
1006
|
it('rejects the pending waiter once request-submission retries are exhausted', async () => {
|
|
932
1007
|
const { session } = makeSession({
|
|
933
1008
|
submitStatement: vi.fn().mockReturnValue(errAsync(new Error('store rejected'))),
|
|
@@ -938,6 +1013,40 @@ describe('session', () => {
|
|
|
938
1013
|
const waited = await session.waitForResponseMessage(requestId);
|
|
939
1014
|
expect(waited.isErr()).toBe(true);
|
|
940
1015
|
}, 2000);
|
|
1016
|
+
it('absorbs a superseded older submission rejected as ExpiryTooLow without surfacing an error', async () => {
|
|
1017
|
+
// Two messages batch onto one outgoing request: the first submission (requestId A) is in
|
|
1018
|
+
// flight when the second (requestId B, higher expiry, SAME tokens) is sent. B lands first and
|
|
1019
|
+
// sets the channel priority; A then lands at a now-lower expiry and the store rejects it with
|
|
1020
|
+
// ExpiryTooLow. A is superseded, so its rejection is expected protocol behaviour — it must not
|
|
1021
|
+
// be logged as an error and must not reject the shared waiters (B carries them).
|
|
1022
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
1023
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
1024
|
+
await delay();
|
|
1025
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1026
|
+
try {
|
|
1027
|
+
const first = (await session.submitRequestMessage(rawCodec, new Uint8Array([1])))._unsafeUnwrap();
|
|
1028
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([2])); // batches onto the same outgoing request
|
|
1029
|
+
await delay(); // let both submissions reach submitStatement
|
|
1030
|
+
expect(pendings).toHaveLength(2);
|
|
1031
|
+
const liveRequestId = lastSubmittedRequestId(adapter); // the newer (B) submission
|
|
1032
|
+
const live = pendings.find(p => p.requestId === liveRequestId);
|
|
1033
|
+
const superseded = pendings.find(p => p.requestId !== liveRequestId);
|
|
1034
|
+
let firstWaiterRejected = false;
|
|
1035
|
+
void session.waitForResponseMessage(first.requestId).mapErr(() => {
|
|
1036
|
+
firstWaiterRejected = true;
|
|
1037
|
+
});
|
|
1038
|
+
live.settle(ok(undefined)); // B lands, claims the channel priority
|
|
1039
|
+
await delay();
|
|
1040
|
+
superseded.settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late and is rejected
|
|
1041
|
+
await delay();
|
|
1042
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
1043
|
+
expect(firstWaiterRejected).toBe(false); // superseded failure must not reject the shared waiter
|
|
1044
|
+
}
|
|
1045
|
+
finally {
|
|
1046
|
+
errorSpy.mockRestore();
|
|
1047
|
+
session.dispose();
|
|
1048
|
+
}
|
|
1049
|
+
}, 3000);
|
|
941
1050
|
});
|
|
942
1051
|
describe('dispose', () => {
|
|
943
1052
|
it('rejects pending waitForRequestMessage waiters', async () => {
|
|
@@ -957,6 +1066,37 @@ describe('session', () => {
|
|
|
957
1066
|
await new Promise(resolve => setTimeout(resolve, 100)); // retry window elapses
|
|
958
1067
|
expect(queryStatements.mock.calls.length).toBe(callsBeforeDispose); // disposed → no further init queries
|
|
959
1068
|
}, 3000);
|
|
1069
|
+
it('rejects submitRequestMessage after dispose instead of hanging', async () => {
|
|
1070
|
+
const { session, adapter } = makeSession();
|
|
1071
|
+
await delay();
|
|
1072
|
+
session.dispose();
|
|
1073
|
+
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1074
|
+
expect(result.isErr()).toBe(true); // surfaced immediately, not a token left pending forever
|
|
1075
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
1076
|
+
});
|
|
1077
|
+
it('rejects submitResponseMessage after dispose', async () => {
|
|
1078
|
+
const { session } = makeSession();
|
|
1079
|
+
await delay();
|
|
1080
|
+
session.dispose();
|
|
1081
|
+
const result = await session.submitResponseMessage('any-id', 'success');
|
|
1082
|
+
expect(result.isErr()).toBe(true);
|
|
1083
|
+
});
|
|
1084
|
+
it('does not re-activate when disposed while init is in flight', async () => {
|
|
1085
|
+
// dispose() lands during init's query await; init must bail before restoring state / flipping
|
|
1086
|
+
// phase to 'active', otherwise a torn-down session looks alive and accepts new work.
|
|
1087
|
+
let resolveQueries;
|
|
1088
|
+
const gate = new Promise(resolve => (resolveQueries = resolve));
|
|
1089
|
+
const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(gate));
|
|
1090
|
+
const { session, adapter } = makeSession({ queryStatements });
|
|
1091
|
+
const queued = await session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
|
|
1092
|
+
expect(queued.isOk()).toBe(true);
|
|
1093
|
+
session.dispose(); // dispose mid-init
|
|
1094
|
+
resolveQueries([]); // init resumes — must bail before draining the queue / activating
|
|
1095
|
+
await settle();
|
|
1096
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no resurrection-driven submit
|
|
1097
|
+
const after = await session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
1098
|
+
expect(after.isErr()).toBe(true); // session stays disposed
|
|
1099
|
+
}, 3000);
|
|
960
1100
|
});
|
|
961
1101
|
// The in-memory adapter replicates the store's observable contract; `fidelity` pins the double's
|
|
962
1102
|
// behaviour, then end-to-end flows run two mirrored sessions (host + mobile) over ONE shared store.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { fromHex } from '@novasamatech/scale';
|
|
1
2
|
import { getStatementSigner, statementCodec } from '@novasamatech/sdk-statement';
|
|
2
|
-
import { compact } from '@polkadot-api/substrate-bindings';
|
|
3
3
|
import { errAsync, fromPromise, fromThrowable, okAsync } from 'neverthrow';
|
|
4
|
-
import {
|
|
4
|
+
import { compact } from 'scale-ts';
|
|
5
5
|
import { deriveSlotAccountPublicKey, deriveSr25519PublicKey, signSlotAccountSecret, signWithSr25519Secret, verifySlotAccountSignature, verifySr25519Signature, } from '../crypto.js';
|
|
6
6
|
import { toError } from '../helpers.js';
|
|
7
7
|
function createSr25519SchemeProver(secret, scheme) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Priority epoch base: seconds at 2025-11-15T00:00:00Z. The low word is a u32 priority counted FROM
|
|
3
|
+
* this epoch (spec §1), not the raw Unix timestamp. iOS (StatementPriorityFactory.unixOffset) and
|
|
4
|
+
* Android subtract the same offset; omitting it makes the TS low word ~1.76e9 larger than every
|
|
5
|
+
* mobile client's, so any cross-client or shared-channel priority comparison would always rank a
|
|
6
|
+
* TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
9
|
+
/**
|
|
10
|
+
* Strictly-increasing source of statement expiries for one signing account,
|
|
11
|
+
* with a floor that can be raised to a chain-reported minimum.
|
|
12
|
+
*
|
|
13
|
+
* Layout: u64 = (0xFFFFFFFF << 32) | priority — see the module doc above.
|
|
14
|
+
* Supersession and account-quota eviction compare the whole u64 with
|
|
15
|
+
* strictly-greater semantics, so every writer signing with the SAME account
|
|
16
|
+
* must draw from ONE allocator instance: independent counters produce
|
|
17
|
+
* same-second priority ties that the store rejects.
|
|
18
|
+
*/
|
|
19
|
+
export type ExpiryAllocator = {
|
|
20
|
+
/** Next expiry: wall-clock-floored priority, bumped to stay strictly increasing. */
|
|
21
|
+
next(): bigint;
|
|
22
|
+
/**
|
|
23
|
+
* Adopt a chain-reported minimum (`AccountFullError` / `ExpiryTooLowError`
|
|
24
|
+
* `.min`, or the max expiry seen in channel history) so the next `next()`
|
|
25
|
+
* clears it. The chain is the source of truth for the floor; recomputing
|
|
26
|
+
* from the wall clock can never clear a pinned-high minimum.
|
|
27
|
+
*/
|
|
28
|
+
raiseFloor(min: bigint): void;
|
|
29
|
+
};
|
|
30
|
+
export declare function createExpiryAllocator(): ExpiryAllocator;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement expiry/priority, u64 = (expiration_epoch << 32) | priority (spec layout).
|
|
3
|
+
* We pin the high word to 0xFFFFFFFF (max → effectively non-expiring, matching iOS &
|
|
4
|
+
* Android) and use the low word as a wall-clock-floored monotonic priority, so channel
|
|
5
|
+
* supersession is driven by the priority regardless of how the store compares the field.
|
|
6
|
+
*/
|
|
7
|
+
const NEVER_EXPIRE_HIGH = 0xffffffffn;
|
|
8
|
+
/**
|
|
9
|
+
* Priority epoch base: seconds at 2025-11-15T00:00:00Z. The low word is a u32 priority counted FROM
|
|
10
|
+
* this epoch (spec §1), not the raw Unix timestamp. iOS (StatementPriorityFactory.unixOffset) and
|
|
11
|
+
* Android subtract the same offset; omitting it makes the TS low word ~1.76e9 larger than every
|
|
12
|
+
* mobile client's, so any cross-client or shared-channel priority comparison would always rank a
|
|
13
|
+
* TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
|
|
14
|
+
*/
|
|
15
|
+
export const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
16
|
+
/** Returns a value strictly greater than `current` (i.e. `max(current + 1, now-priority)`). */
|
|
17
|
+
function nextExpiry(current) {
|
|
18
|
+
const nowSecs = BigInt(Math.floor(Date.now() / 1000));
|
|
19
|
+
const priority = nowSecs > PRIORITY_EPOCH_OFFSET ? nowSecs - PRIORITY_EPOCH_OFFSET : 0n;
|
|
20
|
+
const timestampPriority = (NEVER_EXPIRE_HIGH << 32n) | priority;
|
|
21
|
+
return timestampPriority > current ? timestampPriority : current + 1n;
|
|
22
|
+
}
|
|
23
|
+
export function createExpiryAllocator() {
|
|
24
|
+
let current = 0n;
|
|
25
|
+
return {
|
|
26
|
+
next() {
|
|
27
|
+
current = nextExpiry(current);
|
|
28
|
+
return current;
|
|
29
|
+
},
|
|
30
|
+
raiseFloor(min) {
|
|
31
|
+
if (min > current)
|
|
32
|
+
current = min;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PRIORITY_EPOCH_OFFSET, createExpiryAllocator } from './allocator.js';
|
|
3
|
+
const NOW_SECS = 1_790_000_000; // 2026-09-22, safely past the 2025-11-15 priority epoch
|
|
4
|
+
describe('createExpiryAllocator', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(NOW_SECS * 1000);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
it('pins the high 32 bits to 0xFFFFFFFF and counts the low word from the priority epoch', () => {
|
|
13
|
+
const allocator = createExpiryAllocator();
|
|
14
|
+
const expiry = allocator.next();
|
|
15
|
+
expect(expiry >> 32n).toBe(0xffffffffn);
|
|
16
|
+
expect(expiry & 0xffffffffn).toBe(BigInt(NOW_SECS) - PRIORITY_EPOCH_OFFSET);
|
|
17
|
+
});
|
|
18
|
+
it('is strictly monotonic within the same second', () => {
|
|
19
|
+
const allocator = createExpiryAllocator();
|
|
20
|
+
const first = allocator.next();
|
|
21
|
+
const second = allocator.next();
|
|
22
|
+
expect(second).toBe(first + 1n);
|
|
23
|
+
});
|
|
24
|
+
it('jumps above a raised floor so the next submit clears the chain minimum', () => {
|
|
25
|
+
const allocator = createExpiryAllocator();
|
|
26
|
+
const chainMin = (0xffffffffn << 32n) | 4000000000n; // a poisoned account's minimum
|
|
27
|
+
allocator.raiseFloor(chainMin);
|
|
28
|
+
expect(allocator.next()).toBeGreaterThan(chainMin);
|
|
29
|
+
});
|
|
30
|
+
it('ignores a floor below the current value', () => {
|
|
31
|
+
const allocator = createExpiryAllocator();
|
|
32
|
+
const before = allocator.next();
|
|
33
|
+
allocator.raiseFloor(0n);
|
|
34
|
+
expect(allocator.next()).toBeGreaterThan(before);
|
|
35
|
+
});
|
|
36
|
+
it('keeps independent instances independent', () => {
|
|
37
|
+
const a = createExpiryAllocator();
|
|
38
|
+
const b = createExpiryAllocator();
|
|
39
|
+
a.raiseFloor((0xffffffffn << 32n) | 4000000000n);
|
|
40
|
+
expect(b.next() & 0xffffffffn).toBe(BigInt(NOW_SECS) - PRIORITY_EPOCH_OFFSET);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ResultAsync } from 'neverthrow';
|
|
2
|
+
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* AccountFull / ExpiryTooLow are priority errors: never a chain/statement
|
|
5
|
+
* failure, only a sign the submitter's expiry lagged the chain's priority
|
|
6
|
+
* floor (channel supersession for ExpiryTooLow, account-quota eviction for
|
|
7
|
+
* AccountFull — both report the minimum to clear).
|
|
8
|
+
*/
|
|
9
|
+
export declare function isPriorityTooLow(error: unknown): error is ExpiryTooLowError | AccountFullError;
|
|
10
|
+
export type SubmitRetryOptions = {
|
|
11
|
+
/** Retry budget for non-priority (transient infra) errors. 0 = propagate immediately. */
|
|
12
|
+
attempts: number;
|
|
13
|
+
/**
|
|
14
|
+
* Retry budget for priority errors. A number gives bounded retries then
|
|
15
|
+
* propagation (for callers with their own outer retry/outbox). 'unbounded'
|
|
16
|
+
* retries while `shouldRetry()` holds and, once it no longer does, settles a
|
|
17
|
+
* priority rejection as success — the submission lost the channel race to a
|
|
18
|
+
* newer statement, which is benign (session semantics).
|
|
19
|
+
*/
|
|
20
|
+
priorityAttempts: number | 'unbounded';
|
|
21
|
+
/** Backoff before each retry: a constant, or a per-retry schedule (last entry repeats). */
|
|
22
|
+
delaysMs: number | number[];
|
|
23
|
+
/** Liveness gate, re-checked before every retry. Default: always live. */
|
|
24
|
+
shouldRetry?: () => boolean;
|
|
25
|
+
/** Observe each scheduled retry (logging hook). `attempt` is 0-based. */
|
|
26
|
+
onRetry?: (info: {
|
|
27
|
+
attempt: number;
|
|
28
|
+
delayMs: number;
|
|
29
|
+
error: Error;
|
|
30
|
+
}) => void;
|
|
31
|
+
};
|
|
32
|
+
export declare function submitWithRetry(submit: () => ResultAsync<void, Error>, options: SubmitRetryOptions): ResultAsync<void, Error>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ResultAsync, err, ok } from 'neverthrow';
|
|
2
|
+
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* AccountFull / ExpiryTooLow are priority errors: never a chain/statement
|
|
5
|
+
* failure, only a sign the submitter's expiry lagged the chain's priority
|
|
6
|
+
* floor (channel supersession for ExpiryTooLow, account-quota eviction for
|
|
7
|
+
* AccountFull — both report the minimum to clear).
|
|
8
|
+
*/
|
|
9
|
+
export function isPriorityTooLow(error) {
|
|
10
|
+
return error instanceof ExpiryTooLowError || error instanceof AccountFullError;
|
|
11
|
+
}
|
|
12
|
+
function delayFor(delaysMs, attempt) {
|
|
13
|
+
if (typeof delaysMs === 'number')
|
|
14
|
+
return delaysMs;
|
|
15
|
+
return delaysMs[Math.min(attempt, delaysMs.length - 1)] ?? 0;
|
|
16
|
+
}
|
|
17
|
+
export function submitWithRetry(submit, options) {
|
|
18
|
+
const { attempts, priorityAttempts, delaysMs, shouldRetry = () => true, onRetry } = options;
|
|
19
|
+
// How to settle once we stop retrying: under the 'unbounded' policy a
|
|
20
|
+
// no-longer-live submission rejected with a priority error simply lost the
|
|
21
|
+
// channel race to a newer, higher-priority statement — benign, so report success.
|
|
22
|
+
const settle = (error) => priorityAttempts === 'unbounded' && !shouldRetry() && isPriorityTooLow(error) ? ok() : err(error);
|
|
23
|
+
// Iterative on purpose: a recursive ResultAsync chain holds one pending wrapper
|
|
24
|
+
// promise per attempt, which grows without bound under 'unbounded' retries.
|
|
25
|
+
const run = async () => {
|
|
26
|
+
let attemptsLeft = attempts;
|
|
27
|
+
let priorityLeft = priorityAttempts;
|
|
28
|
+
for (let attempt = 0;; attempt++) {
|
|
29
|
+
const result = await submit();
|
|
30
|
+
if (result.isOk())
|
|
31
|
+
return result;
|
|
32
|
+
const error = result.error;
|
|
33
|
+
const priority = isPriorityTooLow(error);
|
|
34
|
+
const budgetLeft = priority ? priorityLeft : attemptsLeft;
|
|
35
|
+
if (!shouldRetry() || (typeof budgetLeft === 'number' && budgetLeft <= 0))
|
|
36
|
+
return settle(error);
|
|
37
|
+
const delayMs = delayFor(delaysMs, attempt);
|
|
38
|
+
onRetry?.({ attempt, delayMs, error });
|
|
39
|
+
if (priority) {
|
|
40
|
+
if (priorityLeft !== 'unbounded')
|
|
41
|
+
priorityLeft -= 1;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
attemptsLeft -= 1;
|
|
45
|
+
}
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
47
|
+
if (!shouldRetry())
|
|
48
|
+
return settle(error);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return new ResultAsync(run());
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { errAsync, okAsync } from 'neverthrow';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
4
|
+
import { isPriorityTooLow, submitWithRetry } from './retry.js';
|
|
5
|
+
const FAST = { delaysMs: 1 }; // keep wall-clock time negligible
|
|
6
|
+
describe('isPriorityTooLow', () => {
|
|
7
|
+
it('matches exactly the two priority error classes', () => {
|
|
8
|
+
expect(isPriorityTooLow(new AccountFullError(0n, 1n))).toBe(true);
|
|
9
|
+
expect(isPriorityTooLow(new ExpiryTooLowError(0n, 1n))).toBe(true);
|
|
10
|
+
expect(isPriorityTooLow(new Error('store rejected'))).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
describe('submitWithRetry', () => {
|
|
14
|
+
it("priorityAttempts 'unbounded': priority errors retry past the non-priority budget until they land", async () => {
|
|
15
|
+
let calls = 0;
|
|
16
|
+
const submit = vi.fn(() => ++calls <= 6 ? errAsync(new AccountFullError(0n, 1n)) : okAsync(undefined));
|
|
17
|
+
const result = await submitWithRetry(submit, { ...FAST, attempts: 3, priorityAttempts: 'unbounded' });
|
|
18
|
+
expect(result.isOk()).toBe(true);
|
|
19
|
+
expect(calls).toBe(7);
|
|
20
|
+
});
|
|
21
|
+
it("priorityAttempts 'unbounded': a no-longer-live priority rejection settles as success", async () => {
|
|
22
|
+
const submit = vi.fn(() => errAsync(new ExpiryTooLowError(0n, 1n)));
|
|
23
|
+
const result = await submitWithRetry(submit, {
|
|
24
|
+
...FAST,
|
|
25
|
+
attempts: 3,
|
|
26
|
+
priorityAttempts: 'unbounded',
|
|
27
|
+
shouldRetry: () => false,
|
|
28
|
+
});
|
|
29
|
+
expect(result.isOk()).toBe(true); // lost the channel race — benign
|
|
30
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
it('priorityAttempts budgeted: priority errors consume their budget then propagate', async () => {
|
|
33
|
+
const submit = vi.fn(() => errAsync(new AccountFullError(0n, 1n)));
|
|
34
|
+
const result = await submitWithRetry(submit, { ...FAST, attempts: 0, priorityAttempts: 3 });
|
|
35
|
+
expect(result.isErr()).toBe(true);
|
|
36
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(AccountFullError);
|
|
37
|
+
expect(submit).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
|
38
|
+
});
|
|
39
|
+
it('attempts 0: a non-priority error propagates immediately', async () => {
|
|
40
|
+
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
41
|
+
const result = await submitWithRetry(submit, { ...FAST, attempts: 0, priorityAttempts: 3 });
|
|
42
|
+
expect(result.isErr()).toBe(true);
|
|
43
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
it('non-priority errors consume the attempts budget then propagate', async () => {
|
|
46
|
+
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
47
|
+
const result = await submitWithRetry(submit, { ...FAST, attempts: 2, priorityAttempts: 'unbounded' });
|
|
48
|
+
expect(result.isErr()).toBe(true);
|
|
49
|
+
expect(submit).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
|
50
|
+
});
|
|
51
|
+
it('per-attempt delay schedule is honored and reported via onRetry', async () => {
|
|
52
|
+
let calls = 0;
|
|
53
|
+
const submit = vi.fn(() => ++calls <= 2 ? errAsync(new AccountFullError(0n, 1n)) : okAsync(undefined));
|
|
54
|
+
const seen = [];
|
|
55
|
+
const result = await submitWithRetry(submit, {
|
|
56
|
+
attempts: 0,
|
|
57
|
+
priorityAttempts: 3,
|
|
58
|
+
delaysMs: [1, 2, 3],
|
|
59
|
+
onRetry: ({ attempt, delayMs }) => seen.push({ attempt, delayMs }),
|
|
60
|
+
});
|
|
61
|
+
expect(result.isOk()).toBe(true);
|
|
62
|
+
expect(seen).toEqual([
|
|
63
|
+
{ attempt: 0, delayMs: 1 },
|
|
64
|
+
{ attempt: 1, delayMs: 2 },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
it('a negative budget propagates immediately instead of looping', async () => {
|
|
68
|
+
const submit = vi.fn(() => errAsync(new Error('store rejected')));
|
|
69
|
+
const result = await submitWithRetry(submit, { ...FAST, attempts: -1, priorityAttempts: 3 });
|
|
70
|
+
expect(result.isErr()).toBe(true);
|
|
71
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
it('a shouldRetry flip during the backoff settles a priority rejection as success', async () => {
|
|
74
|
+
let live = true;
|
|
75
|
+
const submit = vi.fn(() => {
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
live = false; // superseded while the backoff sleep is pending
|
|
78
|
+
});
|
|
79
|
+
return errAsync(new ExpiryTooLowError(0n, 1n));
|
|
80
|
+
});
|
|
81
|
+
const result = await submitWithRetry(submit, {
|
|
82
|
+
...FAST,
|
|
83
|
+
attempts: 0,
|
|
84
|
+
priorityAttempts: 'unbounded',
|
|
85
|
+
shouldRetry: () => live,
|
|
86
|
+
});
|
|
87
|
+
expect(result.isOk()).toBe(true); // settled after the delay, no second attempt
|
|
88
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
it('shouldRetry is re-checked before each retry and stops the loop', async () => {
|
|
91
|
+
let live = true;
|
|
92
|
+
const submit = vi.fn(() => {
|
|
93
|
+
live = false; // superseded after the first attempt
|
|
94
|
+
return errAsync(new Error('store rejected'));
|
|
95
|
+
});
|
|
96
|
+
const result = await submitWithRetry(submit, {
|
|
97
|
+
...FAST,
|
|
98
|
+
attempts: 3,
|
|
99
|
+
priorityAttempts: 'unbounded',
|
|
100
|
+
shouldRetry: () => live,
|
|
101
|
+
});
|
|
102
|
+
expect(result.isErr()).toBe(true); // non-priority + not live → propagate, no settle
|
|
103
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ResultAsync } from 'neverthrow';
|
|
2
|
+
import type { StatementStoreAdapter } from '../adapter/types.js';
|
|
3
|
+
import type { StatementProver } from '../session/statementProver.js';
|
|
4
|
+
import type { ExpiryAllocator } from './allocator.js';
|
|
5
|
+
import type { SubmitRetryOptions } from './retry.js';
|
|
6
|
+
export type SubmitStatementParams = {
|
|
7
|
+
statementStore: StatementStoreAdapter;
|
|
8
|
+
prover: StatementProver;
|
|
9
|
+
/**
|
|
10
|
+
* Shared per-signing-account expiry source
|
|
11
|
+
**/
|
|
12
|
+
allocator: ExpiryAllocator;
|
|
13
|
+
channel: Uint8Array;
|
|
14
|
+
topics: Uint8Array[];
|
|
15
|
+
/**
|
|
16
|
+
* Opaque payload — encryption (if any) is the caller's concern.
|
|
17
|
+
**/
|
|
18
|
+
data: Uint8Array;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* One submit attempt: allocate the next expiry, build and prove the
|
|
22
|
+
* statement, submit it, and on a priority rejection adopt the chain-reported
|
|
23
|
+
* minimum into the allocator so the NEXT attempt clears it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function submitStatementOnce(params: SubmitStatementParams): ResultAsync<void, Error>;
|
|
26
|
+
export declare function signAndSubmitStatement(params: SubmitStatementParams & {
|
|
27
|
+
retry: SubmitRetryOptions;
|
|
28
|
+
}): ResultAsync<void, Error>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { toHex } from '@novasamatech/scale';
|
|
2
|
+
import { isPriorityTooLow, submitWithRetry } from './retry.js';
|
|
3
|
+
/**
|
|
4
|
+
* One submit attempt: allocate the next expiry, build and prove the
|
|
5
|
+
* statement, submit it, and on a priority rejection adopt the chain-reported
|
|
6
|
+
* minimum into the allocator so the NEXT attempt clears it.
|
|
7
|
+
*/
|
|
8
|
+
export function submitStatementOnce(params) {
|
|
9
|
+
const { statementStore, prover, allocator, channel, topics, data } = params;
|
|
10
|
+
const unsigned = {
|
|
11
|
+
expiry: allocator.next(),
|
|
12
|
+
channel: toHex(channel),
|
|
13
|
+
topics: topics.map(toHex),
|
|
14
|
+
data,
|
|
15
|
+
};
|
|
16
|
+
return prover
|
|
17
|
+
.generateMessageProof(unsigned)
|
|
18
|
+
.andThen(statementStore.submitStatement)
|
|
19
|
+
.orTee(error => {
|
|
20
|
+
// The chain is the source of truth for the account/channel priority floor.
|
|
21
|
+
if (isPriorityTooLow(error)) {
|
|
22
|
+
allocator.raiseFloor(error.min);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function signAndSubmitStatement(params) {
|
|
27
|
+
return submitWithRetry(() => submitStatementOnce(params), params.retry);
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { errAsync, okAsync } from 'neverthrow';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { AccountFullError } from '../adapter/types.js';
|
|
4
|
+
import { createExpiryAllocator } from './allocator.js';
|
|
5
|
+
import { signAndSubmitStatement, submitStatementOnce } from './submitStatement.js';
|
|
6
|
+
const NOW_SECS = 1_790_000_000;
|
|
7
|
+
// Passthrough prover: "signs" by returning the statement unchanged.
|
|
8
|
+
const fakeProver = {
|
|
9
|
+
generateMessageProof: (stmt) => okAsync(stmt),
|
|
10
|
+
};
|
|
11
|
+
function makeStore(failFirstWithMin) {
|
|
12
|
+
const submitted = [];
|
|
13
|
+
let calls = 0;
|
|
14
|
+
const adapter = {
|
|
15
|
+
submitStatement: vi.fn((stmt) => {
|
|
16
|
+
submitted.push(stmt);
|
|
17
|
+
calls += 1;
|
|
18
|
+
return failFirstWithMin !== undefined && calls === 1
|
|
19
|
+
? errAsync(new AccountFullError(stmt.expiry ?? 0n, failFirstWithMin))
|
|
20
|
+
: okAsync(undefined);
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
return { adapter, submitted };
|
|
24
|
+
}
|
|
25
|
+
const baseParams = (adapter) => ({
|
|
26
|
+
statementStore: adapter,
|
|
27
|
+
prover: fakeProver,
|
|
28
|
+
allocator: createExpiryAllocator(),
|
|
29
|
+
channel: new Uint8Array(32),
|
|
30
|
+
topics: [new Uint8Array(32)],
|
|
31
|
+
data: new Uint8Array([1]),
|
|
32
|
+
});
|
|
33
|
+
describe('submitStatementOnce', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.useFakeTimers();
|
|
36
|
+
vi.setSystemTime(NOW_SECS * 1000);
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
it('submits with the pinned-high expiry layout', async () => {
|
|
42
|
+
const { adapter, submitted } = makeStore();
|
|
43
|
+
const result = await submitStatementOnce(baseParams(adapter));
|
|
44
|
+
expect(result.isOk()).toBe(true);
|
|
45
|
+
expect(submitted).toHaveLength(1);
|
|
46
|
+
expect((submitted[0].expiry ?? 0n) >> 32n).toBe(0xffffffffn);
|
|
47
|
+
});
|
|
48
|
+
it('raises the allocator floor on a priority rejection so the next attempt clears the minimum', async () => {
|
|
49
|
+
const chainMin = (0xffffffffn << 32n) | 4000000000n;
|
|
50
|
+
const { adapter, submitted } = makeStore(chainMin);
|
|
51
|
+
const params = baseParams(adapter);
|
|
52
|
+
const first = await submitStatementOnce(params);
|
|
53
|
+
const second = await submitStatementOnce(params);
|
|
54
|
+
expect(first.isErr()).toBe(true);
|
|
55
|
+
expect(second.isOk()).toBe(true);
|
|
56
|
+
expect(submitted[1].expiry ?? 0n).toBeGreaterThan(chainMin); // adopted min, not wall clock
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('signAndSubmitStatement', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
vi.setSystemTime(NOW_SECS * 1000);
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.useRealTimers();
|
|
66
|
+
});
|
|
67
|
+
it('retries a priority rejection above the chain-reported minimum within the priority budget', async () => {
|
|
68
|
+
const chainMin = (0xffffffffn << 32n) | 4000000000n;
|
|
69
|
+
const { adapter, submitted } = makeStore(chainMin);
|
|
70
|
+
const promise = signAndSubmitStatement({
|
|
71
|
+
...baseParams(adapter),
|
|
72
|
+
retry: { attempts: 0, priorityAttempts: 3, delaysMs: [500, 1500, 3000] },
|
|
73
|
+
});
|
|
74
|
+
await vi.advanceTimersByTimeAsync(600); // cover the 500ms first backoff
|
|
75
|
+
const result = await promise;
|
|
76
|
+
expect(result.isOk()).toBe(true);
|
|
77
|
+
expect(submitted).toHaveLength(2);
|
|
78
|
+
expect(submitted[1].expiry ?? 0n).toBeGreaterThan(chainMin);
|
|
79
|
+
});
|
|
80
|
+
it('propagates after exhausting the priority budget on a persistent rejection', async () => {
|
|
81
|
+
const submitted = [];
|
|
82
|
+
const adapter = {
|
|
83
|
+
submitStatement: vi.fn((stmt) => {
|
|
84
|
+
submitted.push(stmt);
|
|
85
|
+
// Chain min keeps rising above whatever we submit — never lands.
|
|
86
|
+
return errAsync(new AccountFullError(stmt.expiry ?? 0n, (stmt.expiry ?? 0n) + 1000000n));
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
const promise = signAndSubmitStatement({
|
|
90
|
+
...baseParams(adapter),
|
|
91
|
+
retry: { attempts: 0, priorityAttempts: 3, delaysMs: 1 },
|
|
92
|
+
});
|
|
93
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
94
|
+
const result = await promise;
|
|
95
|
+
expect(result.isErr()).toBe(true);
|
|
96
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(AccountFullError);
|
|
97
|
+
expect(submitted).toHaveLength(4); // 1 initial + 3 priority retries
|
|
98
|
+
});
|
|
99
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/statement-store",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.8.7-
|
|
4
|
+
"version": "0.8.7-3",
|
|
5
5
|
"description": "Statement store integration",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/scale": "0.8.7-
|
|
28
|
+
"@novasamatech/scale": "0.8.7-3",
|
|
29
29
|
"@novasamatech/sdk-statement": "^0.6.0",
|
|
30
|
-
"@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-
|
|
30
|
+
"@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-3",
|
|
31
31
|
"@polkadot-api/substrate-bindings": "^0.20.3",
|
|
32
32
|
"@polkadot-api/substrate-client": "^0.7.0",
|
|
33
33
|
"@polkadot-labs/hdkd-helpers": "^0.0.30",
|