@openzeppelin/guardian-client 0.16.2 → 0.17.0-rc.2

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.
@@ -454,6 +454,21 @@ describe('conversion', () => {
454
454
  expect(result.salt).toBe(original.salt);
455
455
  });
456
456
 
457
+ it('chainAnchor survives roundtrip as chain_anchor on the wire', () => {
458
+ const original: ProposalMetadata = {
459
+ proposalType: 'add_signer',
460
+ targetThreshold: 2,
461
+ signerCommitments: ['0xabc'],
462
+ chainAnchor: 'bW9jay1jaGFpbi1hbmNob3I=',
463
+ };
464
+
465
+ const server = toServerProposalMetadata(original);
466
+ expect(server.chain_anchor).toBe('bW9jay1jaGFpbi1hbmNob3I=');
467
+
468
+ const result = fromServerProposalMetadata(server);
469
+ expect(result.chainAnchor).toBe('bW9jay1jaGFpbi1hbmNob3I=');
470
+ });
471
+
457
472
  it('p2id noteType survives roundtrip as note_type on the wire (issue #322)', () => {
458
473
  const original: ProposalMetadata = {
459
474
  proposalType: 'p2id',
@@ -469,5 +484,24 @@ describe('conversion', () => {
469
484
  const result = fromServerProposalMetadata(server);
470
485
  expect(result.noteType).toBe('private');
471
486
  });
487
+
488
+ it('p2ide heights survive roundtrip as reclaim_height/timelock_height on the wire (issue #366)', () => {
489
+ const original: ProposalMetadata = {
490
+ proposalType: 'p2id',
491
+ recipientId: '0xrecipient',
492
+ faucetId: '0xfaucet',
493
+ amount: '1000',
494
+ reclaimHeight: 12345,
495
+ timelockHeight: 700,
496
+ };
497
+
498
+ const server = toServerProposalMetadata(original);
499
+ expect(server.reclaim_height).toBe(12345);
500
+ expect(server.timelock_height).toBe(700);
501
+
502
+ const result = fromServerProposalMetadata(server);
503
+ expect(result.reclaimHeight).toBe(12345);
504
+ expect(result.timelockHeight).toBe(700);
505
+ });
472
506
  });
473
507
  });
package/src/conversion.ts CHANGED
@@ -6,6 +6,9 @@ import type {
6
6
  DeltaProposalRequest,
7
7
  DeltaStatus,
8
8
  ExecutionDelta,
9
+ HistoryEntry,
10
+ HistoryNote,
11
+ HistoryPage,
9
12
  LookupResponse,
10
13
  ProposalSignature,
11
14
  ProposalMetadata,
@@ -20,6 +23,9 @@ import type {
20
23
  ServerDeltaProposalRequest,
21
24
  ServerDeltaStatus,
22
25
  ServerExecutionDelta,
26
+ ServerHistoryEntry,
27
+ ServerHistoryNote,
28
+ ServerHistoryPage,
23
29
  ServerLookupResponse,
24
30
  ServerProposalSignature,
25
31
  ServerProposalMetadata,
@@ -109,6 +115,9 @@ export function fromServerProposalMetadata(server: ServerProposalMetadata): Prop
109
115
  faucetId: server.faucet_id,
110
116
  amount: server.amount,
111
117
  noteType: server.note_type,
118
+ chainAnchor: server.chain_anchor,
119
+ reclaimHeight: server.reclaim_height,
120
+ timelockHeight: server.timelock_height,
112
121
  };
113
122
  }
114
123
 
@@ -221,6 +230,9 @@ export function toServerProposalMetadata(meta: ProposalMetadata): ServerProposal
221
230
  faucet_id: meta.faucetId,
222
231
  amount: meta.amount,
223
232
  note_type: meta.noteType,
233
+ chain_anchor: meta.chainAnchor,
234
+ reclaim_height: meta.reclaimHeight,
235
+ timelock_height: meta.timelockHeight,
224
236
  };
225
237
  }
226
238
 
@@ -266,3 +278,37 @@ export function toServerExecutionDelta(delta: ExecutionDelta): ServerExecutionDe
266
278
  status: toServerDeltaStatus(delta.status),
267
279
  };
268
280
  }
