@openzeppelin/guardian-client 0.15.2 → 0.16.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.
Files changed (42) hide show
  1. package/README.md +31 -0
  2. package/dist/conversion.d.ts.map +1 -1
  3. package/dist/conversion.js +8 -2
  4. package/dist/conversion.js.map +1 -1
  5. package/dist/error-codes.d.ts +30 -0
  6. package/dist/error-codes.d.ts.map +1 -0
  7. package/dist/error-codes.js +81 -0
  8. package/dist/error-codes.js.map +1 -0
  9. package/dist/http.d.ts +66 -10
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +156 -29
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/server-types.d.ts +17 -0
  18. package/dist/server-types.d.ts.map +1 -1
  19. package/dist/types.d.ts +38 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/conversion.test.ts +53 -0
  23. package/src/conversion.ts +8 -2
  24. package/src/error-codes.test.ts +69 -0
  25. package/src/error-codes.ts +91 -0
  26. package/src/http.test.ts +234 -29
  27. package/src/http.ts +190 -29
  28. package/src/index.ts +9 -0
  29. package/src/server-types.ts +16 -1
  30. package/src/types.ts +36 -1
  31. package/dist/auth-request.test.d.ts +0 -2
  32. package/dist/auth-request.test.d.ts.map +0 -1
  33. package/dist/auth-request.test.js +0 -10
  34. package/dist/auth-request.test.js.map +0 -1
  35. package/dist/conversion.test.d.ts +0 -2
  36. package/dist/conversion.test.d.ts.map +0 -1
  37. package/dist/conversion.test.js +0 -332
  38. package/dist/conversion.test.js.map +0 -1
  39. package/dist/http.test.d.ts +0 -2
  40. package/dist/http.test.d.ts.map +0 -1
  41. package/dist/http.test.js +0 -750
  42. package/dist/http.test.js.map +0 -1
package/src/http.test.ts CHANGED
@@ -81,18 +81,17 @@ describe('GuardianHttpClient', () => {
81
81
  statusText: 'Conflict',
82
82
  text: async () =>
83
83
  JSON.stringify({
84
- success: false,
85
84
  code: 'GUARDIAN_ACCOUNT_RELEASED',
86
- error: 'Account was released',
87
- retryable: false,
88
- released_at: '2026-07-06T10:00:00Z',
85
+ message: 'This account has moved to a different guardian. Reconnect it to continue.',
86
+ meta: { retryable: false, released_at: '2026-07-06T10:00:00Z' },
89
87
  }),
90
88
  });
91
89
 
92
90
  const error = await client.getPubkey().catch((e) => e);
93
91
  expect(error).toBeInstanceOf(GuardianHttpError);
94
92
  expect(error.status).toBe(409);
95
- expect(error.code).toBe('GUARDIAN_ACCOUNT_RELEASED');
93
+ expect(error.code).toBe('account_released');
94
+ expect(error.rawCode).toBe('GUARDIAN_ACCOUNT_RELEASED');
96
95
  expect(error.releasedAt).toBe('2026-07-06T10:00:00Z');
97
96
  });
98
97
 
@@ -103,9 +102,9 @@ describe('GuardianHttpClient', () => {
103
102
  statusText: 'Not Found',
104
103
  text: async () =>
105
104
  JSON.stringify({
106
- success: false,
107
105
  code: 'account_not_found',
108
- error: "Account '0xabc' not found",
106
+ message: "We couldn't find that. It may have been completed or removed.",
107
+ meta: { retryable: false },
109
108
  }),
110
109
  });
111
110
 
@@ -457,6 +456,175 @@ describe('GuardianHttpClient', () => {
457
456
  });
458
457
  });
459
458
 
