@novasamatech/statement-store 0.8.7-2 → 0.8.7-4

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.
@@ -97,9 +97,9 @@ export function createPapiStatementStoreAdapter(lazyClient) {
97
97
  case 'dataTooLarge':
98
98
  return errAsync(new DataTooLargeError(result.submitted_size, result.available_size));
99
99
  case 'channelPriorityTooLow':
100
- return errAsync(new ExpiryTooLowError(result.submitted_expiry, result.min_expiry));
100
+ return errAsync(new ExpiryTooLowError(BigInt(result.submitted_expiry), BigInt(result.min_expiry)));
101
101
  case 'accountFull':
102
- return errAsync(new AccountFullError(result.submitted_expiry, result.min_expiry));
102
+ return errAsync(new AccountFullError(BigInt(result.submitted_expiry), BigInt(result.min_expiry)));
103
103
  case 'storeFull':
104
104
  return errAsync(new StorageFullError());
105
105
  case 'noAllowance':
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';
@@ -1 +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;
1
9
  export declare function nextExpiry(current: bigint): bigint;
@@ -13,7 +13,7 @@ const NEVER_EXPIRE_HIGH = 0xffffffffn;
13
13
  * mobile client's, so any cross-client or shared-channel priority comparison would always rank a
14
14
  * TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
15
15
  */
16
- const PRIORITY_EPOCH_OFFSET = 1763164800n;
16
+ export const PRIORITY_EPOCH_OFFSET = 1763164800n;
17
17
  export function nextExpiry(current) {
18
18
  const nowSecs = BigInt(Math.floor(Date.now() / 1000));
19
19
  const priority = nowSecs > PRIORITY_EPOCH_OFFSET ? nowSecs - PRIORITY_EPOCH_OFFSET : 0n;
@@ -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,4 +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 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;
@@ -1,14 +1,15 @@
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
- import { nextExpiry } from './priority.js';
12
13
  import { StatementData } from './scale/statementData.js';
13
14
  const DEFAULT_MAX_REQUEST_SIZE = 4096;
14
15
  // Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
@@ -68,38 +69,17 @@ function makeDeferred() {
68
69
  promise.catch(() => undefined);
69
70
  return { resolve, reject, promise };
70
71
  }
71
- // Retry a submit on failure with a short backoff. `shouldRetry` is re-checked before each retry:
72
- // once the submission is superseded, aborted, or the session is disposed it returns false, so a
73
- // stale retry can never resurrect an old statement.
74
- //
75
- // ExpiryTooLow is treated specially: it is never a chain/statement failure, only a sign our
76
- // in-memory expiry lagged the channel's on-chain priority. submitStatementData has already resynced
77
- // us above the reported minimum, so the next attempt submits higher. We therefore retry ExpiryTooLow
78
- // WITHOUT spending the `attemptsLeft` transient-failure budget — keeping at it until it lands or the
79
- // submission is superseded — and, once superseded, swallow it as success. The upshot: ExpiryTooLow
80
- // never surfaces to callers. (Other errors keep the bounded retry and propagate when exhausted.)
81
- function submitWithRetry(submit, attemptsLeft, shouldRetry) {
82
- // How to settle once we stop retrying: a no-longer-live submission rejected with ExpiryTooLow
83
- // simply lost the channel race to a newer, higher-priority statement — benign, so report success.
84
- const settle = (error) => !shouldRetry() && error instanceof ExpiryTooLowError ? okAsync(undefined) : errAsync(error);
85
- return submit().orElse(error => {
86
- const expiryTooLow = error instanceof ExpiryTooLowError;
87
- if (!shouldRetry() || (!expiryTooLow && attemptsLeft <= 0))
88
- return settle(error);
89
- // ExpiryTooLow is always recoverable while live, so it doesn't consume the retry budget.
90
- const nextAttempts = expiryTooLow ? attemptsLeft : attemptsLeft - 1;
91
- return ResultAsync.fromSafePromise(new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))).andThen(() => shouldRetry() ? submitWithRetry(submit, nextAttempts, shouldRetry) : settle(error));
92
- });
93
- }
94
- 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, }) {
95
73
  const outgoingSessionId = createSessionId(sessionKey, localAccount, remoteAccount);
96
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);
97
78
  // Message bytes must fit within the statement limit minus the fixed wire overhead.
98
79
  const maxPayloadSize = Math.max(0, maxRequestSize - STATEMENT_OVERHEAD);
