@zktx.io/sui-mcp 0.5.0 → 0.6.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.
Files changed (87) hide show
  1. package/README.md +6 -6
  2. package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +3 -3
  3. package/dist/core/activity/localDataService.js +71 -52
  4. package/dist/core/activity/localDataValidation.js +1 -3
  5. package/dist/core/activity/sqliteActivityStore.js +24 -16
  6. package/dist/core/activity/sqliteActivityStoreSchema.js +83 -12
  7. package/dist/core/preferences/sqlitePreferencesRepository.js +5 -3
  8. package/dist/core/session/cardSession.js +1 -1
  9. package/dist/core/session/cardSessionStore.js +53 -3
  10. package/dist/core/session/sessionStore.js +0 -17
  11. package/dist/core/session/settingsFileIdentity.js +11 -0
  12. package/dist/core/session/settingsFileProtocol.js +25 -0
  13. package/dist/core/session/settingsFileStore.js +346 -0
  14. package/dist/core/session/settingsInputError.js +34 -0
  15. package/dist/core/session/settingsOperation.js +224 -0
  16. package/dist/core/session/settingsPreparationSql.js +15 -0
  17. package/dist/core/session/settingsWorkflow.js +1219 -0
  18. package/dist/core/session/sqliteSessionStore.js +0 -40
  19. package/dist/core/session/sqliteSettingsWorkflowStore.js +463 -0
  20. package/dist/core/session/sqliteWalletWorkflowStore.js +164 -41
  21. package/dist/core/session/status.js +2 -2
  22. package/dist/core/session/walletConnection.js +27 -8
  23. package/dist/core/session/walletWorkflow.js +73 -15
  24. package/dist/core/session/workflowState.js +16 -8
  25. package/dist/core/session/workflowView.js +18 -2
  26. package/dist/mcp/responseGuidance.js +10 -5
  27. package/dist/mcp/serverInfo.js +3 -4
  28. package/dist/mcp/toolNames.js +2 -2
  29. package/dist/mcp/tools/account/index.js +1 -19
  30. package/dist/mcp/tools/read/readToolHelpers.js +5 -4
  31. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  32. package/dist/mcp/tools/session/shared.js +1 -0
  33. package/dist/mcp/tools/session/statusTools.js +4 -3
  34. package/dist/mcp/tools/settings/index.js +8 -28
  35. package/dist/mcp-app/account.html +14 -14
  36. package/dist/mcp-app/account.notices.txt +1 -1
  37. package/dist/mcp-app/chart.html +9 -9
  38. package/dist/mcp-app/chart.notices.txt +1 -1
  39. package/dist/mcp-app/connect.html +14 -14
  40. package/dist/mcp-app/connect.notices.txt +1 -1
  41. package/dist/mcp-app/receipt.html +1298 -1463
  42. package/dist/mcp-app/receipt.notices.txt +226 -1204
  43. package/dist/mcp-app/review.html +1298 -1463
  44. package/dist/mcp-app/review.notices.txt +226 -1204
  45. package/dist/mcp-app/settings.html +3133 -0
  46. package/dist/mcp-app/settings.notices.txt +973 -0
  47. package/dist/mcp-ui/contracts.js +8 -2
  48. package/dist/mcp-ui/readCards.js +1 -1
  49. package/dist/mcp-ui/tools.js +33 -2
  50. package/dist/review-server/server.js +5 -51
  51. package/dist/runtime/application.js +41 -14
  52. package/dist/runtime/localSettingsService.js +26 -2
  53. package/dist/runtime/shared/authenticatedFetch.js +6 -2
  54. package/dist/runtime/shared/control.js +5 -6
  55. package/dist/runtime/shared/http.js +14 -0
  56. package/dist/runtime/shared/mcpHttp.js +9 -4
  57. package/dist/runtime/shared/server.js +1 -1
  58. package/dist/runtime/smokeMainnetRead.js +0 -1
  59. package/dist/runtime/walletConnectStorage.js +2 -4
  60. package/dist/runtime/walletSdkChildRuntime.js +2 -1
  61. package/dist/runtime/walletSdkIpc.js +2 -1
  62. package/dist/runtime/walletSdkProcess.js +3 -2
  63. package/docs/AGENT_BEHAVIOR.md +44 -10
  64. package/docs/AGENT_DEVELOPMENT_POLICY.md +16 -12
  65. package/docs/FRONTEND_POLICY.md +105 -28
  66. package/docs/LOCAL_DB_ARCHITECTURE.md +143 -20
  67. package/docs/MCP_SETUP.md +99 -39
  68. package/docs/MCP_TOOLS.md +273 -24
  69. package/docs/SDK_API.md +51 -10
  70. package/docs/UTILITY_INDEX.md +9 -6
  71. package/docs/WALLET_CONNECTION.md +30 -9
  72. package/package.json +13 -7
  73. package/dist/core/activity/schemaVersion.js +0 -2
  74. package/dist/core/session/keyedRecordStore.js +0 -18
  75. package/dist/core/session/settingsSession.js +0 -1
  76. package/dist/core/session/settingsSessions.js +0 -45
  77. package/dist/review-app/brand-dark.svg +0 -1
  78. package/dist/review-app/brand-light.svg +0 -1
  79. package/dist/review-app/favicon.svg +0 -1
  80. package/dist/review-app/settings.css +0 -1
  81. package/dist/review-app/settings.js +0 -1
  82. package/dist/review-app/ui.css +0 -809
  83. package/dist/review-server/assets.js +0 -48
  84. package/dist/review-server/html.js +0 -45
  85. package/dist/review-server/http.js +0 -47
  86. package/dist/review-server/middleware/reviewToken.js +0 -7
  87. package/dist/review-server/settingsApi.js +0 -158
