@zktx.io/sui-mcp 0.7.0 → 0.8.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 (71) hide show
  1. package/README.md +89 -42
  2. package/dist/core/activity/localDataService.js +8 -21
  3. package/dist/core/activity/localDataValidation.js +22 -46
  4. package/dist/core/activity/sqliteAccountStore.js +46 -0
  5. package/dist/core/activity/sqliteActivityStore.js +65 -147
  6. package/dist/core/activity/sqliteActivityStoreSchema.js +44 -28
  7. package/dist/core/activity/sqliteActivityStoreTypes.js +9 -0
  8. package/dist/core/activity/transactionActivityService.js +2 -2
  9. package/dist/core/preferences/preferencesStore.js +2 -2
  10. package/dist/core/preferences/sqlitePreferencesRepository.js +10 -0
  11. package/dist/core/session/cardResult.js +41 -0
  12. package/dist/core/session/cardSession.js +1 -1
  13. package/dist/core/session/cardSessionStore.js +51 -19
  14. package/dist/core/session/chainAccounts.js +120 -0
  15. package/dist/core/session/sessionStore.js +4 -8
  16. package/dist/core/session/settingsFileStore.js +3 -30
  17. package/dist/core/session/settingsOperation.js +27 -19
  18. package/dist/core/session/settingsWorkflow.js +161 -87
  19. package/dist/core/session/sqliteCardStore.js +34 -14
  20. package/dist/core/session/sqliteSessionStore.js +5 -5
  21. package/dist/core/session/sqliteSettingsWorkflowStore.js +135 -41
  22. package/dist/core/session/sqliteTransactionRequest.js +18 -0
  23. package/dist/core/session/sqliteWalletConnectionOperation.js +13 -0
  24. package/dist/core/session/sqliteWalletWorkflowStore.js +569 -333
  25. package/dist/core/session/status.js +2 -2
  26. package/dist/core/session/wait.js +6 -11
  27. package/dist/core/session/walletConnection.js +71 -46
  28. package/dist/core/session/walletConnectionOperations.js +24 -0
  29. package/dist/core/session/walletRuntime.js +7 -13
  30. package/dist/core/session/walletWorkflow.js +248 -99
  31. package/dist/core/session/workflowState.js +104 -38
  32. package/dist/core/session/workflowView.js +92 -101
  33. package/dist/mcp/activeAccountResponse.js +3 -0
  34. package/dist/mcp/responseGuidance.js +7 -9
  35. package/dist/mcp/serverInfo.js +11 -10
  36. package/dist/mcp/toolErrors.js +5 -3
  37. package/dist/mcp/toolNames.js +1 -0
  38. package/dist/mcp/tools/action/prepareSuiActionReview.js +9 -16
  39. package/dist/mcp/tools/read/readToolHelpers.js +9 -7
  40. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  41. package/dist/mcp/tools/session/shared.js +2 -2
  42. package/dist/mcp/tools/session/statusTools.js +12 -10
  43. package/dist/mcp/tools/session/walletConnectionTools.js +26 -14
  44. package/dist/mcp/tools/settings/index.js +7 -4
  45. package/dist/mcp-app/account.html +7 -7
  46. package/dist/mcp-app/chart.html +3 -3
  47. package/dist/mcp-app/connect.html +13 -13
  48. package/dist/mcp-app/disconnect.html +3637 -0
  49. package/dist/mcp-app/disconnect.notices.txt +1480 -0
  50. package/dist/mcp-app/receipt.html +393 -393
  51. package/dist/mcp-app/review.html +459 -459
  52. package/dist/mcp-app/settings.html +7 -7
  53. package/dist/mcp-ui/contracts.js +3 -1
  54. package/dist/mcp-ui/tools.js +4 -4
  55. package/dist/runtime/application.js +1 -1
  56. package/dist/runtime/localSettingsService.js +7 -4
  57. package/dist/runtime/runtimeTermination.js +3 -0
  58. package/dist/runtime/smokeMainnetRead.js +5 -4
  59. package/dist/runtime/walletConnectTransport.js +16 -16
  60. package/dist/runtime/walletSdkChildRuntime.js +29 -12
  61. package/dist/runtime/walletSdkIpc.js +5 -4
  62. package/dist/runtime/walletSdkProcess.js +24 -8
  63. package/docs/AGENT_BEHAVIOR.md +82 -54
  64. package/docs/LOCAL_DB_ARCHITECTURE.md +100 -70
  65. package/docs/MCP_SETUP.md +384 -347
  66. package/docs/MCP_TOOLS.md +292 -177
  67. package/docs/SDK_API.md +15 -6
  68. package/docs/TRANSACTION_ACTIVITY_LOG.md +4 -0
  69. package/docs/UTILITY_INDEX.md +9 -7
  70. package/docs/WALLET_CONNECTION.md +162 -167
  71. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { PreferencesStoreError, assertLocalSettingKey } from "./preferencesStore.js";
2
2
  import { parseGraphqlUrl, parseGrpcUrl } from "../suiEndpoint.js";