459
+ describe('abandonCandidate', () => {
460
+ it('should record an abandon intent and map the response to camelCase', async () => {
461
+ client.setSigner(mockSigner);
462
+
463
+ mockFetch.mockResolvedValueOnce({
464
+ ok: true,
465
+ json: async () => ({
466
+ account_id: '0x' + 'a'.repeat(30),
467
+ nonce: 7,
468
+ state: 'pending',
469
+ abandon_requested_at: '2026-07-14T12:00:00Z',
470
+ }),
471
+ });
472
+
473
+ const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7);
474
+
475
+ expect(result).toEqual({
476
+ accountId: '0x' + 'a'.repeat(30),
477
+ nonce: 7,
478
+ state: 'pending',
479
+ abandonRequestedAt: '2026-07-14T12:00:00Z',
480
+ });
481
+
482
+ // Wire format is snake_case
483
+ expect(mockFetch).toHaveBeenCalledWith(
484
+ 'http://localhost:3000/delta/candidate/abandon',
485
+ expect.objectContaining({
486
+ method: 'POST',
487
+ body: JSON.stringify({
488
+ account_id: '0x' + 'a'.repeat(30),
489
+ nonce: 7,
490
+ }),
491
+ })
492
+ );
493
+ });
494
+
495
+ it('surfaces 409 GUARDIAN_CANDIDATE_LANDED with a parseable error envelope', async () => {
496
+ client.setSigner(mockSigner);
497
+
498
+ mockFetch.mockResolvedValueOnce({
499
+ ok: false,
500
+ status: 409,
501
+ statusText: 'Conflict',
502
+ text: async () =>
503
+ JSON.stringify({
504
+ code: 'GUARDIAN_CANDIDATE_LANDED',
505
+ message: "This transaction already went through, so it can't be abandoned.",
506
+ meta: { retryable: false },
507
+ }),
508
+ });
509
+
510
+ const error = await client
511
+ .abandonCandidate('0x' + 'a'.repeat(30), 7)
512
+ .catch((e) => e);
513
+ expect(error).toBeInstanceOf(GuardianHttpError);
514
+ expect(error.status).toBe(409);
515
+ expect(error.code).toBe('candidate_landed');
516
+ expect(error.rawCode).toBe('GUARDIAN_CANDIDATE_LANDED');
517
+ });
518
+
519
+ it('surfaces 404 delta_not_found when no candidate exists at the nonce', async () => {
520
+ client.setSigner(mockSigner);
521
+
522
+ mockFetch.mockResolvedValueOnce({
523
+ ok: false,
524
+ status: 404,
525
+ statusText: 'Not Found',
526
+ text: async () =>
527
+ JSON.stringify({
528
+ code: 'delta_not_found',
529
+ message: "We couldn't find that. It may have been completed or removed.",
530
+ meta: { retryable: false },
531
+ }),
532
+ });
533
+
534
+ const error = await client
535
+ .abandonCandidate('0x' + 'a'.repeat(30), 7)
536
+ .catch((e) => e);
537
+ expect(error).toBeInstanceOf(GuardianHttpError);
538
+ expect(error.status).toBe(404);
539
+ expect(error.code).toBe('delta_not_found');
540
+ });
541
+ });
542
+
543
+ describe('abandonStatus', () => {
544
+ const serverDelta = (status: object) => ({
545
+ account_id: '0x' + 'a'.repeat(30),
546
+ nonce: 7,
547
+ prev_commitment: '0x' + 'b'.repeat(64),
548
+ delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [] },
549
+ status,
550
+ });
551
+
552
+ it('classifies a still-pending candidate as waiting', async () => {
553
+ client.setSigner(mockSigner);
554
+ mockFetch.mockResolvedValueOnce({
555
+ ok: true,
556
+ json: async () =>
557
+ serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
558
+ });
559
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('waiting');
560
+ });
561
+
562
+ it('classifies a canonicalized delta as landed', async () => {
563
+ client.setSigner(mockSigner);
564
+ mockFetch.mockResolvedValueOnce({
565
+ ok: true,
566
+ json: async () =>
567
+ serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
568
+ });
569
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('landed');
570
+ });
571
+
572
+ it('classifies a retained delta as retained, not abandoned (issue #345)', async () => {
573
+ // The Guardian gave up verifying and released the account, but the
574
+ // on-chain outcome is still uncertain: 'retained' means "unlocked
575
+ // but unresolved" — reporting it as 'abandoned' would wrongly
576
+ // imply the transaction definitively did not land.
577
+ client.setSigner(mockSigner);
578
+ mockFetch.mockResolvedValueOnce({
579
+ ok: true,
580
+ json: async () =>
581
+ serverDelta({
582
+ status: 'retained',
583
+ timestamp: '2026-07-14T12:00:00Z',
584
+ reason: 'retry_exhausted',
585
+ }),
586
+ });
587
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('retained');
588
+ });
589
+
590
+ it('classifies a client-abandoned discard as abandoned', async () => {
591
+ client.setSigner(mockSigner);
592
+ mockFetch.mockResolvedValueOnce({
593
+ ok: true,
594
+ json: async () =>
595
+ serverDelta({
596
+ status: 'discarded',
597
+ timestamp: '2026-07-14T12:00:00Z',
598
+ reason: 'client_abandoned',
599
+ }),
600
+ });
601
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('abandoned');
602
+ });
603
+
604
+ it('classifies a reasonless discard and a missing delta as unexpected', async () => {
605
+ client.setSigner(mockSigner);
606
+ mockFetch.mockResolvedValueOnce({
607
+ ok: true,
608
+ json: async () =>
609
+ serverDelta({ status: 'discarded', timestamp: '2026-07-14T12:00:00Z' }),
610
+ });
611
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
612
+
613
+ mockFetch.mockResolvedValueOnce({
614
+ ok: false,
615
+ status: 404,
616
+ statusText: 'Not Found',
617
+ text: async () =>
618
+ JSON.stringify({
619
+ code: 'delta_not_found',
620
+ message: "We couldn't find that. It may have been completed or removed.",
621
+ meta: { retryable: false },
622
+ }),
623
+ });
624
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
625
+ });
626
+ });
627
+
460
628
  describe('signDeltaProposal', () => {
461
629
  it('should sign a delta proposal', async () => {
462
630
  client.setSigner(mockSigner);
@@ -825,6 +993,27 @@ describe('GuardianHttpError', () => {
825
993
  expect(error.name).toBe('GuardianHttpError');
826
994
  });
827
995
 
996
+ it('parses a { code, message, meta } body into structured accessors (feature 009)', () => {
997
+ const body = JSON.stringify({
998
+ code: 'rate_limit_exceeded',
999
+ message: 'Too many requests — please try again shortly.',
1000
+ meta: { retryable: true, retry_after_secs: 30 },
1001
+ });
1002
+ const error = new GuardianHttpError(429, 'Too Many Requests', body);
1003
+ expect(error.code).toBe('rate_limit_exceeded');
1004
+ expect(error.userMessage).toBe('Too many requests — please try again shortly.');
1005
+ expect(error.meta?.retryable).toBe(true);
1006
+ expect(error.meta?.retryAfterSecs).toBe(30); // snake_case → camelCase
1007
+ });
1008
+
1009
+ it('leaves accessors undefined for a non-JSON / non-conforming body', () => {
1010
+ const plain = new GuardianHttpError(502, 'Bad Gateway', 'upstream exploded');
1011
+ expect(plain.code).toBeNull();
1012
+ expect(plain.releasedAt).toBeNull();
1013
+ expect(plain.userMessage).toBeUndefined();
1014
+ expect(plain.meta).toBeUndefined();
1015
+ });
1016
+
828
1017
  describe('error envelope contract (account-paused path)', () => {
829
1018
  let client: GuardianHttpClient;
830
1019
  beforeEach(() => {
@@ -835,15 +1024,18 @@ describe('GuardianHttpError', () => {
835
1024
  it('surfaces 409 GUARDIAN_ACCOUNT_PAUSED with a parseable error envelope on pushDeltaProposal', async () => {
836
1025
  client.setSigner(mockSigner);
837
1026
 
838
- // The server's GuardianError::AccountPaused → IntoResponse contract.
839
- // Locks client/server in lockstep: a regression to the legacy
1027
+ // The server's GuardianError::AccountPaused → IntoResponse contract,
1028
+ // reshaped to { code, message, meta } (feature 009). Locks client/server
1029
+ // in lockstep: a regression to the legacy { success, error, paused_* } or
840
1030
  // "(400, {delta: {account_id: 'error text'}})" shape would break this.
841
1031
  const envelope = {
842
- success: false,
843
1032
  code: 'GUARDIAN_ACCOUNT_PAUSED',
844
- error: 'Account is paused: compliance review',
845
- paused_at: '2026-05-20T10:00:00Z',
846
- paused_reason: 'compliance review',
1033
+ message: "This account is paused and can't approve transactions right now.",
1034
+ meta: {
1035
+ retryable: false,
1036
+ paused_at: '2026-05-20T10:00:00Z',
1037
+ paused_reason: 'compliance review',
1038
+ },
847
1039
  };
848
1040
 
849
1041
  mockFetch.mockResolvedValueOnce({
@@ -864,26 +1056,37 @@ describe('GuardianHttpError', () => {
864
1056
  .catch((e) => e as GuardianHttpError);
865
1057
 
866
1058
  expect(error).toBeInstanceOf(GuardianHttpError);
867
- expect((error as GuardianHttpError).status).toBe(409);
868
-
869
- const parsed = JSON.parse((error as GuardianHttpError).body);
870
- expect(parsed.success).toBe(false);
871
- expect(parsed.code).toBe('GUARDIAN_ACCOUNT_PAUSED');
872
- expect(typeof parsed.error).toBe('string');
873
- expect(parsed.paused_at).toBe('2026-05-20T10:00:00Z');
874
- expect(parsed.paused_reason).toBe('compliance review');
875
- // Negative assertion: legacy "stuff error into delta.account_id" shape.
1059
+ const e = error as GuardianHttpError;
1060
+ expect(e.status).toBe(409);
1061
+
1062
+ // Structured accessors parsed from { code, message, meta } (feature 009).
1063
+ expect(e.code).toBe('account_paused');
1064
+ expect(e.rawCode).toBe('GUARDIAN_ACCOUNT_PAUSED');
1065
+ expect(typeof e.userMessage).toBe('string');
1066
+ expect(e.userMessage).not.toContain('compliance review'); // sanitized
1067
+ expect(e.meta?.retryable).toBe(false);
1068
+ expect(e.meta?.pausedAt).toBe('2026-05-20T10:00:00Z');
1069
+ expect(e.meta?.pausedReason).toBe('compliance review');
1070
+
1071
+ const parsed = JSON.parse(e.body);
1072
+ // Legacy fields gone; not a domain object.
1073
+ expect(parsed.success).toBeUndefined();
1074
+ expect(parsed.error).toBeUndefined();
876
1075
  expect(parsed.delta).toBeUndefined();
877
1076
  });
878
1077
 
879
1078
  it('surfaces 401 AUTHENTICATION_FAILED with a parseable error envelope', async () => {
880
1079
  client.setSigner(mockSigner);
881
1080
  const envelope = {
882
- success: false,
883
1081
  code: 'authentication_failed',
884
- error: 'Invalid signature',
1082
+ message: 'Your session has expired. Please sign in again.',
1083
+ meta: { retryable: false },
885
1084
  };
886
- mockFetch.mockResolvedValueOnce({
1085
+ // `authentication_failed` triggers the replay-retry path (the specific
1086
+ // "Replay attack" detail is sanitized off the wire in feature 009, so we
1087
+ // retry on the auth code). Use a persistent mock so the retries resolve
1088
+ // and the final attempt throws the typed error.
1089
+ mockFetch.mockResolvedValue({
887
1090
  ok: false,
888
1091
  status: 401,
889
1092
  statusText: 'Unauthorized',
@@ -899,10 +1102,12 @@ describe('GuardianHttpError', () => {
899
1102
  .catch((e) => e as GuardianHttpError);
900
1103
 
901
1104
  expect(error).toBeInstanceOf(GuardianHttpError);
902
- expect((error as GuardianHttpError).status).toBe(401);
903
- const parsed = JSON.parse((error as GuardianHttpError).body);
904
- expect(parsed.success).toBe(false);
905
- expect(parsed.code).toBe('authentication_failed');
1105
+ const e = error as GuardianHttpError;
1106
+ expect(e.status).toBe(401);
1107
+ expect(e.code).toBe('authentication_failed');
1108
+ expect(typeof e.userMessage).toBe('string');
1109
+ const parsed = JSON.parse(e.body);
1110
+ expect(parsed.success).toBeUndefined();
906
1111
  expect(parsed.delta).toBeUndefined();
907
1112
  });
908
1113
  });
package/src/http.ts CHANGED
@@ -1,4 +1,10 @@
1
+ import {
2
+ type GuardianErrorCode,
3
+ normalizeGuardianErrorCode,
4
+ } from './error-codes.js';
1
5
  import type {
6
+ AbandonCandidateResponse,
7
+ AbandonStatus,
2
8
  ConfigureRequest,
3
9
  ConfigureResponse,
4
10
  DeltaObject,
@@ -16,6 +22,8 @@ import type {
16
22
  } from './types.js';
17
23
  import { RequestAuthPayload } from './auth-request.js';
18
24
  import type {
25
+ ServerAbandonCandidateRequest,
26
+ ServerAbandonCandidateResponse,
19
27
  ServerDeltaObject,
20
28
  ServerDeltaProposalResponse,
21
29
  ServerLookupResponse,
@@ -38,21 +46,105 @@ import {
38
46
  } from './conversion.js';
39
47
 
40
48
  /**
41
- * Error thrown by the GUARDIAN HTTP client.
49
+ * Structured machine-readable side-data on a GUARDIAN error
50
+ * (feature `009-human-readable-errors`). `retryable` is always present.
51
+ */
52
+ export interface GuardianErrorMeta {
53
+ retryable: boolean;
54
+ retryAfterSecs?: number;
55
+ missingPermissions?: string[];
56
+ pausedAt?: string;
57
+ pausedReason?: string | null;
58
+ releasedAt?: string;
59
+ }
60
+
61
+ interface ParsedGuardianError {
62
+ /** Typed code, or `null` when the wire code is outside the known vocabulary. */
63
+ code: GuardianErrorCode | null;
64
+ /** Verbatim wire code, kept even when it does not narrow to the union. */
65
+ rawCode: string;
66
+ message: string;
67
+ meta: GuardianErrorMeta;
68
+ }
69
+
70
+ /**
71
+ * Parse a GUARDIAN error body `{ code, message, meta }` (feature 009),
72
+ * mapping the server's snake_case `meta` fields to camelCase. Returns
73
+ * `undefined` for non-JSON or non-conforming bodies — including a missing
74
+ * `meta` or a non-boolean `meta.retryable`, which the contract requires;
75
+ * treating those as conforming would silently misclassify retryability for
76
+ * bodies from older servers or intermediary proxies.
77
+ */
78
+ function parseGuardianErrorBody(body: string): ParsedGuardianError | undefined {
79
+ let json: unknown;
80
+ try {
81
+ json = JSON.parse(body);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ if (typeof json !== 'object' || json === null) return undefined;
86
+ const obj = json as Record<string, unknown>;
87
+ if (typeof obj.code !== 'string' || typeof obj.message !== 'string') return undefined;
88
+
89
+ if (typeof obj.meta !== 'object' || obj.meta === null || Array.isArray(obj.meta)) {
90
+ return undefined;
91
+ }
92
+ const rawMeta = obj.meta as Record<string, unknown>;
93
+ if (typeof rawMeta.retryable !== 'boolean') return undefined;
94
+ const code = normalizeGuardianErrorCode(obj.code);
95
+ const meta: GuardianErrorMeta = { retryable: rawMeta.retryable };
96
+ if (
97
+ typeof rawMeta.retry_after_secs === 'number' &&
98
+ Number.isInteger(rawMeta.retry_after_secs) &&
99
+ rawMeta.retry_after_secs >= 0
100
+ ) {
101
+ meta.retryAfterSecs = rawMeta.retry_after_secs;
102
+ }
103
+ if (Array.isArray(rawMeta.missing_permissions)) {
104
+ meta.missingPermissions = rawMeta.missing_permissions.filter(
105
+ (x): x is string => typeof x === 'string'
106
+ );
107
+ }
108
+ if (typeof rawMeta.paused_at === 'string') meta.pausedAt = rawMeta.paused_at;
109
+ if (typeof rawMeta.released_at === 'string') meta.releasedAt = rawMeta.released_at;
110
+ if (typeof rawMeta.paused_reason === 'string' || rawMeta.paused_reason === null) {
111
+ meta.pausedReason = rawMeta.paused_reason as string | null;
112
+ }
113
+ return { code, rawCode: obj.code, message: obj.message, meta };
114
+ }
115
+
116
+ /**
117
+ * Error thrown by the GUARDIAN HTTP client. Parses the `{ code, message, meta }`
118
+ * error body (feature 009): branch on {@link code}, display {@link userMessage}.
42
119
  */
43
120
  export class GuardianHttpError extends Error {
44
121
  /**
45
- * Stable machine-readable error code from the server's JSON error
46
- * envelope (e.g. `GUARDIAN_ACCOUNT_RELEASED`, `GUARDIAN_ACCOUNT_PAUSED`,
47
- * `commitment_mismatch`), or `null` when the body is not a JSON
48
- * envelope. Callers SHOULD branch on this rather than on `body` text
49
- * or the HTTP status alone.
122
+ * Typed, compiler-checked Guardian error code (issue #318), normalized to
123
+ * snake_case (e.g. `account_paused`, `account_released`,
124
+ * `commitment_mismatch`). `null` when the body is not a conforming JSON
125
+ * envelope OR the server emitted a code outside this client's known
126
+ * vocabulary — in the latter case {@link rawCode} still carries the
127
+ * verbatim wire string. Branch on this rather than on `body` text or the
128
+ * HTTP status alone; comparing against a non-member literal is a type
129
+ * error, so typos are caught at compile time.
50
130
  */
51
- public readonly code: string | null;
131
+ public readonly code: GuardianErrorCode | null;
132
+ /**
133
+ * Verbatim wire code as the server sent it (e.g.
134
+ * `GUARDIAN_ACCOUNT_PAUSED`), including codes a newer server may emit
135
+ * that this client does not know. `null` only when the body carried no
136
+ * conforming envelope. For logging/telemetry; branch on {@link code}.
137
+ */
138
+ public readonly rawCode: string | null;
139
+ /** Short, user-safe message — safe to display verbatim in a wallet UI. */
140
+ readonly userMessage?: string;
141
+ /** Structured side-data (`retryable`, `retryAfterSecs`, …). */
142
+ readonly meta?: GuardianErrorMeta;
52
143
  /**
53
144
  * RFC 3339 UTC timestamp at which the guardian released the account
54
- * after it switched to a different guardian. Present only when
55
- * `code === 'GUARDIAN_ACCOUNT_RELEASED'` (HTTP 409); the account is
145
+ * after it switched to a different guardian. Convenience accessor for
146
+ * `meta.releasedAt`; present only when `code === 'account_released'`
147
+ * (wire form `GUARDIAN_ACCOUNT_RELEASED`, HTTP 409); the account is
56
148
  * terminal on this server until re-onboarded via `configure`.
57
149
  */
58
150
  public readonly releasedAt: string | null;
@@ -62,26 +154,17 @@ export class GuardianHttpError extends Error {
62
154
  public readonly statusText: string,
63
155
  public readonly body: string
64
156
  ) {
65
- super(`GUARDIAN HTTP error ${status}: ${statusText} - ${body}`);
157
+ // Only the parsed, user-safe message is folded into Error.message; the
158
+ // raw body (which may carry backend/proxy internals) stays on the `body`
159
+ // field for diagnostics only.
160
+ const parsed = parseGuardianErrorBody(body);
161
+ super(`GUARDIAN HTTP error ${status}: ${statusText}${parsed ? ` - ${parsed.message}` : ''}`);
66
162
  this.name = 'GuardianHttpError';
67
- let code: string | null = null;
68
- let releasedAt: string | null = null;
69
- try {
70
- const parsed: unknown = JSON.parse(body);
71
- if (parsed !== null && typeof parsed === 'object') {
72
- const record = parsed as Record<string, unknown>;
73
- if (typeof record['code'] === 'string') {
74
- code = record['code'];
75
- }
76
- if (typeof record['released_at'] === 'string') {
77
- releasedAt = record['released_at'];
78
- }
79
- }
80
- } catch {
81
- // Non-JSON body (e.g. proxy HTML error page): keep raw `body` only.
82
- }
83
- this.code = code;
84
- this.releasedAt = releasedAt;
163
+ this.code = parsed?.code ?? null;
164
+ this.rawCode = parsed?.rawCode ?? null;
165
+ this.userMessage = parsed?.message;
166
+ this.meta = parsed?.meta;
167
+ this.releasedAt = parsed?.meta.releasedAt ?? null;
85
168
  }
86
169
  }
87
170
 
@@ -206,6 +289,75 @@ export class GuardianHttpClient {
206
289
  };
207
290
  }
208
291
 
292
+ /**
293
+ * Request abandonment of a pending canonicalization candidate whose
294
+ * transaction will never land on-chain (issue #319).
295
+ *
296
+ * Records an abandon *intent* (`202 Accepted`): the delta stays a
297
+ * candidate — the account stays locked — until the guardian's worker
298
+ * confirms over the abandon quarantine that the transaction did not
299
+ * land, then discards the delta as `client_abandoned` and releases the
300
+ * account (typically well under a minute). Poll {@link abandonStatus}
301
+ * for the resolution. Refused with `GUARDIAN_CANDIDATE_LANDED` (409)
302
+ * when the transaction actually landed. Retries are idempotent and
303
+ * preserve the original request timestamp.
304
+ */
305
+ async abandonCandidate(accountId: string, nonce: number): Promise<AbandonCandidateResponse> {
306
+ const serverRequest: ServerAbandonCandidateRequest = { account_id: accountId, nonce };
307
+ const response = await this.fetchAuthenticated('/delta/candidate/abandon', {
308
+ method: 'POST',
309
+ body: JSON.stringify(serverRequest),
310
+ }, accountId, serverRequest);
311
+ const server = (await response.json()) as ServerAbandonCandidateResponse;
312
+ return {
313
+ accountId: server.account_id,
314
+ nonce: server.nonce,
315
+ state: server.state,
316
+ abandonRequestedAt: server.abandon_requested_at,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Poll the resolution of an abandon request made with
322
+ * {@link abandonCandidate}: `'waiting'` while the quarantine runs,
323
+ * `'landed'` if the transaction landed after all (the delta
324
+ * canonicalized), `'abandoned'` once the delta is discarded as
325
+ * client-abandoned and the account released, `'retained'` when the
326
+ * guardian stopped verifying and released the account but the
327
+ * on-chain outcome is still uncertain (reconciliation may promote the
328
+ * delta until its retention TTL expires — sync and check chain before
329
+ * replacing it), `'unexpected'` for any state no abandon flow
330
+ * produces (including a missing delta).
331
+ */
332
+ async abandonStatus(accountId: string, nonce: number): Promise<AbandonStatus> {
333
+ let delta: DeltaObject;
334
+ try {
335
+ delta = await this.getDelta(accountId, nonce);
336
+ } catch (e) {
337
+ if (e instanceof GuardianHttpError && e.code === 'delta_not_found') {
338
+ return 'unexpected';
339
+ }
340
+ throw e;
341
+ }
342
+ switch (delta.status.status) {
343
+ case 'candidate':
344
+ return 'waiting';
345
+ case 'canonical':
346
+ return 'landed';
347
+ // The Guardian gave up verifying and released the account (issue
348
+ // #345): unlocked, but unresolved — deliberately distinct from
349
+ // 'abandoned', which would wrongly imply the transaction did not
350
+ // land.
351
+ case 'retained':
352
+ return 'retained';
353
+ case 'discarded':
354
+ return delta.status.reason === 'client_abandoned' ? 'abandoned' : 'unexpected';
355
+ default:
356
+ return 'unexpected';
357
+ }
358
+ }
359
+
360
+
209
361
  async signDeltaProposal(request: SignProposalRequest): Promise<DeltaObject> {
210
362
  const serverRequest = toServerSignProposalRequest(request);
211
363
  const response = await this.fetchAuthenticated('/delta/proposal', {
@@ -352,7 +504,16 @@ export class GuardianHttpClient {
352
504
  },
353
505
  });
354
506
  } catch (err) {
355
- if (retries > 0 && err instanceof GuardianHttpError && err.body.includes('Replay attack')) {
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.
512
+ if (
513
+ retries > 0 &&
514
+ err instanceof GuardianHttpError &&
515
+ err.code === 'authentication_failed'
516
+ ) {
356
517
  await new Promise((resolve) => setTimeout(resolve, 50));
357
518
  return this.fetchAuthenticated(path, init, accountId, requestPayload, retries - 1);
358
519
  }
package/src/index.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  export { GuardianHttpClient, GuardianHttpError } from './http.js';
2
+ export type { GuardianErrorMeta } from './http.js';
3
+ export {
4
+ GUARDIAN_ERROR_CODES,
5
+ isGuardianErrorCode,
6
+ normalizeGuardianErrorCode,
7
+ } from './error-codes.js';
8
+ export type { GuardianErrorCode } from './error-codes.js';
2
9
  export { RequestAuthPayload } from './auth-request.js';
3
10
 
4
11
  export type {
12
+ AbandonCandidateResponse,
13
+ AbandonStatus,
5
14
  Signer,
6
15
  FalconSignature,
7
16
  EcdsaSignature,
@@ -21,7 +21,8 @@ export type ServerDeltaStatus =
21
21
  | { status: 'pending'; timestamp: string; proposer_id: string; cosigner_sigs: ServerCosignerSignature[] }
22
22
  | { status: 'candidate'; timestamp: string }
23
23
  | { status: 'canonical'; timestamp: string }
24
- | { status: 'discarded'; timestamp: string };
24
+ | { status: 'retained'; timestamp: string; reason?: 'retry_exhausted' | 'diverged' }
25
+ | { status: 'discarded'; timestamp: string; reason?: string };
25
26
 
26
27
  export type ServerProposalType =
27
28
  | 'add_signer'
@@ -54,6 +55,8 @@ export interface ServerProposalMetadata {
54
55
  recipient_id?: string;
55
56
  faucet_id?: string;
56
57
  amount?: string;
58
+ /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */
59
+ note_type?: string;
57
60
  }
58
61
 
59
62
  export interface ServerDeltaObject {
@@ -137,6 +140,18 @@ export interface ServerProposalsResponse {
137
140
  proposals: ServerDeltaObject[];
138
141
  }
139
142
 
143
+ export interface ServerAbandonCandidateRequest {
144
+ account_id: string;
145
+ nonce: number;
146
+ }
147
+
148
+ export interface ServerAbandonCandidateResponse {
149
+ account_id: string;
150
+ nonce: number;
151
+ state: 'pending' | 'abandoned' | 'retained';
152
+ abandon_requested_at?: string;
153
+ }
154
+
140
155
  export interface ServerSignProposalRequest {
141
156
  account_id: string;
142
157
  commitment: string;