99
80
  const state = {
100
81
  phase: 'initialization',
101
82
  initError: null,
102
- expiry: 0n,
103
83
  outgoingRequest: null,
104
84
  incomingRequests: new Map(),
105
85
  messageQueue: [],
@@ -118,28 +98,13 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
118
98
  // Id of the most recent response we initiated (responses share one channel, so only the
119
99
  // latest is live — a retry for an older one must not resurrect it).
120
100
  let lastResponseRequestId = null;
121
- // Submit on `channel`/`topicSessionId` at the next (strictly increasing) expiry.
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.
122
104
  function submitStatementData(channel, topicSessionId, data) {
123
- state.expiry = nextExpiry(state.expiry);
124
- const expiry = state.expiry;
125
105
  return encryption
126
106
  .encrypt(data)
127
- .map(encrypted => ({
128
- expiry,
129
- channel: toHex(channel),
130
- topics: [toHex(topicSessionId)],
131
- data: encrypted,
132
- }))
133
- .asyncAndThen(prover.generateMessageProof)
134
- .andThen(statementStore.submitStatement)
135
- .orElse(error => {
136
- // The chain is the source of truth for a channel's priority. If our in-memory counter
137
- // drifted behind it (a prior run, another writer, or propagation lag), resync to the
138
- // reported minimum so the retry — and every later submit — clears it.
139
- if (error instanceof ExpiryTooLowError && error.min > state.expiry)
140
- state.expiry = error.min;
141
- return errAsync(error);
142
- });
107
+ .asyncAndThen(encrypted => submitStatementOnce({ statementStore, prover, allocator, channel, topics: [topicSessionId], data: encrypted }));
143
108
  }
144
109
  // Settle and remove the pending-delivery entries for the given tokens.
145
110
  function settleTokens(tokens, settle) {
@@ -151,19 +116,32 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
151
116
  }
152
117
  }
153
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.
154
129
  function encodeAndSubmitRequest(requestId, messages) {
155
130
  encodeStatementData({ tag: 'request', value: { requestId, data: messages } })
156
- .asyncAndThen(data => submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data), MAX_SUBMIT_RETRIES,
157
- // Only keep retrying while this is still the live submission (not superseded by a
158
- // newer retransmit, aborted via clearOutgoingStatement, or disposed).
159
- () => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId))
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
+ }))
160
139
  .mapErr(e => {
161
- // ExpiryTooLow is handled in submitWithRetry (retried until it lands, swallowed once
162
- // superseded), so an error reaching here is a different, genuine failure. If this submission
163
- // was already superseded by a newer retransmit (same tokens) it is not the live request's
164
- // concern drop it silently; the newer one carries the waiters. Otherwise the bounded
165
- // retries are exhausted on the LIVE submission: the request never landed, so fail its
166
- // waiters rather than let them hang.
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.
167
145
  const outgoing = state.outgoingRequest;
168
146
  if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
169
147
  return;
@@ -368,14 +346,12 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
368
346
  if (s.expiry !== undefined && s.expiry > maxExpiry)
369
347
  maxExpiry = s.expiry;
370
348
  }
371
- // Never regress the counter. The query is a snapshot taken when init() began; a statement
372
- // submitted while init was in flight (e.g. an auto-ACK for a peer request that arrived during
373
- // the query) has already advanced both state.expiry and the on-chain channel past that
374
- // snapshot. Overwriting unconditionally would drop the counter below the on-chain priority,
375
- // making the next submit collide at an equal expiry.
376
- const seeded = nextExpiry(maxExpiry);
377
- if (seeded > state.expiry)
378
- 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);
379
355
  for (const s of [...ownStatements, ...peerStatements]) {
380
356
  if (s.data)
381
357
  state.seenStatements.add(toHex(s.data));
@@ -474,28 +450,29 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
474
450
  lastResponseRequestId = requestId;
475
451
  // Responses go on OUR outgoing topic/response-channel (per spec: the responder
476
452
  // publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
477
- return (submitWithRetry(() => submitStatementData(createResponseChannel(outgoingSessionId), outgoingSessionId, encoded.value), MAX_SUBMIT_RETRIES,
478
- // Stop retrying once a newer response supersedes this one (shared response channel) or disposed.
479
- () => !disposed && lastResponseRequestId === requestId)
480
- // Answered: it no longer needs replaying to future subscribers.
481
- .andTee(() => pruneBufferedRequest(requestId))
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
+ })
482
460
  .orElse(error => {
483
- // ExpiryTooLow is handled in submitWithRetry (swallowed once a newer response supersedes
484
- // this one on the shared channel), so an error here is a different failure. If this is no
485
- // longer the latest response (superseded) or the session is disposed, keep the request
486
- // marked answeredre-answering would only clobber the newer response and absorb the
487
- // error. NOTE: the shared response channel still only exposes the latest response to the
488
- // peer, so reliably ACKing several outstanding requests needs the protocol-level fix
489
- // tracked separately.
490
- if (disposed || lastResponseRequestId !== requestId) {
491
- pruneBufferedRequest(requestId);
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 responseand 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)
492
468
  return okAsync(undefined);
493
- }
494
469
  // The live response genuinely failed after exhausting retries — roll back so a later
495
470
  // peer retransmit can still be answered, and surface the error.
496
471
  incoming.responded = false;
497
472
  return errAsync(error);
498
- }));
473
+ })
474
+ // Answered (or absorbed as such): it no longer needs replaying to future subscribers.
475
+ .andTee(() => pruneBufferedRequest(requestId)));
499
476
  },