3
+ import { DEFAULT_WALLET_CHAINS, parseWalletChains } from "../session/chainAccounts.js";
3
4
  export class SqlitePreferencesRepository {
4
5
  db;
5
6
  constructor(db) {
@@ -11,6 +12,11 @@ export class SqlitePreferencesRepository {
11
12
  ON CONFLICT(key) DO NOTHING`);
12
13
  insert.run("suiGrpcUrl", encodeSettingValue("suiGrpcUrl", defaults.suiGrpcUrl), now.toISOString());
13
14
  insert.run("suiGraphqlUrl", encodeSettingValue("suiGraphqlUrl", defaults.suiGraphqlUrl), now.toISOString());
15
+ insert.run("walletConnectionChains", encodeSettingValue("walletConnectionChains", DEFAULT_WALLET_CHAINS), now.toISOString());
16
+ }
17
+ getWalletConnectionChainsSync() {
18
+ const record = this.getLocalSettingSync("walletConnectionChains");
19
+ return record ? parseWalletChains(record.value) : [...DEFAULT_WALLET_CHAINS];
14
20
  }
15
21
  async getSuiGrpcUrl() {
16
22
  const record = await this.getLocalSetting("suiGrpcUrl");
@@ -92,6 +98,8 @@ function rowToLocalSetting(row) {
92
98
  };
93
99
  }
94
100
  function encodeSettingValue(key, value) {
101
+ if (key === "walletConnectionChains")
102
+ return JSON.stringify(parseWalletChains(value));
95
103
  if (key === "suiGrpcUrl") {
96
104
  if (typeof value !== "string") {
97
105
  throw new PreferencesStoreError("invalid_value", "suiGrpcUrl setting value must be a string", { key });
@@ -114,6 +122,8 @@ function decodeSettingValue(key, valueJson) {
114
122
  catch {
115
123
  throw new PreferencesStoreError("malformed_json", "Malformed local setting JSON", { key });
116
124
  }
125
+ if (key === "walletConnectionChains")
126
+ return parseWalletChains(parsed);
117
127
  if (key === "suiGrpcUrl") {
118
128
  return asSuiGrpcUrlValue(parsed);
119
129
  }
@@ -0,0 +1,41 @@
1
+ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
2
+ import { closedWalletCardSchema, closedReviewCardSchema, REVIEW_BOUNDARY } from "./workflowView.js";
3
+ import { fixedReviewTarget } from "./workflowState.js";
4
+ export function walletCardResult(record, at, input) {
5
+ return closedWalletCardSchema.parse({ kind: record.state.kind, allowedActions: [], observe: false, actionRemainingMs: 0,
6
+ result: { message: input.message, recordedAt: at.toISOString(), ...(input.pendingWork ? { pendingWork: input.pendingWork } : {}) },
7
+ ...(input.connection?.walletName ? { walletName: input.connection.walletName } : {}),
8
+ networks: (input.connection?.chainResults ?? []).map(({ chainId, status }) => ({ chainId, status })) });
9
+ }
10
+ export function reviewCardResult(record, at, input) {
11
+ const { session, request, state } = input;
12
+ if (request && request.reviewSessionId !== record.state.input.reviewSessionId)
13
+ throw new Error("The request does not belong to this card.");
14
+ const target = session && !session.plans[0]?.reviewModel ? fixedReviewTarget(session, record.state.input) : undefined;
15
+ const mode = record.scope === "review_manage" ? "review_manage" : "review";
16
+ const message = record.state.error ?? (request?.execution ? request.execution.status === "success" ? "Transaction succeeded on Sui." : "Transaction failed on Sui."
17
+ : mode === "review_manage" && record.state.reason === "expired" ? "This result lookup expired. Ask in chat for a new result card for the same transaction."
18
+ : input.observationStopped && request && request.requestStatus !== "stopped" ? "Result checking was stopped. This does not cancel the transaction. Ask in chat for a new result card for the same transaction."
19
+ : request ? request.reason ?? "No chain execution result has been confirmed."
20
+ : session?.plans[0]?.reviewModel ? "This proposal review has ended. It provided no wallet approval or execution."
21
+ : record.state.reason === "cancelled" ? "This review was cancelled. No approval request was sent."
22
+ : record.state.reason === "expired" ? "This review expired. Ask in chat for a new transaction review."
23
+ : record.state.reason === "server_restarted" ? "This review ended when the server restarted. Ask in chat for a new transaction review."
24
+ : "This review has ended.");
25
+ const gas = state?.simulation?.gasCostSummary;
26
+ const result = closedReviewCardSchema.parse({ kind: "review", mode, allowedActions: [], observe: false, actionRemainingMs: 0,
27
+ result: { message, recordedAt: at.toISOString() }, boundary: REVIEW_BOUNDARY,
28
+ ...(target ? { reviewTarget: target } : {}),
29
+ ...(session?.plans[0] ? { review: { reviewSessionId: session.id, plan: session.plans[0], reviewRevision: request?.reviewRevision ?? session.reviewRevision,
30
+ status: session.status === "expired" ? "expired" : state?.status ?? session.status, account: state?.account ?? session.account, preparing: false,
31
+ ...(state ? { state } : {}), ...(session.preparationError ? { error: session.preparationError } : {}) } } : {}),
32
+ ...(request ? { request } : {}), ...(input.observationStopped ? { observationStopped: true } : {}),
33
+ ...(input.receipt === undefined ? {} : { receipt: input.receipt }),
34
+ ...(gas ? { netGasMist: (BigInt(gas.computationCostRaw) + BigInt(gas.storageCostRaw) - BigInt(gas.storageRebateRaw)).toString() } : {}) });
35
+ assertNoForbiddenMcpFields(result);
36
+ return result;
37
+ }
38
+ export function endedCardMessage(reason) {
39
+ return reason === "cancelled" ? "This card was cancelled." : reason === "expired" ? "The input period ended."
40
+ : reason === "server_restarted" ? "This card ended when the server restarted." : "This card has ended.";
41
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  export const walletDisplaySchema = z.object({
3
3
  connectionId: z.string().min(1), pairingUri: z.string().startsWith("wc:"), expiresAt: z.string().datetime()
4
4
  }).strict();
5
- export const cardKindSchema = z.enum(["account", "receipt", "chart", "connect", "review", "settings"]);
5
+ export const cardKindSchema = z.enum(["account", "receipt", "chart", "connect", "disconnect", "review", "settings"]);
6
6
  export const cardStateSchema = z.object({
7
7
  cardId: z.string().min(1), kind: cardKindSchema,
8
8
  state: z.enum(["ready", "running", "closed"]),
@@ -29,11 +29,13 @@ export class CardStore {
29
29
  }
30
30
  async create(kind, input, execute = false) {
31
31
  this.assertCurrent();
32
- const business = kind === "connect" || kind === "review";
33
- if (kind === "settings" && (!this.options.settings || execute || Object.keys(input).length))
32
+ const business = kind === "connect" || kind === "disconnect" || kind === "review";
33
+ if (kind === "settings" && (!this.options.settings || execute))
34
34
  throw new CardError("Settings cards are unavailable for this input.");
35
35
  if (business && (!this.options.workflow || execute))
36
36
  throw new CardError("This type of card is unavailable.");
37
+ if (kind === "connect" || kind === "disconnect")
38
+ input = await this.options.workflow.connectionCardInput(kind, input);
37
39
  const parsed = execute ? parseCardInput(kind, input) : undefined;
38
40
  let preparation = { status: "ready" };
39
41
  if (business) {
@@ -51,23 +53,30 @@ export class CardStore {
51
53
  }
52
54
  assertNoForbiddenMcpFields({ input, preparation });
53
55
  const { base, token } = createLocalSessionBase(this.now(), DEFAULT_SESSION_TTL_MS);
54
- const record = { ownerId: this.options.ownerId, tokenHash: base.tokenHash,
55
- scope: kind === "settings" ? "settings" : kind === "connect" ? "connect" : kind === "review" ? input.mode === "manage" ? "review_manage" : "review" : "read",
56
- ...(kind === "settings" ? { operationId: randomUUID() } : {}),
56
+ const settingsTarget = input.operationStatus ?? input.backupDelivery;
57
+ const settingsOperationId = settingsTarget && typeof settingsTarget === "object" && "operationId" in settingsTarget ? String(settingsTarget.operationId) : randomUUID();
58
+ let record = { ownerId: this.options.ownerId, tokenHash: base.tokenHash,
59
+ scope: kind === "settings" ? "settings" : kind === "connect" || kind === "disconnect" ? kind : kind === "review" ? input.mode === "manage" ? "review_manage" : "review" : "read",
60
+ ...(kind === "settings" ? { operationId: settingsOperationId } : {}),
61
+ ...((kind === "connect" || kind === "disconnect") ? { operationId: String(input.operationId) } : {}),
57
62
  ...(kind === "review" && input.mode === "manage" && typeof input.attemptId === "string" ? { operationId: input.attemptId } : {}),
58
- state: { cardId: base.id, kind, state: preparation.status === "failed" ? "closed" : "ready", revision: 0,
63
+ state: { cardId: base.id, kind, state: preparation.status !== "ready" ? "closed" : "ready", revision: 0,
59
64
  createdAt: base.createdAt, expiresAt: base.expiresAt, input,
60
- ...(preparation.status === "failed" ? { reason: "failed", error: preparation.error } :
65
+ ...(preparation.status === "failed" ? { reason: "failed", error: preparation.error } : preparation.status === "completed" ? { reason: "completed" } :
61
66
  preparation.data === undefined ? {} : { data: preparation.data }) } };
67
+ if (business && record.state.state === "closed")
68
+ record = this.options.workflow.terminalCard(record);
62
69
  if (kind === "settings")
63
- this.options.settings.create(record);
70
+ await this.options.settings.create(record);
64
71
  else
65
72
  this.options.records.create(record);
73
+ record = this.current(base.id);
66
74
  if (preparation.status === "failed")
67
75
  this.reportFailure(record, "input_preparation");
68
- const result = parsed === undefined ? await this.describe(record, false) : await this.submit({
69
- cardId: base.id, permission: token, revision: 0, input: parsed
70
- });
76
+ const result = kind === "connect" || kind === "disconnect" ? { snapshot: (await this.readWalletOperation(base.id)).card }
77
+ : parsed === undefined ? await this.describe(record, false) : await this.submit({
78
+ cardId: base.id, permission: token, revision: 0, input: parsed
79
+ });
71
80
  return { ...result, permission: token };
72
81
  }
73
82
  async read(input) { return this.describe(this.require(input), true); }
@@ -80,16 +89,27 @@ export class CardStore {
80
89
  return this.options.settings.readFile(record, input.fileId, input.offset, limit, input.attemptId);
81
90
  }
82
91
  async readSaved(cardId) { return (await this.describe(this.current(cardId), false)).snapshot; }
92
+ async readWalletOperation(cardId) {
93
+ if (!this.options.workflow)
94
+ throw new CardError("Wallet operations are unavailable.");
95
+ const result = this.options.workflow.readOperation(this.current(cardId));
96
+ return { card: this.snapshot(result.data ? { ...result.record, state: { ...result.record.state, data: result.data } } : result.record, result.evaluatedAt), operation: result.operation,
97
+ walletAvailability: result.walletAvailability, evaluatedAt: result.evaluatedAt };
98
+ }
83
99
  async describe(record, uiObservation) {
100
+ if (record.state.state === "closed")
101
+ return this.response(record);
84
102
  if (record.state.kind === "settings") {
85
103
  if (!this.options.settings)
86
104
  throw new CardError("Settings cards are unavailable.");
87
105
  const described = await this.options.settings.describe(record);
106
+ if (described.record.state.state === "closed")
107
+ return this.response(described.record);
88
108
  return { snapshot: cardSnapshotSchema.parse({ ...described.record.state, data: described.data,
89
109
  inputRemainingMs: described.record.state.state === "ready" ? Math.max(0, Date.parse(described.record.state.expiresAt) - this.now().getTime()) : 0,
90
110
  pollAfterMs: EXECUTION_POLLING_INTERVAL_SECONDS * 1000 }) };
91
111
  }
92
- if (record.state.kind !== "connect" && record.state.kind !== "review")
112
+ if (record.state.kind !== "connect" && record.state.kind !== "disconnect" && record.state.kind !== "review")
93
113
  return this.response(record);
94
114
  if (!this.options.workflow)
95
115
  throw new CardError("Wallet actions are unavailable.");
@@ -97,6 +117,8 @@ export class CardStore {
97
117
  const current = view.evaluated.record;
98
118
  if (!current)
99
119
  throw new CardError("Saved card data is unavailable.");
120
+ if (current.state.state === "closed")
121
+ return this.response(current);
100
122
  const snapshot = cardSnapshotSchema.parse({ ...current.state, data: view.data,
101
123
  inputRemainingMs: view.evaluated.inputRemainingMs,
102
124
  pollAfterMs: (current.state.kind === "review" ? EXECUTION_POLLING_INTERVAL_SECONDS : WALLET_CONNECTION_POLL_SECONDS) * 1000 });
@@ -106,13 +128,20 @@ export class CardStore {
106
128
  }
107
129
  async act(input) {
108
130
  const record = this.require(input);
131
+ if (record.state.state === "closed" && input.input.action !== "settings_report_download") {
132
+ const normalized = record.state.kind === "settings" ? settingsActionSchema.safeParse(input.input) : undefined;
133
+ if (isDeepStrictEqual(record.acceptedInput, normalized?.success ? normalized.data : input.input) || record.state.reason === "cancelled" &&
134
+ (input.input.action === "stop_waiting" || input.input.action === "cancel" || input.input.action === "stop_connection" && input.input.operationId === record.operationId))
135
+ return this.response(record);
136
+ return { ...this.response(record), error: { code: "card_conflict", message: "This card has ended. Ask in chat for a new request." } };
137
+ }
109
138
  if (record.state.kind === "settings") {
110
139
  if (!this.options.settings)
111
140
  throw new CardError("Settings cards are unavailable.");
112
141
  const parsed = settingsActionSchema.safeParse(input.input);
113
142
  if (!parsed.success)
114
143
  return { ...await this.describe(record, false), error: { code: "invalid_card_input", message: "This Settings action is not valid." } };
115
- if ((parsed.data.action === "settings_confirm" || parsed.data.action === "settings_download_backup") && record.acceptedInput !== undefined && isDeepStrictEqual(record.acceptedInput, parsed.data))
144
+ if ((parsed.data.action === "settings_confirm" || parsed.data.action === "settings_download_backup" || parsed.data.action === "settings_save_wallet_networks" || parsed.data.action === "settings_clear_read_address") && record.acceptedInput !== undefined && isDeepStrictEqual(record.acceptedInput, parsed.data))
116
145
  return this.describe(record, false);
117
146
  if (input.revision !== record.state.revision && !isSettingsDownloadFileAction(parsed.data))
118
147
  return { ...await this.describe(record, false), error: { code: "card_conflict", message: "This Settings selection changed. Review the current selection before continuing." } };
@@ -131,7 +160,7 @@ export class CardStore {
131
160
  }
132
161
  return this.describe(this.current(input.cardId), false);
133
162
  }
134
- if ((record.state.kind !== "connect" && record.state.kind !== "review") || !this.options.workflow) {
163
+ if ((record.state.kind !== "connect" && record.state.kind !== "disconnect" && record.state.kind !== "review") || !this.options.workflow) {
135
164
  return this.conflict(record, "This card does not accept wallet workflow actions.");
136
165
  }
137
166
  let parsed;
@@ -156,11 +185,11 @@ export class CardStore {
156
185
  code: error instanceof WalletUnavailableError ? "wallet_unavailable" : "card_conflict", message: error.message
157
186
  } };
158
187
  }
159
- return this.describe(this.current(input.cardId), false);
188
+ return this.describe(this.current(input.cardId), true);
160
189
  }
161
190
  async submit(input) {
162
191
  const record = this.require(input);
163
- if (record.state.kind === "connect" || record.state.kind === "review" || record.state.kind === "settings")
192
+ if (record.state.kind === "connect" || record.state.kind === "disconnect" || record.state.kind === "review" || record.state.kind === "settings")
164
193
  throw new CardError("This card does not accept data selections.");
165
194
  let parsed;
166
195
  try {
@@ -198,7 +227,7 @@ export class CardStore {
198
227
  const complete = { ...admitted, ...("receiptDisplay" in projected ? { receiptDisplay: projected.receiptDisplay } : {}),
199
228
  state: { ...admitted.state, data: projected.data, state: "closed", reason: "completed", revision: admitted.state.revision + 1 } };
200
229
  try {
201
- this.options.records.replace(admitted, complete);
230
+ this.options.records.replace(admitted, complete, this.now());
202
231
  }
203
232
  catch {
204
233
  return this.fail(admitted, "result_storage", "The result could not be saved. This request will not be repeated automatically.");
@@ -210,7 +239,7 @@ export class CardStore {
210
239
  this.assertCurrent();
211
240
  this.reportFailure(record, stage);
212
241
  this.options.records.replace(record, { ...record, state: { ...record.state,
213
- state: "closed", reason: "failed", error: message, revision: record.state.revision + 1 } });
242
+ state: "closed", reason: "failed", error: message, revision: record.state.revision + 1 } }, this.now());
214
243
  return this.response(this.current(record.state.cardId));
215
244
  }
216
245
  reportFailure(record, stage) {
@@ -244,8 +273,11 @@ export class CardStore {
244
273
  return snapshot;
245
274
  }
246
275
  response(record) {
276
+ if (["connect", "disconnect", "review"].includes(record.state.kind) && record.state.state !== "closed")
277
+ record = this.options.workflow.expireCard(record);
247
278
  const evaluated = this.options.records.evaluate(record, () => this.now());
248
279
  record = evaluated.record;
249
- return { snapshot: this.snapshot(record, evaluated.evaluatedAt), ...(record.receiptDisplay === undefined ? {} : { receiptDisplay: record.receiptDisplay }) };
280
+ const receipt = record.state.kind === "review" && record.state.state === "closed" ? this.options.workflow?.closedReceiptDisplay(record) : undefined;
281
+ return { ...(receipt ?? {}), snapshot: this.snapshot(record, evaluated.evaluatedAt), ...(record.receiptDisplay === undefined ? {} : { receiptDisplay: record.receiptDisplay }) };
250
282
  }
251
283
  }
@@ -0,0 +1,120 @@
1
+ import { fromBase58 } from "@mysten/sui/utils";
2
+ import { z } from "zod";
3
+ import { parseSuiAddress } from "../suiAddress.js";
4
+ export const WALLET_CHAINS = ["sui:mainnet", "eip155:1", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"];
5
+ export const SUI_MAINNET_WALLET_CHAIN = WALLET_CHAINS[0];
6
+ export const walletChainSchema = z.enum(WALLET_CHAINS);
7
+ export const walletGrantRevisionsSchema = z.record(walletChainSchema, z.number().int().nonnegative());
8
+ export function initialGrantRevisions() {
9
+ return Object.fromEntries(WALLET_CHAINS.map((chain) => [chain, 0]));
10
+ }
11
+ export const walletChainsSchema = z.array(walletChainSchema).refine((chains) => new Set(chains).size === chains.length, "Networks must be distinct");
12
+ export function parseWalletChains(value) {
13
+ const selected = new Set(walletChainsSchema.parse(value));
14
+ return WALLET_CHAINS.filter((chain) => selected.has(chain));
15
+ }
16
+ export const DEFAULT_WALLET_CHAINS = [SUI_MAINNET_WALLET_CHAIN];
17
+ export const WALLET_CHAIN_LABELS = {
18
+ "sui:mainnet": "Sui", "eip155:1": "Ethereum", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "Solana"
19
+ };
20
+ export function normalizeChainAddress(chainId, value) {
21
+ if (chainId === SUI_MAINNET_WALLET_CHAIN)
22
+ return parseSuiAddress(value);
23
+ if (chainId === "eip155:1")
24
+ return /^0x[0-9a-fA-F]{40}$/.test(value) ? value.toLowerCase() : undefined;
25
+ try {
26
+ return fromBase58(value).length === 32 ? value : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export const chainAccountSchema = z.object({ chainId: walletChainSchema, address: z.string() }).strict()
33
+ .refine((value) => normalizeChainAddress(value.chainId, value.address) === value.address, "Expected a normalized chain address");
34
+ export const walletGrantAccountSchema = z.object({ address: z.string(), displayAddress: z.string().optional(),
35
+ methods: z.array(z.string()), events: z.array(z.string()) }).strict();
36
+ export const walletGrantSchema = z.object({ chainId: walletChainSchema, accounts: z.array(walletGrantAccountSchema),
37
+ invalidAccounts: z.literal(true).optional() }).strict().superRefine((grant, context) => {
38
+ if (new Set(grant.accounts.map((a) => a.address)).size !== grant.accounts.length ||
39
+ grant.invalidAccounts && grant.accounts.length || grant.accounts.some((account) => normalizeChainAddress(grant.chainId, account.address) !== account.address ||
40
+ account.displayAddress !== undefined && normalizeChainAddress(grant.chainId, account.displayAddress) !== account.address)) {
41
+ context.addIssue({ code: "custom", message: "Wallet grant accounts must match their chain" });
42
+ }
43
+ });
44
+ export const walletGrantsSchema = z.array(walletGrantSchema).refine((grants) => new Set(grants.map((grant) => grant.chainId)).size === grants.length, "Wallet grants must have distinct chains");
45
+ export const walletChainResultSchema = z.discriminatedUnion("status", [
46
+ chainAccountSchema.safeExtend({ status: z.literal("bound") }).strict(),
47
+ z.object({ chainId: walletChainSchema, status: z.enum(["not_approved", "multiple_accounts", "invalid_response"]) }).strict()
48
+ ]);
49
+ export const walletChainResultsSchema = z.array(walletChainResultSchema).refine((results) => new Set(results.map((result) => result.chainId)).size === results.length, "One result per requested chain is required");
50
+ export function fixedChainAccount(connection, chainId) {
51
+ const result = connection.chainResults.find((item) => item.chainId === chainId);
52
+ return result?.status === "bound" ? result.address : undefined;
53
+ }
54
+ export function approvedChainResults(chains, grants) {
55
+ return WALLET_CHAINS.filter((chain) => chains.includes(chain)).map((chainId) => {
56
+ const grant = grants.find((item) => item.chainId === chainId);
57
+ if (grant?.invalidAccounts)
58
+ return { chainId, status: "invalid_response" };
59
+ if (!grant?.accounts.length)
60
+ return { chainId, status: "not_approved" };
61
+ if (grant.accounts.length !== 1)
62
+ return { chainId, status: "multiple_accounts" };
63
+ return { chainId, status: "bound", address: grant.accounts[0].address };
64
+ });
65
+ }
66
+ const namespaceSchema = z.object({ accounts: z.array(z.string()), methods: z.array(z.string()), events: z.array(z.string()) });
67
+ export function normalizeWalletGrants(namespaces) {
68
+ const chains = new Map();
69
+ for (const [namespace, value] of Object.entries(namespaces)) {
70
+ const allowed = WALLET_CHAINS.filter((chain) => namespace === chain || namespace === chain.split(":")[0]);
71
+ if (!allowed.length)
72
+ continue;
73
+ const parsed = namespaceSchema.safeParse(value);
74
+ const entry = (chain) => {
75
+ let result = chains.get(chain);
76
+ if (!result) {
77
+ result = { invalid: false, accounts: new Map() };
78
+ chains.set(chain, result);
79
+ }
80
+ return result;
81
+ };
82
+ if (!parsed.success) {
83
+ for (const chain of allowed)
84
+ entry(chain).invalid = true;
85
+ continue;
86
+ }
87
+ for (const caipAccount of parsed.data.accounts) {
88
+ const chain = allowed.find((id) => caipAccount.startsWith(`${id}:`));
89
+ if (!chain) {
90
+ if (!/^[^:]+:[^:]+:.+$/.test(caipAccount))
91
+ for (const id of allowed)
92
+ entry(id).invalid = true;
93
+ continue;
94
+ }
95
+ const rawAddress = caipAccount.slice(chain.length + 1), address = normalizeChainAddress(chain, rawAddress);
96
+ const grant = entry(chain);
97
+ if (!address) {
98
+ grant.invalid = true;
99
+ continue;
100
+ }
101
+ const prior = grant.accounts.get(address);
102
+ grant.accounts.set(address, { address,
103
+ ...(rawAddress === address ? prior?.displayAddress ? { displayAddress: prior.displayAddress } : {} : { displayAddress: rawAddress }),
104
+ methods: [...new Set([...(prior?.methods ?? []), ...parsed.data.methods])].sort(),
105
+ events: [...new Set([...(prior?.events ?? []), ...parsed.data.events])].sort() });
106
+ }
107
+ }
108
+ return WALLET_CHAINS.flatMap((chainId) => {
109
+ const grant = chains.get(chainId);
110
+ return grant ? [{ chainId, accounts: grant.invalid ? [] : [...grant.accounts.values()].sort((a, b) => a.address.localeCompare(b.address)),
111
+ ...(grant.invalid ? { invalidAccounts: true } : {}) }] : [];
112
+ });
113
+ }
114
+ export function walletGrantAccount(grants, chainId, address) {
115
+ return grants.find((grant) => grant.chainId === chainId)?.accounts.find((account) => account.address === address);
116
+ }
117
+ export function comparableGrantScope(grant) {
118
+ return grant && { chainId: grant.chainId, invalidAccounts: grant.invalidAccounts,
119
+ accounts: grant.accounts.map(({ address, methods, events }) => ({ address, methods, events })) };
120
+ }
@@ -188,17 +188,13 @@ export class LocalSessionStore {
188
188
  if (!normalizedAccount) {
189
189
  throw new SessionStoreError("input_invalid", "Invalid wallet account address");
190
190
  }
191
- const activeAccount = await this.activityStore.getActiveAccount();
192
- if (!activeAccount) {
193
- throw new SessionStoreError("active_account_not_set", "Review account binding requires an active read account");
194
- }
195
- if (activeAccount.address !== normalizedAccount) {
196
- throw new SessionStoreError("invalid_session_transition", `Review account does not match active read account: ${id}`);
197
- }
198
191
  const nextSession = cloneLocalSession(session);
199
192
  if (session.account && session.account !== normalizedAccount) {
200
193
  throw new SessionStoreError("invalid_session_transition", `Review session already bound to a different account: ${id}`);
201
194
  }
195
+ const knownAccount = await this.activityStore.getKnownAccount(normalizedAccount);
196
+ if (!knownAccount)
197
+ throw new SessionStoreError("active_account_not_set", "Review account binding requires a known Sui account");
202
198
  if (nextSession.status === "proposed") {
203
199
  transition(nextSession, "awaiting_wallet");
204
200
  transition(nextSession, "wallet_connected");
@@ -239,7 +235,7 @@ export class LocalSessionStore {
239
235
  async recordReviewStateInternal(id, state, privateArtifacts, now, preparation) {
240
236
  const session = await this.requireMutableSession(id, now);
241
237
  if (preparation && (session.preparationId !== preparation.id || session.walletConnectionId !== preparation.connectionId ||
242
- session.walletConnectionRevision !== preparation.connectionRevision))
238
+ session.walletGrantRevision !== preparation.grantRevision))
243
239
  this.throwStaleReviewSession(id);
244
240
  assertSameSessionId(id, state.reviewSessionId, "Review state");
245
241
  assertPlanInSession(session, state.planId);
@@ -42,16 +42,11 @@ export class SettingsFileStore {
42
42
  }
43
43
  stagingPath(file) {
44
44
  this.assertId(file.fileId);
45
- return join(this.directory("staging"), `${file.fileId}.${file.role === "upload" ? "upload" : isSettingsDownloadFile(file) ? "download" : "stage"}`);
46
- }
47
- finalPath(file) {
48
- this.assertId(file.operationId);
49
- if (file.role !== "export" || file.relativePath !== `backups/${file.operationId}.json`) {
50
- throw new SettingsConflict("The backup location does not match this request.");
45
+ if (file.role === "export" && !isSettingsDownloadFile(file)) {
46
+ throw new SettingsConflict("The private backup path does not match this request.");
51
47
  }
52
- return join(this.directory("backups"), `${file.operationId}.json`);
48
+ return join(this.directory("staging"), `${file.fileId}.${file.role === "upload" ? "upload" : "download"}`);
53
49
  }
54
- exportPath(file) { return this.finalPath(file); }
55
50
  localBackupPath(file) {
56
51
  this.assertId(file.operationId);
57
52
  if (!isSettingsDownloadFile(file))
@@ -277,28 +272,6 @@ export class SettingsFileStore {
277
272
  throw new SettingsConflict("This file is not the private download snapshot.");
278
273
  return this.readVerifiedRange(file, this.directory("staging"), this.stagingPath(file), offset, length);
279
274
  }
280
- async verifyExport(file) {
281
- const directory = this.directory("backups");
282
- const info = await lstat(directory);
283
- if (!info.isDirectory() || info.isSymbolicLink())
284
- throw new SettingsConflict("The local backup directory changed.");
285
- const handle = await this.handle(this.finalPath(file), constants.O_RDONLY);
286
- try {
287
- if ((await handle.stat()).size !== file.totalSize)
288
- throw new SettingsConflict("The local backup length changed.");
289
- const hash = createHash("sha256");
290
- for await (const chunk of handle.createReadStream({ autoClose: false }))
291
- hash.update(chunk);
292
- if (hash.digest("hex") !== file.sha256)
293
- throw new SettingsConflict("The local backup content changed.");
294
- }
295
- finally {
296
- await handle.close();
297
- }
298
- }
299
- async readExportRange(file, offset, length) {
300
- return this.readVerifiedRange(file, this.directory("backups"), this.finalPath(file), offset, length);
301
- }
302
275
  async readVerifiedRange(file, directory, path, offset, length) {
303
276
  if (!file.sealed || file.invalidated || !Number.isSafeInteger(offset) || !Number.isSafeInteger(length) ||
304
277
  offset < 0 || length < 1 || offset + length > file.totalSize)
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { assetReadAccountSchema, walletAvailabilitySchema } from "./walletConnection.js";
3
3
  import { LOCAL_DATA_EXPORT_FORMAT } from "../activity/localDataTypes.js";
4
- import { suiAddressStringSchema } from "../suiAddress.js";
4
+ import { walletChainsSchema, parseWalletChains } from "./chainAccounts.js";
5
5
  export const SETTINGS_IMPORT_MAX_SIZE = 16 * 1024 * 1024;
6
6
  const identity = z.string().uuid();
7
7
  const size = z.number().int().nonnegative().safe();
@@ -18,12 +18,22 @@ export const restoreCheckSchema = z.discriminatedUnion("status", [
18
18
  ]);
19
19
  export const storedRestoreCheckSchema = z.object({ fileId: identity, sha256, totalSize: size, dataEpoch: size,
20
20
  userDataRevision: size, check: restoreCheckSchema }).strict();
21
- export const settingsDraftSchema = z.discriminatedUnion("kind", [
21
+ export const settingsSelectionSchema = z.discriminatedUnion("kind", [
22
22
  z.object({ kind: z.literal("set_endpoint"), endpoint: z.enum(["grpc", "graphql"]), value: z.string().min(1) }).strict(),
23
23
  z.object({ kind: z.literal("restore_endpoint"), endpoint: z.enum(["grpc", "graphql"]) }).strict(),
24
- z.object({ kind: z.enum(["clear_read_address", "export_local_data", "download_backup", "import_local_data", "reset_local_data", "restart_wallet_service"]) }).strict()
24
+ z.object({ kind: z.enum(["import_local_data", "reset_local_data", "restart_wallet_service"]) }).strict()
25
+ ]);
26
+ export const settingsDraftSchema = z.discriminatedUnion("kind", [
27
+ z.object({ kind: z.literal("clear_read_address") }).strict(),
28
+ ...settingsSelectionSchema.options,
29
+ z.object({ kind: z.literal("set_wallet_networks"), chains: walletChainsSchema }).strict(),
30
+ z.object({ kind: z.literal("download_backup") }).strict()
25
31
  ]);
26
- export const settingsDownloadReportSchema = z.enum(["acknowledged", "not_completed", "unconfirmed", "unsupported"]);
32
+ export const settingsCardInputSchema = z.object({
33
+ operationStatus: z.object({ operationId: z.string().uuid() }).strict().optional(),
34
+ backupDelivery: z.object({ operationId: z.string().uuid(), fileId: z.string().uuid() }).strict().optional()
35
+ }).strict().refine((input) => !(input.operationStatus && input.backupDelivery), "Choose exactly one card purpose.");
36
+ export const settingsDownloadReportSchema = z.enum(["acknowledged", "not_completed", "unconfirmed", "unsupported", "not_sent"]);
27
37
  export const settingsDownloadFailureSchema = z.object({ method: z.literal("ui/download-file"), code: z.literal(-32601) }).strict();
28
38
  export const settingsDownloadEndSchema = z.enum(["acknowledged", "cancelled", "expired", "server_restarted", "data_replaced", "source_unavailable", "stored_locally", "local_save_failed"]);
29
39
  export const settingsFileIdentitySchema = z.object({ device: z.string().regex(/^\d+$/), inode: z.string().regex(/^\d+$/) }).strict();
@@ -31,7 +41,7 @@ const localSaveFailure = z.enum(["not_published", "target_conflict", "source_una
31
41
  const storedLocalSaveSchema = z.object({ status: z.enum(["needs_consent", "saving", "saved", "failed"]),
32
42
  attemptId: identity, fileId: identity, sha256, totalSize: size, destination: z.string(), ownerId: z.string().min(1),
33
43
  currentSaveAttemptId: identity.optional(),
34
- attempts: z.array(z.object({ saveAttemptId: identity, previousSaveAttemptId: identity.optional(),
44
+ attempts: z.array(z.object({ saveAttemptId: identity, cardId: z.string().min(1), previousSaveAttemptId: identity.optional(),
35
45
  admittedAt: z.string().datetime(), allowPossibleCopy: z.boolean() }).strict()),
36
46
  sourceIdentity: settingsFileIdentitySchema.optional(), savedAt: z.string().datetime().optional(), failure: localSaveFailure.optional()
37
47
  }).strict().superRefine((value, context) => {
@@ -47,7 +57,7 @@ const storedLocalSaveSchema = z.object({ status: z.enum(["needs_consent", "savin
47
57
  });
48
58
  export const storedSettingsDownloadSchema = z.object({
49
59
  currentAttemptId: identity,
50
- attempts: z.array(z.object({ attemptId: identity, admittedAt: z.string().datetime(), previousAttemptId: identity.optional(),
60
+ attempts: z.array(z.object({ attemptId: identity, cardId: z.string().min(1), admittedAt: z.string().datetime(), previousAttemptId: identity.optional(),
51
61
  hostReport: settingsDownloadReportSchema.optional(), failure: settingsDownloadFailureSchema.optional() }).strict()).min(1),
52
62
  readyAt: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(), endReason: settingsDownloadEndSchema.optional(),
53
63
  localSave: storedLocalSaveSchema.optional()
@@ -69,6 +79,9 @@ export const storedSettingsDownloadSchema = z.object({
69
79
  });
70
80
  export const settingsDownloadFileActionSchema = z.enum(["settings_retry_download", "settings_report_download", "settings_cancel_download", "settings_store_backup"]);
71
81
  export const settingsActionSchema = z.discriminatedUnion("action", [
82
+ z.object({ action: z.literal("settings_clear_read_address"), operationId: identity, connectionId: z.string().min(1), account: z.string().min(1) }).strict(),
83
+ z.object({ action: z.literal("settings_save_wallet_networks"), operationId: identity,
84
+ chains: walletChainsSchema.transform(parseWalletChains), expectedChains: walletChainsSchema.transform(parseWalletChains) }).strict(),
72
85
  z.object({ action: z.literal("settings_download_backup"), operationId: identity, attemptId: identity }).strict(),
73
86
  z.object({ action: z.literal("settings_retry_download"), operationId: identity, fileId: identity,
74
87
  previousAttemptId: identity, attemptId: identity }).strict(),
@@ -77,7 +90,7 @@ export const settingsActionSchema = z.discriminatedUnion("action", [
77
90
  z.object({ action: z.literal("settings_store_backup"), operationId: identity, fileId: identity, attemptId: identity,
78
91
  saveAttemptId: identity, previousSaveAttemptId: identity.optional(), allowPossibleCopy: z.boolean() }).strict(),
79
92
  z.object({ action: z.literal("settings_cancel_download"), operationId: identity, fileId: identity, attemptId: identity }).strict(),
80
- z.object({ action: z.literal("settings_select"), draft: settingsDraftSchema }).strict(),
93
+ z.object({ action: z.literal("settings_select"), draft: settingsSelectionSchema }).strict(),
81
94
  z.object({ action: z.literal("settings_prepare"), operationId: identity }).strict(),
82
95
  z.object({ action: z.literal("settings_begin_upload"), operationId: identity,
83
96
  name: z.string().min(1), totalSize: size.max(SETTINGS_IMPORT_MAX_SIZE), sha256 }).strict(),
@@ -107,7 +120,7 @@ export class SettingsConflict extends Error {
107
120
  }
108
121
  export const settingsResultSchema = z.object({
109
122
  outcome: z.enum(["completed", "failed", "interrupted", "superseded"]), recordedAt: z.string().datetime(),
110
- message: z.string().min(1), details: z.record(z.string(), z.unknown()).optional(), completionScope: z.literal("backup_snapshot").optional()
123
+ message: z.string().min(1), details: z.record(z.string(), z.unknown()).optional(), completionScope: z.literal("backup_delivery").optional()
111
124
  }).strict();
112
125
  const endpointView = z.object({ storedValue: z.string(), effectiveValue: z.string(),
113
126
  source: z.enum(["environment", "local_db", "builtin_default"]), pendingStoredValue: z.string().optional(),
@@ -121,8 +134,7 @@ const localDataCounts = z.object({ accounts: count, reviewSessions: count, revie
121
134
  externalActivityTransactions: count, localSettings: count }).strict();
122
135
  const importPreview = z.object({ status: z.literal("valid"), format: z.literal(LOCAL_DATA_EXPORT_FORMAT), network: z.literal("mainnet"),
123
136
  exportedAt: z.string().datetime(), currentCounts: localDataCounts, incomingCounts: localDataCounts, willReplace: z.literal(true),
124
- activeAccountChange: z.enum(["unchanged", "set", "cleared"]), currentReadAddress: suiAddressStringSchema.nullable(),
125
- incomingReadAddress: suiAddressStringSchema.nullable(), restartRequiredAfterImport: z.boolean() }).strict();
137
+ restartRequiredAfterImport: z.boolean() }).strict();
126
138
  export const settingsViewSchema = z.object({
127
139
  kind: z.literal("settings"), operationId: identity,
128
140
  phase: z.enum(["draft", "uploading", "sealed", "prepared", "executing", "stopping", "starting", "terminal"]),
@@ -142,13 +154,13 @@ export const settingsViewSchema = z.object({
142
154
  priorRunId: identity.optional(), nextRunId: identity.optional(), supersedesOperationId: identity.optional(), supersededByOperationId: identity.optional(),
143
155
  allowedActions: z.array(z.enum(settingsActionSchema.options.map((option) => option.shape.action.value))), observe: z.boolean(),
144
156
  facts: z.object({ server: z.object({ name: z.string(), version: z.string(), network: z.literal("mainnet") }).strict(),
145
- localSettings: z.object({ suiGrpcUrl: endpointView, suiGraphqlUrl: endpointView }).strict(), readAddress: z.string().optional(),
157
+ localSettings: z.object({ suiGrpcUrl: endpointView, suiGraphqlUrl: endpointView, walletConnectionChains: walletChainsSchema }).strict(), readAddress: z.string().optional(), readConnectionId: z.string().optional(),
146
158
  assetReadAccount: assetReadAccountSchema, walletAvailability: walletAvailabilitySchema,
147
159
  connectionReconciliationRequired: z.literal(true).optional(), observedAt: z.string().datetime().optional(),
148
160
  counts: localDataCounts }).strict().optional()
149
161
  }).strict().superRefine((value, context) => {
150
162
  if (value.download && (value.draft?.kind !== "download_backup" || !value.file ||
151
- value.download.availability === "available" && (!value.file.sealed || !value.result || value.download.endReason || !value.download.expiresAt) ||
163
+ value.download.availability === "available" && (!value.file.sealed || value.download.endReason || !value.download.expiresAt) ||
152
164
  value.download.availability === "ended" && !value.download.endReason)) {
153
165
  context.addIssue({ code: "custom", message: "Download delivery must describe its own snapshot and lifetime." });
154
166
  }
@@ -174,20 +186,16 @@ export const settingsViewSchema = z.object({
174
186
  (value.phase === "prepared" || value.allowedActions.includes("settings_prepare"))) {
175
187
  context.addIssue({ code: "custom", message: "A rejected backup requires an explicit new selection." });
176
188
  }
177
- if (value.backupLocation && !(value.result?.outcome === "completed" && (value.draft?.kind === "export_local_data" ||
178
- value.draft?.kind === "download_backup" && value.download?.localSave?.status === "saved" && value.download.endReason === "stored_locally"))) {
189
+ if (value.backupLocation && !(value.result?.outcome === "completed" &&
190
+ value.draft?.kind === "download_backup" && value.download?.localSave?.status === "saved" && value.download.endReason === "stored_locally")) {
179
191
  context.addIssue({ code: "custom", message: "A backup location requires its recorded successful export." });
180
192
  }
181
193
  if (value.download?.localSave?.status === "saved" && !value.backupLocation) {
182
194
  context.addIssue({ code: "custom", message: "A saved backup must identify its verified location." });
183
195
  }
184
- if (value.draft?.kind === "export_local_data" && value.result?.outcome === "completed" &&
185
- typeof value.result.details?.path === "string" && !value.backupLocation) {
186
- context.addIssue({ code: "custom", message: "A recorded backup path must provide its folder and file name." });
187
- }
188
196
  if (value.temporaryFileCleanup && !value.result)
189
197
  context.addIssue({ code: "custom", message: "Cleanup of an applied or ended operation requires its recorded result." });
190
- if ((value.phase === "terminal") !== (value.result !== undefined) || value.phase === "terminal" && (value.observe || value.allowedActions.length)) {
198
+ if ((value.phase === "terminal") !== (value.result !== undefined) || value.phase === "terminal" && (value.observe || value.allowedActions.length || value.download?.allowedFileActions.length)) {
191
199
  context.addIssue({ code: "custom", message: "A terminal Settings result cannot grant input or observation." });
192
200
  }
193
201
  if (value.phase === "prepared" && (!value.preparationId || !value.draft || !value.preparation))