@@ -10,7 +10,7 @@ import { reviewStateOutputSchema } from "../action/schemas.js";
10
10
  import { LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION } from "./liveReviewSessionContract.js";
11
11
  import { SqliteCardRecordStore } from "./sqliteCardStore.js";
12
12
  import { receiptDisplaySchema } from "./cardSession.js";
13
- import { isOwnedConnectedWallet, walletConnectionSelection, walletConnectionSchema, SUI_MAINNET_WALLET_CHAIN, SUI_SIGN_TRANSACTION_METHOD } from "./walletConnection.js";
13
+ import { isOwnedConnectedWallet, walletConnectionSelection, walletConnectionSchema, walletRecoveryImpactSchema, SUI_MAINNET_WALLET_CHAIN, SUI_SIGN_TRANSACTION_METHOD } from "./walletConnection.js";
14
14
  import { SIGNATURE_WAIT_MS, CHAIN_RECEIPT_LOOKUP_MAX_AGE_MS, INITIAL_CHAIN_OBSERVATION_STATUSES, isInitialChainObservation, transactionRequestSchema, transactionExecutionSummarySchema, requestTransitions } from "./transactionRequest.js";
15
15
  export class WorkflowConflict extends Error {
16
16
  events;
@@ -24,12 +24,14 @@ const interruptionReasons = {
24
24
  wallet_change: "The wallet connection changed or became unavailable before submission. Nothing was submitted.",
25
25
  connection_conflict: "More than one wallet connection is present. Nothing was submitted.",
26
26
  disconnect_requested: "Wallet disconnection was requested before submission. Nothing was submitted.",
27
+ wallet_service_restarted: "The wallet service was restarted before submission. Nothing was submitted.",
27
28
  wallet_service_lost: "The wallet service stopped before submission. Nothing was submitted."
28
29
  };
29
30
  const preparationInterruptionReasons = {
30
31
  wallet_change: "The selected wallet connection changed.",
31
32
  connection_conflict: "More than one wallet connection is present. Disconnect the connections you no longer need, then update this review.",
32
33
  disconnect_requested: "Disconnection of the selected wallet was requested. Connect a wallet before updating this review.",
34
+ wallet_service_restarted: "The wallet service was restarted. Update this review before requesting approval.",
33
35
  wallet_service_lost: "The wallet service stopped. Restore the wallet connection and update this review before requesting approval."
34
36
  };
35
37
  /** Purpose-specific mutations on the activity owner's connection. Each method
@@ -42,8 +44,16 @@ export class SqliteWalletWorkflowStore {
42
44
  materialStore;
43
45
  evaluateState;
44
46
  readActiveAccount;
47
+ readSdkSnapshot;
45
48
  cards;
46
- constructor(db, ownerId, writeActiveAccount, clock, materialStore, evaluateState, readActiveAccount) {
49
+ runId;
50
+ setWalletRunId(runId) { this.runId = runId; }
51
+ assertWalletInputAllowed() {
52
+ if (this.db.prepare("SELECT wallet_input_blocked FROM settings_control WHERE id=1").get().wallet_input_blocked) {
53
+ throw new WorkflowConflict("Wallet input is blocked while the connection service is being recovered.");
54
+ }
55
+ }
56
+ constructor(db, ownerId, writeActiveAccount, clock, materialStore, evaluateState, readActiveAccount, readSdkSnapshot) {
47
57
  this.db = db;
48
58
  this.ownerId = ownerId;
49
59
  this.writeActiveAccount = writeActiveAccount;
@@ -51,9 +61,10 @@ export class SqliteWalletWorkflowStore {
51
61
  this.materialStore = materialStore;
52
62
  this.evaluateState = evaluateState;
53
63
  this.readActiveAccount = readActiveAccount;
64
+ this.readSdkSnapshot = readSdkSnapshot;
54
65
  this.cards = new SqliteCardRecordStore(db);
55
66
  }
56
- evaluate(input) { return this.evaluateState(input); }
67
+ reconcileAndEvaluate(input) { return this.evaluateState(input); }
57
68
  currentCard(expected) {
58
69
  const current = this.cards.get(expected.state.cardId);
59
70
  if (!current || current.tokenHash !== expected.tokenHash || current.scope !== expected.scope || current.ownerId !== expected.ownerId ||
@@ -94,15 +105,34 @@ export class SqliteWalletWorkflowStore {
94
105
  recordWalletFailure(input) {
95
106
  // Retried by the workflow until revocation is durably recorded. The SDK
96
107
  // run is already fenced, so a failed write cannot preserve usable authority.
97
- this.invalidateWalletRun("wallet_service_lost", new Date(input.observedAt));
108
+ this.invalidateWalletRun("wallet_service_lost", new Date(input.observedAt), input.runId);
98
109
  }
99
110
  publishWalletState() { this.publishAvailableWallets(); }
100
- applyWalletObservation(snapshot, previous, ready) {
111
+ applyWalletObservation(snapshot, previous, ready, recordReady) {
101
112
  this.db.transaction(() => {
102
113
  for (const record of this.connections()) {
103
- if (!record.topic || record.connection.status !== "connected")
114
+ if (!record.topic)
104
115
  continue;
105
116
  const observed = snapshot.sessions.find((item) => item.topic === record.topic);
117
+ if (record.connection.status !== "connected") {
118
+ // Startup can transfer management ownership; ordinary observations
119
+ // update the current run's facts without restoring terminal authority.
120
+ if ((ready || record.ownerId === this.ownerId && record.runId === snapshot.runId) &&
121
+ observed?.status === "present" && !record.sdkPending &&
122
+ !this.pendingDisconnect(record.connection.connectionId, record.ownerId)) {
123
+ const transferred = record.ownerId !== this.ownerId || record.runId !== snapshot.runId;
124
+ const changed = !isDeepStrictEqual(record.connection.accounts, observed.session.accounts) ||
125
+ !isDeepStrictEqual(record.connection.methods, observed.session.methods) ||
126
+ record.connection.expiresAt !== observed.session.expiresAt || record.connection.walletName !== observed.session.walletName;
127
+ if (transferred)
128
+ this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,run_id=? WHERE id=?")
129
+ .run(this.ownerId, snapshot.runId, record.connection.connectionId);
130
+ if (transferred || changed)
131
+ this.updateConnection(record.connection.connectionId, { accounts: observed.session.accounts,
132
+ methods: observed.session.methods, expiresAt: observed.session.expiresAt, walletName: observed.session.walletName }, this.clock());
133
+ }
134
+ continue;
135
+ }
106
136
  if (ready && observed?.status === "present" && !record.sdkPending && !this.pendingDisconnect(record.connection.connectionId, record.ownerId)) {
107
137
  this.restoreConnection(observed.session, this.clock());
108
138
  }
@@ -118,25 +148,38 @@ export class SqliteWalletWorkflowStore {
118
148
  }
119
149
  }
120
150
  this.invalidateConflictingConnections(this.clock());
151
+ if (ready)
152
+ recordReady?.();
121
153
  if (ready)
122
154
  this.publishAvailableWallets();
123
155
  }).immediate();
124
156
  }
125
- invalidateWalletRun(cause, now = this.clock()) {
157
+ invalidateWalletRun(cause, now = this.clock(), runId) {
126
158
  this.db.transaction(() => {
159
+ const targets = new Set();
160
+ if (runId !== undefined) {
161
+ for (const row of this.db.prepare("SELECT connection_id FROM live_request_authority WHERE owner_id=? AND run_id=?")
162
+ .all(this.ownerId, runId))
163
+ targets.add(row.connection_id);
164
+ for (const row of this.db.prepare("SELECT wallet_connection_id FROM live_review_sessions WHERE owner_id=? AND preparation_run_id=? AND wallet_connection_id IS NOT NULL")
165
+ .all(this.ownerId, runId))
166
+ targets.add(row.wallet_connection_id);
167
+ }
127
168
  for (const record of this.connections()) {
128
- if (record.ownerId !== this.ownerId)
169
+ if (record.ownerId !== this.ownerId || runId !== undefined && record.runId !== runId)
129
170
  continue;
130
- this.invalidateConnectionRequests(record.connection.connectionId, now, cause);
171
+ targets.add(record.connection.connectionId);
131
172
  if (["connected", "awaiting_approval"].includes(record.connection.status))
132
173
  this.updateConnection(record.connection.connectionId, {}, now);
133
174
  }
175
+ for (const id of targets)
176
+ this.invalidateConnectionRequests(id, now, cause, runId);
134
177
  this.publishAvailableWallets();
135
178
  }).immediate();
136
179
  }
137
180
  connection(id) {
138
181
  const row = this.db.prepare("SELECT * FROM live_wallet_connections WHERE id=?").get(id);
139
- return row ? { connection: walletConnectionSchema.parse(JSON.parse(row.connection_json)), ownerId: row.owner_id,
182
+ return row ? { connection: walletConnectionSchema.parse(JSON.parse(row.connection_json)), ownerId: row.owner_id, ...(row.run_id === null ? {} : { runId: row.run_id }),
140
183
  sdkPending: row.sdk_pending === 1, ...(row.topic === null ? {} : { topic: row.topic }) } : undefined;
141
184
  }
142
185
  connections() {
@@ -146,20 +189,49 @@ export class SqliteWalletWorkflowStore {
146
189
  connectionView(connection) {
147
190
  return { ...connection, ...(this.pendingDisconnect(connection.connectionId) ? { pendingAction: "disconnect" } : {}) };
148
191
  }
149
- connectionViews() {
150
- return this.connections().filter((item) => item.ownerId === this.ownerId).map((item) => this.connectionView(item.connection));
192
+ connectionPresence(records, snapshot) {
193
+ const pendingConnectionIds = records.filter((item) => item.ownerId === this.ownerId &&
194
+ (item.sdkPending || !!this.pendingDisconnect(item.connection.connectionId))).map((item) => item.connection.connectionId);
195
+ if (!snapshot || snapshot.runId !== this.runId)
196
+ return { confirmed: false, connections: [], unrecorded: false, pendingConnectionIds };
197
+ const connections = [];
198
+ let unrecorded = false;
199
+ for (const session of snapshot.sessions) {
200
+ if (session.status !== "present")
201
+ continue;
202
+ const record = records.find((item) => item.topic === session.topic && item.ownerId === this.ownerId && item.runId === this.runId);
203
+ if (record)
204
+ connections.push({ connectionId: record.connection.connectionId, expiresAt: session.session.expiresAt });
205
+ else
206
+ unrecorded = true;
207
+ }
208
+ return { confirmed: true, connections, unrecorded, pendingConnectionIds };
209
+ }
210
+ readConnectionFacts(now) {
211
+ // A deferred read transaction reuses an outer admission transaction when
212
+ // present. It never repairs callbacks, advances deadlines or reserves I/O.
213
+ return this.db.transaction(() => {
214
+ const records = this.connections(), snapshot = this.readSdkSnapshot(), account = this.readActiveAccount();
215
+ return { evaluatedAt: now.toISOString(), activeAccount: account?.address, activeAccountConnectionId: account?.walletId,
216
+ connections: records.filter((item) => item.ownerId === this.ownerId).map((item) => this.connectionView(item.connection)),
217
+ connectionPresence: this.connectionPresence(records, snapshot),
218
+ ...(snapshot ? { walletObservation: { runId: snapshot.runId, sequence: snapshot.sequence, observedAt: snapshot.observedAt } } : {}) };
219
+ }).deferred();
151
220
  }
152
221
  connectionSelection(now = this.clock()) {
153
- return walletConnectionSelection(this.connectionViews(), now.getTime(), this.readActiveAccount());
222
+ const facts = this.readConnectionFacts(now);
223
+ return walletConnectionSelection(facts.connections, now.getTime(), facts.connectionPresence, facts.activeAccount ? { address: facts.activeAccount, walletId: facts.activeAccountConnectionId } : undefined);
154
224
  }
155
225
  isUsableConnection(id, now = this.clock()) {
156
226
  return this.connectionSelection(now).connection?.connectionId === id;
157
227
  }
158
228
  invalidateConflictingConnections(now) {
159
- const conflict = this.connectionSelection(now).conflict;
160
- if (conflict)
161
- for (const id of conflict.connectionIds)
162
- this.invalidateConnectionRequests(id, now, "connection_conflict");
229
+ const selection = this.connectionSelection(now);
230
+ if (selection.conflict || selection.connectionReconciliationRequired)
231
+ for (const record of this.connections()) {
232
+ if (record.ownerId === this.ownerId)
233
+ this.invalidateConnectionRequests(record.connection.connectionId, now, selection.conflict ? "connection_conflict" : "wallet_change");
234
+ }
163
235
  }
164
236
  settleConnection(id, source) {
165
237
  this.db.transaction(() => {
@@ -172,10 +244,13 @@ export class SqliteWalletWorkflowStore {
172
244
  // visible and the original pairing's failed cleanup write arrives late.
173
245
  const disconnect = this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect' AND operation_id=?
174
246
  AND json_extract(accepted_input_json,'$.action')='disconnect'`).get(this.ownerId, id);
175
- if (disconnect && disconnect.id !== source.cardId)
247
+ if (source.action === "connect" && disconnect && disconnect.id !== source.cardId)
248
+ return;
249
+ const activeDisconnect = this.pendingDisconnect(id);
250
+ if (source.action === "disconnect" && activeDisconnect && activeDisconnect.state.cardId !== source.cardId)
176
251
  return;
177
252
  const record = this.connection(id);
178
- if (!record || record.ownerId !== this.ownerId)
253
+ if (!record || record.ownerId !== this.ownerId || record.runId !== source.runId)
179
254
  return;
180
255
  if (source.action === "connect" && record.connection.status === "awaiting_approval") {
181
256
  const status = source.unrecordedOutcome ?? "failed";
@@ -241,10 +316,10 @@ export class SqliteWalletWorkflowStore {
241
316
  const connection = card.state.kind === "connect" && card.operationId ? this.connection(card.operationId)?.connection : undefined;
242
317
  // Wallet-dependent commands already passed the workflow availability guard.
243
318
  // This store checks DB eligibility; it does not measure SDK health.
244
- const storedAccount = this.readActiveAccount(), activeAccount = storedAccount?.address, targetAccount = session?.account ?? activeAccount;
245
- const facts = { evaluatedAt: now.toISOString(), ownerId: this.ownerId, record: card, session, request,
246
- authority: request ? this.authority(request.attemptId) : undefined, connection, connections: this.connectionViews(),
247
- walletAvailability: { status: "available" }, activeAccount, activeAccountConnectionId: storedAccount?.walletId,
319
+ const connectionFacts = this.readConnectionFacts(now), targetAccount = session?.account ?? connectionFacts.activeAccount;
320
+ const facts = { ...connectionFacts, ownerId: this.ownerId, record: card, session, request,
321
+ authority: request ? this.authority(request.attemptId) : undefined, connection,
322
+ walletAvailability: { status: "available" },
248
323
  busyForAccount: !!targetAccount && this.busyForAccount(targetAccount, now) };
249
324
  if (!workflowEligibility(facts).allowedActions.includes(action))
250
325
  throw new WorkflowConflict("That action is not available right now.");
@@ -307,17 +382,51 @@ export class SqliteWalletWorkflowStore {
307
382
  publishConnection(connectionId, status) {
308
383
  // Connection updates are not completion of every operation referencing it.
309
384
  // In particular, session_update(connected) must not finish a pending disconnect.
385
+ const outcome = this.connection(connectionId)?.connection;
310
386
  this.db.prepare(`UPDATE live_read_cards SET revision=revision+1,
387
+ result_json=CASE WHEN state='running' AND json_extract(accepted_input_json,'$.action')='disconnect'
388
+ AND @disconnectTerminal THEN @outcome ELSE result_json END,
311
389
  reason=CASE WHEN state='running' AND
312
390
  CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
313
391
  THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'completed' ELSE reason END,
314
392
  state=CASE WHEN state='running' AND
315
393
  CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
316
394
  THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'closed' ELSE state END
317
- WHERE scope='connect' AND operation_id=@id`).run({ id: connectionId,
395
+ WHERE scope='connect' AND operation_id=@id`).run({ id: connectionId, outcome: outcome ? JSON.stringify({ disconnectOutcome: outcome }) : null,
318
396
  disconnectTerminal: Number(status !== "connected" && status !== "awaiting_approval"),
319
397
  connectionTerminal: Number(status !== "awaiting_approval") });
320
398
  }
399
+ recoveryImpact(runId) {
400
+ return walletRecoveryImpactSchema.parse({
401
+ runId,
402
+ connections: this.connections().filter((record) => record.ownerId === this.ownerId && record.runId === runId)
403
+ .map((record) => ({ connectionId: record.connection.connectionId, revision: record.connection.revision,
404
+ status: record.connection.status, accounts: record.connection.accounts, walletName: record.connection.walletName,
405
+ sdkPending: record.sdkPending, disconnectPending: !!this.pendingDisconnect(record.connection.connectionId) })),
406
+ preparations: this.db.prepare(`SELECT id,revision,review_revision,preparation_id,wallet_connection_id FROM live_review_sessions
407
+ WHERE owner_id=? AND preparation_run_id=? AND status!='expired' ORDER BY id`).all(this.ownerId, runId),
408
+ requests: this.db.prepare(`SELECT r.attempt_id,r.revision,r.request_status,l.can_submit,l.sdk_pending,l.submit_pending,l.lookup_pending
409
+ FROM review_requests r JOIN live_request_authority l ON l.attempt_id=r.attempt_id
410
+ WHERE l.owner_id=? AND (l.run_id=? OR r.request_status IN ('submitting','awaiting_chain_result','outcome_unknown'))
411
+ ORDER BY r.attempt_id`).all(this.ownerId, runId)
412
+ });
413
+ }
414
+ recordRunExit(runId, parentProcessing = []) {
415
+ this.db.transaction(() => {
416
+ this.db.prepare(`UPDATE live_request_authority SET sdk_pending=0 WHERE owner_id=? AND run_id=? AND sdk_pending=1
417
+ AND attempt_id NOT IN (SELECT value FROM json_each(?))`).run(this.ownerId, runId, JSON.stringify(parentProcessing));
418
+ for (const record of this.connections()) {
419
+ if (record.ownerId !== this.ownerId || record.runId !== runId)
420
+ continue;
421
+ const pending = this.pendingDisconnect(record.connection.connectionId);
422
+ if (pending)
423
+ this.finishDisconnect(pending.state.cardId, record.connection.connectionId, false, this.clock());
424
+ else if (record.connection.status === "awaiting_approval")
425
+ this.updateConnection(record.connection.connectionId, { status: "stopped", reason: "The wallet service ended before this connection was confirmed." }, this.clock(), undefined, false);
426
+ this.db.prepare("UPDATE live_wallet_connections SET sdk_pending=0 WHERE id=? AND run_id=? AND sdk_pending=1").run(record.connection.connectionId, runId);
427
+ }
428
+ }).immediate();
429
+ }
321
430
  publishAvailableWallets() {
322
431
  this.db.prepare("UPDATE live_read_cards SET revision=revision+1 WHERE owner_id=? AND kind IN ('connect','review') AND state='ready'").run(this.ownerId);
323
432
  }
@@ -328,14 +437,15 @@ export class SqliteWalletWorkflowStore {
328
437
  admitConnection(expected, input) {
329
438
  return this.db.transaction(() => {
330
439
  const now = this.clock();
440
+ this.assertWalletInputAllowed();
331
441
  this.expireConnections(now);
332
442
  const card = this.requireInput(expected, now);
333
443
  this.requireAction(card, "connect", now);
334
444
  const connection = walletConnectionSchema.parse({ connectionId: randomUUID(), status: "awaiting_approval", revision: 0,
335
445
  accounts: [], methods: [], chain: SUI_MAINNET_WALLET_CHAIN, createdAt: now.toISOString(), updatedAt: now.toISOString(),
336
446
  expiresAt: card.state.expiresAt });
337
- this.db.prepare("INSERT INTO live_wallet_connections VALUES (?,?,?,?,?,?,?)")
338
- .run(connection.connectionId, this.ownerId, connection.status, 0, null, 1, JSON.stringify(connection));
447
+ this.db.prepare("INSERT INTO live_wallet_connections (id,owner_id,status,revision,topic,sdk_pending,connection_json,run_id) VALUES (?,?,?,?,?,?,?,?)")
448
+ .run(connection.connectionId, this.ownerId, connection.status, 0, null, 1, JSON.stringify(connection), this.runId ?? null);
339
449
  this.consume(card, input, connection.connectionId);
340
450
  // Every other live input observes the admitted connection before the SDK
341
451
  // responds, so an older selection cannot bypass the one-connection rule.
@@ -372,13 +482,17 @@ export class SqliteWalletWorkflowStore {
372
482
  const existing = this.db.prepare("SELECT id FROM live_wallet_connections WHERE topic=?").get(session.topic);
373
483
  const id = existing?.id ?? randomUUID();
374
484
  const prior = existing ? this.connection(id) : undefined;
485
+ if (prior?.ownerId === this.ownerId && prior.runId === this.runId && prior.connection.status === "connected" && !prior.sdkPending &&
486
+ isDeepStrictEqual(prior.connection.accounts, session.accounts) && isDeepStrictEqual(prior.connection.methods, session.methods) &&
487
+ prior.connection.expiresAt === session.expiresAt && prior.connection.walletName === session.walletName)
488
+ return prior;
375
489
  const next = walletConnectionSchema.parse({ connectionId: id, status: "connected", revision: (prior?.connection.revision ?? -1) + 1,
376
490
  accounts: session.accounts, methods: session.methods, chain: session.chain,
377
491
  createdAt: prior?.connection.createdAt ?? now.toISOString(), updatedAt: now.toISOString(),
378
492
  expiresAt: session.expiresAt, walletName: session.walletName });
379
- this.db.prepare(`INSERT INTO live_wallet_connections VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
493
+ this.db.prepare(`INSERT INTO live_wallet_connections (id,owner_id,status,revision,topic,sdk_pending,connection_json,run_id) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
380
494
  owner_id=excluded.owner_id, status=excluded.status, revision=excluded.revision, topic=excluded.topic,
381
- sdk_pending=0, connection_json=excluded.connection_json`).run(id, this.ownerId, next.status, next.revision, session.topic, 0, JSON.stringify(next));
495
+ sdk_pending=0, connection_json=excluded.connection_json,run_id=excluded.run_id`).run(id, this.ownerId, next.status, next.revision, session.topic, 0, JSON.stringify(next), this.runId ?? null);
382
496
  this.publishConnection(id, next.status);
383
497
  // Restoring the SDK session never restores a cleared read-account context.
384
498
  return this.connection(id);
@@ -386,6 +500,7 @@ export class SqliteWalletWorkflowStore {
386
500
  useAccount(expected, input, connectionId, account) {
387
501
  this.db.transaction(() => {
388
502
  const now = this.clock();
503
+ this.assertWalletInputAllowed();
389
504
  const card = this.requireInput(expected, now), connection = this.connection(connectionId);
390
505
  if (card.scope !== "connect" || !this.isUsableConnection(connectionId, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(connectionId)) ||
391
506
  Date.parse(connection.connection.expiresAt) <= now.getTime() || !connection.connection.accounts.includes(account)) {
@@ -400,8 +515,10 @@ export class SqliteWalletWorkflowStore {
400
515
  admitDisconnect(expected, input, connectionId) {
401
516
  return this.db.transaction(() => {
402
517
  const now = this.clock();
518
+ this.assertWalletInputAllowed();
403
519
  const card = this.requireInput(expected, now), connection = this.connection(connectionId);
404
- if (card.scope !== "connect" || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(connectionId))) {
520
+ if (card.scope !== "connect" || !connection || connection.ownerId !== this.ownerId || connection.runId !== this.runId ||
521
+ connection.sdkPending || !this.connectionSelection(now).manageableConnectionIds.includes(connectionId)) {
405
522
  throw new WorkflowConflict("The wallet connection is no longer available.");
406
523
  }
407
524
  this.requireAction(card, "disconnect", now);
@@ -456,6 +573,7 @@ export class SqliteWalletWorkflowStore {
456
573
  admitRequest(expected, input, material, selectedConnection, walletRunId) {
457
574
  const result = this.db.transaction(() => {
458
575
  const now = this.clock();
576
+ this.assertWalletInputAllowed();
459
577
  const binding = { reviewSessionId: material.reviewSessionId, planId: material.planId, account: material.account,
460
578
  reviewRevision: material.reviewRevision, transactionDigest: material.reviewedTransactionDigest,
461
579
  connectionId: selectedConnection.connectionId, connectionRevision: selectedConnection.revision };
@@ -471,7 +589,8 @@ export class SqliteWalletWorkflowStore {
471
589
  if (!current || !materialStillMatches(material, current, sessions.revision(current.id), artifacts, stored, now)) {
472
590
  // Expected expiry commits its normal cleanup, but no request authority.
473
591
  // A changed candidate is a conflict and must not invalidate newer data.
474
- const evaluated = this.evaluateState({ expectedCard: card, candidate: { session: material.review, rowRevision: material.rowRevision,
592
+ const evaluated = this.evaluateState({ purpose: "review", reviewSessionId: material.reviewSessionId,
593
+ readTarget: this.reviewReadTarget(material.reviewSessionId, card), expectedCard: card, candidate: { session: material.review, rowRevision: material.rowRevision,
475
594
  artifacts: material.artifacts, material }, walletAvailability: { status: "available", walletRunId } }, now);
476
595
  return new WorkflowConflict("The transaction details used by this review are no longer current.", evaluated.events);
477
596
  }
@@ -498,8 +617,8 @@ export class SqliteWalletWorkflowStore {
498
617
  (attempt_id,review_session_id,plan_id,review_revision,account_id,transaction_digest,review_state_json,request_status,revision,created_at,updated_at)
499
618
  VALUES (?,?,?,?,?,?,?,'awaiting_signature',0,?,?)`).run(id, review.id, binding.planId, binding.reviewRevision, account.id, binding.transactionDigest, review.review_state_json, now.toISOString(), now.toISOString());
500
619
  this.db.prepare(`INSERT INTO live_request_authority
501
- (attempt_id,owner_id,connection_id,connection_revision,can_submit,sdk_pending,submit_pending,lookup_pending,signature_deadline)
502
- VALUES (?,?,?,?,1,1,0,0,?)`).run(id, this.ownerId, binding.connectionId, binding.connectionRevision, new Date(Math.min(now.getTime() + SIGNATURE_WAIT_MS, Date.parse(review.expires_at))).toISOString());
620
+ (attempt_id,owner_id,connection_id,connection_revision,can_submit,sdk_pending,submit_pending,lookup_pending,signature_deadline,run_id)
621
+ VALUES (?,?,?,?,1,1,0,0,?,?)`).run(id, this.ownerId, binding.connectionId, binding.connectionRevision, new Date(Math.min(now.getTime() + SIGNATURE_WAIT_MS, Date.parse(review.expires_at))).toISOString(), walletRunId);
503
622
  this.db.prepare(`UPDATE live_review_sessions SET current_attempt_id=?, revision=revision+1, write_contract_version=? WHERE id=?`)
504
623
  .run(id, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, review.id);
505
624
  this.db.prepare("UPDATE review_sessions SET current_attempt_id=?, updated_at=? WHERE id=?").run(id, now.toISOString(), review.id);
@@ -605,7 +724,8 @@ export class SqliteWalletWorkflowStore {
605
724
  return this.db.transaction(() => {
606
725
  const now = this.clock();
607
726
  const request = this.request(id), authority = this.authority(id);
608
- if (!request || !authority || authority.owner_id !== this.ownerId || authority.can_submit !== 1 ||
727
+ if (!request || !authority || authority.owner_id !== this.ownerId || authority.can_submit !== 1 || authority.run_id !== (this.runId ?? null) ||
728
+ this.db.prepare("SELECT wallet_input_blocked FROM settings_control WHERE id=1").get().wallet_input_blocked !== 0 ||
609
729
  request.requestStatus !== "awaiting_signature" || Date.parse(authority.signature_deadline) <= now.getTime())
610
730
  return false;
611
731
  const connection = this.connection(authority.connection_id);
@@ -650,11 +770,12 @@ export class SqliteWalletWorkflowStore {
650
770
  resumeObservation(id) {
651
771
  this.db.prepare("UPDATE live_request_authority SET observation_stopped=0 WHERE attempt_id=? AND owner_id=?").run(id, this.ownerId);
652
772
  }
653
- invalidateConnectionRequests(connectionId, now, cause) {
773
+ invalidateConnectionRequests(connectionId, now, cause, runId) {
654
774
  this.db.transaction(() => {
655
775
  const rows = this.db.prepare(`SELECT * FROM live_review_sessions v WHERE wallet_connection_id=? AND owner_id=? AND status!='expired'
776
+ AND (? IS NULL OR v.preparation_run_id=?)
656
777
  AND NOT EXISTS (SELECT 1 FROM review_requests r WHERE r.review_session_id=v.id AND r.review_revision=v.review_revision)`)
657
- .all(connectionId, this.ownerId);
778
+ .all(connectionId, this.ownerId, runId ?? null, runId ?? null);
658
779
  for (const row of rows) {
659
780
  const state = row.review_state_json ? JSON.parse(row.review_state_json) : undefined;
660
781
  // Later session observations cannot rewrite why an already interrupted
@@ -676,8 +797,8 @@ export class SqliteWalletWorkflowStore {
676
797
  this.publishReview(row.id);
677
798
  }
678
799
  }).immediate();
679
- for (const row of this.db.prepare("SELECT attempt_id FROM live_request_authority WHERE connection_id=? AND owner_id=?")
680
- .all(connectionId, this.ownerId))
800
+ for (const row of this.db.prepare("SELECT attempt_id FROM live_request_authority WHERE connection_id=? AND owner_id=? AND (? IS NULL OR run_id=?)")
801
+ .all(connectionId, this.ownerId, runId ?? null, runId ?? null))
681
802
  this.stopWaiting(row.attempt_id, now, cause);
682
803
  }
683
804
  cancelInput(expected) {
@@ -693,6 +814,7 @@ export class SqliteWalletWorkflowStore {
693
814
  beginReviewPreparation(expected, reviewRevision, connection, account) {
694
815
  return this.db.transaction(() => {
695
816
  const now = this.clock();
817
+ this.assertWalletInputAllowed();
696
818
  const card = this.requireInput(expected, now);
697
819
  if (card.scope !== "review" || typeof card.state.input.reviewSessionId !== "string")
698
820
  throw new WorkflowConflict("This card cannot update the review.");
@@ -713,8 +835,8 @@ export class SqliteWalletWorkflowStore {
713
835
  if (this.busyForAccount(selection.account, now) || !currentConnection.connection.accounts.includes(selection.account))
714
836
  throw new WorkflowConflict("The review could not be updated with the selected wallet account.");
715
837
  const preparation = randomUUID();
716
- this.db.prepare(`UPDATE live_review_sessions SET preparation_id=?,preparation_error=NULL,wallet_connection_id=?,wallet_connection_revision=?,revision=revision+1,write_contract_version=? WHERE id=?`)
717
- .run(preparation, connection.connectionId, connection.revision, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
838
+ this.db.prepare(`UPDATE live_review_sessions SET preparation_id=?,preparation_run_id=?,preparation_error=NULL,wallet_connection_id=?,wallet_connection_revision=?,revision=revision+1,write_contract_version=? WHERE id=?`)
839
+ .run(preparation, this.runId ?? null, connection.connectionId, connection.revision, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
718
840
  this.publishReview(id);
719
841
  return preparation;
720
842
  }).immediate();
@@ -798,10 +920,11 @@ export class SqliteWalletWorkflowStore {
798
920
  }
799
921
  }
800
922
  export function hasUnsettledWalletWork(db, now = new Date()) {
801
- return !!db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
923
+ return !!db.prepare("SELECT 1 FROM live_wallet_connections WHERE sdk_pending=1 LIMIT 1").get() ||
924
+ !!db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
802
925
  WHERE l.sdk_pending=1 OR l.submit_pending=1 OR l.lookup_pending=1
803
926
  OR (r.request_status IN (SELECT value FROM json_each(?)) AND (l.lookup_deadline IS NULL OR l.lookup_deadline>?)) LIMIT 1`)
804
- .get(JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
927
+ .get(JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
805
928
  }
806
929
  export function readStoredTransactionRequest(db, id) {
807
930
  const row = db.prepare(`SELECT r.*, a.sui_address AS account, e.result_json FROM review_requests r
@@ -46,10 +46,10 @@ export function executionPollingHint() {
46
46
  recommendedIntervalSeconds: EXECUTION_POLLING_INTERVAL_SECONDS
47
47
  };
48
48
  }
49
- export function reviewStatusResponse({ session, request, walletAvailability, walletObservation, progress, connectionConflict }) {
49
+ export function reviewStatusResponse({ session, request, walletAvailability, walletObservation, progress, connectionConflict, connectionReconciliationRequired }) {
50
50
  const pollingStatus = getExecutionPollingStatus(session, request);
51
51
  return {
52
- walletAvailability, walletObservation, progress, connectionConflict,
52
+ walletAvailability, walletObservation, progress, connectionConflict, connectionReconciliationRequired,
53
53
  reviewSessionId: session.id, status: session.status, reviewRevision: session.reviewRevision,
54
54
  account: session.account, plans: session.plans, reviewState: session.reviewState,
55
55
  pollingStatus, statusCategory: executionStatusCategory(pollingStatus), pollingHint: executionPollingHint(),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { suiAddressStringSchema } from "../suiAddress.js";
3
+ import { transactionRequestStatusSchema } from "./transactionRequest.js";
3
4
  export const SUI_MAINNET_WALLET_CHAIN = "sui:mainnet";
4
5
  export const SUI_SIGN_TRANSACTION_METHOD = "sui_signTransaction";
5
6
  export const WALLET_CONNECTION_POLL_SECONDS = 5;
@@ -45,6 +46,18 @@ export function workflowProgress(waiting, requiresWallet, availability) {
45
46
  export const walletConnectionStatusSchema = z.enum([
46
47
  "awaiting_approval", "connected", "rejected", "failed", "expired", "stopped", "disconnected"
47
48
  ]);
49
+ // Recorded recovery facts, not proof of a currently usable wallet connection.
50
+ export const walletRecoveryImpactSchema = z.object({
51
+ runId: z.string().uuid(),
52
+ connections: z.array(z.object({ connectionId: z.string().min(1), revision: z.number().int().nonnegative(),
53
+ status: walletConnectionStatusSchema, accounts: z.array(suiAddressStringSchema), walletName: z.string().optional(),
54
+ sdkPending: z.boolean(), disconnectPending: z.boolean() }).strict()),
55
+ preparations: z.array(z.object({ id: z.string().min(1), revision: z.number().int().nonnegative(),
56
+ review_revision: z.number().int().nonnegative(), preparation_id: z.string().nullable(), wallet_connection_id: z.string().nullable() }).strict()),
57
+ requests: z.array(z.object({ attempt_id: z.string().min(1), revision: z.number().int().nonnegative(),
58
+ request_status: transactionRequestStatusSchema, can_submit: z.number().int().min(0).max(1),
59
+ sdk_pending: z.number().int().min(0).max(1), submit_pending: z.number().int().min(0).max(1), lookup_pending: z.number().int().min(0).max(1) }).strict())
60
+ }).strict();
48
61
  export const walletConnectionSchema = z.object({
49
62
  connectionId: z.string().min(1),
50
63
  status: walletConnectionStatusSchema,
@@ -63,21 +76,27 @@ export const connectionConflictSchema = z.object({
63
76
  connectionIds: z.array(z.string().min(1)).min(2).refine((ids) => new Set(ids).size === ids.length)
64
77
  }).strict();
65
78
  export const CONNECTION_CONFLICT_MESSAGE = "More than one wallet connection is saved. Disconnect the connections you no longer need before continuing.";
79
+ export const CONNECTION_RECONCILIATION_MESSAGE = "A wallet connection in the connection service is not recorded here. Remove the retained connection in your wallet app, then check its status. If needed, open Settings and use Troubleshooting to restart the connection service and inspect it again. Restarting does not approve an unrecorded connection.";
66
80
  export const assetReadAccountSchema = z.discriminatedUnion("status", [
67
81
  z.object({ status: z.literal("available"), account: suiAddressStringSchema }).strict(),
68
82
  z.object({ status: z.literal("address_required") }).strict()
69
83
  ]);
70
- // Management may name every recorded target. Account use requires exactly one
71
- // live connection and no connection operation still awaiting its outcome.
72
- export function walletConnectionSelection(connections, at, stored) {
84
+ export function walletConnectionSelection(connections, at, presence, stored) {
73
85
  const connected = connections.filter((item) => item.status === "connected" && Date.parse(item.expiresAt) > at);
74
- const pending = connections.some((item) => item.pendingAction || item.status === "awaiting_approval");
75
- const connection = connected.length === 1 && !pending ? connected[0] : undefined;
86
+ const pending = presence.pendingConnectionIds.length > 0 || connections.some((item) => item.pendingAction || item.status === "awaiting_approval");
87
+ const presentIds = presence.connections.map((item) => item.connectionId);
88
+ const occupied = new Set([...connected.map((item) => item.connectionId), ...presentIds]);
89
+ const reconciliation = presence.confirmed && presence.unrecorded && !pending;
90
+ const connection = presence.confirmed && !presence.unrecorded && occupied.size === 1 && connected.length === 1 &&
91
+ presence.connections.some((item) => item.connectionId === connected[0].connectionId && Date.parse(item.expiresAt) > at) && !pending ? connected[0] : undefined;
76
92
  return {
77
- ...(connected.length > 1 ? { conflict: { reason: "multiple_connections",
78
- connectionIds: connected.map((item) => item.connectionId).sort() } } : {}),
93
+ ...(occupied.size > 1 ? { conflict: { reason: "multiple_connections",
94
+ connectionIds: [...occupied].sort() } } : {}),
95
+ ...(reconciliation ? { connectionReconciliationRequired: true } : {}),
96
+ manageableConnectionIds: connections.filter((item) => !presence.pendingConnectionIds.includes(item.connectionId) && !item.pendingAction && item.status !== "awaiting_approval" &&
97
+ (item.status === "connected" || presence.confirmed && presentIds.includes(item.connectionId))).map((item) => item.connectionId),
79
98
  ...(connection ? { connection } : {}),
80
- pairingAllowed: connected.length === 0 && !pending,
99
+ pairingAllowed: presence.confirmed && !presence.unrecorded && occupied.size === 0 && !pending,
81
100
  assetReadAccount: connection && stored?.walletId === connection.connectionId && connection.accounts.includes(stored.address)
82
101
  ? { status: "available", account: stored.address } : { status: "address_required" }
83
102
  };