@zktx.io/sui-mcp 0.6.2 → 0.8.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 +94 -52
  2. package/dist/adapters/adapterPromptSurfaces.js +1 -1
  3. package/dist/core/activity/localDataService.js +8 -21
  4. package/dist/core/activity/localDataValidation.js +22 -46
  5. package/dist/core/activity/sqliteAccountStore.js +46 -0
  6. package/dist/core/activity/sqliteActivityStore.js +65 -147
  7. package/dist/core/activity/sqliteActivityStoreSchema.js +42 -26
  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/read/deepbookReadHelpers.js +1 -1
  12. package/dist/core/read/readResponseGuidance.js +6 -6
  13. package/dist/core/session/cardResult.js +41 -0
  14. package/dist/core/session/cardSession.js +1 -1
  15. package/dist/core/session/cardSessionStore.js +51 -19
  16. package/dist/core/session/chainAccounts.js +120 -0
  17. package/dist/core/session/sessionStore.js +4 -8
  18. package/dist/core/session/settingsFileProtocol.js +1 -1
  19. package/dist/core/session/settingsFileStore.js +3 -30
  20. package/dist/core/session/settingsOperation.js +27 -19
  21. package/dist/core/session/settingsWorkflow.js +161 -87
  22. package/dist/core/session/sqliteCardStore.js +34 -14
  23. package/dist/core/session/sqliteSessionStore.js +5 -5
  24. package/dist/core/session/sqliteSettingsWorkflowStore.js +135 -41
  25. package/dist/core/session/sqliteTransactionRequest.js +18 -0
  26. package/dist/core/session/sqliteWalletConnectionOperation.js +13 -0
  27. package/dist/core/session/sqliteWalletWorkflowStore.js +569 -333
  28. package/dist/core/session/status.js +2 -2
  29. package/dist/core/session/wait.js +6 -11
  30. package/dist/core/session/walletConnection.js +71 -46
  31. package/dist/core/session/walletConnectionOperations.js +24 -0
  32. package/dist/core/session/walletRuntime.js +7 -13
  33. package/dist/core/session/walletWorkflow.js +248 -99
  34. package/dist/core/session/workflowState.js +104 -38
  35. package/dist/core/session/workflowView.js +92 -101
  36. package/dist/mcp/activeAccountResponse.js +3 -0
  37. package/dist/mcp/prompts.js +10 -4
  38. package/dist/mcp/resources.js +1 -1
  39. package/dist/mcp/responseGuidance.js +7 -9
  40. package/dist/mcp/server.js +2 -2
  41. package/dist/mcp/serverInfo.js +21 -19
  42. package/dist/mcp/toolErrors.js +5 -3
  43. package/dist/mcp/toolNames.js +47 -46
  44. package/dist/mcp/tools/action/prepareSuiActionReview.js +13 -20
  45. package/dist/mcp/tools/read/deepbookReadTools.js +1 -1
  46. package/dist/mcp/tools/read/index.js +2 -2
  47. package/dist/mcp/tools/read/readToolHelpers.js +9 -7
  48. package/dist/mcp/tools/read/serverStatusTools.js +4 -5
  49. package/dist/mcp/tools/read/transactionActivityTools.js +3 -3
  50. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  51. package/dist/mcp/tools/session/shared.js +2 -2
  52. package/dist/mcp/tools/session/statusTools.js +12 -10
  53. package/dist/mcp/tools/session/walletConnectionTools.js +29 -17
  54. package/dist/mcp/tools/settings/index.js +7 -4
  55. package/dist/mcp-app/account.html +7 -7
  56. package/dist/mcp-app/chart.html +3 -3
  57. package/dist/mcp-app/connect.html +13 -13
  58. package/dist/mcp-app/disconnect.html +3637 -0
  59. package/dist/mcp-app/disconnect.notices.txt +1480 -0
  60. package/dist/mcp-app/receipt.html +393 -393
  61. package/dist/mcp-app/review.html +459 -459
  62. package/dist/mcp-app/settings.html +7 -7
  63. package/dist/mcp-ui/contracts.js +3 -1
  64. package/dist/mcp-ui/tools.js +4 -4
  65. package/dist/runtime/application.js +3 -3
  66. package/dist/runtime/config.js +3 -6
  67. package/dist/runtime/localSettingsService.js +11 -8
  68. package/dist/runtime/shared/control.js +2 -2
  69. package/dist/runtime/smokeMainnetRead.js +23 -22
  70. package/dist/runtime/smokeReview.js +7 -7
  71. package/dist/runtime/start.js +1 -1
  72. package/dist/runtime/walletConnectTransport.js +16 -16
  73. package/dist/runtime/walletSdkChildRuntime.js +29 -12
  74. package/dist/runtime/walletSdkIpc.js +5 -4
  75. package/dist/runtime/walletSdkProcess.js +24 -8
  76. package/docs/AGENT_BEHAVIOR.md +149 -120
  77. package/docs/LOCAL_DB_ARCHITECTURE.md +103 -75
  78. package/docs/MCP_SETUP.md +365 -382
  79. package/docs/MCP_TOOLS.md +387 -271
  80. package/docs/SDK_API.md +23 -11
  81. package/docs/SIGNABLE_ADAPTER_CONTRACT.md +6 -6
  82. package/docs/TRANSACTION_ACTIVITY_LOG.md +11 -7
  83. package/docs/UTILITY_INDEX.md +66 -64
  84. package/docs/WALLET_CONNECTION.md +162 -166
  85. package/package.json +1 -1
  86. package/protocols/deepbook-margin.md +1 -1
  87. package/protocols/deepbook-v3.md +2 -2
@@ -1,47 +1,51 @@
1
- import { walletRecoverySchema } from "./walletRuntime.js";
1
+ import { walletCardResult, reviewCardResult, endedCardMessage } from "./cardResult.js";
2
+ import { readStoredWalletConnectionOperation } from "./sqliteWalletConnectionOperation.js";
3
+ import { readStoredTransactionRequest } from "./sqliteTransactionRequest.js";
2
4
  import { materialStillMatches } from "./reviewValidity.js";
3
5
  import { SqliteSessionRecordStore, SqlitePrivateReviewArtifactStore } from "./sqliteSessionStore.js";
4
- import { reviewPreparationAccount, workflowEligibility } from "./workflowState.js";
6
+ import { fixedReviewTarget, workflowEligibility, walletActionDecision } from "./workflowState.js";
5
7
  import { SessionStoreError } from "./sessionErrors.js";
6
- import { randomUUID } from "node:crypto";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { SqlitePreferencesRepository } from "../preferences/sqlitePreferencesRepository.js";
10
+ import { pendingWalletConnectionOperationSchema, publicConnectionOperation } from "./walletConnectionOperations.js";
11
+ import { initialGrantRevisions, fixedChainAccount, approvedChainResults } from "./chainAccounts.js";
7
12
  import { isDeepStrictEqual } from "node:util";
8
13
  import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
9
14
  import { reviewStateOutputSchema } from "../action/schemas.js";
10
15
  import { LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION } from "./liveReviewSessionContract.js";
11
16
  import { SqliteCardRecordStore } from "./sqliteCardStore.js";
12
17
  import { receiptDisplaySchema } from "./cardSession.js";
