@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.
- package/README.md +50 -2
- package/dist/conversion.d.ts +4 -2
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +37 -0
- package/dist/conversion.js.map +1 -1
- package/dist/error-codes.d.ts +1 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +1 -0
- package/dist/error-codes.js.map +1 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +34 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/server-types.d.ts +36 -0
- package/dist/server-types.d.ts.map +1 -1
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/conversion.test.ts +34 -0
- package/src/conversion.ts +46 -0
- package/src/error-codes.ts +1 -0
- package/src/http.test.ts +272 -6
- package/src/http.ts +38 -6
- package/src/index.ts +10 -0
- package/src/server-types.ts +43 -0
- package/src/types.ts +98 -0
package/src/conversion.test.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/error-codes.ts
CHANGED
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('
|
|
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: '
|
|
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
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
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 === '
|
|
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';
|
package/src/server-types.ts
CHANGED
|
@@ -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
|
+
}
|