281
+
282
+ function fromServerHistoryNote(server: ServerHistoryNote): HistoryNote {
283
+ return {
284
+ noteId: server.note_id,
285
+ tag: server.tag,
286
+ noteType: server.note_type,
287
+ assets: server.assets.map((asset) => ({
288
+ assetId: asset.asset_id,
289
+ kind: asset.kind,
290
+ amount: asset.amount,
291
+ })),
292
+ sender: server.sender,
293
+ recipient: server.recipient,
294
+ };
295
+ }
296
+
297
+ export function fromServerHistoryEntry(server: ServerHistoryEntry): HistoryEntry {
298
+ return {
299
+ nonce: server.nonce,
300
+ status: server.status,
301
+ timestamp: server.timestamp,
302
+ newCommitment: server.new_commitment ?? undefined,
303
+ inputNotes: server.input_notes.map(fromServerHistoryNote),
304
+ outputNotes: server.output_notes.map(fromServerHistoryNote),
305
+ decodeWarnings: server.decode_warnings ?? [],
306
+ };
307
+ }
308
+
309
+ export function fromServerHistoryPage(server: ServerHistoryPage): HistoryPage {
310
+ return {
311
+ entries: server.items.map(fromServerHistoryEntry),
312
+ nextCursor: server.next_cursor ?? undefined,
313
+ };
314
+ }
@@ -15,6 +15,7 @@ export const GUARDIAN_ERROR_CODES = [
15
15
  'account_paused',
16
16
  'account_released',
17
17
  'authentication_failed',
18
+ 'authentication_replay',
18
19
  'authorization_failed',
19
20
  'candidate_landed',
20
21
  'commitment_mismatch',
package/src/http.test.ts CHANGED
@@ -232,6 +232,52 @@ describe('GuardianHttpClient', () => {
232
232
 
233
233
  await expect(client.configure(request)).rejects.toThrow('No signer configured');
234
234
  });
235
+
236
+ it('retries a configure replay rejection with fresh authentication', async () => {
237
+ client.setSigner(mockSigner);
238
+ const signRequest = mockSigner.signRequest;
239
+ if (!signRequest) {
240
+ throw new Error('test signer must implement signRequest');
241
+ }
242
+ const signRequestMock = vi.mocked(signRequest);
243
+ signRequestMock.mockImplementation(
244
+ (_accountId, timestamp) => `0x${timestamp.toString(16).padStart(128, '0')}`
245
+ );
246
+ mockFetch
247
+ .mockResolvedValueOnce({
248
+ ok: false,
249
+ headers: new Headers(),
250
+ status: 401,
251
+ statusText: 'Unauthorized',
252
+ text: async () =>
253
+ JSON.stringify({
254
+ code: 'authentication_replay',
255
+ message: 'Guardian received this request out of order. Please try again.',
256
+ meta: { retryable: true },
257
+ }),
258
+ })
259
+ .mockResolvedValueOnce({
260
+ ok: true,
261
+ json: async () => ({ success: true, message: 'Account configured' }),
262
+ });
263
+
264
+ await client.configure({
265
+ accountId: '0x' + 'd'.repeat(30),
266
+ auth: {
267
+ MidenFalconRpo: {
268
+ cosigner_commitments: ['0x' + 'e'.repeat(64)],
269
+ },
270
+ },
271
+ initialState: { data: 'base64data', accountId: '0x' + 'd'.repeat(30) },
272
+ });
273
+
274
+ expect(mockFetch).toHaveBeenCalledTimes(2);
275
+ const timestamps = mockFetch.mock.calls.map((call) =>
276
+ Number((call[1].headers as Record<string, string>)['x-timestamp'])
277
+ );
278
+ expect(timestamps[1]).toBeGreaterThan(timestamps[0]);
279
+ expect(signRequestMock).toHaveBeenCalledTimes(2);
280
+ });
235
281
  });
236
282
 
237
283
  describe('getState', () => {
@@ -848,6 +894,136 @@ describe('GuardianHttpClient', () => {
848
894
  });
849
895
  });
850
896
 
