@openzeppelin/guardian-operator-client 0.14.6 → 0.15.0-rc.0

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/dist/http.js CHANGED
@@ -14,7 +14,32 @@ const DASHBOARD_ERROR_CODES = new Set([
14
14
  // Snapshot-specific codes (FR-045).
15
15
  'unsupported_for_network',
16
16
  'account_data_unavailable',
17
+ // Server emits SCREAMING_SNAKE_CASE; the TS union surfaces snake_case
18
+ // to match the rest of the DashboardErrorCode vocabulary.
19
+ // `parseErrorBody` does the mapping at the boundary.
20
+ 'insufficient_operator_permission',
21
+ // Same wire-form / TS-form mapping as `insufficient_operator_permission`.
22
+ 'account_paused',
17
23
  ]);
24
+ /** Server-emitted wire form for the permission-denial error code. */
25
+ const WIRE_INSUFFICIENT_OPERATOR_PERMISSION = 'GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION';
26
+ /** Server-emitted wire form for the account-paused error code. */
27
+ const WIRE_ACCOUNT_PAUSED = 'GUARDIAN_ACCOUNT_PAUSED';
28
+ /**
29
+ * Map a server-emitted error `code` to the typed
30
+ * {@link DashboardErrorCode} surface. The only mapping today is for
31
+ * the permission-denial code; every other code matches by string
32
+ * equality.
33
+ */
34
+ function mapDashboardErrorCode(raw) {
35
+ if (raw === WIRE_INSUFFICIENT_OPERATOR_PERMISSION) {
36
+ return 'insufficient_operator_permission';
37
+ }
38
+ if (raw === WIRE_ACCOUNT_PAUSED) {
39
+ return 'account_paused';
40
+ }
41
+ return raw;
42
+ }
18
43
  /**
19
44
  * Parse an error response body into a typed `{ code, message }` shape.
20
45
  * Clients SHOULD branch on `code` rather than on the HTTP status alone
@@ -59,14 +84,55 @@ export async function parseErrorBody(response) {
59
84
  }
60
85
  const record = raw;
61
86
  const codeRaw = record['code'];
62
- const code = typeof codeRaw === 'string' ? codeRaw : null;
87
+ const code = mapDashboardErrorCode(typeof codeRaw === 'string' ? codeRaw : null);
63
88
  const messageRaw = record['error'];
64
89
  const message = typeof messageRaw === 'string' ? messageRaw : null;
65
90
  const retryRaw = record['retry_after_secs'];
66
91
  const retryAfterSecs = typeof retryRaw === 'number' && Number.isInteger(retryRaw)
67
92
  ? retryRaw
68
93
  : undefined;
69
- return { code, message, retryAfterSecs };
94
+ // Feature 006-operator-authz FR-016: populate `missingPermissions`
95
+ // and `retryable` only when the server emitted the permission-denial
96
+ // code. Every other code path leaves both fields undefined so the
97
+ // additive envelope extension is invisible to existing parsers. The
98
+ // contract pins `retryable=false` for this code; surface that
99
+ // unconditionally so a non-compliant server can't mislead retry
100
+ // policy code into retrying a permission denial.
101
+ let missingPermissions;
102
+ let retryable;
103
+ let pausedAt;
104
+ let pausedReason;
105
+ if (code === 'insufficient_operator_permission') {
106
+ const missingRaw = record['missing_permissions'];
107
+ if (Array.isArray(missingRaw) &&
108
+ missingRaw.every((v) => typeof v === 'string')) {
109
+ missingPermissions = missingRaw;
110
+ }
111
+ retryable = false;
112
+ }
113
+ else if (code === 'account_paused') {
114
+ const pausedAtRaw = record['paused_at'];
115
+ if (typeof pausedAtRaw === 'string') {
116
+ pausedAt = pausedAtRaw;
117
+ }
118
+ const reasonRaw = record['paused_reason'];
119
+ if (typeof reasonRaw === 'string') {
120
+ pausedReason = reasonRaw;
121
+ }
122
+ else if (reasonRaw === null) {
123
+ pausedReason = null;
124
+ }
125
+ retryable = false;
126
+ }
127
+ return {
128
+ code,
129
+ message,
130
+ retryAfterSecs,
131
+ missingPermissions,
132
+ retryable,
133
+ pausedAt,
134
+ pausedReason,
135
+ };
70
136
  }
71
137
  /**
72
138
  * Type guard narrowing an arbitrary string to {@link DashboardErrorCode}.
@@ -143,8 +209,19 @@ export class GuardianOperatorHttpClient {
143
209
  async listAccounts(options = {}) {
144
210
  const url = new URL('dashboard/accounts', this.baseUrl);
145
211
  applyPaginationParams(url, options);
212
+ if (options.paused !== undefined) {
213
+ url.searchParams.set('paused', String(options.paused));
214
+ }
146
215
  return this.request(url, { method: 'GET' }, parseAccountListPage);
147
216
  }
217
+ /**
218
+ * Return the authenticated operator's identity and effective
219
+ * permission set. Succeeds for any valid session, including
220
+ * operators with `permissions: []` (returns an empty array).
221
+ */
222
+ async getSession() {
223
+ return this.request(new URL('dashboard/session', this.baseUrl), { method: 'GET' }, parseSessionInfo);
224
+ }
148
225
  /**
149
226
  * Inventory and lifecycle health snapshot per feature
150
227
  * `005-operator-dashboard-metrics` US2 / FR-008..FR-012.
@@ -152,6 +229,28 @@ export class GuardianOperatorHttpClient {
152
229
  async getDashboardInfo() {
153
230
  return this.request(new URL('dashboard/info', this.baseUrl), { method: 'GET' }, parseDashboardInfo);
154
231
  }
232
+ /**
233
+ * Pause mutating actions for the given account. Requires
234
+ * `accounts:pause`. Idempotent: re-pausing an already-paused account
235
+ * returns success with the original `pausedAt` preserved.
236
+ */
237
+ async pauseAccount(accountId, reason) {
238
+ const encoded = encodeURIComponent(accountId);
239
+ return this.request(new URL(`dashboard/accounts/${encoded}/pause`, this.baseUrl), { method: 'POST', body: JSON.stringify({ reason }) }, parsePauseResponse);
240
+ }
241
+ /**
242
+ * Clear pause state for the given account. Requires `accounts:pause`.
243
+ * Idempotent: unpausing an already-active account returns success
244
+ * with no state change.
245
+ */
246
+ async unpauseAccount(accountId, reason) {
247
+ const encoded = encodeURIComponent(accountId);
248
+ const init = { method: 'POST' };
249
+ if (reason !== undefined) {
250
+ init.body = JSON.stringify({ reason });
251
+ }
252
+ return this.request(new URL(`dashboard/accounts/${encoded}/unpause`, this.baseUrl), init, parseUnpauseResponse);
253
+ }
155
254
  async getAccount(accountId) {
156
255
  const encodedAccountId = encodeURIComponent(accountId);
157
256
  return this.request(new URL(`dashboard/accounts/${encodedAccountId}`, this.baseUrl), { method: 'GET' }, parseAccountResponse);
@@ -189,6 +288,23 @@ export class GuardianOperatorHttpClient {
189
288
  applyPaginationParams(url, options);
190
289
  return this.request(url, { method: 'GET' }, parseDeltaPage);
191
290
  }
291
+ /**
292
+ * Fetch the full detail projection of one canonical delta. The
293
+ * nonce is serialized as a canonical base-10 `u64` URL segment;
294
+ * unknown account and unknown nonce both surface as
295
+ * `404 delta_not_found`.
296
+ */
297
+ async getAccountDeltaDetail(accountId, nonce, options = {}) {
298
+ if (!Number.isSafeInteger(nonce) || nonce < 0) {
299
+ throw new GuardianOperatorContractError('getAccountDeltaDetail.nonce', `nonce must be a non-negative safe integer, got ${nonce}`);
300
+ }
301
+ const encodedAccountId = encodeURIComponent(accountId);
302
+ const url = new URL(`dashboard/accounts/${encodedAccountId}/deltas/${nonce.toString()}`, this.baseUrl);
303
+ if (options.includeRaw) {
304
+ url.searchParams.set('include', 'raw');
305
+ }
306
+ return this.request(url, { method: 'GET' }, parseDeltaDetail);
307
+ }
192
308
  /**
193
309
  * List the per-account in-flight multisig proposal queue for
194
310
  * `accountId`, paginated newest-first by `(nonce DESC, commitment
@@ -353,6 +469,30 @@ function parseLogoutResponse(value) {
353
469
  success: requireSuccess(record, 'logout response'),
354
470
  };
355
471
  }
472
+ function parseSessionInfo(value) {
473
+ const record = asRecord(value, 'session response');
474
+ const permissions = requireStringArray(record, 'permissions', 'session response');
475
+ for (const [index, permission] of permissions.entries()) {
476
+ if (!KNOWN_OPERATOR_PERMISSIONS.has(permission)) {
477
+ throw new GuardianOperatorContractError('session response', `permissions[${index}] is not a known operator permission: ${JSON.stringify(permission)}`);
478
+ }
479
+ }
480
+ return {
481
+ operatorId: requireString(record, 'operator_id', 'session response'),
482
+ permissions: permissions,
483
+ };
484
+ }
485
+ /**
486
+ * Stable v1 operator permission vocabulary. Mirrors
487
+ * `crates/server/src/dashboard/permissions.rs::Permission::as_str`.
488
+ * Surfaces server/client drift as a contract failure instead of
489
+ * silently flowing unknown strings through.
490
+ */
491
+ const KNOWN_OPERATOR_PERMISSIONS = new Set([
492
+ 'dashboard:read',
493
+ 'accounts:pause',
494
+ 'policies:write',
495
+ ]);
356
496
  function parseAccountListPage(value) {
357
497
  return parsePagedResult(value, parseAccountSummary, 'accounts page');
358
498
  }
@@ -460,7 +600,7 @@ function parseAccountSnapshot(value) {
460
600
  }
461
601
  function parseAccountSummary(value, context) {
462
602
  const record = asRecord(value, context);
463
- return {
603
+ const summary = {
464
604
  accountId: requireString(record, 'account_id', context),
465
605
  authScheme: requireString(record, 'auth_scheme', context),
466
606
  authorizedSignerCount: requireInteger(record, 'authorized_signer_count', context),
@@ -469,7 +609,16 @@ function parseAccountSummary(value, context) {
469
609
  stateStatus: parseStateStatus(requireString(record, 'state_status', context), `${context}.state_status`),
470
610
  createdAt: requireString(record, 'created_at', context),
471
611
  updatedAt: requireString(record, 'updated_at', context),
612
+ pausedAt: requireNullableString(record, 'paused_at', context),
613
+ pausedReason: requireNullableString(record, 'paused_reason', context),
472
614
  };
615
+ if (record.account_id_bech32 !== undefined && record.account_id_bech32 !== null) {
616
+ if (typeof record.account_id_bech32 !== 'string') {
617
+ throw new GuardianOperatorContractError(context, 'account_id_bech32 must be a string when present');
618
+ }
619
+ summary.accountIdBech32 = record.account_id_bech32;
620
+ }
621
+ return summary;
473
622
  }
474
623
  function parseAccountDetail(value, context) {
475
624
  const summary = parseAccountSummary(value, context);
@@ -481,6 +630,33 @@ function parseAccountDetail(value, context) {
481
630
  stateUpdatedAt: requireNullableString(record, 'state_updated_at', context),
482
631
  };
483
632
  }
633
+ function parseAccountStatus(value, context) {
634
+ if (value === 'active' || value === 'paused') {
635
+ return value;
636
+ }
637
+ throw new GuardianOperatorContractError(context, `expected account status "active" or "paused", got ${JSON.stringify(value)}`);
638
+ }
639
+ function parsePauseResponse(value) {
640
+ const ctx = 'pause response';
641
+ const record = asRecord(value, ctx);
642
+ return {
643
+ accountId: requireString(record, 'account_id', ctx),
644
+ beforeState: parseAccountStatus(requireString(record, 'before_state', ctx), `${ctx}.before_state`),
645
+ afterState: parseAccountStatus(requireString(record, 'after_state', ctx), `${ctx}.after_state`),
646
+ pausedAt: requireString(record, 'paused_at', ctx),
647
+ pausedReason: requireString(record, 'paused_reason', ctx),
648
+ };
649
+ }
650
+ function parseUnpauseResponse(value) {
651
+ const ctx = 'unpause response';
652
+ const record = asRecord(value, ctx);
653
+ return {
654
+ accountId: requireString(record, 'account_id', ctx),
655
+ beforeState: parseAccountStatus(requireString(record, 'before_state', ctx), `${ctx}.before_state`),
656
+ afterState: parseAccountStatus(requireString(record, 'after_state', ctx), `${ctx}.after_state`),
657
+ reason: requireNullableString(record, 'reason', ctx),
658
+ };
659
+ }
484
660
  function parseErrorResponse(value) {
485
661
  const record = asRecord(value, 'error response');
486
662
  const success = requireBoolean(record, 'success', 'error response');
@@ -505,13 +681,64 @@ function parseErrorResponse(value) {
505
681
  if (typeof codeValue !== 'string') {
506
682
  throw new GuardianOperatorContractError('error response', 'code must be a string when present');
507
683
  }
508
- code = codeValue;
684
+ code = mapDashboardErrorCode(codeValue) ?? undefined;
685
+ }
686
+ // Feature 006-operator-authz FR-016: populate `missingPermissions`
687
+ // and `retryable` only on the permission-denial code. Tolerate
688
+ // either ordering or omission on every other code so legacy 5xx /
689
+ // 4xx errors continue to parse byte-for-byte as before.
690
+ let missingPermissions;
691
+ let retryable;
692
+ let pausedAt;
693
+ let pausedReason;
694
+ if (code === 'insufficient_operator_permission') {
695
+ const missingRaw = record.missing_permissions;
696
+ if (missingRaw !== undefined) {
697
+ if (!Array.isArray(missingRaw) ||
698
+ !missingRaw.every((v) => typeof v === 'string')) {
699
+ throw new GuardianOperatorContractError('error response', 'missing_permissions must be an array of strings');
700
+ }
701
+ missingPermissions = missingRaw;
702
+ }
703
+ const retryableRaw = record.retryable;
704
+ if (retryableRaw !== undefined) {
705
+ if (retryableRaw !== false) {
706
+ // FR-016 pins `retryable: false` for permission denials.
707
+ // Surface server contract drift loudly rather than letting
708
+ // retry policy code retry an unretryable failure.
709
+ throw new GuardianOperatorContractError('error response', 'retryable must be false for insufficient_operator_permission');
710
+ }
711
+ retryable = false;
712
+ }
713
+ }
714
+ else if (code === 'account_paused') {
715
+ const pausedAtRaw = record.paused_at;
716
+ if (typeof pausedAtRaw !== 'string') {
717
+ throw new GuardianOperatorContractError('error response', 'paused_at must be a string for account_paused');
718
+ }
719
+ pausedAt = pausedAtRaw;
720
+ const reasonRaw = record.paused_reason;
721
+ if (typeof reasonRaw === 'string' || reasonRaw === null) {
722
+ pausedReason = reasonRaw;
723
+ }
724
+ else if (reasonRaw !== undefined) {
725
+ throw new GuardianOperatorContractError('error response', 'paused_reason must be a string or null for account_paused');
726
+ }
727
+ const retryableRaw = record.retryable;
728
+ if (retryableRaw !== undefined && retryableRaw !== false) {
729
+ throw new GuardianOperatorContractError('error response', 'retryable must be false for account_paused');
730
+ }
731
+ retryable = false;
509
732
  }
510
733
  return {
511
734
  success: false,
512
735
  code,
513
736
  error: requireString(record, 'error', 'error response'),
514
737
  retryAfterSecs,
738
+ missingPermissions,
739
+ retryable,
740
+ pausedAt,
741
+ pausedReason,
515
742
  };
516
743
  }
517
744
  function parseStateStatus(value, context) {
@@ -585,6 +812,20 @@ function requireStringArray(record, key, context) {
585
812
  return entry;
586
813
  });
587
814
  }
815
+ function requireNonNegativeInteger(value, key, context) {
816
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
817
+ throw new GuardianOperatorContractError(context, `field "${key}" must be a non-negative integer`);
818
+ }
819
+ return value;
820
+ }
821
+ function assertStringArray(value, key, context) {
822
+ value.forEach((entry, index) => {
823
+ if (typeof entry !== 'string') {
824
+ throw new GuardianOperatorContractError(context, `field "${key}" entry ${index} must be a string`);
825
+ }
826
+ });
827
+ return value;
828
+ }
588
829
  // ---------------------------------------------------------------------------
589
830
  // Pagination helpers — feature `005-operator-dashboard-metrics`.
590
831
  // ---------------------------------------------------------------------------
@@ -660,12 +901,24 @@ function parseDeltaEntry(value, context) {
660
901
  }
661
902
  entry.accountId = record.account_id;
662
903
  }