500
477
  waitForRequestMessage(codec, filter) {
501
478
  const promise = new Promise((resolve, reject) => {
@@ -595,12 +572,12 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
595
572
  return errAsync(encoded.error);
596
573
  // Supersede the live batch with an empty one. Use submitStatementData so the
597
574
  // empty statement goes out at a STRICTLY higher expiry — the store rejects an
598
- // equal-or-lower expiry on the same channel, so reusing state.expiry would
599
- // leave the original request live on-chain. Route through submitWithRetry with
600
- // shouldRetry:()=>false so it inherits the single ExpiryTooLow policy a rejection
601
- // means the channel already advanced past us, so the clear already happened → absorb it —
602
- // without retrying (clearing is a one-shot supersede, not a request that must land).
603
- return submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, encoded.value), 0, () => false);
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));
604
581
  },
605
582
  dispose() {
606
583
  disposed = true;
@@ -3,7 +3,7 @@ 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';
@@ -747,13 +747,16 @@ describe('session', () => {
747
747
  expect(second.isOk()).toBe(true);
748
748
  expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
749
749
  }, 3000);
750
- it('absorbs a superseded response rejected as ExpiryTooLow and keeps the request answered', async () => {
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) => {
751
754
  // Two incoming requests are answered on the SHARED response channel. The response to A is in
752
755
  // flight when the response to B takes over the channel; A then lands at a now-lower expiry and
753
- // the store rejects it (ExpiryTooLow). That supersession is expected: B's response owns the
754
- // channel, so A's rejection must be absorbed (not surfaced) and A must stay marked answered —
755
- // re-answering would only clobber B. (Returning ok here is also what stops respondToRequests
756
- // from logging it as a failed response.)
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.)
757
760
  const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
758
761
  const { subscribeStatements, callbacks } = capturingSubscribe();
759
762
  const { submitStatement, pendings } = deferredSubmit();
@@ -768,7 +771,7 @@ describe('session', () => {
768
771
  await delay(); // both reach submitStatement
769
772
  expect(pendings).toHaveLength(2);
770
773
  pendings.find(p => p.requestId === 'B').settle(ok(undefined)); // B lands, owns the channel
771
- pendings.find(p => p.requestId === 'A').settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late, rejected
774
+ pendings.find(p => p.requestId === 'A').settle(err(new PriorityError(0n, 0n))); // A lands late, rejected
772
775
  const resA = await resAPromise;
773
776
  const resB = await resBPromise;
774
777
  expect(resB.isOk()).toBe(true);
@@ -950,14 +953,18 @@ describe('session', () => {
950
953
  expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2); // 1 failure + ≥1 retry
951
954
  session.dispose();
952
955
  }, 3000);
953
- it('resyncs its expiry above the chain minimum after an ExpiryTooLow rejection', async () => {
954
- // The in-memory expiry counter has drifted behind the channel's real priority (prior run /
955
- // other writer / propagation lag). The chain reports the minimum; the retry must clear it.
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.
956
963
  const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n; // well above the wall-clock priority
957
964
  let calls = 0;
958
965
  const submitStatement = vi.fn((stmt) => {
959
966
  calls++;
960
- return calls === 1 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined);
967
+ return calls === 1 ? errAsync(new PriorityError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined);
961
968
  });
962
969
  const { session, adapter } = makeSession({ submitStatement });
963
970
  await delay();
@@ -968,14 +975,17 @@ describe('session', () => {
968
975
  expect(retried.expiry ?? 0n).toBeGreaterThan(CHAIN_MIN); // healed past the chain minimum
969
976
  session.dispose();
970
977
  }, 3000);
971
- it('keeps retrying a live ExpiryTooLow past the transient-retry cap until it lands', async () => {
972
- // ExpiryTooLow is a sync artifact, not a chain failure: while the submission is still live the
973
- // session keeps retrying (resyncing each time) BEYOND MAX_SUBMIT_RETRIES until it lands, and
974
- // never surfaces ExpiryTooLow to the caller. (A non-ExpiryTooLow error gives up at the cap
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 —
975
985
  // see the test below.)
976
986
  const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n;
977
987
  let calls = 0;
978
- const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
988
+ const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new PriorityError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
979
989
  const { session } = makeSession({ submitStatement });
980
990
  await delay();
981
991
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -986,7 +996,7 @@ describe('session', () => {
986
996
  await new Promise(resolve => setTimeout(resolve, 300)); // 6 retries × 25ms backoff + slack
987
997
  expect(calls).toBeGreaterThanOrEqual(7); // retried well past the 3-attempt cap, then landed
988
998
  expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
989
- expect(waiterRejected).toBe(false); // ExpiryTooLow never surfaced to the caller
999
+ expect(waiterRejected).toBe(false); // the priority error never surfaced to the caller
990
1000
  }
991
1001
  finally {
992
1002
  errorSpy.mockRestore();
@@ -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 { fromHex } from 'polkadot-api/utils';
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-2",
4
+ "version": "0.8.7-4",
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-2",
28
+ "@novasamatech/scale": "0.8.7-4",
29
29
  "@novasamatech/sdk-statement": "^0.6.0",
30
- "@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-2",
30
+ "@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-4",
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",