897
+ // --- getDeltaHistory (issue #413) -----
898
+
899
+ describe('getDeltaHistory', () => {
900
+ const accountId = '0x' + 'a'.repeat(30);
901
+
902
+ const serverPage = {
903
+ items: [
904
+ {
905
+ nonce: 5,
906
+ status: 'canonical',
907
+ timestamp: '2026-08-01T12:00:05Z',
908
+ new_commitment: '0x' + 'c'.repeat(64),
909
+ input_notes: [],
910
+ output_notes: [
911
+ {
912
+ note_id: '0x' + 'd'.repeat(64),
913
+ tag: 'p2id',
914
+ note_type: 'public',
915
+ assets: [
916
+ { asset_id: '0x' + 'e'.repeat(30), kind: 'fungible', amount: '100' },
917
+ ],
918
+ sender: accountId,
919
+ recipient: '0x' + 'f'.repeat(30),
920
+ },
921
+ ],
922
+ },
923
+ {
924
+ nonce: 4,
925
+ status: 'canonical',
926
+ timestamp: '2026-08-01T12:00:04Z',
927
+ new_commitment: null,
928
+ input_notes: [],
929
+ output_notes: [],
930
+ decode_warnings: [{ section: 'tx_summary', reason: 'malformed_tx_summary' }],
931
+ },
932
+ ],
933
+ next_cursor: 'opaque-cursor',
934
+ };
935
+
936
+ it('returns a camelCase page and maps notes and warnings', async () => {
937
+ client.setSigner(mockSigner);
938
+ mockFetch.mockResolvedValueOnce({
939
+ ok: true,
940
+ json: async () => serverPage,
941
+ });
942
+
943
+ const result = await client.getDeltaHistory(accountId, { limit: 2 });
944
+
945
+ expect(result.nextCursor).toBe('opaque-cursor');
946
+ expect(result.entries).toHaveLength(2);
947
+ expect(result.entries[0].nonce).toBe(5);
948
+ expect(result.entries[0].newCommitment).toBe('0x' + 'c'.repeat(64));
949
+ expect(result.entries[0].decodeWarnings).toEqual([]);
950
+ expect(result.entries[0].status).toBe('canonical');
951
+ expect(result.entries[0].outputNotes[0]).toEqual({
952
+ noteId: '0x' + 'd'.repeat(64),
953
+ tag: 'p2id',
954
+ noteType: 'public',
955
+ assets: [{ assetId: '0x' + 'e'.repeat(30), kind: 'fungible', amount: '100' }],
956
+ sender: accountId,
957
+ recipient: '0x' + 'f'.repeat(30),
958
+ });
959
+ expect(result.entries[1].newCommitment).toBeUndefined();
960
+ expect(result.entries[1].decodeWarnings).toEqual([
961
+ { section: 'tx_summary', reason: 'malformed_tx_summary' },
962
+ ]);
963
+
964
+ expect(mockFetch).toHaveBeenCalledWith(
965
+ `http://localhost:3000/delta/history?account_id=${accountId}&limit=2`,
966
+ expect.objectContaining({
967
+ method: 'GET',
968
+ headers: expect.objectContaining({
969
+ 'x-pubkey': mockSigner.publicKey,
970
+ }),
971
+ })
972
+ );
973
+ });
974
+
975
+ it('signs the same key set it sends: omitted options stay omitted', async () => {
976
+ client.setSigner(mockSigner);
977
+ mockFetch.mockResolvedValueOnce({
978
+ ok: true,
979
+ json: async () => ({ items: [], next_cursor: null }),
980
+ });
981
+
982
+ const result = await client.getDeltaHistory(accountId);
983
+
984
+ expect(result.entries).toEqual([]);
985
+ expect(result.nextCursor).toBeUndefined();
986
+ expect(mockFetch).toHaveBeenCalledWith(
987
+ `http://localhost:3000/delta/history?account_id=${accountId}`,
988
+ expect.anything()
989
+ );
990
+ // The signed payload must byte-match the server's canonical JSON
991
+ // of its query struct: only account_id when limit/cursor are
992
+ // omitted, and limit as a string when present.
993
+ const signRequest = mockSigner.signRequest as ReturnType<typeof vi.fn>;
994
+ const lastCall = signRequest.mock.calls.at(-1);
995
+ if (lastCall === undefined) {
996
+ throw new Error('Expected signRequest to be called');
997
+ }
998
+ const authPayload = lastCall[2];
999
+ expect(authPayload.toCanonicalJson()).toBe(JSON.stringify({ account_id: accountId }));
1000
+ });
1001
+
1002
+ it('passes the cursor through query and signed payload', async () => {
1003
+ client.setSigner(mockSigner);
1004
+ mockFetch.mockResolvedValueOnce({
1005
+ ok: true,
1006
+ json: async () => ({ items: [], next_cursor: null }),
1007
+ });
1008
+
1009
+ await client.getDeltaHistory(accountId, { limit: 10, cursor: 'abc123' });
1010
+
1011
+ expect(mockFetch).toHaveBeenCalledWith(
1012
+ `http://localhost:3000/delta/history?account_id=${accountId}&limit=10&cursor=abc123`,
1013
+ expect.anything()
1014
+ );
1015
+ const signRequest = mockSigner.signRequest as ReturnType<typeof vi.fn>;
1016
+ const lastCall = signRequest.mock.calls.at(-1);
1017
+ if (lastCall === undefined) {
1018
+ throw new Error('Expected signRequest to be called');
1019
+ }
1020
+ const authPayload = lastCall[2];
1021
+ expect(authPayload.toCanonicalJson()).toBe(
1022
+ JSON.stringify({ account_id: accountId, cursor: 'abc123', limit: '10' })
1023
+ );
1024
+ });
1025
+ });
1026
+
851
1027
  // --- lookupAccountByKeyCommitment -----