904
+ if (record.category !== undefined && record.category !== null) {
905
+ entry.category = parseDeltaCategory(requireString(record, 'category', context), `${context}.category`);
906
+ }
663
907
  if (record.proposal_type !== undefined) {
664
908
  if (typeof record.proposal_type !== 'string') {
665
909
  throw new GuardianOperatorContractError(context, 'proposal_type must be a string when present');
666
910
  }
667
911
  entry.proposalType = record.proposal_type;
668
912
  }
913
+ if (record.note_counts !== undefined && record.note_counts !== null) {
914
+ entry.noteCounts = parseDeltaNoteCounts(requireField(record, 'note_counts', context), `${context}.note_counts`);
915
+ }
916
+ if (record.assets !== undefined) {
917
+ entry.assets = parseDeltaAssetSummaryArray(record.assets, `${context}.assets`);
918
+ }
919
+ if (record.counterparty !== undefined && record.counterparty !== null) {
920
+ entry.counterparty = parseDeltaCounterpartySummary(record.counterparty, `${context}.counterparty`);
921
+ }
669
922
  return entry;
670
923
  }
671
924
  function parseProposalEntry(value, context) {
@@ -694,6 +947,254 @@ function parseProposalEntry(value, context) {
694
947
  }
695
948
  return entry;
696
949
  }
