@openzeppelin/guardian-client 0.16.1 → 0.17.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/http.ts CHANGED
@@ -11,6 +11,8 @@ import type {
11
11
  DeltaProposalRequest,
12
12
  DeltaProposalResponse,
13
13
  ExecutionDelta,
14
+ HistoryOptions,
15
+ HistoryPage,
14
16
  LookupResponse,
15
17
  PubkeyResponse,
16
18
  PushDeltaResponse,
@@ -26,6 +28,7 @@ import type {
26
28
  ServerAbandonCandidateResponse,
27
29
  ServerDeltaObject,
28
30
  ServerDeltaProposalResponse,
31
+ ServerHistoryPage,
29
32
  ServerLookupResponse,
30
33
  ServerProposalsResponse,
31
34
  ServerPubkeyResponse,
@@ -37,6 +40,7 @@ import type {
37
40
  import {
38
41
  fromServerConfigureResponse,
39
42
  fromServerDeltaObject,
43
+ fromServerHistoryPage,
40
44
  fromServerLookupResponse,
41
45
  fromServerStateObject,
42
46
  toServerConfigureRequest,
@@ -149,10 +153,13 @@ export class GuardianHttpError extends Error {
149
153
  */
150
154
  public readonly releasedAt: string | null;
151
155
 
156
+ private readonly headerRetryAfterSecs?: number;
157
+
152
158
  constructor(
153
159
  public readonly status: number,
154
160
  public readonly statusText: string,
155
- public readonly body: string
161
+ public readonly body: string,
162
+ retryAfterHeader?: string | null
156
163
  ) {
157
164
  // Only the parsed, user-safe message is folded into Error.message; the
158
165
  // raw body (which may carry backend/proxy internals) stays on the `body`
@@ -165,7 +172,37 @@ export class GuardianHttpError extends Error {
165
172
  this.userMessage = parsed?.message;
166
173
  this.meta = parsed?.meta;
167
174
  this.releasedAt = parsed?.meta.releasedAt ?? null;
175
+ this.headerRetryAfterSecs = parseRetryAfterSeconds(retryAfterHeader);
176
+ }
177
+
178
+ /**
179
+ * Whether the server marked this error safe to retry: `meta.retryable`
180
+ * from the envelope, falling back to the status class (429 rejections
181
+ * happen before any handler runs). Mirrors the Rust client's
182
+ * `ClientError::is_retryable`.
183
+ */
184
+ isRetryable(): boolean {
185
+ return this.meta?.retryable ?? this.status === 429;
168
186
  }
187
+
188
+ /**
189
+ * Server-provided backoff hint in seconds: the `Retry-After` header,
190
+ * falling back to `meta.retryAfterSecs`. Unparseable values mean no
191
+ * hint. Mirrors the Rust client's `ClientError::retry_after`.
192
+ */
193
+ retryAfterSecs(): number | undefined {
194
+ return this.headerRetryAfterSecs ?? this.meta?.retryAfterSecs;
195
+ }
196
+ }
197
+
198
+ // Decimal digits only, mirroring the Rust client's `parse::<u64>()`: no
199
+ // signs, exponents, hex, or empty strings (`Number('')` is 0).
200
+ function parseRetryAfterSeconds(header: string | null | undefined): number | undefined {
201
+ if (typeof header !== 'string') return undefined;
202
+ const trimmed = header.trim();
203
+ if (!/^\d+$/.test(trimmed)) return undefined;
204
+ const secs = Number(trimmed);
205
+ return Number.isSafeInteger(secs) ? secs : undefined;
169
206
  }
170
207
 
171
208
  /**
@@ -419,6 +456,33 @@ export class GuardianHttpClient {
419
456
  return fromServerDeltaObject(server);
420
457
  }
421
458
 
459
+ /**
460
+ * Fetch one page of the account's canonical delta history
461
+ * (issue #413), newest-first by nonce, with decoded input/output
462
+ * note summaries. Pass `options.cursor` from a previous page's
463
+ * `nextCursor` to resume; an absent `nextCursor` on the result means
464
+ * the feed is exhausted. Read-only: served while the account is
465
+ * paused. Only transactions pushed through Guardian appear.
466
+ */
467
+ async getDeltaHistory(accountId: string, options: HistoryOptions = {}): Promise<HistoryPage> {
468
+ // Signed payload and query string must carry the same values: the
469
+ // server signs the canonical JSON of its query struct, where limit
470
+ // stays a string and omitted parameters are omitted keys.
471
+ const requestPayload: Record<string, string> = { account_id: accountId };
472
+ if (options.limit !== undefined) {
473
+ requestPayload.limit = options.limit.toString();
474
+ }
475
+ if (options.cursor !== undefined) {
476
+ requestPayload.cursor = options.cursor;
477
+ }
478
+ const params = new URLSearchParams(requestPayload);
479
+ const response = await this.fetchAuthenticated(`/delta/history?${params}`, {
480
+ method: 'GET',
481
+ }, accountId, requestPayload);
482
+ const server = (await response.json()) as ServerHistoryPage;
483
+ return fromServerHistoryPage(server);
484
+ }
485
+
422
486
  private async fetch(path: string, init: RequestInit): Promise<Response> {
423
487
  const url = `${this.baseUrl}${path}`;
424
488
  const response = await fetch(url, {
@@ -431,7 +495,12 @@ export class GuardianHttpClient {
431
495
 
432
496
  if (!response.ok) {
433
497
  const body = await response.text();
434
- throw new GuardianHttpError(response.status, response.statusText, body);
498
+ throw new GuardianHttpError(
499
+ response.status,
500
+ response.statusText,
501
+ body,
502
+ response.headers.get('Retry-After')
503
+ );
435
504
  }
436
505
 
437
506
  return response;
@@ -504,15 +573,16 @@ export class GuardianHttpClient {
504
573
  },
505
574
  });
506
575
  } catch (err) {
507
- // Replay rejections (stale timestamp) are transient: retry once with a
508
- // fresh timestamp. The specific "Replay attack" detail is now sanitized
509
- // off the wire (feature 009), so branch on the stable auth code instead.
510
- // Retrying a genuine auth failure is harmless — one extra attempt that
511
- // also fails — and only happens when a retry budget remains.
576
+ // Replay rejections are transient: the request was correctly signed
577
+ // but lost the server's per-signer timestamp CAS. Retry with a fresh
578
+ // timestamp and signature, branching only on the dedicated
579
+ // `authentication_replay` code (issue #367); terminal authentication
580
+ // failures (invalid signature, clock outside the skew window) are
581
+ // never retried.
512
582
  if (
513
583
  retries > 0 &&
514
584
  err instanceof GuardianHttpError &&
515
- err.code === 'authentication_failed'
585
+ err.code === 'authentication_replay'
516
586
  ) {
517
587
  await new Promise((resolve) => setTimeout(resolve, 50));
518
588
  return this.fetchAuthenticated(path, init, accountId, requestPayload, retries - 1);
package/src/index.ts CHANGED
@@ -34,4 +34,14 @@ export type {
34
34
  SignProposalRequest,
35
35
  LookupAccount,
36
36
  LookupResponse,
37
+ HistoryDecodeSection,
38
+ HistoryDecodeWarning,
39
+ HistoryEntry,
40
+ HistoryEntryStatus,
41
+ HistoryNote,
42
+ HistoryNoteAsset,
43
+ HistoryNoteTag,
44
+ HistoryNoteVisibility,
45
+ HistoryOptions,
46
+ HistoryPage,
37
47
  } from './types.js';
@@ -57,6 +57,12 @@ export interface ServerProposalMetadata {
57
57
  amount?: string;
58
58
  /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */
59
59
  note_type?: string;
60
+ /** Base64-serialized Miden `ChainAnchor` pinning the proposal's reference block. */
61
+ chain_anchor?: string;
62
+ /** P2IDE reclaim block height (issue #366). Presence of either height means a P2IDE note. */
63
+ reclaim_height?: number;
64
+ /** P2IDE timelock block height (issue #366). */
65
+ timelock_height?: number;
60
66
  }
61
67
 
62
68
  export interface ServerDeltaObject {
@@ -179,3 +185,40 @@ export interface ServerLookupAccount {
179
185
  export interface ServerLookupResponse {
180
186
  accounts: ServerLookupAccount[];
181
187
  }
188
+
189
+ // --- Delta history (issue #413) ---
190
+
191
+ export interface ServerHistoryNoteAsset {
192
+ asset_id: string;
193
+ kind: 'fungible' | 'non_fungible';
194
+ amount?: string;
195
+ }
196
+
197
+ export interface ServerHistoryNote {
198
+ note_id: string;
199
+ tag: 'p2id' | 'p2ide' | 'pswap' | 'mint' | 'burn' | 'custom';
200
+ note_type: 'public' | 'private';
201
+ assets: ServerHistoryNoteAsset[];
202
+ sender?: string;
203
+ recipient?: string;
204
+ }
205
+
206
+ export interface ServerHistoryDecodeWarning {
207
+ section: 'tx_summary' | 'metadata' | 'input_notes' | 'output_notes' | 'vault' | 'storage';
208
+ reason: string;
209
+ }
210
+
211
+ export interface ServerHistoryEntry {
212
+ nonce: number;
213
+ status: 'canonical';
214
+ timestamp: string;
215
+ new_commitment: string | null;
216
+ input_notes: ServerHistoryNote[];
217
+ output_notes: ServerHistoryNote[];
218
+ decode_warnings?: ServerHistoryDecodeWarning[];
219
+ }
220
+
221
+ export interface ServerHistoryPage {
222
+ items: ServerHistoryEntry[];
223
+ next_cursor: string | null;
224
+ }
package/src/types.ts CHANGED
@@ -105,6 +105,17 @@ export interface ProposalMetadata {
105
105
  amount?: string;
106
106
  /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */
107
107
  noteType?: string;
108
+ /**
109
+ * Base64-serialized Miden `ChainAnchor` pinning the reference block the
110
+ * proposal's transaction summary was built at. Since protocol 0.16 the
111
+ * signed summary binds the reference block commitment, so cosigners and the
112
+ * executor need this anchor to reproduce the summary the proposer signed.
113
+ */
114
+ chainAnchor?: string;
115
+ /** P2IDE reclaim block height (issue #366). Presence of either height means a P2IDE note. */
116
+ reclaimHeight?: number;
117
+ /** P2IDE timelock block height (issue #366). */
118
+ timelockHeight?: number;
108
119
  }
109
120
 
110
121
  export interface DeltaObject {
@@ -244,3 +255,90 @@ export interface LookupAccount {
244
255
  export interface LookupResponse {
245
256
  accounts: LookupAccount[];
246
257
  }
258
+
259
+ /** Note classification decoded from the on-chain note script. */
260
+ export type HistoryNoteTag = 'p2id' | 'p2ide' | 'pswap' | 'mint' | 'burn' | 'custom';
261
+
262
+ /** On-chain note visibility from the note metadata. */
263
+ export type HistoryNoteVisibility = 'public' | 'private';
264
+
265
+ /** Which section of the persisted payload failed to decode. */
266
+ export type HistoryDecodeSection =
267
+ | 'tx_summary'
268
+ | 'metadata'
269
+ | 'input_notes'
270
+ | 'output_notes'
271
+ | 'vault'
272
+ | 'storage';
273
+
274
+ /**
275
+ * Delta lifecycle status of a history entry. Only `canonical` is
276
+ * emitted today; the set widens if the feed gains a status filter.
277
+ */
278
+ export type HistoryEntryStatus = 'canonical';
279
+
280
+ /**
281
+ * One decoded asset inside a history note. `amount` is a base-10
282
+ * string for fungible assets, absent for non-fungible ones.
283
+ */
284
+ export interface HistoryNoteAsset {
285
+ assetId: string;
286
+ kind: 'fungible' | 'non_fungible';
287
+ amount?: string;
288
+ }
289
+
290
+ /**
291
+ * One decoded note attached to a history entry. `sender` / `recipient`
292
+ * are account IDs when the note script exposes them.
293
+ */
294
+ export interface HistoryNote {
295
+ noteId: string;
296
+ tag: HistoryNoteTag;
297
+ /** On-chain visibility from the note metadata. */
298
+ noteType: HistoryNoteVisibility;
299
+ assets: HistoryNoteAsset[];
300
+ sender?: string;
301
+ recipient?: string;
302
+ }
303
+
304
+ /**
305
+ * Why a history entry's note sections are empty: the persisted payload
306
+ * could not be decoded server-side (schema drift). The entry itself is
307
+ * still returned.
308
+ */
309
+ export interface HistoryDecodeWarning {
310
+ section: HistoryDecodeSection;
311
+ reason: string;
312
+ }
313
+
314
+ /** One canonical transaction in an account's history (issue #413). */
315
+ export interface HistoryEntry {
316
+ nonce: number;
317
+ /** Delta lifecycle status; always `canonical` today. */
318
+ status: HistoryEntryStatus;
319
+ /** RFC 3339 UTC timestamp at which the delta became canonical. */
320
+ timestamp: string;
321
+ /**
322
+ * Account commitment after this transaction; `undefined` when the
323
+ * stored row predates commitment recording.
324
+ */
325
+ newCommitment?: string;
326
+ inputNotes: HistoryNote[];
327
+ outputNotes: HistoryNote[];
328
+ decodeWarnings: HistoryDecodeWarning[];
329
+ }
330
+
331
+ /** One page of canonical delta history, newest-first by nonce. */
332
+ export interface HistoryPage {
333
+ entries: HistoryEntry[];
334
+ /** Opaque resume token; `undefined` when the feed is exhausted. */
335
+ nextCursor?: string;
336
+ }
337
+
338
+ /** Options for `getDeltaHistory`. */
339
+ export interface HistoryOptions {
340
+ /** Page size in `[1, 500]`; server default 50 when omitted. */
341
+ limit?: number;
342
+ /** Opaque `nextCursor` from a previous page. */
343
+ cursor?: string;
344
+ }