852
1028
 
853
1029
  describe('lookupAccountByKeyCommitment', () => {
@@ -1114,17 +1290,13 @@ describe('GuardianHttpError', () => {
1114
1290
  expect(parsed.delta).toBeUndefined();
1115
1291
  });
1116
1292
 
1117
- it('surfaces 401 AUTHENTICATION_FAILED with a parseable error envelope', async () => {
1293
+ it('does not retry a terminal 401 authentication_failed rejection', async () => {
1118
1294
  client.setSigner(mockSigner);
1119
1295
  const envelope = {
1120
1296
  code: 'authentication_failed',
1121
- message: 'Your session has expired. Please sign in again.',
1297
+ message: 'Guardian could not authenticate this request.',
1122
1298
  meta: { retryable: false },
1123
1299
  };
1124
- // `authentication_failed` triggers the replay-retry path (the specific
1125
- // "Replay attack" detail is sanitized off the wire in feature 009, so we
1126
- // retry on the auth code). Use a persistent mock so the retries resolve
1127
- // and the final attempt throws the typed error.
1128
1300
  mockFetch.mockResolvedValue({
1129
1301
  ok: false,
1130
1302
  headers: new Headers(),
@@ -1146,9 +1318,103 @@ describe('GuardianHttpError', () => {
1146
1318
  expect(e.status).toBe(401);
1147
1319
  expect(e.code).toBe('authentication_failed');
1148
1320
  expect(typeof e.userMessage).toBe('string');
1321
+ expect(mockFetch).toHaveBeenCalledTimes(1);
1149
1322
  const parsed = JSON.parse(e.body);
1150
1323
  expect(parsed.success).toBeUndefined();
1151
1324
  expect(parsed.delta).toBeUndefined();
1152
1325
  });
1326
+
1327
+ it('retries an authentication_replay rejection with a fresh timestamp and signature', async () => {
1328
+ client.setSigner(mockSigner);
1329
+ const signRequest = mockSigner.signRequest;
1330
+ if (!signRequest) {
1331
+ throw new Error('test signer must implement signRequest');
1332
+ }
1333
+ const signRequestMock = vi.mocked(signRequest);
1334
+ signRequestMock.mockClear();
1335
+ const signatureForTimestamp = (_accountId: string, timestamp: number) =>
1336
+ `0x${timestamp.toString(16).padStart(128, '0')}`;
1337
+ signRequestMock
1338
+ .mockImplementationOnce(signatureForTimestamp)
1339
+ .mockImplementationOnce(signatureForTimestamp);
1340
+ const replayResponse = {
1341
+ ok: false,
1342
+ headers: new Headers(),
1343
+ status: 401,
1344
+ statusText: 'Unauthorized',
1345
+ text: async () =>
1346
+ JSON.stringify({
1347
+ code: 'authentication_replay',
1348
+ message: 'Guardian received this request out of order. Please try again.',
1349
+ meta: { retryable: true },
1350
+ }),
1351
+ };
1352
+ const serverResponse = {
1353
+ delta: {
1354
+ account_id: '0x' + 'a'.repeat(30),
1355
+ nonce: 1,
1356
+ prev_commitment: '0x' + 'b'.repeat(64),
1357
+ delta_payload: { tx_summary: { data: '' }, signatures: [] },
1358
+ status: {
1359
+ status: 'pending',
1360
+ timestamp: '2024-01-01T00:00:00Z',
1361
+ proposer_id: '0x' + 'c'.repeat(64),
1362
+ cosigner_sigs: [],
1363
+ },
1364
+ },
1365
+ commitment: '0x' + 'd'.repeat(64),
1366
+ };
1367
+ mockFetch
1368
+ .mockResolvedValueOnce(replayResponse)
1369
+ .mockResolvedValueOnce({ ok: true, json: async () => serverResponse });
1370
+
1371
+ const result = await client.pushDeltaProposal({
1372
+ accountId: '0x' + 'a'.repeat(30),
1373
+ nonce: 1,
1374
+ deltaPayload: { txSummary: { data: '' }, signatures: [] },
1375
+ });
1376
+
1377
+ expect(result.commitment).toBe('0x' + 'd'.repeat(64));
1378
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1379
+ const timestamps = mockFetch.mock.calls.map((call) =>
1380
+ Number((call[1].headers as Record<string, string>)['x-timestamp'])
1381
+ );
1382
+ const signatures = mockFetch.mock.calls.map(
1383
+ (call) => (call[1].headers as Record<string, string>)['x-signature']
1384
+ );
1385
+ expect(timestamps[1]).toBeGreaterThan(timestamps[0]);
1386
+ expect(signatures[1]).not.toBe(signatures[0]);
1387
+ expect(signRequestMock).toHaveBeenCalledTimes(2);
1388
+ });
1389
+
1390
+ it('gives up after exhausting the bounded replay retry budget', async () => {
1391
+ client.setSigner(mockSigner);
1392
+ mockFetch.mockResolvedValue({
1393
+ ok: false,
1394
+ headers: new Headers(),
1395
+ status: 401,
1396
+ statusText: 'Unauthorized',
1397
+ text: async () =>
1398
+ JSON.stringify({
1399
+ code: 'authentication_replay',
1400
+ message: 'Guardian received this request out of order. Please try again.',
1401
+ meta: { retryable: true },
1402
+ }),
1403
+ });
1404
+
1405
+ const error = await client
1406
+ .pushDeltaProposal({
1407
+ accountId: '0x' + 'a'.repeat(30),
1408
+ nonce: 1,
1409
+ deltaPayload: { txSummary: { data: '' }, signatures: [] },
1410
+ })
1411
+ .catch((e) => e as GuardianHttpError);
1412
+
1413
+ expect(error).toBeInstanceOf(GuardianHttpError);
1414
+ const e = error as GuardianHttpError;
1415
+ expect(e.code).toBe('authentication_replay');
1416
+ expect(e.meta?.retryable).toBe(true);
1417
+ expect(mockFetch).toHaveBeenCalledTimes(3);
1418
+ });
1153
1419
  });
1154
1420
  });
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,
@@ -452,6 +456,33 @@ export class GuardianHttpClient {
452
456
  return fromServerDeltaObject(server);
453
457
  }
454
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
+
455
486
  private async fetch(path: string, init: RequestInit): Promise<Response> {
456
487
  const url = `${this.baseUrl}${path}`;
457
488
  const response = await fetch(url, {
@@ -542,15 +573,16 @@ export class GuardianHttpClient {
542
573
  },
543
574
  });
544
575
  } catch (err) {
545
- // Replay rejections (stale timestamp) are transient: retry once with a
546
- // fresh timestamp. The specific "Replay attack" detail is now sanitized
547
- // off the wire (feature 009), so branch on the stable auth code instead.
548
- // Retrying a genuine auth failure is harmless — one extra attempt that
549
- // 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.
550
582
  if (
551
583
  retries > 0 &&
552
584
  err instanceof GuardianHttpError &&
553
- err.code === 'authentication_failed'
585
+ err.code === 'authentication_replay'
554
586
  ) {
555
587
  await new Promise((resolve) => setTimeout(resolve, 50));
556
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
+ }