950
+ const DELTA_CATEGORY_VALUES = [
951
+ 'asset_transfer',
952
+ 'note_consumption',
953
+ 'note_creation',
954
+ 'account_storage_change',
955
+ 'guardian_switch',
956
+ 'custom',
957
+ ];
958
+ function parseDeltaCategory(value, context) {
959
+ if (DELTA_CATEGORY_VALUES.includes(value)) {
960
+ return value;
961
+ }
962
+ throw new GuardianOperatorContractError(context, `invalid category "${value}" — expected one of ${DELTA_CATEGORY_VALUES.join(', ')}`);
963
+ }
964
+ function parseDeltaAssetSummary(value, context) {
965
+ const record = asRecord(value, context);
966
+ const kind = requireString(record, 'kind', context);
967
+ if (kind !== 'fungible' && kind !== 'non_fungible') {
968
+ throw new GuardianOperatorContractError(context, `asset kind must be "fungible" or "non_fungible", got "${kind}"`);
969
+ }
970
+ const asset = {
971
+ assetId: requireString(record, 'asset_id', context),
972
+ kind: kind,
973
+ };
974
+ if (record.amount !== undefined && record.amount !== null) {
975
+ if (typeof record.amount !== 'string') {
976
+ throw new GuardianOperatorContractError(context, 'asset amount must be a string when present');
977
+ }
978
+ asset.amount = record.amount;
979
+ }
980
+ return asset;
981
+ }
982
+ function parseDeltaAssetSummaryArray(value, context) {
983
+ if (!Array.isArray(value)) {
984
+ throw new GuardianOperatorContractError(context, 'expected an array');
985
+ }
986
+ return value.map((item, index) => parseDeltaAssetSummary(item, `${context}[${index}]`));
987
+ }
988
+ function parseDeltaCounterpartySummary(value, context) {
989
+ const record = asRecord(value, context);
990
+ const direction = requireString(record, 'direction', context);
991
+ if (direction !== 'in' && direction !== 'out') {
992
+ throw new GuardianOperatorContractError(context, `counterparty direction must be "in" or "out", got "${direction}"`);
993
+ }
994
+ return {
995
+ accountId: requireString(record, 'account_id', context),
996
+ direction: direction,
997
+ };
998
+ }
999
+ function parseDeltaNoteCounts(value, context) {
1000
+ const record = asRecord(value, context);
1001
+ const input = requireInteger(record, 'input', context);
1002
+ const output = requireInteger(record, 'output', context);
1003
+ if (input < 0 || output < 0) {
1004
+ throw new GuardianOperatorContractError(context, 'note counts must be non-negative integers');
1005
+ }
1006
+ return { input, output };
1007
+ }
1008
+ function parseDeltaProposalMetadata(value, context) {
1009
+ const record = asRecord(value, context);
1010
+ const proposal = {
1011
+ proposalType: requireString(record, 'proposal_type', context),
1012
+ };
1013
+ if (typeof record.description === 'string')
1014
+ proposal.description = record.description;
1015
+ if (typeof record.salt === 'string')
1016
+ proposal.salt = record.salt;
1017
+ if (record.required_signatures !== undefined)
1018
+ proposal.requiredSignatures = requireNonNegativeInteger(record.required_signatures, 'required_signatures', context);
1019
+ if (typeof record.recipient_id === 'string')
1020
+ proposal.recipientId = record.recipient_id;
1021
+ if (typeof record.faucet_id === 'string')
1022
+ proposal.faucetId = record.faucet_id;
1023
+ if (typeof record.amount === 'string')
1024
+ proposal.amount = record.amount;
1025
+ if (record.note_ids !== undefined)
1026
+ proposal.noteIds = assertStringArray(requireArray(record, 'note_ids', context), 'note_ids', context);
1027
+ if (record.consume_notes_metadata_version !== undefined)
1028
+ proposal.consumeNotesMetadataVersion = requireNonNegativeInteger(record.consume_notes_metadata_version, 'consume_notes_metadata_version', context);
1029
+ if (record.consume_notes_notes !== undefined)
1030
+ proposal.consumeNotesNotes = assertStringArray(requireArray(record, 'consume_notes_notes', context), 'consume_notes_notes', context);
1031
+ if (record.target_threshold !== undefined)
1032
+ proposal.targetThreshold = requireNonNegativeInteger(record.target_threshold, 'target_threshold', context);
1033
+ if (record.signer_commitments !== undefined)
1034
+ proposal.signerCommitments = assertStringArray(requireArray(record, 'signer_commitments', context), 'signer_commitments', context);
1035
+ if (typeof record.new_guardian_pubkey === 'string')
1036
+ proposal.newGuardianPubkey = record.new_guardian_pubkey;
1037
+ if (typeof record.new_guardian_endpoint === 'string')
1038
+ proposal.newGuardianEndpoint = record.new_guardian_endpoint;
1039
+ if (typeof record.target_procedure === 'string')
1040
+ proposal.targetProcedure = record.target_procedure;
1041
+ return proposal;
1042
+ }
1043
+ function parseDeltaDetail(value) {
1044
+ const ctx = 'delta detail';
1045
+ const record = asRecord(value, ctx);
1046
+ const detail = {
1047
+ accountId: requireString(record, 'account_id', ctx),
1048
+ nonce: requireInteger(record, 'nonce', ctx),
1049
+ status: parseDeltaStatus(requireString(record, 'status', ctx), `${ctx}.status`),
1050
+ statusTimestamp: requireString(record, 'status_timestamp', ctx),
1051
+ prevCommitment: requireString(record, 'prev_commitment', ctx),
1052
+ newCommitment: requireNullableString(record, 'new_commitment', ctx),
1053
+ inputNotes: parseDecodedNoteArray(requireField(record, 'input_notes', ctx), `${ctx}.input_notes`),
1054
+ outputNotes: parseDecodedNoteArray(requireField(record, 'output_notes', ctx), `${ctx}.output_notes`),
1055
+ vaultChanges: parseVaultChangeArray(requireField(record, 'vault_changes', ctx), `${ctx}.vault_changes`),
1056
+ storageChanges: parseStorageChangeArray(requireField(record, 'storage_changes', ctx), `${ctx}.storage_changes`),
1057
+ };
1058
+ if (record.retry_count !== undefined) {
1059
+ const retry = record.retry_count;
1060
+ if (typeof retry !== 'number' || !Number.isInteger(retry) || retry < 0) {
1061
+ throw new GuardianOperatorContractError(ctx, 'retry_count must be a non-negative integer when present');
1062
+ }
1063
+ detail.retryCount = retry;
1064
+ }
1065
+ if (record.category !== undefined && record.category !== null) {
1066
+ detail.category = parseDeltaCategory(requireString(record, 'category', ctx), `${ctx}.category`);
1067
+ }
1068
+ if (record.proposal !== undefined && record.proposal !== null) {
1069
+ detail.proposal = parseDeltaProposalMetadata(record.proposal, `${ctx}.proposal`);
1070
+ }
1071
+ if (Array.isArray(record.decode_warnings) && record.decode_warnings.length > 0) {
1072
+ detail.decodeWarnings = record.decode_warnings.map((w, i) => parseDecodeWarning(w, `${ctx}.decode_warnings[${i}]`));
1073
+ }
1074
+ if (typeof record.raw_transaction_summary === 'string') {
1075
+ detail.rawTransactionSummary = record.raw_transaction_summary;
1076
+ }
1077
+ return detail;
1078
+ }
1079
+ function parseDecodedNoteArray(value, context) {
1080
+ if (!Array.isArray(value)) {
1081
+ throw new GuardianOperatorContractError(context, 'expected an array of decoded notes');
1082
+ }
1083
+ return value.map((entry, i) => parseDecodedNote(entry, `${context}[${i}]`));
1084
+ }
1085
+ const NOTE_TAG_VALUES = [
1086
+ 'p2id',
1087
+ 'p2ide',
1088
+ 'pswap',
1089
+ 'mint',
1090
+ 'burn',
1091
+ 'custom',
1092
+ ];
1093
+ function parseDecodedNote(value, context) {
1094
+ const record = asRecord(value, context);
1095
+ const tag = requireString(record, 'tag', context);
1096
+ if (!NOTE_TAG_VALUES.includes(tag)) {
1097
+ throw new GuardianOperatorContractError(context, `unknown note tag "${tag}"`);
1098
+ }
1099
+ const assets = requireField(record, 'assets', context);
1100
+ if (!Array.isArray(assets)) {
1101
+ throw new GuardianOperatorContractError(context, 'assets must be an array');
1102
+ }
1103
+ const note = {
1104
+ noteId: requireString(record, 'note_id', context),
1105
+ tag: tag,
1106
+ assets: assets.map((a, i) => parseDecodedAsset(a, `${context}.assets[${i}]`)),
1107
+ };
1108
+ if (typeof record.sender === 'string')
1109
+ note.sender = record.sender;
1110
+ if (typeof record.recipient === 'string')
1111
+ note.recipient = record.recipient;
1112
+ return note;
1113
+ }
1114
+ function parseDecodedAsset(value, context) {
1115
+ const record = asRecord(value, context);
1116
+ const kind = requireString(record, 'kind', context);
1117
+ if (kind !== 'fungible' && kind !== 'non_fungible') {
1118
+ throw new GuardianOperatorContractError(context, `asset kind must be "fungible" or "non_fungible", got "${kind}"`);
1119
+ }
1120
+ const asset = {
1121
+ assetId: requireString(record, 'asset_id', context),
1122
+ kind: kind,
1123
+ };
1124
+ if (typeof record.amount === 'string')
1125
+ asset.amount = record.amount;
1126
+ return asset;
1127
+ }
1128
+ function parseVaultChangeArray(value, context) {
1129
+ if (!Array.isArray(value)) {
1130
+ throw new GuardianOperatorContractError(context, 'expected an array of vault changes');
1131
+ }
1132
+ return value.map((entry, i) => parseVaultChange(entry, `${context}[${i}]`));
1133
+ }
1134
+ function parseVaultChange(value, context) {
1135
+ const record = asRecord(value, context);
1136
+ const kind = requireString(record, 'kind', context);
1137
+ if (kind === 'fungible') {
1138
+ return {
1139
+ kind: 'fungible',
1140
+ assetId: requireString(record, 'asset_id', context),
1141
+ change: requireString(record, 'change', context),
1142
+ };
1143
+ }
1144
+ if (kind === 'non_fungible') {
1145
+ const added = requireField(record, 'added', context);
1146
+ const removed = requireField(record, 'removed', context);
1147
+ if (!Array.isArray(added) || !Array.isArray(removed)) {
1148
+ throw new GuardianOperatorContractError(context, 'non_fungible added/removed must be arrays');
1149
+ }
1150
+ return {
1151
+ kind: 'non_fungible',
1152
+ assetId: requireString(record, 'asset_id', context),
1153
+ added: assertStringArray(added, 'added', context),
1154
+ removed: assertStringArray(removed, 'removed', context),
1155
+ };
1156
+ }
1157
+ throw new GuardianOperatorContractError(context, `vault change kind must be "fungible" or "non_fungible", got "${kind}"`);
1158
+ }
1159
+ function parseStorageChangeArray(value, context) {
1160
+ if (!Array.isArray(value)) {
1161
+ throw new GuardianOperatorContractError(context, 'expected an array of storage changes');
1162
+ }
1163
+ return value.map((entry, i) => parseStorageChange(entry, `${context}[${i}]`));
1164
+ }
1165
+ function parseStorageChange(value, context) {
1166
+ const record = asRecord(value, context);
1167
+ const change = {
1168
+ slotName: requireString(record, 'slot_name', context),
1169
+ after: requireNullableString(record, 'after', context),
1170
+ };
1171
+ if (record.key !== undefined && record.key !== null) {
1172
+ change.key = requireString(record, 'key', context);
1173
+ }
1174
+ if (record.before !== undefined) {
1175
+ change.before = requireNullableString(record, 'before', context);
1176
+ }
1177
+ return change;
1178
+ }
1179
+ const DECODE_SECTION_VALUES = [
1180
+ 'tx_summary',
1181
+ 'metadata',
1182
+ 'input_notes',
1183
+ 'output_notes',
1184
+ 'vault',
1185
+ 'storage',
1186
+ ];
1187
+ function parseDecodeWarning(value, context) {
1188
+ const record = asRecord(value, context);
1189
+ const section = requireString(record, 'section', context);
1190
+ if (!DECODE_SECTION_VALUES.includes(section)) {
1191
+ throw new GuardianOperatorContractError(context, `unknown decode section "${section}"`);
1192
+ }
1193
+ return {
1194
+ section: section,
1195
+ reason: requireString(record, 'reason', context),
1196
+ };
1197
+ }
697
1198
  function parseDeltaStatus(value, context) {
698
1199
  if (value === 'candidate' || value === 'canonical' || value === 'discarded') {
699
1200
  return value;