13
- import { isOwnedConnectedWallet, walletConnectionSelection, walletConnectionSchema, walletRecoveryImpactSchema, SUI_MAINNET_WALLET_CHAIN, SUI_SIGN_TRANSACTION_METHOD } from "./walletConnection.js";
14
- import { SIGNATURE_WAIT_MS, CHAIN_RECEIPT_LOOKUP_MAX_AGE_MS, INITIAL_CHAIN_OBSERVATION_STATUSES, isInitialChainObservation, transactionRequestSchema, transactionExecutionSummarySchema, requestTransitions } from "./transactionRequest.js";
18
+ import { isOwnedConnectedWallet, usableWalletConnection, connectionHasSuiSigningScope, walletConnectionEligibility, walletConnectionSchema, walletRecoveryImpactSchema, SUI_MAINNET_WALLET_CHAIN } from "./walletConnection.js";
19
+ import { SIGNATURE_WAIT_MS, CHAIN_RECEIPT_LOOKUP_MAX_AGE_MS, INITIAL_CHAIN_OBSERVATION_STATUSES, isInitialChainObservation, transactionExecutionSummarySchema, requestTransitions } from "./transactionRequest.js";
15
20
  export class WorkflowConflict extends Error {
16
21
  events;
17
- constructor(message, events = []) {
22
+ reason;
23
+ constructor(message, events = [], reason) {
18
24
  super(message);
19
25
  this.events = events;
26
+ this.reason = reason;
20
27
  }
21
28
  }
22
29
  const interruptionReasons = {
23
30
  user_stop: "User stopped waiting before submission.",
24
31
  wallet_change: "The wallet connection changed or became unavailable before submission. Nothing was submitted.",
25
- connection_conflict: "More than one wallet connection is present. Nothing was submitted.",
26
32
  disconnect_requested: "Wallet disconnection was requested before submission. Nothing was submitted.",
27
33
  wallet_service_restarted: "The wallet service was restarted before submission. Nothing was submitted.",
28
34
  wallet_service_lost: "The wallet service stopped before submission. Nothing was submitted."
29
35
  };
30
36
  const preparationInterruptionReasons = {
31
- wallet_change: "The selected wallet connection changed.",
32
- connection_conflict: "More than one wallet connection is present. Disconnect the connections you no longer need, then update this review.",
33
- disconnect_requested: "Disconnection of the selected wallet was requested. Connect a wallet before updating this review.",
37
+ wallet_change: "The original wallet connection's Sui authorization changed.",
38
+ disconnect_requested: "Wallet disconnection was requested for the original connection. This review cannot use a replacement connection.",
34
39
  wallet_service_restarted: "The wallet service was restarted. Update this review before requesting approval.",
35
40
  wallet_service_lost: "The wallet service stopped. Restore the wallet connection and update this review before requesting approval."
36
41
  };
37
42
  export class SqliteWalletWorkflowStore {
38
43
  db;
39
44
  ownerId;
40
- writeActiveAccount;
45
+ accounts;
41
46
  clock;
42
47
  materialStore;
43
48
  evaluateState;
44
- readActiveAccount;
45
49
  readSdkSnapshot;
46
50
  cards;
47
51
  runId;
@@ -51,17 +55,46 @@ export class SqliteWalletWorkflowStore {
51
55
  throw new WorkflowConflict("Wallet input is blocked while the connection service is being recovered.");
52
56
  }
53
57
  }
54
- constructor(db, ownerId, writeActiveAccount, clock, materialStore, evaluateState, readActiveAccount, readSdkSnapshot) {
58
+ constructor(db, ownerId, accounts, clock, materialStore, evaluateState, readSdkSnapshot) {
55
59
  this.db = db;
56
60
  this.ownerId = ownerId;
57
- this.writeActiveAccount = writeActiveAccount;
61
+ this.accounts = accounts;
58
62
  this.clock = clock;
59
63
  this.materialStore = materialStore;
60
64
  this.evaluateState = evaluateState;
61
- this.readActiveAccount = readActiveAccount;
62
65
  this.readSdkSnapshot = readSdkSnapshot;
63
66
  this.cards = new SqliteCardRecordStore(db);
64
67
  }
68
+ cardResult(record, at, pendingWork) {
69
+ if (record.state.kind === "connect" || record.state.kind === "disconnect") {
70
+ const operation = record.operationId ? this.cardOperation(record) : undefined;
71
+ const id = operation?.connectionId ?? (record.state.kind === "disconnect" && typeof record.state.input.connectionId === "string" ? record.state.input.connectionId : undefined);
72
+ return walletCardResult(record, at, { message: record.state.error ?? operation?.message ?? endedCardMessage(record.state.reason),
73
+ connection: id ? this.connection(id)?.connection : undefined, pendingWork });
74
+ }
75
+ const session = new SqliteSessionRecordStore(this.db).get(String(record.state.input.reviewSessionId));
76
+ const id = record.operationId ?? (typeof record.state.input.attemptId === "string" ? record.state.input.attemptId : undefined);
77
+ const request = id ? this.request(id) : undefined;
78
+ return reviewCardResult(record, at, { session, request, state: id ? this.requestReview(id) ?? session?.reviewState : session?.reviewState,
79
+ receipt: id ? this.executionDetails(id)?.data : undefined, observationStopped: !!(id && this.authority(id)?.observation_stopped) });
80
+ }
81
+ replaceCard(expected, next, at = this.clock(), pendingWork) {
82
+ if (next.state.state === "closed")
83
+ next = { ...next, state: { ...next.state, data: this.cardResult(next, at, pendingWork) } };
84
+ return this.cards.replace(expected, next, at);
85
+ }
86
+ endCard(record, reason, at = this.clock(), error) {
87
+ if (record.state.state === "closed")
88
+ return record;
89
+ if (!this.replaceCard(record, { ...record, state: { ...record.state, state: "closed", reason,
90
+ ...(error ? { error } : {}), revision: record.state.revision + 1 } }, at))
91
+ throw new WorkflowConflict("The card changed before its result could be saved.");
92
+ return this.cards.get(record.state.cardId);
93
+ }
94
+ expireCard(record, at = this.clock()) {
95
+ record = this.currentCard(record);
96
+ return record.state.state === "ready" && Date.parse(record.state.expiresAt) <= at.getTime() ? this.endCard(record, "expired", at) : record;
97
+ }
65
98
  reconcileAndEvaluate(input) { return this.evaluateState(input); }
66
99
  currentCard(expected) {
67
100
  const current = this.cards.get(expected.state.cardId);
@@ -86,66 +119,47 @@ export class SqliteWalletWorkflowStore {
86
119
  !!(card?.scope === "review" && card.state.state === "ready" && !card.operationId && Date.parse(card.state.expiresAt) > at &&
87
120
  liveReview && liveReview.status !== "expired" && Date.parse(liveReview.expiresAt) > at) };
88
121
  }
89
- walletRecovery(record) {
90
- if (record.acceptedInput?.action !== "restart_wallet_service")
91
- return undefined;
92
- const recovery = walletRecoverySchema.parse(record.state.data);
93
- if (recovery.priorRunId !== record.acceptedInput.walletRunId || record.operationId !== undefined || record.scope !== "connect") {
94
- throw new WorkflowConflict("The saved wallet recovery does not match this card.");
95
- }
96
- if (record.state.state === "closed" && record.state.reason === "server_restarted") {
97
- return { kind: recovery.kind, priorRunId: recovery.priorRunId, admittedAt: recovery.admittedAt, updatedAt: recovery.updatedAt,
98
- ...("nextRunId" in recovery ? { nextRunId: recovery.nextRunId } : {}), outcome: "server_restarted",
99
- message: "The server restarted before this wallet recovery was confirmed." };
100
- }
101
- return recovery;
102
- }
103
122
  recordWalletFailure(input) {
104
123
  this.invalidateWalletRun("wallet_service_lost", new Date(input.observedAt), input.runId);
105
124
  }
106
- publishWalletState() { this.publishAvailableWallets(); }
107
- applyWalletObservation(snapshot, previous, ready, recordReady) {
125
+ applyWalletObservation(snapshot, _previous, ready, recordReady) {
108
126
  this.db.transaction(() => {
109
- for (const record of this.connections()) {
110
- if (!record.topic)
111
- continue;
112
- const observed = snapshot.sessions.find((item) => item.topic === record.topic);
113
- if (record.connection.status !== "connected") {
114
- if ((ready || record.ownerId === this.ownerId && record.runId === snapshot.runId) &&
115
- observed?.status === "present" && !record.sdkPending &&
116
- !this.pendingDisconnect(record.connection.connectionId, record.ownerId)) {
117
- const transferred = record.ownerId !== this.ownerId || record.runId !== snapshot.runId;
118
- const changed = !isDeepStrictEqual(record.connection.accounts, observed.session.accounts) ||
119
- !isDeepStrictEqual(record.connection.methods, observed.session.methods) ||
120
- record.connection.expiresAt !== observed.session.expiresAt || record.connection.walletName !== observed.session.walletName;
121
- if (transferred)
122
- this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,run_id=? WHERE id=?")
123
- .run(this.ownerId, snapshot.runId, record.connection.connectionId);
124
- if (transferred || changed)
125
- this.updateConnection(record.connection.connectionId, { accounts: observed.session.accounts,
126
- methods: observed.session.methods, expiresAt: observed.session.expiresAt, walletName: observed.session.walletName }, this.clock());
127
- }
128
- continue;
129
- }
130
- if (ready && observed?.status === "present" && !record.sdkPending && !this.pendingDisconnect(record.connection.connectionId, record.ownerId)) {
131
- this.restoreConnection(observed.session, this.clock());
127
+ if (ready)
128
+ this.db.prepare(`UPDATE wallet_connection_operations SET sdk_pending=0
129
+ WHERE owner_id!=? OR run_id IS NOT NULL AND run_id!=?`).run(this.ownerId, snapshot.runId);
130
+ let record = this.currentConnection();
131
+ if (record && ready) {
132
+ this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,run_id=? WHERE id=? AND current_slot=1")
133
+ .run(this.ownerId, snapshot.runId, record.connection.connectionId);
134
+ record = this.currentConnection();
135
+ }
136
+ const observed = record?.topic ? snapshot.sessions.find((item) => item.topic === record.topic) : undefined;
137
+ if (record && record.ownerId === this.ownerId && record.runId === snapshot.runId && observed) {
138
+ const id = record.connection.connectionId;
139
+ if (observed.status !== "present") {
140
+ if (record.connection.status === "connected")
141
+ this.applyConnectionChange(id, { status: observed.status === "absent" ? "disconnected" : "failed",
142
+ reason: observed.status === "absent" ? "The wallet session is no longer present." : "The retained wallet session cannot be used." }, this.clock());
143
+ const pending = this.pendingDisconnect(id);
144
+ if (pending?.status === "running" && observed.status === "absent")
145
+ this.finishDisconnect(pending.operationId, id, true, this.clock());
132
146
  }
133
- else if (record.ownerId === this.ownerId) {
134
- if (!observed || observed.status === "absent")
135
- this.applyConnectionChange(record.connection.connectionId, { status: "disconnected", reason: "The wallet session is no longer present in the connection service." }, this.clock());
136
- else if (observed.status === "unusable")
137
- this.applyConnectionChange(record.connection.connectionId, { status: "failed", reason: "This wallet session is no longer usable for Sui mainnet." }, this.clock());
138
- else if (observed.status === "present" && previous?.sessions.find((item) => item.topic === record.topic)?.version !== observed.version) {
139
- this.applyConnectionChange(record.connection.connectionId, { accounts: observed.session.accounts,
140
- methods: observed.session.methods, expiresAt: observed.session.expiresAt, walletName: observed.session.walletName }, this.clock());
147
+ else {
148
+ const session = observed.session;
149
+ if (record.connection.lifecycleRevision !== observed.lifecycleRevision ||
150
+ record.connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] !== observed.grantRevisions[SUI_MAINNET_WALLET_CHAIN]) {
151
+ this.invalidateConnectionRequests(id, this.clock(), "wallet_change");
152
+ }
153
+ if (!isDeepStrictEqual(record.connection.grants, session.grants) || !isDeepStrictEqual(record.connection.grantRevisions, observed.grantRevisions) ||
154
+ record.connection.lifecycleRevision !== observed.lifecycleRevision || record.connection.expiresAt !== session.expiresAt || record.connection.walletName !== session.walletName) {
155
+ this.updateConnection(id, { grants: session.grants, grantRevisions: observed.grantRevisions, lifecycleRevision: observed.lifecycleRevision,
156
+ expiresAt: session.expiresAt, walletName: session.walletName }, this.clock());
141
157
  }
142
158
  }
143
159
  }
144
- this.invalidateConflictingConnections(this.clock());
145
- if (ready)
160
+ if (ready) {
146
161
  recordReady?.();
147
- if (ready)
148
- this.publishAvailableWallets();
162
+ }
149
163
  }).immediate();
150
164
  }
151
165
  invalidateWalletRun(cause, now = this.clock(), runId) {
@@ -168,100 +182,149 @@ export class SqliteWalletWorkflowStore {
168
182
  }
169
183
  for (const id of targets)
170
184
  this.invalidateConnectionRequests(id, now, cause, runId);
171
- this.publishAvailableWallets();
172
185
  }).immediate();
173
186
  }
174
- connection(id) {
175
- const row = this.db.prepare("SELECT * FROM live_wallet_connections WHERE id=?").get(id);
176
- return row ? { connection: walletConnectionSchema.parse(JSON.parse(row.connection_json)), ownerId: row.owner_id, ...(row.run_id === null ? {} : { runId: row.run_id }),
177
- sdkPending: row.sdk_pending === 1, ...(row.topic === null ? {} : { topic: row.topic }) } : undefined;
178
- }
179
- connections() {
180
- return this.db.prepare("SELECT id FROM live_wallet_connections ORDER BY id").all()
181
- .map(({ id }) => this.connection(id));
182
- }
183
- connectionView(connection) {
184
- return { ...connection, ...(this.pendingDisconnect(connection.connectionId) ? { pendingAction: "disconnect" } : {}) };
185
- }
186
- connectionPresence(records, snapshot) {
187
- const pendingConnectionIds = records.filter((item) => item.ownerId === this.ownerId &&
188
- (item.sdkPending || !!this.pendingDisconnect(item.connection.connectionId))).map((item) => item.connection.connectionId);
189
- if (!snapshot || snapshot.runId !== this.runId)
190
- return { confirmed: false, connections: [], unrecorded: false, pendingConnectionIds };
191
- const connections = [];
192
- let unrecorded = false;
193
- for (const session of snapshot.sessions) {
194
- if (session.status !== "present")
195
- continue;
196
- const record = records.find((item) => item.topic === session.topic && item.ownerId === this.ownerId && item.runId === this.runId);
197
- if (record)
198
- connections.push({ connectionId: record.connection.connectionId, expiresAt: session.session.expiresAt });
199
- else
200
- unrecorded = true;
201
- }
202
- return { confirmed: true, connections, unrecorded, pendingConnectionIds };
187
+ connectionRows(id, current = false) {
188
+ const rows = this.db.prepare(`SELECT c.*,EXISTS(SELECT 1 FROM wallet_connection_operations o
189
+ WHERE o.connection_id=c.id AND o.sdk_pending=1) AS sdk_pending,
190
+ EXISTS(SELECT 1 FROM wallet_connection_operations o WHERE o.connection_id=c.id AND o.action='disconnect'
191
+ AND (o.status='running' OR o.sdk_pending=1)) AS disconnect_pending,
192
+ pending.operation_id AS pending_operation_id,pending.action AS pending_action,
193
+ pending.status AS pending_status,pending.sdk_pending AS pending_settlement
194
+ FROM live_wallet_connections c LEFT JOIN wallet_connection_operations pending ON pending.connection_id=c.id
195
+ AND (pending.status='running' OR pending.sdk_pending=1)
196
+ ${current ? "WHERE c.current_slot=1" : id === undefined ? "" : "WHERE c.id=?"} ORDER BY c.id`)
197
+ .all(...(id === undefined ? [] : [id]));
198
+ if (new Set(rows.map((row) => row.id)).size !== rows.length)
199
+ throw new WorkflowConflict("A wallet connection has conflicting pending operations.");
200
+ return rows.map((row) => ({ connection: walletConnectionSchema.parse(JSON.parse(row.connection_json)), ownerId: row.owner_id,
201
+ sdkPending: row.sdk_pending === 1, disconnectPending: row.disconnect_pending === 1, current: row.current_slot === 1, dataEpoch: row.data_epoch, readAddressCleared: row.read_address_cleared === 1,
202
+ ...(row.pending_operation_id ? { pendingOperation: pendingWalletConnectionOperationSchema.parse({ operationId: row.pending_operation_id,
203
+ action: row.pending_action, status: row.pending_status, settlementPending: row.pending_settlement === 1 }) } : {}),
204
+ ...(row.run_id === null ? {} : { runId: row.run_id }), ...(row.topic === null ? {} : { topic: row.topic }) }));
205
+ }
206
+ connection(id) { return this.connectionRows(id)[0]; }
207
+ connections() { return this.connectionRows(); }
208
+ currentConnection() { return this.connectionRows(undefined, true)[0]; }
209
+ connectionPresence(record, snapshot) {
210
+ const currentRun = !!snapshot && snapshot.runId === this.runId;
211
+ const observed = currentRun && record?.topic ? snapshot.sessions.find((item) => item.topic === record.topic) : undefined;
212
+ const epoch = this.db.prepare("SELECT data_epoch FROM settings_control WHERE id=1").get().data_epoch;
213
+ const pending = !!record?.pendingOperation;
214
+ return { confirmed: currentRun && (!record?.topic || !!observed), managed: !record || record.ownerId === this.ownerId && record.runId === this.runId,
215
+ dataCurrent: !record || record.dataEpoch === epoch, pending, hasRecordedSession: !!record?.topic,
216
+ unrecorded: !!(currentRun && !(record?.sdkPending && !record.topic) && snapshot.sessions.some((item) => item.status !== "absent" && item.topic !== record?.topic)),
217
+ ...(observed ? { observation: { status: observed.status, ...(observed.status === "present" ? { expiresAt: observed.session.expiresAt } : {}) } } : {}) };
203
218
  }
204
219
  readConnectionFacts(now) {
205
220
  return this.db.transaction(() => {
206
- const records = this.connections(), snapshot = this.readSdkSnapshot(), account = this.readActiveAccount();
207
- return { evaluatedAt: now.toISOString(), activeAccount: account?.address, activeAccountConnectionId: account?.walletId,
208
- connections: records.filter((item) => item.ownerId === this.ownerId).map((item) => this.connectionView(item.connection)),
209
- connectionPresence: this.connectionPresence(records, snapshot),
221
+ const record = this.currentConnection(), snapshot = this.readSdkSnapshot();
222
+ const account = this.accounts.currentReadAccount(SUI_MAINNET_WALLET_CHAIN);
223
+ return { evaluatedAt: now.toISOString(), activeAccount: account?.address,
224
+ requestedChains: new SqlitePreferencesRepository(this.db).getWalletConnectionChainsSync(),
225
+ readAddressCleared: !!record?.readAddressCleared,
226
+ ...(record ? { connection: { ...record.connection, disconnectImpact: this.disconnectImpact(record.connection.connectionId) },
227
+ pendingOperation: record.pendingOperation } : {}),
228
+ connectionPresence: this.connectionPresence(record, snapshot),
210
229
  ...(snapshot ? { walletObservation: { runId: snapshot.runId, sequence: snapshot.sequence, observedAt: snapshot.observedAt } } : {}) };
211
230
  }).deferred();
212
231
  }
213
- connectionSelection(now = this.clock()) {
232
+ connectionEligibility(now = this.clock()) {
214
233
  const facts = this.readConnectionFacts(now);
215
- return walletConnectionSelection(facts.connections, now.getTime(), facts.connectionPresence, facts.activeAccount ? { address: facts.activeAccount, walletId: facts.activeAccountConnectionId } : undefined);
216
- }
217
- isUsableConnection(id, now = this.clock()) {
218
- return this.connectionSelection(now).connection?.connectionId === id;
234
+ return walletConnectionEligibility(facts.connection, now.getTime(), facts.connectionPresence, facts.activeAccount, facts.readAddressCleared);
235
+ }
236
+ isUsableConnection(id, now = this.clock(), chainId = SUI_MAINNET_WALLET_CHAIN, account) {
237
+ const record = this.currentConnection();
238
+ return !!usableWalletConnection(record?.connection, now.getTime(), this.connectionPresence(record, this.readSdkSnapshot()), id, chainId, account);
239
+ }
240
+ operation(id) { return readStoredWalletConnectionOperation(this.db, id); }
241
+ cardOperation(card, id = card.operationId) {
242
+ const operation = id ? this.operation(id) : undefined;
243
+ if (operation && operation.cardId !== card.state.cardId)
244
+ throw new WorkflowConflict("This operation belongs to a different card.");
245
+ return operation;
246
+ }
247
+ requireConnectionCard(expected, now) {
248
+ const card = this.currentCard(expected);
249
+ if (!["connect", "disconnect"].includes(card.scope ?? "") || card.ownerId !== this.ownerId || card.state.state !== "ready" || card.acceptedInput !== undefined || card.state.revision !== expected.state.revision ||
250
+ Date.parse(card.state.expiresAt) <= now.getTime())
251
+ throw new WorkflowConflict("This wallet request card no longer accepts actions.");
252
+ return card;
253
+ }
254
+ recoverConnectionOperation(expected, input) {
255
+ const card = this.currentCard(expected);
256
+ if (card.scope !== input.action || card.operationId !== input.operationId ||
257
+ input.action === "disconnect" && card.state.input.connectionId !== input.connectionId) {
258
+ throw new WorkflowConflict("This action does not match the card's fixed purpose, operation and target.");
259
+ }
260
+ const operation = this.operation(input.operationId);
261
+ if (!operation)
262
+ return undefined;
263
+ if (operation.cardId !== expected.state.cardId || operation.ownerId !== this.ownerId || !isDeepStrictEqual(operation.input, input)) {
264
+ throw new WorkflowConflict("This operation ID already belongs to a different request.");
265
+ }
266
+ return operation;
267
+ }
268
+ admitConnectionOperation(card, input, now, connectionId, pending = false) {
269
+ const epoch = this.db.prepare("SELECT data_epoch FROM settings_control WHERE id=1").get().data_epoch;
270
+ this.db.prepare(`INSERT INTO wallet_connection_operations
271
+ (operation_id,card_id,owner_id,run_id,data_epoch,action,connection_id,status,input_json,sdk_pending,created_at,updated_at,expires_at)
272
+ VALUES (?,?,?,?,?,?,?,'running',?,?,?,?,?)`).run(input.operationId, card.state.cardId, this.ownerId, input.walletRunId, epoch, input.action, connectionId, JSON.stringify(input), Number(pending), now.toISOString(), now.toISOString(), card.state.expiresAt);
273
+ if (!this.replaceCard(card, { ...card, acceptedInput: input, operationId: input.operationId, state: { ...card.state, state: "running", revision: card.state.revision + 1 } })) {
274
+ throw new WorkflowConflict("The connection details changed. Check the updated card and try again.");
275
+ }
219
276
  }
220
- invalidateConflictingConnections(now) {
221
- const selection = this.connectionSelection(now);
222
- if (selection.conflict || selection.connectionReconciliationRequired)
223
- for (const record of this.connections()) {
224
- if (record.ownerId === this.ownerId)
225
- this.invalidateConnectionRequests(record.connection.connectionId, now, selection.conflict ? "connection_conflict" : "wallet_change");
277
+ finishConnectionOperation(id, status, message, now, endReason, pendingWork) {
278
+ const changed = this.db.prepare(`UPDATE wallet_connection_operations SET status=?,result_json=?,updated_at=?
279
+ WHERE operation_id=? AND status='running' AND result_json IS NULL`).run(status, JSON.stringify({ message, ...(endReason ? { endReason } : {}) }), now.toISOString(), id);
280
+ if (!changed.changes)
281
+ return;
282
+ const op = this.operation(id);
283
+ const card = this.cards.get(op.cardId);
284
+ if (card && card.state.state !== "closed") {
285
+ const reason = endReason === "user_stopped" ? "cancelled" : endReason === "expired" ? "expired" : status === "completed" ? "completed" : "failed";
286
+ if (!this.replaceCard(card, { ...card, state: { ...card.state, state: "closed", reason, revision: card.state.revision + 1 } }, now, pendingWork)) {
287
+ throw new WorkflowConflict("The operation result could not be saved with its card.");
226
288
  }
289
+ }
290
+ }
291
+ pendingDisconnect(connectionId, ownerId = this.ownerId) {
292
+ const row = this.db.prepare(`SELECT operation_id FROM wallet_connection_operations WHERE owner_id=? AND connection_id=? AND action='disconnect'
293
+ AND (status='running' OR sdk_pending=1) ORDER BY created_at DESC,operation_id DESC LIMIT 1`).get(ownerId, connectionId);
294
+ return row ? this.operation(row.operation_id) : undefined;
295
+ }
296
+ pendingConnectionOperations() {
297
+ return this.db.prepare(`SELECT operation_id FROM wallet_connection_operations
298
+ WHERE owner_id=? AND (status='running' OR sdk_pending=1) ORDER BY created_at,operation_id`)
299
+ .all(this.ownerId).map(({ operation_id }) => this.operation(operation_id));
227
300
  }
228
301
  settleConnection(id, source) {
229
302
  this.db.transaction(() => {
230
- const card = this.cards.get(source.cardId);
231
- if (!card || card.ownerId !== this.ownerId || card.operationId !== id || card.acceptedInput?.action !== source.action ||
232
- card.acceptedInput.walletRunId !== source.runId)
233
- return;
234
- const disconnect = this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect' AND operation_id=?
235
- AND json_extract(accepted_input_json,'$.action')='disconnect'`).get(this.ownerId, id);
236
- if (source.action === "connect" && disconnect && disconnect.id !== source.cardId)
237
- return;
238
- const activeDisconnect = this.pendingDisconnect(id);
239
- if (source.action === "disconnect" && activeDisconnect && activeDisconnect.state.cardId !== source.cardId)
303
+ const operation = this.operation(source.operationId);
304
+ if (!operation || operation.cardId !== source.cardId || operation.connectionId !== id || operation.runId !== source.runId ||
305
+ operation.ownerId !== this.ownerId)
240
306
  return;
241
- const record = this.connection(id);
242
- if (!record || record.ownerId !== this.ownerId || record.runId !== source.runId)
307
+ const epoch = this.db.prepare("SELECT data_epoch FROM settings_control WHERE id=1").get().data_epoch;
308
+ if (operation.dataEpoch !== epoch)
243
309
  return;
244
- if (source.action === "connect" && record.connection.status === "awaiting_approval") {
245
- const status = source.unrecordedOutcome ?? "failed";
246
- this.updateConnection(id, { status, reason: status === "stopped" ? "The wallet service ended before this connection was confirmed."
247
- : status === "rejected" ? "The wallet declined the connection request."
248
- : "The wallet connection could not be recorded. Check any retained connection in your wallet app." }, this.clock(), undefined, false);
310
+ if (source.cleanup) {
311
+ const retained = this.connection(id);
312
+ if (!retained || retained.ownerId !== this.ownerId || retained.runId !== source.runId ||
313
+ retained.topic !== source.cleanup.topic || operation.status === "completed")
314
+ return;
315
+ this.updateConnection(id, { status: source.cleanup.absent ? "disconnected" : "failed",
316
+ reason: source.cleanup.absent ? "The unaccepted wallet session was removed."
317
+ : "This wallet connection was not accepted for account use, and its removal could not be confirmed. Disconnect it before connecting again." }, this.clock());
249
318
  }
250
- else
251
- this.db.prepare("UPDATE live_wallet_connections SET sdk_pending=0 WHERE id=? AND owner_id=?").run(id, this.ownerId);
319
+ if (operation.status === "running" && source.action === "connect")
320
+ this.finishConnectionOperation(operation.operationId, source.unrecordedOutcome === "stopped" ? "stopped" : "failed", source.unrecordedOutcome === "rejected" ? "The connection request was declined in your wallet."
321
+ : "The wallet operation could not be confirmed. Check the saved connection state.", this.clock());
322
+ this.db.prepare("UPDATE wallet_connection_operations SET sdk_pending=0 WHERE operation_id=?").run(operation.operationId);
323
+ const connection = this.connection(id);
324
+ if (source.action === "connect" && connection?.connection.status === "awaiting_approval")
325
+ this.updateConnection(id, { status: source.unrecordedOutcome ?? "failed", reason: "The connection was not recorded as approved." }, this.clock());
252
326
  }).immediate();
253
327
  }
254
- pendingDisconnect(connectionId, ownerId = this.ownerId) {
255
- const row = this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect'
256
- AND state='running' AND operation_id=? AND json_extract(accepted_input_json,'$.action')='disconnect'`)
257
- .get(ownerId, connectionId);
258
- return row ? this.cards.get(row.id) : undefined;
259
- }
260
- pendingConnectionCards(now) {
261
- return this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND kind='connect'
262
- AND state!='closed' AND (state!='ready' OR expires_at>?) ORDER BY created_at DESC,id DESC`)
263
- .all(this.ownerId, now.toISOString()).map(({ id }) => this.cards.get(id));
264
- }
265
328
  request(id) { return readStoredTransactionRequest(this.db, id); }
266
329
  requestForRevision(id, revision) {
267
330
  const row = this.db.prepare("SELECT attempt_id FROM review_requests WHERE review_session_id=? AND review_revision=?")
@@ -302,39 +365,56 @@ export class SqliteWalletWorkflowStore {
302
365
  const request = target?.attemptId ? this.request(target.attemptId) : undefined;
303
366
  if (card.state.kind === "review" && !session)
304
367
  throw new WorkflowConflict("The saved review is unavailable.");
305
- const connection = card.state.kind === "connect" && card.operationId ? this.connection(card.operationId)?.connection : undefined;
306
- const connectionFacts = this.readConnectionFacts(now), targetAccount = session?.account ?? connectionFacts.activeAccount;
368
+ const connectionFacts = this.readConnectionFacts(now), targetAccount = session ? fixedReviewTarget(session, card.state.input)?.account : undefined;
307
369
  const facts = { ...connectionFacts, ownerId: this.ownerId, record: card, session, request,
308
- authority: request ? this.authority(request.attemptId) : undefined, connection,
370
+ authority: request ? this.authority(request.attemptId) : undefined,
309
371
  walletAvailability: { status: "available" },
310
372
  busyForAccount: !!targetAccount && this.busyForAccount(targetAccount, now) };
311
- if (!workflowEligibility(facts).allowedActions.includes(action))
373
+ const eligibility = workflowEligibility(facts);
374
+ if (!eligibility.allowedActions.includes(action))
312
375
  throw new WorkflowConflict("That action is not available right now.");
376
+ return eligibility;
313
377
  }
314
378
  manage(expected, action) {
315
379
  return this.db.transaction(() => {
316
- const now = this.clock(), card = this.cards.get(expected.state.cardId);
317
- if (!card || card.ownerId !== this.ownerId || card.tokenHash !== expected.tokenHash ||
318
- card.state.revision !== expected.state.revision || card.scope !== expected.scope)
380
+ const now = this.clock(), card = this.currentCard(expected);
381
+ if (card.ownerId !== this.ownerId || card.state.kind !== "review")
319
382
  throw new WorkflowConflict("This request does not match the card's current state.");
320
383
  const id = card.scope === "review_manage" ? String(card.state.input.attemptId) : card.operationId;
321
- if (action !== "stop_connection")
322
- this.advanceRequestDeadlines(now, id);
384
+ const request = id ? this.request(id) : undefined;
385
+ if (!id || !request || request.reviewSessionId !== card.state.input.reviewSessionId)
386
+ throw new WorkflowConflict("This transaction request does not match the card.");
387
+ if (action === "stop_waiting" && card.state.state === "closed" && card.state.reason === "cancelled")
388
+ return id;
389
+ if (card.state.revision !== expected.state.revision)
390
+ throw new WorkflowConflict("This request does not match the card's current state.");
391
+ this.advanceRequestDeadlines(now, id);
323
392
  this.requireAction(card, action, now);
324
- if (action === "stop_connection")
325
- this.updateConnection(id, { status: "stopped", reason: "The connection request was stopped here. The approval screen may still be open in your wallet." }, now);
326
- else if (action === "stop_waiting")
327
- this.stopWaiting(id, now, "user_stop");
328
- else
393
+ if (action === "stop_waiting") {
394
+ this.stopWaiting(id, now, "user_stop", card.state.cardId);
395
+ this.closeCancelledCard(this.cards.get(card.state.cardId));
396
+ }
397
+ else {
398
+ if (card.scope !== "review_manage" || card.state.state !== "ready" || card.acceptedInput !== undefined)
399
+ throw new WorkflowConflict("Open a new result card for another lookup.");
400
+ this.consume(card, { action: "read_result" }, id);
329
401
  this.resumeObservation(id);
402
+ }
330
403
  return id;
331
404
  }).immediate();
332
405
  }
333
- expireConnections(now) {
334
- for (const record of this.connections())
406
+ expireConnections(now, operationId) {
407
+ const connectionId = operationId ? this.operation(operationId)?.connectionId : undefined;
408
+ const records = operationId ? connectionId ? this.connectionRows(connectionId) : [] : this.connections();
409
+ for (const record of records)
335
410
  if (record.ownerId === this.ownerId && record.connection.status === "awaiting_approval" &&
336
- Date.parse(record.connection.expiresAt) <= now.getTime())
411
+ Date.parse(record.connection.expiresAt) <= now.getTime()) {
337
412
  this.updateConnection(record.connection.connectionId, { status: "expired", reason: "The time to approve this connection has ended." }, now);
413
+ const rows = this.db.prepare("SELECT operation_id FROM wallet_connection_operations WHERE connection_id=? AND action='connect' AND status='running'")
414
+ .all(record.connection.connectionId);
415
+ for (const row of rows)
416
+ this.finishConnectionOperation(row.operation_id, "stopped", "The connection request expired.", now, "expired", this.operation(row.operation_id)?.settlementPending ? "approval_response" : undefined);
417
+ }
338
418
  }
339
419
  beginObservation(id, explicit) {
340
420
  return this.db.transaction(() => {
@@ -357,37 +437,96 @@ export class SqliteWalletWorkflowStore {
357
437
  ...card.state, state: terminal ? "closed" : "running", revision: card.state.revision + 1,
358
438
  ...(terminal ? { reason: "completed" } : {})
359
439
  } };
360
- if (!this.cards.replace(card, next))
440
+ if (!this.replaceCard(card, next, this.clock()))
361
441
  throw new WorkflowConflict("The card changed before this action was accepted.");
362
442
  }
363
- publish(operationId, terminal) {
364
- this.db.prepare(`UPDATE live_read_cards SET revision=revision+1,
365
- state=CASE WHEN @terminal=1 THEN 'closed' ELSE state END,
366
- reason=CASE WHEN @terminal=1 THEN 'completed' ELSE reason END
367
- WHERE operation_id=@id`).run({ terminal: terminal ? 1 : 0, id: operationId });
368
- }
369
- publishConnection(connectionId, status) {
370
- const outcome = this.connection(connectionId)?.connection;
371
- this.db.prepare(`UPDATE live_read_cards SET revision=revision+1,
372
- result_json=CASE WHEN state='running' AND json_extract(accepted_input_json,'$.action')='disconnect'
373
- AND @disconnectTerminal THEN @outcome ELSE result_json END,
374
- reason=CASE WHEN state='running' AND
375
- CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
376
- THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'completed' ELSE reason END,
377
- state=CASE WHEN state='running' AND
378
- CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
379
- THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'closed' ELSE state END
380
- WHERE scope='connect' AND operation_id=@id`).run({ id: connectionId, outcome: outcome ? JSON.stringify({ disconnectOutcome: outcome }) : null,
381
- disconnectTerminal: Number(status !== "connected" && status !== "awaiting_approval"),
382
- connectionTerminal: Number(status !== "awaiting_approval") });
443
+ publish(operationId, terminal, excludedCardId) {
444
+ const rows = this.db.prepare("SELECT id FROM live_read_cards WHERE operation_id=? AND state!='closed'").all(operationId);
445
+ for (const { id } of rows) {
446
+ if (id === excludedCardId)
447
+ continue;
448
+ const card = this.cards.get(id);
449
+ if (!this.replaceCard(card, { ...card, state: { ...card.state, revision: card.state.revision + 1,
450
+ ...(terminal ? { state: "closed", reason: "completed" } : {}) } }, this.clock()))
451
+ throw new WorkflowConflict("The request result could not be saved with its card.");
452
+ }
453
+ }
454
+ closeConnectionCardsWithChangedNetworks(chains) {
455
+ for (const { id } of this.db.prepare("SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect' AND state='ready'").all(this.ownerId)) {
456
+ const card = this.cards.get(id);
457
+ if (!isDeepStrictEqual(card.state.input.requestedChains, chains))
458
+ this.invalidateConnectionCard(card);
459
+ }
460
+ }
461
+ reconcileWalletCard(card, facts) {
462
+ if (card.ownerId !== this.ownerId || card.state.state !== "ready")
463
+ return card;
464
+ let message;
465
+ if (card.scope === "connect") {
466
+ if (!isDeepStrictEqual(card.state.input.requestedChains, facts.requestedChains) ||
467
+ (card.state.input.connectionId ?? null) !== (facts.connection?.connectionId ?? null))
468
+ return this.invalidateConnectionCard(card);
469
+ if (facts.pendingOperation && facts.pendingOperation.operationId !== card.operationId) {
470
+ message = "Another wallet request is still pending. No connection request was sent from this card. Ask in chat to check the existing request.";
471
+ }
472
+ else if (facts.connection && facts.connectionPresence.confirmed &&
473
+ ["present", "unusable"].includes(facts.connectionPresence.observation?.status ?? "")) {
474
+ message = "A wallet session is already present. No connection request was sent. To change the wallet or networks, ask in chat to disconnect and then request a new connection.";
475
+ }
476
+ }
477
+ else if (card.scope === "disconnect") {
478
+ const replaced = !!facts.connection && facts.connection.connectionId !== card.state.input.connectionId;
479
+ const absent = facts.connection?.connectionId === card.state.input.connectionId && facts.connectionPresence.confirmed &&
480
+ facts.connectionPresence.observation?.status === "absent";
481
+ if (replaced || absent)
482
+ message = "The wallet session for this card is no longer current. No disconnection request was sent from this card. Ask in chat for current connection status.";
483
+ }
484
+ return message ? this.invalidateConnectionCard(card, message) : card;
485
+ }
486
+ invalidateConnectionCard(card, message = "The networks or connection state for this request changed. Ask for a new wallet connection card. No connection request was sent.") {
487
+ if (card.state.state === "closed")
488
+ return card;
489
+ if (!this.replaceCard(card, { ...card, state: { ...card.state, state: "closed", reason: "failed", revision: card.state.revision + 1,
490
+ error: message } }, this.clock()))
491
+ throw new WorkflowConflict("The card changed before it could be closed.");
492
+ return this.cards.get(card.state.cardId);
493
+ }
494
+ operationRead(expected, context) {
495
+ return this.db.transaction(() => {
496
+ let record = this.expireCard(this.currentCard(expected));
497
+ if (!["connect", "disconnect"].includes(record.scope ?? ""))
498
+ throw new WorkflowConflict("A wallet operation card is required.");
499
+ const now = this.clock(), connectionFacts = record.state.state === "closed" ? undefined : this.readConnectionFacts(now);
500
+ if (connectionFacts)
501
+ record = this.reconcileWalletCard(record, connectionFacts);
502
+ const operation = this.cardOperation(record);
503
+ const facts = connectionFacts && record.state.state !== "closed" ? { ...connectionFacts, ...context,
504
+ record, ownerId: this.ownerId, hasReviewInput: false, busyForAccount: false,
505
+ operation: operation ? publicConnectionOperation(operation) : undefined } : undefined;
506
+ const evaluated = facts ? { ...facts, ...workflowEligibility(facts), events: [] } : undefined;
507
+ if (record.acceptedInput && !operation)
508
+ throw new WorkflowConflict("The admitted operation is unavailable.");
509
+ return { record, operation: operation ? publicConnectionOperation(operation) : null, evaluated,
510
+ evaluatedAt: evaluated?.evaluatedAt ?? this.clock().toISOString() };
511
+ }).deferred();
512
+ }
513
+ closeCancelledCard(expected) {
514
+ const card = this.currentCard(expected);
515
+ if (card.ownerId !== this.ownerId)
516
+ throw new WorkflowConflict("This card no longer accepts actions.");
517
+ if (card.state.state === "closed" && card.state.reason === "cancelled")
518
+ return;
519
+ if (!this.replaceCard(card, { ...card, state: { ...card.state, state: "closed", reason: "cancelled", revision: card.state.revision + 1 } }, this.clock())) {
520
+ throw new WorkflowConflict("Card changed before cancellation.");
521
+ }
383
522
  }
384
523
  recoveryImpact(runId) {
385
524
  return walletRecoveryImpactSchema.parse({
386
525
  runId,
387
526
  connections: this.connections().filter((record) => record.ownerId === this.ownerId && record.runId === runId)
388
527
  .map((record) => ({ connectionId: record.connection.connectionId, revision: record.connection.revision,
389
- status: record.connection.status, accounts: record.connection.accounts, walletName: record.connection.walletName,
390
- sdkPending: record.sdkPending, disconnectPending: !!this.pendingDisconnect(record.connection.connectionId) })),
528
+ status: record.connection.status, chainResults: record.connection.chainResults, walletName: record.connection.walletName,
529
+ sdkPending: record.sdkPending, disconnectPending: record.disconnectPending })),
391
530
  preparations: this.db.prepare(`SELECT id,revision,review_revision,preparation_id,wallet_connection_id FROM live_review_sessions
392
531
  WHERE owner_id=? AND preparation_run_id=? AND status!='expired' ORDER BY id`).all(this.ownerId, runId),
393
532
  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
@@ -400,43 +539,63 @@ export class SqliteWalletWorkflowStore {
400
539
  this.db.transaction(() => {
401
540
  this.db.prepare(`UPDATE live_request_authority SET sdk_pending=0 WHERE owner_id=? AND run_id=? AND sdk_pending=1
402
541
  AND attempt_id NOT IN (SELECT value FROM json_each(?))`).run(this.ownerId, runId, JSON.stringify(parentProcessing));
403
- for (const record of this.connections()) {
404
- if (record.ownerId !== this.ownerId || record.runId !== runId)
405
- continue;
406
- const pending = this.pendingDisconnect(record.connection.connectionId);
407
- if (pending)
408
- this.finishDisconnect(pending.state.cardId, record.connection.connectionId, false, this.clock());
409
- else if (record.connection.status === "awaiting_approval")
410
- this.updateConnection(record.connection.connectionId, { status: "stopped", reason: "The wallet service ended before this connection was confirmed." }, this.clock(), undefined, false);
411
- 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);
542
+ const rows = this.db.prepare("SELECT operation_id FROM wallet_connection_operations WHERE owner_id=? AND run_id=? AND sdk_pending=1")
543
+ .all(this.ownerId, runId);
544
+ for (const row of rows) {
545
+ const op = this.operation(row.operation_id);
546
+ if (op.status === "running") {
547
+ this.finishConnectionOperation(op.operationId, "stopped", "The wallet service ended before this operation was confirmed.", this.clock(), "run_ended");
548
+ if (op.connectionId)
549
+ this.updateConnection(op.connectionId, { status: op.action === "connect" ? "stopped" : "failed",
550
+ reason: "The wallet service ended before this operation was confirmed." }, this.clock());
551
+ }
412
552
  }
553
+ this.db.prepare("UPDATE wallet_connection_operations SET sdk_pending=0 WHERE owner_id=? AND run_id=?").run(this.ownerId, runId);
413
554
  }).immediate();
414
555
  }
415
- publishAvailableWallets() {
416
- 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);
417
- }
418
556
  publishReview(id) {
419
557
  this.db.prepare(`UPDATE live_read_cards SET revision=revision+1 WHERE owner_id=? AND kind='review' AND state='ready'
420
558
  AND json_extract(input_json,'$.reviewSessionId')=?`).run(this.ownerId, id);
421
559
  }
422
560
  admitConnection(expected, input) {
423
- return this.db.transaction(() => {
561
+ const admitted = this.db.transaction(() => {
562
+ const recovered = this.recoverConnectionOperation(expected, input);
563
+ if (recovered)
564
+ return { connection: this.connection(recovered.connectionId), operationId: recovered.operationId, created: false };
424
565
  const now = this.clock();
425
566
  this.assertWalletInputAllowed();
426
567
  this.expireConnections(now);
427
- const card = this.requireInput(expected, now);
428
- this.requireAction(card, "connect", now);
568
+ const card = this.requireConnectionCard(expected, now);
569
+ if (this.runId !== input.walletRunId) {
570
+ throw new WorkflowConflict("This card cannot start that wallet connection.");
571
+ }
572
+ const facts = this.readConnectionFacts(now);
573
+ if (this.reconcileWalletCard(card, facts).state.state === "closed")
574
+ return undefined;
575
+ if (!input.chains.length || !isDeepStrictEqual([...input.chains].sort(), [...facts.requestedChains].sort())) {
576
+ throw new WorkflowConflict("The requested networks changed. Check the current networks before connecting.");
577
+ }
578
+ const decision = walletActionDecision({ ...facts, walletAvailability: { status: "available" } }, "connect");
579
+ if (!decision.allowed)
580
+ throw new WorkflowConflict(decision.nextStep.message, [], decision.nextStep.reason);
429
581
  const connection = walletConnectionSchema.parse({ connectionId: randomUUID(), status: "awaiting_approval", revision: 0,
430
- accounts: [], methods: [], chain: SUI_MAINNET_WALLET_CHAIN, createdAt: now.toISOString(), updatedAt: now.toISOString(),
431
- expiresAt: card.state.expiresAt });
432
- this.db.prepare("INSERT INTO live_wallet_connections (id,owner_id,status,revision,topic,sdk_pending,connection_json,run_id) VALUES (?,?,?,?,?,?,?,?)")
433
- .run(connection.connectionId, this.ownerId, connection.status, 0, null, 1, JSON.stringify(connection), this.runId ?? null);
434
- this.consume(card, input, connection.connectionId);
435
- this.publishAvailableWallets();
436
- return this.connection(connection.connectionId);
582
+ requestedChains: input.chains, chainResults: [], grants: [], grantRevisions: initialGrantRevisions(), lifecycleRevision: 0,
583
+ createdAt: now.toISOString(), updatedAt: now.toISOString(), expiresAt: card.state.expiresAt });
584
+ const epoch = this.db.prepare("SELECT data_epoch FROM settings_control WHERE id=1").get().data_epoch;
585
+ this.db.prepare("UPDATE live_wallet_connections SET current_slot=NULL WHERE current_slot=1").run();
586
+ this.db.prepare("INSERT INTO live_wallet_connections (id,owner_id,status,revision,topic,connection_json,run_id,current_slot,data_epoch) VALUES (?,?,?,?,?,?,?,1,?)")
587
+ .run(connection.connectionId, this.ownerId, connection.status, 0, null, JSON.stringify(connection), this.runId, epoch);
588
+ this.admitConnectionOperation(card, input, now, connection.connectionId, true);
589
+ return { connection: this.connection(connection.connectionId), operationId: input.operationId, created: true };
437
590
  }).immediate();
438
- }
439
- updateConnection(id, update, now, topic, sdkPending) {
591
+ if (!admitted)
592
+ throw new WorkflowConflict("The networks or connection state for this request changed. Ask for a new wallet connection card. No connection request was sent.");
593
+ return admitted;
594
+ }
595
+ updateConnection(id, update, now, topic) {
596
+ const allowed = new Set(["status", "grants", "grantRevisions", "lifecycleRevision", "expiresAt", "walletName", "reason"]);
597
+ if (Object.keys(update).some((key) => !allowed.has(key)))
598
+ throw new WorkflowConflict("A fixed connection approval cannot be changed.");
440
599
  return this.db.transaction(() => {
441
600
  const old = this.connection(id);
442
601
  if (!old || old.ownerId !== this.ownerId)
@@ -444,14 +603,8 @@ export class SqliteWalletWorkflowStore {
444
603
  const next = walletConnectionSchema.parse({ ...old.connection, ...update,
445
604
  connectionId: id, revision: old.connection.revision + 1, updatedAt: now.toISOString() });
446
605
  assertNoForbiddenMcpFields(next);
447
- this.db.prepare(`UPDATE live_wallet_connections SET status=?, revision=?, topic=?, sdk_pending=?, connection_json=?
448
- WHERE id=? AND owner_id=? AND revision=?`).run(next.status, next.revision, topic ?? old.topic ?? null, sdkPending === undefined ? Number(old.sdkPending) : Number(sdkPending), JSON.stringify(next), id, this.ownerId, old.connection.revision);
449
- this.invalidateConflictingConnections(now);
450
- this.publishConnection(id, next.status);
451
- this.publishAvailableWallets();
452
- if (next.status === "connected" && next.accounts.length === 1 && old.connection.status === "awaiting_approval" && this.isUsableConnection(id, now)) {
453
- this.writeActiveAccount(next.accounts[0], id, next.walletName, now);
454
- }
606
+ this.db.prepare(`UPDATE live_wallet_connections SET status=?,revision=?,topic=?,connection_json=?
607
+ WHERE id=? AND owner_id=? AND revision=?`).run(next.status, next.revision, topic ?? old.topic ?? null, JSON.stringify(next), id, this.ownerId, old.connection.revision);
455
608
  return this.connection(id);
456
609
  }).immediate();
457
610
  }
@@ -461,74 +614,144 @@ export class SqliteWalletWorkflowStore {
461
614
  this.updateConnection(id, update, now);
462
615
  }).immediate();
463
616
  }
464
- restoreConnection(session, now) {
465
- const existing = this.db.prepare("SELECT id FROM live_wallet_connections WHERE topic=?").get(session.topic);
466
- const id = existing?.id ?? randomUUID();
467
- const prior = existing ? this.connection(id) : undefined;
468
- if (prior?.ownerId === this.ownerId && prior.runId === this.runId && prior.connection.status === "connected" && !prior.sdkPending &&
469
- isDeepStrictEqual(prior.connection.accounts, session.accounts) && isDeepStrictEqual(prior.connection.methods, session.methods) &&
470
- prior.connection.expiresAt === session.expiresAt && prior.connection.walletName === session.walletName)
471
- return prior;
472
- const next = walletConnectionSchema.parse({ connectionId: id, status: "connected", revision: (prior?.connection.revision ?? -1) + 1,
473
- accounts: session.accounts, methods: session.methods, chain: session.chain,
474
- createdAt: prior?.connection.createdAt ?? now.toISOString(), updatedAt: now.toISOString(),
475
- expiresAt: session.expiresAt, walletName: session.walletName });
476
- 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
477
- owner_id=excluded.owner_id, status=excluded.status, revision=excluded.revision, topic=excluded.topic,
478
- 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);
479
- this.publishConnection(id, next.status);
480
- return this.connection(id);
481
- }
482
- useAccount(expected, input, connectionId, account) {
617
+ pairingExpiry(operationId, expiresAt) {
483
618
  this.db.transaction(() => {
484
- const now = this.clock();
485
- this.assertWalletInputAllowed();
486
- const card = this.requireInput(expected, now), connection = this.connection(connectionId);
487
- if (card.scope !== "connect" || !this.isUsableConnection(connectionId, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(connectionId)) ||
488
- Date.parse(connection.connection.expiresAt) <= now.getTime() || !connection.connection.accounts.includes(account)) {
489
- throw new WorkflowConflict("The selected wallet account is no longer available.");
490
- }
491
- this.requireAction(card, "use_account", now);
492
- this.writeActiveAccount(account, connectionId, connection.connection.walletName, now);
493
- this.consume(card, input, connectionId, true);
494
- this.publishAvailableWallets();
619
+ const op = this.operation(operationId);
620
+ if (!op || op.ownerId !== this.ownerId || op.runId !== this.runId || op.status !== "running" || op.action !== "connect")
621
+ return;
622
+ this.db.prepare("UPDATE wallet_connection_operations SET expires_at=?,updated_at=? WHERE operation_id=?")
623
+ .run(expiresAt, this.clock().toISOString(), operationId);
624
+ this.updateConnection(op.connectionId, { expiresAt }, this.clock());
625
+ }).immediate();
626
+ }
627
+ responseOwner(source) {
628
+ const operation = this.operation(source.operationId), record = this.connection(source.id);
629
+ const epoch = this.db.prepare("SELECT data_epoch FROM settings_control WHERE id=1").get().data_epoch;
630
+ if (!operation || operation.action !== "connect" || operation.cardId !== source.cardId || operation.connectionId !== source.id ||
631
+ operation.ownerId !== this.ownerId || operation.runId !== source.runId || source.runId !== this.runId ||
632
+ operation.dataEpoch !== epoch || !record || record.ownerId !== this.ownerId || record.runId !== source.runId || record.dataEpoch !== epoch)
633
+ return undefined;
634
+ return { operation, record };
635
+ }
636
+ captureConnectionSession(source, session) {
637
+ return this.db.transaction(() => {
638
+ const owned = this.responseOwner(source);
639
+ if (!owned)
640
+ return "not_owned";
641
+ const { operation, record } = owned;
642
+ if (record.topic && record.topic !== session.topic)
643
+ return "not_owned";
644
+ if (operation.status === "completed")
645
+ return record.topic === session.topic && record.connection.approvedAt ? "already_published" : "not_owned";
646
+ if (!record.current)
647
+ return "not_owned";
648
+ const existing = this.db.prepare("SELECT id FROM live_wallet_connections WHERE topic=?").get(session.topic);
649
+ if (existing && existing.id !== source.id)
650
+ return "not_owned";
651
+ this.db.prepare("UPDATE live_wallet_connections SET topic=? WHERE id=? AND current_slot=1").run(session.topic, source.id);
652
+ return "captured";
653
+ }).immediate();
654
+ }
655
+ prepareConnectionCleanup(source, session, now) {
656
+ return this.db.transaction(() => {
657
+ const owned = this.responseOwner(source);
658
+ if (!owned || !owned.record.current || owned.record.topic !== session.topic || owned.operation.status === "completed")
659
+ return false;
660
+ const { operation } = owned;
661
+ this.updateConnection(source.id, { status: operation.status === "stopped" ? "stopped" : "failed", grants: session.grants,
662
+ expiresAt: session.expiresAt, walletName: session.walletName, reason: "This wallet connection was not accepted for account use." }, now);
663
+ if (operation.status === "running")
664
+ this.finishConnectionOperation(source.operationId, "failed", "The wallet connection could not be recorded as approved.", now, undefined, "session_removal");
665
+ return true;
666
+ }).immediate();
667
+ }
668
+ completeConnection(source, session, now) {
669
+ return this.db.transaction(() => {
670
+ const owned = this.responseOwner(source);
671
+ if (!owned || owned.record.topic !== session.topic)
672
+ return "not_owned";
673
+ const { operation: op, record } = owned;
674
+ if (op.status === "completed")
675
+ return record.connection.approvedAt ? "already_published" : "not_owned";
676
+ const snapshot = this.readSdkSnapshot(), observed = snapshot?.sessions.find((item) => item.topic === session.topic);
677
+ if (!record.current)
678
+ return "not_owned";
679
+ if (op.status !== "running" || record.connection.status !== "awaiting_approval" ||
680
+ Date.parse(op.expiresAt) <= now.getTime() || snapshot?.runId !== op.runId || observed?.status !== "present")
681
+ return "cleanup_required";
682
+ const chainResults = approvedChainResults(record.connection.requestedChains, session.grants);
683
+ const next = walletConnectionSchema.parse({ ...record.connection, status: "connected", chainResults, approvedAt: now.toISOString(),
684
+ grants: observed.session.grants, grantRevisions: observed.grantRevisions, lifecycleRevision: observed.lifecycleRevision,
685
+ expiresAt: observed.session.expiresAt, walletName: observed.session.walletName, revision: record.connection.revision + 1, updatedAt: now.toISOString() });
686
+ this.db.prepare("UPDATE live_wallet_connections SET status=?,revision=?,topic=?,connection_json=? WHERE id=? AND current_slot=1")
687
+ .run(next.status, next.revision, session.topic, JSON.stringify(next), next.connectionId);
688
+ for (const result of chainResults)
689
+ if (result.status === "bound")
690
+ this.accounts.upsert(result.chainId, result.address, "wallet_connection", now.toISOString());
691
+ this.finishConnectionOperation(source.operationId, "completed", chainResults.some((result) => result.status === "bound") ? "Wallet connection approved." : "The wallet returned no usable address. Disconnect before connecting again.", now);
692
+ return "published";
495
693
  }).immediate();
496
694
  }
497
- admitDisconnect(expected, input, connectionId) {
695
+ stopConnection(expected, operationId) {
498
696
  return this.db.transaction(() => {
697
+ const current = this.currentCard(expected), op = this.cardOperation(current, operationId);
698
+ if (op?.endReason === "user_stopped")
699
+ return op.connectionId;
700
+ if (current.scope !== "connect" || current.ownerId !== this.ownerId || current.state.state === "closed")
701
+ throw new WorkflowConflict("This connection card has ended.");
702
+ if (!op || op.action !== "connect" || op.status !== "running")
703
+ throw new WorkflowConflict("This connection request can no longer be stopped.");
704
+ this.finishConnectionOperation(operationId, "stopped", "Connecting was stopped here. A pending wallet approval will not be used.", this.clock(), "user_stopped", op.settlementPending ? "approval_response" : undefined);
705
+ this.updateConnection(op.connectionId, { status: "stopped", reason: "The connection request was stopped here." }, this.clock());
706
+ this.closeCancelledCard(current);
707
+ return op.connectionId;
708
+ }).immediate();
709
+ }
710
+ disconnectImpact(connectionId) {
711
+ const record = this.connection(connectionId);
712
+ if (!record)
713
+ throw new WorkflowConflict("The wallet connection is unavailable.");
714
+ const requests = this.db.prepare(`SELECT l.attempt_id,r.revision FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
715
+ WHERE l.connection_id=? AND l.can_submit=1 ORDER BY l.attempt_id`).all(connectionId);
716
+ const impact = { connectionId, lifecycleRevision: record.connection.lifecycleRevision, grants: record.connection.grantRevisions,
717
+ chainResults: record.connection.chainResults, requests };
718
+ return { impactId: createHash("sha256").update(JSON.stringify(impact)).digest("hex"), requestCount: requests.length,
719
+ chains: record.connection.requestedChains };
720
+ }
721
+ admitDisconnect(expected, input) {
722
+ return this.db.transaction(() => {
723
+ const prior = this.recoverConnectionOperation(expected, input);
724
+ if (prior)
725
+ return { connection: this.connection(prior.connectionId), created: false };
499
726
  const now = this.clock();
500
727
  this.assertWalletInputAllowed();
501
- const card = this.requireInput(expected, now), connection = this.connection(connectionId);
502
- if (card.scope !== "connect" || !connection || connection.ownerId !== this.ownerId || connection.runId !== this.runId ||
503
- connection.sdkPending || !this.connectionSelection(now).manageableConnectionIds.includes(connectionId)) {
504
- throw new WorkflowConflict("The wallet connection is no longer available.");
505
- }
506
- this.requireAction(card, "disconnect", now);
507
- this.consume(card, input, connectionId);
508
- this.db.prepare("UPDATE live_wallet_connections SET sdk_pending=1 WHERE id=? AND owner_id=?").run(connectionId, this.ownerId);
509
- this.invalidateConnectionRequests(connectionId, now, "disconnect_requested");
510
- this.publishAvailableWallets();
511
- return connection;
728
+ const card = this.requireConnectionCard(expected, now), connection = this.connection(input.connectionId);
729
+ const decision = walletActionDecision({ ...this.readConnectionFacts(now), walletAvailability: { status: "available" } }, "disconnect", input.connectionId);
730
+ if (!decision.allowed)
731
+ throw new WorkflowConflict(decision.nextStep.message, [], decision.nextStep.reason);
732
+ if (!connection?.topic || !connection.current || connection.ownerId !== this.ownerId || connection.runId !== this.runId || input.walletRunId !== this.runId ||
733
+ input.impactId !== this.disconnectImpact(input.connectionId).impactId)
734
+ throw new WorkflowConflict("The connection or pending approvals changed. Review the updated disconnection details.");
735
+ this.admitConnectionOperation(card, input, now, input.connectionId, true);
736
+ this.invalidateConnectionRequests(input.connectionId, now, "disconnect_requested");
737
+ return { connection, created: true };
512
738
  }).immediate();
513
739
  }
514
- finishDisconnect(cardId, connectionId, succeeded, now) {
740
+ finishDisconnect(operationId, connectionId, succeeded, now) {
515
741
  this.db.transaction(() => {
516
- const pending = this.pendingDisconnect(connectionId);
517
- if (pending?.state.cardId !== cardId)
742
+ const op = this.operation(operationId);
743
+ if (!op || op.connectionId !== connectionId || op.status !== "running" || op.ownerId !== this.ownerId)
518
744
  return;
519
745
  this.updateConnection(connectionId, succeeded ? { status: "disconnected" } : {
520
- status: "failed", reason: "The wallet disconnection could not be confirmed. The connection may remain in your wallet app."
746
+ status: "failed", reason: "Wallet disconnection was not confirmed. Check the retained connection in your wallet app."
521
747
  }, now);
748
+ this.finishConnectionOperation(operationId, succeeded ? "completed" : "failed", succeeded ? "Wallet disconnected." : "Wallet disconnection was not confirmed.", now, undefined, succeeded ? undefined : "session_status");
522
749
  }).immediate();
523
750
  }
524
- recoverDisconnect(cardId, connectionId, revision, sessionAbsent) {
525
- this.db.transaction(() => {
526
- const connection = this.connection(connectionId);
527
- if (!connection || connection.ownerId !== this.ownerId || connection.sdkPending ||
528
- connection.connection.revision !== revision || this.pendingDisconnect(connectionId)?.state.cardId !== cardId)
529
- return;
530
- this.finishDisconnect(cardId, connectionId, sessionAbsent, this.clock());
531
- }).immediate();
751
+ recoverDisconnect(operationId, connectionId, revision, sessionAbsent) {
752
+ const connection = this.connection(connectionId);
753
+ if (connection?.connection.revision === revision && !connection.sdkPending)
754
+ this.finishDisconnect(operationId, connectionId, sessionAbsent, this.clock());
532
755
  }
533
756
  recoverRequestAt(expected, input, binding, now) {
534
757
  const request = this.requestForRevision(binding.reviewSessionId, binding.reviewRevision);
@@ -551,13 +774,13 @@ export class SqliteWalletWorkflowStore {
551
774
  recoverRequest(expected, input, binding) {
552
775
  return this.db.transaction(() => this.recoverRequestAt(expected, input, binding, this.clock())).immediate();
553
776
  }
554
- admitRequest(expected, input, material, selectedConnection, walletRunId) {
777
+ admitRequest(expected, input, material, reviewConnection, walletRunId) {
555
778
  const result = this.db.transaction(() => {
556
779
  const now = this.clock();
557
780
  this.assertWalletInputAllowed();
558
781
  const binding = { reviewSessionId: material.reviewSessionId, planId: material.planId, account: material.account,
559
782
  reviewRevision: material.reviewRevision, transactionDigest: material.reviewedTransactionDigest,
560
- connectionId: selectedConnection.connectionId, connectionRevision: selectedConnection.revision };
783
+ connectionId: reviewConnection.connectionId, grantRevision: reviewConnection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] };
561
784
  const recovered = this.recoverRequestAt(expected, input, binding, now);
562
785
  if (recovered)
563
786
  return recovered;
@@ -578,17 +801,18 @@ export class SqliteWalletWorkflowStore {
578
801
  const data = state?.transactionReviewData;
579
802
  const connection = this.connection(binding.connectionId);
580
803
  if (!review || review.status !== "ready_for_wallet_review" || review.preparation_id !== null || review.account !== binding.account ||
804
+ review.wallet_connection_id !== binding.connectionId || review.wallet_grant_revision !== binding.grantRevision ||
581
805
  review.review_revision !== binding.reviewRevision || state?.planId !== binding.planId ||
582
806
  data?.reviewedTransactionDigest !== binding.transactionDigest || Date.parse(review.expires_at) <= now.getTime() ||
583
807
  !this.isUsableConnection(binding.connectionId, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(binding.connectionId)) ||
584
- connection.connection.revision !== binding.connectionRevision || Date.parse(connection.connection.expiresAt) <= now.getTime() ||
585
- !connection.connection.accounts.includes(binding.account) || !connection.connection.methods.includes(SUI_SIGN_TRANSACTION_METHOD)) {
808
+ connection.connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] !== binding.grantRevision || Date.parse(connection.connection.expiresAt) <= now.getTime() ||
809
+ !connectionHasSuiSigningScope(connection.connection, binding.account)) {
586
810
  throw new WorkflowConflict("The account, wallet connection or transaction changed since this review was prepared.");
587
811
  }
588
- this.requireAction(card, "request_signature", now);
589
812
  if (this.busyForAccount(binding.account, now))
590
813
  throw new WorkflowConflict("Another wallet request or transaction result for this account is still pending.");
591
- const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(binding.account);
814
+ this.requireAction(card, "request_signature", now);
815
+ const account = this.db.prepare("SELECT id FROM accounts WHERE chain_id='sui:mainnet' AND address=?").get(binding.account);
592
816
  if (!account)
593
817
  throw new WorkflowConflict("The reviewed account is unavailable.");
594
818
  const id = randomUUID();
@@ -596,14 +820,13 @@ export class SqliteWalletWorkflowStore {
596
820
  (attempt_id,review_session_id,plan_id,review_revision,account_id,transaction_digest,review_state_json,request_status,revision,created_at,updated_at)
597
821
  VALUES (?,?,?,?,?,?,?,'awaiting_signature',0,?,?)`).run(id, review.id, binding.planId, binding.reviewRevision, account.id, binding.transactionDigest, review.review_state_json, now.toISOString(), now.toISOString());
598
822
  this.db.prepare(`INSERT INTO live_request_authority
599
- (attempt_id,owner_id,connection_id,connection_revision,can_submit,sdk_pending,submit_pending,lookup_pending,signature_deadline,run_id)
600
- 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);
823
+ (attempt_id,owner_id,connection_id,grant_revision,can_submit,sdk_pending,submit_pending,lookup_pending,signature_deadline,run_id)
824
+ VALUES (?,?,?,?,1,1,0,0,?,?)`).run(id, this.ownerId, binding.connectionId, binding.grantRevision, new Date(Math.min(now.getTime() + SIGNATURE_WAIT_MS, Date.parse(review.expires_at))).toISOString(), walletRunId);
601
825
  this.db.prepare(`UPDATE live_review_sessions SET current_attempt_id=?, revision=revision+1, write_contract_version=? WHERE id=?`)
602
826
  .run(id, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, review.id);
603
827
  this.db.prepare("UPDATE review_sessions SET current_attempt_id=?, updated_at=? WHERE id=?").run(id, now.toISOString(), review.id);
604
828
  this.consume(card, input, id);
605
829
  this.recordRequestEvent(this.request(id), "request_admitted", undefined, now);
606
- this.publishAvailableWallets();
607
830
  return { request: this.request(id), created: true };
608
831
  }).immediate();
609
832
  if (result instanceof Error)
@@ -612,7 +835,7 @@ export class SqliteWalletWorkflowStore {
612
835
  }
613
836
  busyForAccount(account, now) {
614
837
  return !!this.db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
615
- JOIN accounts a ON a.id=r.account_id WHERE a.sui_address=? AND (l.sdk_pending=1 OR l.submit_pending=1 OR l.lookup_pending=1
838
+ JOIN accounts a ON a.id=r.account_id AND a.chain_id='sui:mainnet' WHERE a.chain_id='sui:mainnet' AND a.address=? AND (l.sdk_pending=1 OR l.submit_pending=1 OR l.lookup_pending=1
616
839
  OR (r.request_status IN (SELECT value FROM json_each(?)) AND l.observation_stopped=0
617
840
  AND (l.lookup_deadline IS NULL OR l.lookup_deadline>?))) LIMIT 1`)
618
841
  .get(account, JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
@@ -643,7 +866,7 @@ export class SqliteWalletWorkflowStore {
643
866
  }).immediate();
644
867
  }
645
868
  recordRequestEvent(request, event, from, now) {
646
- const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(request.account);
869
+ const account = this.db.prepare("SELECT id FROM accounts WHERE chain_id='sui:mainnet' AND address=?").get(request.account);
647
870
  this.db.prepare(`INSERT INTO review_status_transitions
648
871
  (review_session_id,event,attempt_id,domain,from_status,to_status,account_id,reason,transitioned_at)
649
872
  VALUES (?,?,?,'request',?,?,?,?,?)`).run(request.reviewSessionId, event, request.attemptId, from ?? null, request.requestStatus, account.id, request.reason ?? null, now.toISOString());
@@ -663,7 +886,7 @@ export class SqliteWalletWorkflowStore {
663
886
  execution.txDigest !== old.transactionDigest || execution.chainReceipt.sender !== old.account) {
664
887
  throw new WorkflowConflict("Chain result does not match the admitted transaction.");
665
888
  }
666
- const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(old.account);
889
+ const account = this.db.prepare("SELECT id FROM accounts WHERE chain_id='sui:mainnet' AND address=?").get(old.account);
667
890
  this.db.prepare(`INSERT INTO review_executions
668
891
  (attempt_id,review_session_id,plan_id,account_id,status,tx_digest,failure_reason,result_json,recorded_at,updated_at)
669
892
  VALUES (?,?,?,?,?,?,?,?,?,?)`).run(id, old.reviewSessionId, old.planId, account.id, execution.status, execution.txDigest, execution.failureReason ?? null, JSON.stringify(execution), now.toISOString(), now.toISOString());
@@ -684,9 +907,7 @@ export class SqliteWalletWorkflowStore {
684
907
  const result = this.request(id);
685
908
  this.recordRequestEvent(result, options.signatureVerified ? "signature_verified" : next === "completed" ? "chain_result_recorded" : "request_status_changed", old.requestStatus, now);
686
909
  this.db.prepare("UPDATE review_sessions SET updated_at=? WHERE id=?").run(now.toISOString(), old.reviewSessionId);
687
- this.publish(id, !["awaiting_signature", "submitting", "awaiting_chain_result"].includes(next));
688
- if (old.requestStatus === "awaiting_signature" && next !== old.requestStatus)
689
- this.publishAvailableWallets();
910
+ this.publish(id, !["awaiting_signature", "submitting", "awaiting_chain_result"].includes(next), options.excludedCardId);
690
911
  if (["stopped", "request_failed", "outcome_unknown", "completed"].includes(next)) {
691
912
  for (const table of ["live_transaction_materials", "live_private_review_artifacts"]) {
692
913
  this.db.prepare(`DELETE FROM ${table} WHERE review_session_id=? AND EXISTS
@@ -706,8 +927,10 @@ export class SqliteWalletWorkflowStore {
706
927
  request.requestStatus !== "awaiting_signature" || Date.parse(authority.signature_deadline) <= now.getTime())
707
928
  return false;
708
929
  const connection = this.connection(authority.connection_id);
709
- if (!this.isUsableConnection(authority.connection_id, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(authority.connection_id)) ||
710
- connection.connection.revision !== authority.connection_revision || Date.parse(connection.connection.expiresAt) <= now.getTime())
930
+ if (!this.isUsableConnection(authority.connection_id, now, SUI_MAINNET_WALLET_CHAIN, request.account) ||
931
+ !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(authority.connection_id)) ||
932
+ connection.connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] !== authority.grant_revision ||
933
+ !connectionHasSuiSigningScope(connection.connection, request.account) || Date.parse(connection.connection.expiresAt) <= now.getTime())
711
934
  return false;
712
935
  this.db.prepare("UPDATE live_request_authority SET can_submit=0, submit_pending=1, lookup_deadline=? WHERE attempt_id=?")
713
936
  .run(new Date(now.getTime() + CHAIN_RECEIPT_LOOKUP_MAX_AGE_MS).toISOString(), id);
@@ -723,19 +946,26 @@ export class SqliteWalletWorkflowStore {
723
946
  }).immediate();
724
947
  }
725
948
  settle(id, field, pending) {
726
- this.db.prepare(`UPDATE live_request_authority SET ${field}=? WHERE attempt_id=? AND owner_id=?`).run(Number(pending), id, this.ownerId);
949
+ this.db.transaction(() => {
950
+ this.db.prepare(`UPDATE live_request_authority SET ${field}=? WHERE attempt_id=? AND owner_id=?`).run(Number(pending), id, this.ownerId);
951
+ if (field === "lookup_pending" && !pending) {
952
+ const request = this.request(id);
953
+ if (request && !isInitialChainObservation(request.requestStatus))
954
+ this.publish(id, true);
955
+ }
956
+ }).immediate();
727
957
  }
728
- stopWaiting(id, now, cause) {
958
+ stopWaiting(id, now, cause, excludedCardId) {
729
959
  return this.db.transaction(() => {
730
960
  const request = this.request(id), authority = this.authority(id);
731
961
  if (!request || !authority || authority.owner_id !== this.ownerId)
732
962
  return request;
733
963
  if (request.requestStatus === "awaiting_signature")
734
- return this.transitionRequest(id, cause === "wallet_service_lost" ? "request_failed" : "stopped", now, { reason: interruptionReasons[cause] });
964
+ return this.transitionRequest(id, cause === "wallet_service_lost" ? "request_failed" : "stopped", now, { reason: interruptionReasons[cause], ...(excludedCardId ? { excludedCardId } : {}) });
735
965
  if (cause === "user_stop" && authority.observation_stopped === 0 &&
736
966
  ["submitting", "awaiting_chain_result", "outcome_unknown"].includes(request.requestStatus)) {
737
967
  this.db.prepare("UPDATE live_request_authority SET observation_stopped=1 WHERE attempt_id=?").run(id);
738
- this.publish(id, false);
968
+ this.publish(id, false, excludedCardId);
739
969
  }
740
970
  return this.request(id);
741
971
  }).immediate();
@@ -775,11 +1005,20 @@ export class SqliteWalletWorkflowStore {
775
1005
  cancelInput(expected) {
776
1006
  this.db.transaction(() => {
777
1007
  const now = this.clock();
778
- const card = this.requireInput(expected, now);
779
- this.requireAction(card, "cancel", now);
780
- if (!this.cards.replace(card, { ...card, state: { ...card.state, state: "closed", reason: "cancelled", revision: card.state.revision + 1 } })) {
781
- throw new WorkflowConflict("Card changed before cancellation.");
1008
+ const card = this.currentCard(expected);
1009
+ if (card.scope === "connect" && card.state.state === "running" && card.state.revision === expected.state.revision) {
1010
+ const operation = this.cardOperation(card);
1011
+ if (operation?.status === "running") {
1012
+ this.stopConnection(card, operation.operationId);
1013
+ return;
1014
+ }
782
1015
  }
1016
+ this.requireInput(card, now);
1017
+ this.requireAction(card, "cancel", now);
1018
+ const pairing = card.scope === "connect" ? this.cardOperation(card) : undefined;
1019
+ if (pairing?.status === "running")
1020
+ this.stopConnection(card, pairing.operationId);
1021
+ this.closeCancelledCard(card);
783
1022
  }).immediate();
784
1023
  }
785
1024
  beginReviewPreparation(expected, reviewRevision, connection, account) {
@@ -795,19 +1034,25 @@ export class SqliteWalletWorkflowStore {
795
1034
  if (!review || review.preparation_id !== null || review.review_revision !== reviewRevision ||
796
1035
  review.status === "expired" || Date.parse(review.expires_at) <= now.getTime() ||
797
1036
  !this.isUsableConnection(connection.connectionId, now) || !isOwnedConnectedWallet(currentConnection, this.ownerId, !!this.pendingDisconnect(connection.connectionId)) ||
798
- currentConnection.connection.revision !== connection.revision || Date.parse(currentConnection.connection.expiresAt) <= now.getTime() ||
799
- (review.account && this.busyForAccount(review.account, now)))
1037
+ currentConnection.connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] !== connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN] || Date.parse(currentConnection.connection.expiresAt) <= now.getTime())
800
1038
  throw new WorkflowConflict("The review could not be updated with the current wallet and account.");
801
- const readAccount = this.connectionSelection(now).assetReadAccount;
802
- const selection = reviewPreparationAccount(review.account ?? undefined, readAccount.status === "available" ? readAccount.account : undefined, account);
803
- if (!selection.allowed)
804
- throw new WorkflowConflict(selection.message);
805
- this.requireAction(card, "prepare_review", now);
806
- if (this.busyForAccount(selection.account, now) || !currentConnection.connection.accounts.includes(selection.account))
807
- throw new WorkflowConflict("The review could not be updated with the selected wallet account.");
1039
+ if (review.wallet_connection_id && review.wallet_connection_id !== connection.connectionId) {
1040
+ throw new WorkflowConflict("This review remains bound to its original wallet connection.");
1041
+ }
1042
+ const binding = fixedReviewTarget({ ...(review.account ? { account: review.account } : {}),
1043
+ ...(review.wallet_connection_id ? { walletConnectionId: review.wallet_connection_id } : {}) }, card.state.input);
1044
+ if (!binding || binding.connectionId !== connection.connectionId || binding.account !== account)
1045
+ throw new WorkflowConflict("The review target changed before preparation.");
1046
+ if (this.busyForAccount(binding.account, now))
1047
+ throw new WorkflowConflict("Another wallet request or transaction result for this account is still pending.");
1048
+ const target = this.requireAction(card, "prepare_review", now).reviewTarget;
1049
+ if (target?.connectionId !== connection.connectionId || target.account !== account)
1050
+ throw new WorkflowConflict("The review target changed before preparation.");
1051
+ if (fixedChainAccount(currentConnection.connection, SUI_MAINNET_WALLET_CHAIN) !== binding.account)
1052
+ throw new WorkflowConflict("The review could not be updated with its fixed wallet account.");
808
1053
  const preparation = randomUUID();
809
- 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=?`)
810
- .run(preparation, this.runId ?? null, connection.connectionId, connection.revision, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
1054
+ this.db.prepare(`UPDATE live_review_sessions SET preparation_id=?,preparation_run_id=?,preparation_error=NULL,wallet_connection_id=?,wallet_grant_revision=?,revision=revision+1,write_contract_version=? WHERE id=?`)
1055
+ .run(preparation, this.runId ?? null, connection.connectionId, connection.grantRevisions[SUI_MAINNET_WALLET_CHAIN], LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
811
1056
  this.publishReview(id);
812
1057
  return preparation;
813
1058
  }).immediate();
@@ -856,7 +1101,7 @@ export class SqliteWalletWorkflowStore {
856
1101
  this.db.prepare("DELETE FROM live_transaction_materials WHERE review_session_id=?").run(review.id);
857
1102
  this.db.prepare("DELETE FROM live_private_review_artifacts WHERE review_session_id=?").run(review.id);
858
1103
  if (review.status !== "expired") {
859
- const account = review.account ? this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(review.account) : undefined;
1104
+ const account = review.account ? this.db.prepare("SELECT id FROM accounts WHERE chain_id='sui:mainnet' AND address=?").get(review.account) : undefined;
860
1105
  this.db.prepare(`INSERT INTO review_status_transitions(review_session_id,event,from_status,to_status,account_id,reason,transitioned_at)
861
1106
  VALUES (?,'expired',?,'expired',?,'server_restarted',?)`).run(review.id, review.status, account?.id ?? null, now.toISOString());
862
1107
  this.db.prepare("UPDATE review_sessions SET current_status='expired',updated_at=? WHERE id=?").run(now.toISOString(), review.id);
@@ -865,18 +1110,18 @@ export class SqliteWalletWorkflowStore {
865
1110
  this.db.prepare(`UPDATE live_review_sessions SET owner_id=?,status='expired',preparation_id=NULL,
866
1111
  revision=revision+1,write_contract_version=? WHERE owner_id!=?`)
867
1112
  .run(this.ownerId, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, this.ownerId);
868
- for (const record of this.connections()) {
869
- if (record.ownerId === this.ownerId)
870
- continue;
871
- const disconnect = this.pendingDisconnect(record.connection.connectionId, record.ownerId);
872
- if (disconnect || record.connection.status === "awaiting_approval") {
873
- const next = { ...record.connection, status: disconnect ? "failed" : "stopped", revision: record.connection.revision + 1,
874
- reason: disconnect ? "Server restarted before wallet disconnection was confirmed. Check the connection in your wallet app."
875
- : "Server restarted before wallet approval.", updatedAt: now.toISOString() };
876
- this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,status=?,revision=?,sdk_pending=0,connection_json=? WHERE id=?")
877
- .run(this.ownerId, next.status, next.revision, JSON.stringify(next), next.connectionId);
878
- if (disconnect)
879
- this.publishConnection(next.connectionId, next.status);
1113
+ for (const row of this.db.prepare("SELECT operation_id FROM wallet_connection_operations WHERE owner_id!=? AND status='running'")
1114
+ .all(this.ownerId)) {
1115
+ const op = this.operation(row.operation_id);
1116
+ this.finishConnectionOperation(op.operationId, "stopped", "The server restarted before this operation was confirmed.", now, "run_ended");
1117
+ if (op.connectionId) {
1118
+ const record = this.connection(op.connectionId);
1119
+ if (record) {
1120
+ const next = { ...record.connection, status: op.action === "connect" ? "stopped" : "failed",
1121
+ revision: record.connection.revision + 1, updatedAt: now.toISOString(), reason: "The server restarted before the wallet operation was confirmed." };
1122
+ this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,status=?,revision=?,connection_json=? WHERE id=?")
1123
+ .run(this.ownerId, next.status, next.revision, JSON.stringify(next), next.connectionId);
1124
+ }
880
1125
  }
881
1126
  }
882
1127
  const old = this.db.prepare("SELECT attempt_id FROM live_request_authority WHERE owner_id!=?").all(this.ownerId);
@@ -887,29 +1132,20 @@ export class SqliteWalletWorkflowStore {
887
1132
  this.transitionRequest(id, "outcome_unknown", now, { reason: "The server restarted. The request will not be sent again." });
888
1133
  }
889
1134
  }
1135
+ for (const { id } of this.db.prepare("SELECT id FROM live_read_cards WHERE kind IN ('connect','disconnect','review') AND state!='closed' AND owner_id!=?").all(this.ownerId)) {
1136
+ const card = this.cards.get(id);
1137
+ const next = { ...card, ownerId: this.ownerId, state: { ...card.state, state: "closed",
1138
+ reason: card.state.state === "ready" && Date.parse(card.state.expiresAt) <= now.getTime() ? "expired" : "server_restarted", revision: card.state.revision + 1 } };
1139
+ if (!this.replaceCard(card, next, now))
1140
+ throw new WorkflowConflict("The interrupted wallet card could not be saved.");
1141
+ }
890
1142
  }).immediate();
891
1143
  }
892
1144
  }
893
1145
  export function hasUnsettledWalletWork(db, now = new Date()) {
894
- return !!db.prepare("SELECT 1 FROM live_wallet_connections WHERE sdk_pending=1 LIMIT 1").get() ||
1146
+ return !!db.prepare("SELECT 1 FROM wallet_connection_operations WHERE sdk_pending=1 OR status='running' LIMIT 1").get() ||
895
1147
  !!db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
896
1148
  WHERE l.sdk_pending=1 OR l.submit_pending=1 OR l.lookup_pending=1
897
1149
  OR (r.request_status IN (SELECT value FROM json_each(?)) AND (l.lookup_deadline IS NULL OR l.lookup_deadline>?)) LIMIT 1`)
898
1150
  .get(JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
899
1151
  }
900
- export function readStoredTransactionRequest(db, id) {
901
- const row = db.prepare(`SELECT r.*, a.sui_address AS account, e.result_json FROM review_requests r
902
- JOIN accounts a ON a.id=r.account_id LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id
903
- WHERE r.attempt_id=?`).get(id);
904
- if (!row)
905
- return undefined;
906
- const result = transactionRequestSchema.parse({ attemptId: row.attempt_id, reviewSessionId: row.review_session_id,
907
- planId: row.plan_id, reviewRevision: row.review_revision, account: row.account, transactionDigest: row.transaction_digest,
908
- requestStatus: row.request_status, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at,
909
- ...(row.reason === null ? {} : { reason: row.reason }),
910
- ...(row.signature_verified_at === null ? {} : { signatureVerifiedAt: row.signature_verified_at }),
911
- ...(row.submitted_at === null ? {} : { submittedAt: row.submitted_at }),
912
- ...(row.result_json === null ? {} : { execution: JSON.parse(row.result_json) }) });
913
- assertNoForbiddenMcpFields(result);
914
- return result;
915
- }