@zktx.io/sui-mcp 0.5.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 (218) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/@mysten-bcs-2.0.5-Apache-2.0.txt +205 -0
  3. package/LICENSES/@mysten-sui-2.17.0-Apache-2.0.txt +201 -0
  4. package/LICENSES/@mysten-utils-0.3.3-Apache-2.0.txt +205 -0
  5. package/LICENSES/fancy-canvas-2.1.0-MIT.txt +7 -0
  6. package/LICENSES/lightweight-charts-5.2.0-NOTICE.txt +2 -0
  7. package/README.md +288 -0
  8. package/dist/adapters/adapterLifecycleValidators.js +5 -0
  9. package/dist/adapters/adapterPromptSurfaces.js +60 -0
  10. package/dist/adapters/deepbook/deepbookHumanReviewProducer.js +178 -0
  11. package/dist/adapters/deepbook/deepbookQuotePolicy.js +112 -0
  12. package/dist/adapters/deepbook/deepbookReviewEvidence.js +507 -0
  13. package/dist/adapters/deepbook/deepbookReviewLifecycle.js +85 -0
  14. package/dist/adapters/deepbook/deepbookSwapIntent.js +79 -0
  15. package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +254 -0
  16. package/dist/adapters/intentPlanFactories.js +51 -0
  17. package/dist/adapters/reviewAdapters.js +47 -0
  18. package/dist/core/action/adapterLifecycleValidation.js +12 -0
  19. package/dist/core/action/contractNameRegistry.js +151 -0
  20. package/dist/core/action/forbiddenFields.js +43 -0
  21. package/dist/core/action/humanReadableReviewEvidence.js +193 -0
  22. package/dist/core/action/humanReadableReviewProjectionVerifier.js +29 -0
  23. package/dist/core/action/ptbVisualizationProducer.js +71 -0
  24. package/dist/core/action/receiptPtbGraph.js +29 -0
  25. package/dist/core/action/reviewCheckResults.js +6 -0
  26. package/dist/core/action/reviewStateValidation.js +11 -0
  27. package/dist/core/action/reviewTimeSimulationEvidence.js +470 -0
  28. package/dist/core/action/schemas.js +491 -0
  29. package/dist/core/action/signableAdapterContract.js +998 -0
  30. package/dist/core/action/suiChainReceiptEvidence.js +106 -0
  31. package/dist/core/action/suiChainReceiptMapping.js +107 -0
  32. package/dist/core/action/suiChainReceiptReader.js +211 -0
  33. package/dist/core/action/suiChainReceiptVerifier.js +178 -0
  34. package/dist/core/action/swapHumanReadableReviewProjection.js +130 -0
  35. package/dist/core/action/swapQuotePolicyEvidence.js +271 -0
  36. package/dist/core/action/transactionFunding.js +244 -0
  37. package/dist/core/action/transactionObjectOwnershipEvidence.js +256 -0
  38. package/dist/core/action/transactionObjectOwnershipProducer.js +323 -0
  39. package/dist/core/action/types.js +33 -0
  40. package/dist/core/action/walletReviewContractAssembler.js +286 -0
  41. package/dist/core/activity/accountAssetTimeline.js +157 -0
  42. package/dist/core/activity/accountAssetTimelineUsdcReferences.js +120 -0
  43. package/dist/core/activity/activityStore.js +16 -0
  44. package/dist/core/activity/externalActivityCoverage.js +76 -0
  45. package/dist/core/activity/externalActivityTransactionStream.js +82 -0
  46. package/dist/core/activity/localDataService.js +296 -0
  47. package/dist/core/activity/localDataTypes.js +11 -0
  48. package/dist/core/activity/localDataValidation.js +439 -0
  49. package/dist/core/activity/schemaVersion.js +2 -0
  50. package/dist/core/activity/sqliteActivityStore.js +1068 -0
  51. package/dist/core/activity/sqliteActivityStoreRows.js +389 -0
  52. package/dist/core/activity/sqliteActivityStoreSchema.js +367 -0
  53. package/dist/core/activity/sqliteActivityStoreTypes.js +5 -0
  54. package/dist/core/activity/suiFunctionTarget.js +43 -0
  55. package/dist/core/activity/transactionActivityAccountEffects.js +186 -0
  56. package/dist/core/activity/transactionActivityAnalysis.js +295 -0
  57. package/dist/core/activity/transactionActivityClassifier.js +306 -0
  58. package/dist/core/activity/transactionActivityDetails.js +229 -0
  59. package/dist/core/activity/transactionActivityProtocolRules.js +218 -0
  60. package/dist/core/activity/transactionActivityScanPolicy.js +170 -0
  61. package/dist/core/activity/transactionActivityService.js +379 -0
  62. package/dist/core/activity/transactionActivityTypes.js +18 -0
  63. package/dist/core/eventlog/sink.js +35 -0
  64. package/dist/core/evidence/settlementFamilies.js +85 -0
  65. package/dist/core/evidence/userAnswerUse.js +1 -0
  66. package/dist/core/numeric/rawU64.js +63 -0
  67. package/dist/core/preferences/preferencesStore.js +26 -0
  68. package/dist/core/preferences/sqlitePreferencesRepository.js +136 -0
  69. package/dist/core/proposal/externalProposalReview.js +347 -0
  70. package/dist/core/proposal/schemas.js +208 -0
  71. package/dist/core/proposal/types.js +35 -0
  72. package/dist/core/read/amounts.js +14 -0
  73. package/dist/core/read/coinMetadata.js +60 -0
  74. package/dist/core/read/deepbookOfficialIndexerSource.js +373 -0
  75. package/dist/core/read/deepbookRawQuoteClient.js +86 -0
  76. package/dist/core/read/deepbookReadHelpers.js +321 -0
  77. package/dist/core/read/deepbookRegistry.js +157 -0
  78. package/dist/core/read/deepbookSourceOwners.js +159 -0
  79. package/dist/core/read/deepbookUsdcChartService.js +342 -0
  80. package/dist/core/read/intentEvidenceResponseFormatting.js +228 -0
  81. package/dist/core/read/readCardInputs.js +19 -0
  82. package/dist/core/read/readCardResult.js +40 -0
  83. package/dist/core/read/readResponseGuidance.js +402 -0
  84. package/dist/core/read/readService.js +1639 -0
  85. package/dist/core/read/readServiceTypes.js +88 -0
  86. package/dist/core/read/settlementParityFormatting.js +82 -0
  87. package/dist/core/read/walletReadHelpers.js +99 -0
  88. package/dist/core/review/reviewChecks.js +45 -0
  89. package/dist/core/review/reviewComputation.js +38 -0
  90. package/dist/core/review/reviewComputationResult.js +87 -0
  91. package/dist/core/session/cardSession.js +31 -0
  92. package/dist/core/session/cardSessionStore.js +204 -0
  93. package/dist/core/session/keyedRecordStore.js +18 -0
  94. package/dist/core/session/liveReviewSessionContract.js +3 -0
  95. package/dist/core/session/localSession.js +31 -0
  96. package/dist/core/session/privateReviewArtifacts.js +75 -0
  97. package/dist/core/session/reviewValidity.js +49 -0
  98. package/dist/core/session/sessionErrors.js +9 -0
  99. package/dist/core/session/sessionRecordStore.js +40 -0
  100. package/dist/core/session/sessionStore.js +655 -0
  101. package/dist/core/session/settingsSession.js +1 -0
  102. package/dist/core/session/settingsSessions.js +45 -0
  103. package/dist/core/session/sqliteCardStore.js +88 -0
  104. package/dist/core/session/sqliteSessionStore.js +213 -0
  105. package/dist/core/session/sqliteTransactionMaterialStore.js +64 -0
  106. package/dist/core/session/sqliteWalletWorkflowStore.js +821 -0
  107. package/dist/core/session/status.js +60 -0
  108. package/dist/core/session/transactionMaterialStore.js +221 -0
  109. package/dist/core/session/transactionRequest.js +72 -0
  110. package/dist/core/session/wait.js +79 -0
  111. package/dist/core/session/walletConnection.js +92 -0
  112. package/dist/core/session/walletRuntime.js +38 -0
  113. package/dist/core/session/walletWorkflow.js +608 -0
  114. package/dist/core/session/workflowState.js +80 -0
  115. package/dist/core/session/workflowView.js +150 -0
  116. package/dist/core/suiAddress.js +18 -0
  117. package/dist/core/suiEndpoint.js +72 -0
  118. package/dist/mcp/activeAccountResponse.js +24 -0
  119. package/dist/mcp/prompts.js +146 -0
  120. package/dist/mcp/registerTool.js +19 -0
  121. package/dist/mcp/resources.js +72 -0
  122. package/dist/mcp/responseGuidance.js +326 -0
  123. package/dist/mcp/result.js +17 -0
  124. package/dist/mcp/schemas.js +11 -0
  125. package/dist/mcp/server.js +32 -0
  126. package/dist/mcp/serverInfo.js +131 -0
  127. package/dist/mcp/toolErrors.js +118 -0
  128. package/dist/mcp/toolNames.js +58 -0
  129. package/dist/mcp/tools/account/index.js +44 -0
  130. package/dist/mcp/tools/action/prepareSuiActionReview.js +126 -0
  131. package/dist/mcp/tools/read/commonSchemas.js +43 -0
  132. package/dist/mcp/tools/read/deepbookReadTools.js +686 -0
  133. package/dist/mcp/tools/read/index.js +14 -0
  134. package/dist/mcp/tools/read/readToolHelpers.js +97 -0
  135. package/dist/mcp/tools/read/reviewActivityTools.js +179 -0
  136. package/dist/mcp/tools/read/serverStatusTools.js +100 -0
  137. package/dist/mcp/tools/read/transactionActivityOutput.js +371 -0
  138. package/dist/mcp/tools/read/transactionActivityTools.js +858 -0
  139. package/dist/mcp/tools/read/walletReadTools.js +734 -0
  140. package/dist/mcp/tools/session/executionResultTools.js +44 -0
  141. package/dist/mcp/tools/session/index.js +8 -0
  142. package/dist/mcp/tools/session/shared.js +36 -0
  143. package/dist/mcp/tools/session/statusTools.js +69 -0
  144. package/dist/mcp/tools/session/walletConnectionTools.js +69 -0
  145. package/dist/mcp/tools/settings/index.js +66 -0
  146. package/dist/mcp-app/account.html +3133 -0
  147. package/dist/mcp-app/account.notices.txt +973 -0
  148. package/dist/mcp-app/chart.html +3354 -0
  149. package/dist/mcp-app/chart.notices.txt +1194 -0
  150. package/dist/mcp-app/connect.html +3179 -0
  151. package/dist/mcp-app/connect.notices.txt +1012 -0
  152. package/dist/mcp-app/receipt.html +8430 -0
  153. package/dist/mcp-app/receipt.notices.txt +3549 -0
  154. package/dist/mcp-app/review.html +8430 -0
  155. package/dist/mcp-app/review.notices.txt +3549 -0
  156. package/dist/mcp-ui/contracts.js +31 -0
  157. package/dist/mcp-ui/readCards.js +28 -0
  158. package/dist/mcp-ui/tools.js +134 -0
  159. package/dist/review-app/brand-dark.svg +1 -0
  160. package/dist/review-app/brand-light.svg +1 -0
  161. package/dist/review-app/favicon.svg +1 -0
  162. package/dist/review-app/settings.css +1 -0
  163. package/dist/review-app/settings.js +1 -0
  164. package/dist/review-app/ui.css +809 -0
  165. package/dist/review-server/assets.js +48 -0
  166. package/dist/review-server/html.js +45 -0
  167. package/dist/review-server/http.js +47 -0
  168. package/dist/review-server/middleware/hostOrigin.js +48 -0
  169. package/dist/review-server/middleware/reviewToken.js +7 -0
  170. package/dist/review-server/reviewServerPolicy.js +1 -0
  171. package/dist/review-server/server.js +72 -0
  172. package/dist/review-server/settingsApi.js +158 -0
  173. package/dist/runtime/application.js +203 -0
  174. package/dist/runtime/config.js +97 -0
  175. package/dist/runtime/localSettingsService.js +198 -0
  176. package/dist/runtime/logger.js +66 -0
  177. package/dist/runtime/reviewDependencies.js +58 -0
  178. package/dist/runtime/reviewServerAcquire.js +93 -0
  179. package/dist/runtime/runtimeTermination.js +60 -0
  180. package/dist/runtime/shared/authenticatedFetch.js +127 -0
  181. package/dist/runtime/shared/control.js +93 -0
  182. package/dist/runtime/shared/dataAccess.js +66 -0
  183. package/dist/runtime/shared/mcpHttp.js +84 -0
  184. package/dist/runtime/shared/ownerLease.js +50 -0
  185. package/dist/runtime/shared/server.js +98 -0
  186. package/dist/runtime/shared/stdio.js +113 -0
  187. package/dist/runtime/smokeMainnetRead.js +391 -0
  188. package/dist/runtime/smokeMainnetReadAssertions.js +306 -0
  189. package/dist/runtime/smokeReview.js +40 -0
  190. package/dist/runtime/start.js +90 -0
  191. package/dist/runtime/suiEndpoint.js +97 -0
  192. package/dist/runtime/suiTransactionGraphqlMapping.js +200 -0
  193. package/dist/runtime/suiTransactionGraphqlQueries.js +231 -0
  194. package/dist/runtime/suiTransactionGraphqlSource.js +148 -0
  195. package/dist/runtime/walletConnectConfig.js +3 -0
  196. package/dist/runtime/walletConnectStorage.js +99 -0
  197. package/dist/runtime/walletConnectTransport.js +128 -0
  198. package/dist/runtime/walletSdkChild.js +3 -0
  199. package/dist/runtime/walletSdkChildRuntime.js +160 -0
  200. package/dist/runtime/walletSdkIpc.js +26 -0
  201. package/dist/runtime/walletSdkProcess.js +358 -0
  202. package/docs/AGENT_BEHAVIOR.md +524 -0
  203. package/docs/AGENT_DEVELOPMENT_POLICY.md +815 -0
  204. package/docs/FRONTEND_POLICY.md +534 -0
  205. package/docs/LOCAL_DB_ARCHITECTURE.md +381 -0
  206. package/docs/MCP_SETUP.md +742 -0
  207. package/docs/MCP_TOOLS.md +1318 -0
  208. package/docs/SDK_API.md +206 -0
  209. package/docs/SIGNABLE_ADAPTER_CONTRACT.md +396 -0
  210. package/docs/TRANSACTION_ACTIVITY_LOG.md +194 -0
  211. package/docs/UTILITY_INDEX.md +275 -0
  212. package/docs/WALLET_CONNECTION.md +238 -0
  213. package/docs/golden-scenarios/BEHAVIOR_MATRIX.md +166 -0
  214. package/docs/golden-scenarios/INTENT_EVIDENCE_GOLDEN_ANSWERS.md +161 -0
  215. package/docs/golden-scenarios/INTENT_EVIDENCE_MATRIX.md +259 -0
  216. package/package.json +79 -0
  217. package/protocols/deepbook-margin.md +15 -0
  218. package/protocols/deepbook-v3.md +24 -0
@@ -0,0 +1,821 @@
1
+ import { walletRecoverySchema } from "./walletRuntime.js";
2
+ import { materialStillMatches } from "./reviewValidity.js";
3
+ import { SqliteSessionRecordStore, SqlitePrivateReviewArtifactStore } from "./sqliteSessionStore.js";
4
+ import { reviewPreparationAccount, workflowEligibility } from "./workflowState.js";
5
+ import { SessionStoreError } from "./sessionErrors.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { isDeepStrictEqual } from "node:util";
8
+ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
9
+ import { reviewStateOutputSchema } from "../action/schemas.js";
10
+ import { LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION } from "./liveReviewSessionContract.js";
11
+ import { SqliteCardRecordStore } from "./sqliteCardStore.js";
12
+ import { receiptDisplaySchema } from "./cardSession.js";
13
+ import { isOwnedConnectedWallet, walletConnectionSelection, walletConnectionSchema, 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";
15
+ export class WorkflowConflict extends Error {
16
+ events;
17
+ constructor(message, events = []) {
18
+ super(message);
19
+ this.events = events;
20
+ }
21
+ }
22
+ const interruptionReasons = {
23
+ user_stop: "User stopped waiting before submission.",
24
+ 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
+ disconnect_requested: "Wallet disconnection was requested before submission. Nothing was submitted.",
27
+ wallet_service_lost: "The wallet service stopped before submission. Nothing was submitted."
28
+ };
29
+ const preparationInterruptionReasons = {
30
+ wallet_change: "The selected wallet connection changed.",
31
+ connection_conflict: "More than one wallet connection is present. Disconnect the connections you no longer need, then update this review.",
32
+ disconnect_requested: "Disconnection of the selected wallet was requested. Connect a wallet before updating this review.",
33
+ wallet_service_lost: "The wallet service stopped. Restore the wallet connection and update this review before requesting approval."
34
+ };
35
+ /** Purpose-specific mutations on the activity owner's connection. Each method
36
+ * commits card, request and public history together; no network work runs here. */
37
+ export class SqliteWalletWorkflowStore {
38
+ db;
39
+ ownerId;
40
+ writeActiveAccount;
41
+ clock;
42
+ materialStore;
43
+ evaluateState;
44
+ readActiveAccount;
45
+ cards;
46
+ constructor(db, ownerId, writeActiveAccount, clock, materialStore, evaluateState, readActiveAccount) {
47
+ this.db = db;
48
+ this.ownerId = ownerId;
49
+ this.writeActiveAccount = writeActiveAccount;
50
+ this.clock = clock;
51
+ this.materialStore = materialStore;
52
+ this.evaluateState = evaluateState;
53
+ this.readActiveAccount = readActiveAccount;
54
+ this.cards = new SqliteCardRecordStore(db);
55
+ }
56
+ evaluate(input) { return this.evaluateState(input); }
57
+ currentCard(expected) {
58
+ const current = this.cards.get(expected.state.cardId);
59
+ if (!current || current.tokenHash !== expected.tokenHash || current.scope !== expected.scope || current.ownerId !== expected.ownerId ||
60
+ !isDeepStrictEqual(current.state.input, expected.state.input))
61
+ throw new SessionStoreError("input_invalid", "Card access is unavailable.");
62
+ return current;
63
+ }
64
+ reviewReadTarget(reviewSessionId, expectedCard) {
65
+ const card = expectedCard && this.currentCard(expectedCard);
66
+ if (card && (card.state.kind !== "review" || card.state.input.reviewSessionId !== reviewSessionId)) {
67
+ throw new SessionStoreError("session_mismatch", "The card does not match this review.");
68
+ }
69
+ const exactId = card?.scope === "review_manage" ? String(card.state.input.attemptId) : card?.operationId;
70
+ const request = exactId ? this.request(exactId) : this.currentRequest(reviewSessionId);
71
+ if (exactId && !request || request && request.reviewSessionId !== reviewSessionId) {
72
+ throw new SessionStoreError("session_mismatch", "The request identity does not match this review.");
73
+ }
74
+ const liveReview = new SqliteSessionRecordStore(this.db).get(reviewSessionId);
75
+ const at = this.clock().getTime();
76
+ return { reviewSessionId, attemptId: request?.attemptId, walletDependent: !request || request.requestStatus === "awaiting_signature" ||
77
+ !!(card?.scope === "review" && card.state.state === "ready" && !card.operationId && Date.parse(card.state.expiresAt) > at &&
78
+ liveReview && liveReview.status !== "expired" && Date.parse(liveReview.expiresAt) > at) };
79
+ }
80
+ walletRecovery(record) {
81
+ if (record.acceptedInput?.action !== "restart_wallet_service")
82
+ return undefined;
83
+ const recovery = walletRecoverySchema.parse(record.state.data);
84
+ if (recovery.priorRunId !== record.acceptedInput.walletRunId || record.operationId !== undefined || record.scope !== "connect") {
85
+ throw new WorkflowConflict("The saved wallet recovery does not match this card.");
86
+ }
87
+ if (record.state.state === "closed" && record.state.reason === "server_restarted") {
88
+ return { kind: recovery.kind, priorRunId: recovery.priorRunId, admittedAt: recovery.admittedAt, updatedAt: recovery.updatedAt,
89
+ ...("nextRunId" in recovery ? { nextRunId: recovery.nextRunId } : {}), outcome: "server_restarted",
90
+ message: "The server restarted before this wallet recovery was confirmed." };
91
+ }
92
+ return recovery;
93
+ }
94
+ recordWalletFailure(input) {
95
+ // Retried by the workflow until revocation is durably recorded. The SDK
96
+ // run is already fenced, so a failed write cannot preserve usable authority.
97
+ this.invalidateWalletRun("wallet_service_lost", new Date(input.observedAt));
98
+ }
99
+ publishWalletState() { this.publishAvailableWallets(); }
100
+ applyWalletObservation(snapshot, previous, ready) {
101
+ this.db.transaction(() => {
102
+ for (const record of this.connections()) {
103
+ if (!record.topic || record.connection.status !== "connected")
104
+ continue;
105
+ const observed = snapshot.sessions.find((item) => item.topic === record.topic);
106
+ if (ready && observed?.status === "present" && !record.sdkPending && !this.pendingDisconnect(record.connection.connectionId, record.ownerId)) {
107
+ this.restoreConnection(observed.session, this.clock());
108
+ }
109
+ else if (record.ownerId === this.ownerId) {
110
+ if (!observed || observed.status === "absent")
111
+ this.applyConnectionChange(record.connection.connectionId, { status: "disconnected", reason: "The wallet session is no longer present in the connection service." }, this.clock());
112
+ else if (observed.status === "unusable")
113
+ this.applyConnectionChange(record.connection.connectionId, { status: "failed", reason: "This wallet session is no longer usable for Sui mainnet." }, this.clock());
114
+ else if (observed.status === "present" && previous?.sessions.find((item) => item.topic === record.topic)?.version !== observed.version) {
115
+ this.applyConnectionChange(record.connection.connectionId, { accounts: observed.session.accounts,
116
+ methods: observed.session.methods, expiresAt: observed.session.expiresAt, walletName: observed.session.walletName }, this.clock());
117
+ }
118
+ }
119
+ }
120
+ this.invalidateConflictingConnections(this.clock());
121
+ if (ready)
122
+ this.publishAvailableWallets();
123
+ }).immediate();
124
+ }
125
+ invalidateWalletRun(cause, now = this.clock()) {
126
+ this.db.transaction(() => {
127
+ for (const record of this.connections()) {
128
+ if (record.ownerId !== this.ownerId)
129
+ continue;
130
+ this.invalidateConnectionRequests(record.connection.connectionId, now, cause);
131
+ if (["connected", "awaiting_approval"].includes(record.connection.status))
132
+ this.updateConnection(record.connection.connectionId, {}, now);
133
+ }
134
+ this.publishAvailableWallets();
135
+ }).immediate();
136
+ }
137
+ connection(id) {
138
+ const row = this.db.prepare("SELECT * FROM live_wallet_connections WHERE id=?").get(id);
139
+ return row ? { connection: walletConnectionSchema.parse(JSON.parse(row.connection_json)), ownerId: row.owner_id,
140
+ sdkPending: row.sdk_pending === 1, ...(row.topic === null ? {} : { topic: row.topic }) } : undefined;
141
+ }
142
+ connections() {
143
+ return this.db.prepare("SELECT id FROM live_wallet_connections ORDER BY id").all()
144
+ .map(({ id }) => this.connection(id));
145
+ }
146
+ connectionView(connection) {
147
+ return { ...connection, ...(this.pendingDisconnect(connection.connectionId) ? { pendingAction: "disconnect" } : {}) };
148
+ }
149
+ connectionViews() {
150
+ return this.connections().filter((item) => item.ownerId === this.ownerId).map((item) => this.connectionView(item.connection));
151
+ }
152
+ connectionSelection(now = this.clock()) {
153
+ return walletConnectionSelection(this.connectionViews(), now.getTime(), this.readActiveAccount());
154
+ }
155
+ isUsableConnection(id, now = this.clock()) {
156
+ return this.connectionSelection(now).connection?.connectionId === id;
157
+ }
158
+ invalidateConflictingConnections(now) {
159
+ const conflict = this.connectionSelection(now).conflict;
160
+ if (conflict)
161
+ for (const id of conflict.connectionIds)
162
+ this.invalidateConnectionRequests(id, now, "connection_conflict");
163
+ }
164
+ settleConnection(id, source) {
165
+ this.db.transaction(() => {
166
+ const card = this.cards.get(source.cardId);
167
+ if (!card || card.ownerId !== this.ownerId || card.operationId !== id || card.acceptedInput?.action !== source.action ||
168
+ card.acceptedInput.walletRunId !== source.runId)
169
+ return;
170
+ // A connection may progress from pairing to one admitted disconnect.
171
+ // That later operation owns sdk_pending even if its result is already
172
+ // visible and the original pairing's failed cleanup write arrives late.
173
+ const disconnect = this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect' AND operation_id=?
174
+ AND json_extract(accepted_input_json,'$.action')='disconnect'`).get(this.ownerId, id);
175
+ if (disconnect && disconnect.id !== source.cardId)
176
+ return;
177
+ const record = this.connection(id);
178
+ if (!record || record.ownerId !== this.ownerId)
179
+ return;
180
+ if (source.action === "connect" && record.connection.status === "awaiting_approval") {
181
+ const status = source.unrecordedOutcome ?? "failed";
182
+ this.updateConnection(id, { status, reason: status === "stopped" ? "The wallet service ended before this connection was confirmed."
183
+ : status === "rejected" ? "The wallet declined the connection request."
184
+ : "The wallet connection could not be recorded. Check any retained connection in your wallet app." }, this.clock(), undefined, false);
185
+ }
186
+ else
187
+ this.db.prepare("UPDATE live_wallet_connections SET sdk_pending=0 WHERE id=? AND owner_id=?").run(id, this.ownerId);
188
+ }).immediate();
189
+ }
190
+ pendingDisconnect(connectionId, ownerId = this.ownerId) {
191
+ const row = this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND scope='connect'
192
+ AND state='running' AND operation_id=? AND json_extract(accepted_input_json,'$.action')='disconnect'`)
193
+ .get(ownerId, connectionId);
194
+ return row ? this.cards.get(row.id) : undefined;
195
+ }
196
+ pendingConnectionCards(now) {
197
+ return this.db.prepare(`SELECT id FROM live_read_cards WHERE owner_id=? AND kind='connect'
198
+ AND state!='closed' AND (state!='ready' OR expires_at>?) ORDER BY created_at DESC,id DESC`)
199
+ .all(this.ownerId, now.toISOString()).map(({ id }) => this.cards.get(id));
200
+ }
201
+ request(id) { return readStoredTransactionRequest(this.db, id); }
202
+ requestForRevision(id, revision) {
203
+ const row = this.db.prepare("SELECT attempt_id FROM review_requests WHERE review_session_id=? AND review_revision=?")
204
+ .get(id, revision);
205
+ return row ? this.request(row.attempt_id) : undefined;
206
+ }
207
+ currentRequest(reviewSessionId) {
208
+ const row = this.db.prepare("SELECT current_attempt_id FROM review_sessions WHERE id=?").get(reviewSessionId);
209
+ return row?.current_attempt_id ? this.request(row.current_attempt_id) : undefined;
210
+ }
211
+ requestReview(id) {
212
+ const row = this.db.prepare("SELECT review_state_json FROM review_requests WHERE attempt_id=?").get(id);
213
+ return row ? reviewStateOutputSchema.parse(JSON.parse(row.review_state_json)) : undefined;
214
+ }
215
+ authority(id) {
216
+ return this.db.prepare("SELECT * FROM live_request_authority WHERE attempt_id=?").get(id);
217
+ }
218
+ executionDetails(id) {
219
+ const row = this.db.prepare("SELECT model_json,display_json FROM live_execution_details WHERE attempt_id=?").get(id);
220
+ if (!row)
221
+ return undefined;
222
+ const data = JSON.parse(row.model_json);
223
+ assertNoForbiddenMcpFields(data);
224
+ return { data, ...(row.display_json === null ? {} : { receiptDisplay: receiptDisplaySchema.parse(JSON.parse(row.display_json)) }) };
225
+ }
226
+ requireInput(expected, now) {
227
+ const current = this.cards.get(expected.state.cardId);
228
+ if (!current || current.ownerId !== this.ownerId || current.tokenHash !== expected.tokenHash ||
229
+ current.scope !== expected.scope || !isDeepStrictEqual(current.state.input, expected.state.input) ||
230
+ current.state.revision !== expected.state.revision || current.state.state !== "ready" || current.acceptedInput !== undefined ||
231
+ Date.parse(current.state.expiresAt) <= now.getTime())
232
+ throw new WorkflowConflict("This card input is no longer available.");
233
+ return current;
234
+ }
235
+ requireAction(card, action, now) {
236
+ const session = card.state.kind === "review" ? new SqliteSessionRecordStore(this.db).get(String(card.state.input.reviewSessionId)) : undefined;
237
+ const target = session ? this.reviewReadTarget(session.id, card) : undefined;
238
+ const request = target?.attemptId ? this.request(target.attemptId) : undefined;
239
+ if (card.state.kind === "review" && !session)
240
+ throw new WorkflowConflict("The saved review is unavailable.");
241
+ const connection = card.state.kind === "connect" && card.operationId ? this.connection(card.operationId)?.connection : undefined;
242
+ // Wallet-dependent commands already passed the workflow availability guard.
243
+ // This store checks DB eligibility; it does not measure SDK health.
244
+ const storedAccount = this.readActiveAccount(), activeAccount = storedAccount?.address, targetAccount = session?.account ?? activeAccount;
245
+ const facts = { evaluatedAt: now.toISOString(), ownerId: this.ownerId, record: card, session, request,
246
+ authority: request ? this.authority(request.attemptId) : undefined, connection, connections: this.connectionViews(),
247
+ walletAvailability: { status: "available" }, activeAccount, activeAccountConnectionId: storedAccount?.walletId,
248
+ busyForAccount: !!targetAccount && this.busyForAccount(targetAccount, now) };
249
+ if (!workflowEligibility(facts).allowedActions.includes(action))
250
+ throw new WorkflowConflict("That action is not available right now.");
251
+ }
252
+ manage(expected, action) {
253
+ return this.db.transaction(() => {
254
+ const now = this.clock(), card = this.cards.get(expected.state.cardId);
255
+ if (!card || card.ownerId !== this.ownerId || card.tokenHash !== expected.tokenHash ||
256
+ card.state.revision !== expected.state.revision || card.scope !== expected.scope)
257
+ throw new WorkflowConflict("This request does not match the card's current state.");
258
+ const id = card.scope === "review_manage" ? String(card.state.input.attemptId) : card.operationId;
259
+ if (action !== "stop_connection")
260
+ this.advanceRequestDeadlines(now, id);
261
+ this.requireAction(card, action, now);
262
+ if (action === "stop_connection")
263
+ this.updateConnection(id, { status: "stopped", reason: "The connection request was stopped here. The approval screen may still be open in your wallet." }, now);
264
+ else if (action === "stop_waiting")
265
+ this.stopWaiting(id, now, "user_stop");
266
+ else
267
+ this.resumeObservation(id);
268
+ return id;
269
+ }).immediate();
270
+ }
271
+ expireConnections(now) {
272
+ for (const record of this.connections())
273
+ if (record.ownerId === this.ownerId && record.connection.status === "awaiting_approval" &&
274
+ Date.parse(record.connection.expiresAt) <= now.getTime())
275
+ this.updateConnection(record.connection.connectionId, { status: "expired", reason: "The time to approve this connection has ended." }, now);
276
+ }
277
+ beginObservation(id, explicit) {
278
+ return this.db.transaction(() => {
279
+ const now = this.clock();
280
+ this.advanceRequestDeadlines(now, id);
281
+ const request = this.request(id), authority = this.authority(id);
282
+ if (!request || !authority || authority.owner_id !== this.ownerId || authority.lookup_pending === 1 ||
283
+ authority.observation_stopped && !explicit ||
284
+ !(isInitialChainObservation(request.requestStatus) || explicit && request.requestStatus === "outcome_unknown"))
285
+ return undefined;
286
+ if (isInitialChainObservation(request.requestStatus) && !authority.lookup_deadline) {
287
+ throw new WorkflowConflict("The saved transaction observation deadline is unavailable.");
288
+ }
289
+ this.settle(id, "lookup_pending", true);
290
+ return request;
291
+ }).immediate();
292
+ }
293
+ consume(card, input, operationId, terminal = false) {
294
+ const next = { ...card, acceptedInput: input, ...(operationId ? { operationId } : {}), state: {
295
+ ...card.state, state: terminal ? "closed" : "running", revision: card.state.revision + 1,
296
+ ...(terminal ? { reason: "completed" } : {})
297
+ } };
298
+ if (!this.cards.replace(card, next))
299
+ throw new WorkflowConflict("The card changed before this action was accepted.");
300
+ }
301
+ publish(operationId, terminal) {
302
+ this.db.prepare(`UPDATE live_read_cards SET revision=revision+1,
303
+ state=CASE WHEN @terminal=1 THEN 'closed' ELSE state END,
304
+ reason=CASE WHEN @terminal=1 THEN 'completed' ELSE reason END
305
+ WHERE operation_id=@id`).run({ terminal: terminal ? 1 : 0, id: operationId });
306
+ }
307
+ publishConnection(connectionId, status) {
308
+ // Connection updates are not completion of every operation referencing it.
309
+ // In particular, session_update(connected) must not finish a pending disconnect.
310
+ this.db.prepare(`UPDATE live_read_cards SET revision=revision+1,
311
+ reason=CASE WHEN state='running' AND
312
+ CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
313
+ THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'completed' ELSE reason END,
314
+ state=CASE WHEN state='running' AND
315
+ CASE WHEN json_extract(accepted_input_json,'$.action')='disconnect'
316
+ THEN @disconnectTerminal ELSE @connectionTerminal END THEN 'closed' ELSE state END
317
+ WHERE scope='connect' AND operation_id=@id`).run({ id: connectionId,
318
+ disconnectTerminal: Number(status !== "connected" && status !== "awaiting_approval"),
319
+ connectionTerminal: Number(status !== "awaiting_approval") });
320
+ }
321
+ publishAvailableWallets() {
322
+ this.db.prepare("UPDATE live_read_cards SET revision=revision+1 WHERE owner_id=? AND kind IN ('connect','review') AND state='ready'").run(this.ownerId);
323
+ }
324
+ publishReview(id) {
325
+ this.db.prepare(`UPDATE live_read_cards SET revision=revision+1 WHERE owner_id=? AND kind='review' AND state='ready'
326
+ AND json_extract(input_json,'$.reviewSessionId')=?`).run(this.ownerId, id);
327
+ }
328
+ admitConnection(expected, input) {
329
+ return this.db.transaction(() => {
330
+ const now = this.clock();
331
+ this.expireConnections(now);
332
+ const card = this.requireInput(expected, now);
333
+ this.requireAction(card, "connect", now);
334
+ const connection = walletConnectionSchema.parse({ connectionId: randomUUID(), status: "awaiting_approval", revision: 0,
335
+ accounts: [], methods: [], chain: SUI_MAINNET_WALLET_CHAIN, createdAt: now.toISOString(), updatedAt: now.toISOString(),
336
+ expiresAt: card.state.expiresAt });
337
+ this.db.prepare("INSERT INTO live_wallet_connections VALUES (?,?,?,?,?,?,?)")
338
+ .run(connection.connectionId, this.ownerId, connection.status, 0, null, 1, JSON.stringify(connection));
339
+ this.consume(card, input, connection.connectionId);
340
+ // Every other live input observes the admitted connection before the SDK
341
+ // responds, so an older selection cannot bypass the one-connection rule.
342
+ this.publishAvailableWallets();
343
+ return this.connection(connection.connectionId);
344
+ }).immediate();
345
+ }
346
+ updateConnection(id, update, now, topic, sdkPending) {
347
+ return this.db.transaction(() => {
348
+ const old = this.connection(id);
349
+ if (!old || old.ownerId !== this.ownerId)
350
+ return undefined;
351
+ const next = walletConnectionSchema.parse({ ...old.connection, ...update,
352
+ connectionId: id, revision: old.connection.revision + 1, updatedAt: now.toISOString() });
353
+ assertNoForbiddenMcpFields(next);
354
+ this.db.prepare(`UPDATE live_wallet_connections SET status=?, revision=?, topic=?, sdk_pending=?, connection_json=?
355
+ 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);
356
+ this.invalidateConflictingConnections(now);
357
+ this.publishConnection(id, next.status);
358
+ this.publishAvailableWallets();
359
+ if (next.status === "connected" && next.accounts.length === 1 && old.connection.status === "awaiting_approval" && this.isUsableConnection(id, now)) {
360
+ this.writeActiveAccount(next.accounts[0], id, next.walletName, now);
361
+ }
362
+ return this.connection(id);
363
+ }).immediate();
364
+ }
365
+ applyConnectionChange(id, update, now) {
366
+ this.db.transaction(() => {
367
+ this.invalidateConnectionRequests(id, now, "wallet_change");
368
+ this.updateConnection(id, update, now);
369
+ }).immediate();
370
+ }
371
+ restoreConnection(session, now) {
372
+ const existing = this.db.prepare("SELECT id FROM live_wallet_connections WHERE topic=?").get(session.topic);
373
+ const id = existing?.id ?? randomUUID();
374
+ const prior = existing ? this.connection(id) : undefined;
375
+ const next = walletConnectionSchema.parse({ connectionId: id, status: "connected", revision: (prior?.connection.revision ?? -1) + 1,
376
+ accounts: session.accounts, methods: session.methods, chain: session.chain,
377
+ createdAt: prior?.connection.createdAt ?? now.toISOString(), updatedAt: now.toISOString(),
378
+ expiresAt: session.expiresAt, walletName: session.walletName });
379
+ this.db.prepare(`INSERT INTO live_wallet_connections VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
380
+ owner_id=excluded.owner_id, status=excluded.status, revision=excluded.revision, topic=excluded.topic,
381
+ sdk_pending=0, connection_json=excluded.connection_json`).run(id, this.ownerId, next.status, next.revision, session.topic, 0, JSON.stringify(next));
382
+ this.publishConnection(id, next.status);
383
+ // Restoring the SDK session never restores a cleared read-account context.
384
+ return this.connection(id);
385
+ }
386
+ useAccount(expected, input, connectionId, account) {
387
+ this.db.transaction(() => {
388
+ const now = this.clock();
389
+ const card = this.requireInput(expected, now), connection = this.connection(connectionId);
390
+ if (card.scope !== "connect" || !this.isUsableConnection(connectionId, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(connectionId)) ||
391
+ Date.parse(connection.connection.expiresAt) <= now.getTime() || !connection.connection.accounts.includes(account)) {
392
+ throw new WorkflowConflict("The selected wallet account is no longer available.");
393
+ }
394
+ this.requireAction(card, "use_account", now);
395
+ this.writeActiveAccount(account, connectionId, connection.connection.walletName, now);
396
+ this.consume(card, input, connectionId, true);
397
+ this.publishAvailableWallets();
398
+ }).immediate();
399
+ }
400
+ admitDisconnect(expected, input, connectionId) {
401
+ return this.db.transaction(() => {
402
+ const now = this.clock();
403
+ const card = this.requireInput(expected, now), connection = this.connection(connectionId);
404
+ if (card.scope !== "connect" || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(connectionId))) {
405
+ throw new WorkflowConflict("The wallet connection is no longer available.");
406
+ }
407
+ this.requireAction(card, "disconnect", now);
408
+ this.consume(card, input, connectionId);
409
+ this.db.prepare("UPDATE live_wallet_connections SET sdk_pending=1 WHERE id=? AND owner_id=?").run(connectionId, this.ownerId);
410
+ this.invalidateConnectionRequests(connectionId, now, "disconnect_requested");
411
+ this.publishAvailableWallets();
412
+ return connection;
413
+ }).immediate();
414
+ }
415
+ finishDisconnect(cardId, connectionId, succeeded, now) {
416
+ this.db.transaction(() => {
417
+ const pending = this.pendingDisconnect(connectionId);
418
+ // A session deletion event may have already completed this operation.
419
+ if (pending?.state.cardId !== cardId)
420
+ return;
421
+ this.updateConnection(connectionId, succeeded ? { status: "disconnected" } : {
422
+ status: "failed", reason: "The wallet disconnection could not be confirmed. The connection may remain in your wallet app."
423
+ }, now);
424
+ }).immediate();
425
+ }
426
+ recoverDisconnect(cardId, connectionId, revision, sessionAbsent) {
427
+ this.db.transaction(() => {
428
+ const connection = this.connection(connectionId);
429
+ if (!connection || connection.ownerId !== this.ownerId || connection.sdkPending ||
430
+ connection.connection.revision !== revision || this.pendingDisconnect(connectionId)?.state.cardId !== cardId)
431
+ return;
432
+ this.finishDisconnect(cardId, connectionId, sessionAbsent, this.clock());
433
+ }).immediate();
434
+ }
435
+ recoverRequestAt(expected, input, binding, now) {
436
+ const request = this.requestForRevision(binding.reviewSessionId, binding.reviewRevision);
437
+ if (!request)
438
+ return undefined;
439
+ const card = this.requireInput(expected, now), authority = this.authority(request.attemptId);
440
+ if (card.scope !== "review" || card.state.input.reviewSessionId !== binding.reviewSessionId ||
441
+ request.account !== binding.account || !authority || authority.owner_id !== this.ownerId || authority.connection_id !== binding.connectionId) {
442
+ throw new WorkflowConflict("This review already has a request for a different selection.");
443
+ }
444
+ const sameRun = typeof input.walletRunId === "string" && this.db.prepare(`SELECT 1 FROM live_read_cards
445
+ WHERE owner_id=? AND scope='review' AND operation_id=?
446
+ AND json_extract(accepted_input_json,'$.action')='request_signature'
447
+ AND json_extract(accepted_input_json,'$.walletRunId')=? LIMIT 1`).get(this.ownerId, request.attemptId, input.walletRunId);
448
+ if (!sameRun)
449
+ throw new WorkflowConflict("This request belongs to an earlier wallet service run. Check its saved result instead.");
450
+ this.consume(card, input, request.attemptId, !["awaiting_signature", "submitting", "awaiting_chain_result"].includes(request.requestStatus));
451
+ return { request, created: false };
452
+ }
453
+ recoverRequest(expected, input, binding) {
454
+ return this.db.transaction(() => this.recoverRequestAt(expected, input, binding, this.clock())).immediate();
455
+ }
456
+ admitRequest(expected, input, material, selectedConnection, walletRunId) {
457
+ const result = this.db.transaction(() => {
458
+ const now = this.clock();
459
+ const binding = { reviewSessionId: material.reviewSessionId, planId: material.planId, account: material.account,
460
+ reviewRevision: material.reviewRevision, transactionDigest: material.reviewedTransactionDigest,
461
+ connectionId: selectedConnection.connectionId, connectionRevision: selectedConnection.revision };
462
+ const recovered = this.recoverRequestAt(expected, input, binding, now);
463
+ if (recovered)
464
+ return recovered;
465
+ const card = this.requireInput(expected, now);
466
+ if (card.scope !== "review" || card.state.input.reviewSessionId !== material.reviewSessionId)
467
+ throw new WorkflowConflict("The request does not belong to this review card.");
468
+ const sessions = new SqliteSessionRecordStore(this.db), current = sessions.get(material.reviewSessionId);
469
+ const artifacts = new SqlitePrivateReviewArtifactStore(this.db).get(material.reviewSessionId);
470
+ const stored = this.materialStore.getTransactionMaterial(material.transactionMaterial, now);
471
+ if (!current || !materialStillMatches(material, current, sessions.revision(current.id), artifacts, stored, now)) {
472
+ // Expected expiry commits its normal cleanup, but no request authority.
473
+ // A changed candidate is a conflict and must not invalidate newer data.
474
+ const evaluated = this.evaluateState({ expectedCard: card, candidate: { session: material.review, rowRevision: material.rowRevision,
475
+ artifacts: material.artifacts, material }, walletAvailability: { status: "available", walletRunId } }, now);
476
+ return new WorkflowConflict("The transaction details used by this review are no longer current.", evaluated.events);
477
+ }
478
+ const review = this.db.prepare("SELECT * FROM live_review_sessions WHERE id=?").get(binding.reviewSessionId);
479
+ const state = review?.review_state_json ? JSON.parse(review.review_state_json) : undefined;
480
+ const data = state?.transactionReviewData;
481
+ const connection = this.connection(binding.connectionId);
482
+ if (!review || review.status !== "ready_for_wallet_review" || review.preparation_id !== null || review.account !== binding.account ||
483
+ review.review_revision !== binding.reviewRevision || state?.planId !== binding.planId ||
484
+ data?.reviewedTransactionDigest !== binding.transactionDigest || Date.parse(review.expires_at) <= now.getTime() ||
485
+ !this.isUsableConnection(binding.connectionId, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(binding.connectionId)) ||
486
+ connection.connection.revision !== binding.connectionRevision || Date.parse(connection.connection.expiresAt) <= now.getTime() ||
487
+ !connection.connection.accounts.includes(binding.account) || !connection.connection.methods.includes(SUI_SIGN_TRANSACTION_METHOD)) {
488
+ throw new WorkflowConflict("The account, wallet connection or transaction changed since this review was prepared.");
489
+ }
490
+ this.requireAction(card, "request_signature", now);
491
+ if (this.busyForAccount(binding.account, now))
492
+ throw new WorkflowConflict("Another wallet request or transaction result for this account is still pending.");
493
+ const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(binding.account);
494
+ if (!account)
495
+ throw new WorkflowConflict("The reviewed account is unavailable.");
496
+ const id = randomUUID();
497
+ this.db.prepare(`INSERT INTO review_requests
498
+ (attempt_id,review_session_id,plan_id,review_revision,account_id,transaction_digest,review_state_json,request_status,revision,created_at,updated_at)
499
+ VALUES (?,?,?,?,?,?,?,'awaiting_signature',0,?,?)`).run(id, review.id, binding.planId, binding.reviewRevision, account.id, binding.transactionDigest, review.review_state_json, now.toISOString(), now.toISOString());
500
+ this.db.prepare(`INSERT INTO live_request_authority
501
+ (attempt_id,owner_id,connection_id,connection_revision,can_submit,sdk_pending,submit_pending,lookup_pending,signature_deadline)
502
+ VALUES (?,?,?,?,1,1,0,0,?)`).run(id, this.ownerId, binding.connectionId, binding.connectionRevision, new Date(Math.min(now.getTime() + SIGNATURE_WAIT_MS, Date.parse(review.expires_at))).toISOString());
503
+ this.db.prepare(`UPDATE live_review_sessions SET current_attempt_id=?, revision=revision+1, write_contract_version=? WHERE id=?`)
504
+ .run(id, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, review.id);
505
+ this.db.prepare("UPDATE review_sessions SET current_attempt_id=?, updated_at=? WHERE id=?").run(id, now.toISOString(), review.id);
506
+ this.consume(card, input, id);
507
+ this.recordRequestEvent(this.request(id), "request_admitted", undefined, now);
508
+ this.publishAvailableWallets();
509
+ return { request: this.request(id), created: true };
510
+ }).immediate();
511
+ if (result instanceof Error)
512
+ throw result;
513
+ return result;
514
+ }
515
+ busyForAccount(account, now) {
516
+ return !!this.db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
517
+ 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
518
+ OR (r.request_status IN (SELECT value FROM json_each(?)) AND l.observation_stopped=0
519
+ AND (l.lookup_deadline IS NULL OR l.lookup_deadline>?))) LIMIT 1`)
520
+ .get(account, JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
521
+ }
522
+ assertDataReplacementAllowed(now = new Date()) {
523
+ this.db.transaction(() => {
524
+ this.advanceRequestDeadlines(now);
525
+ if (hasUnsettledWalletWork(this.db, now))
526
+ throw new WorkflowConflict("Local data cannot be replaced while a wallet request or its initial chain observation is unsettled.");
527
+ }).immediate();
528
+ }
529
+ advanceRequestDeadlines(now, attemptId) {
530
+ this.db.transaction(() => {
531
+ const expired = this.db.prepare(`SELECT r.attempt_id,r.request_status FROM review_requests r
532
+ JOIN live_request_authority l ON l.attempt_id=r.attempt_id WHERE l.owner_id=@owner
533
+ AND (@attempt IS NULL OR r.attempt_id=@attempt)
534
+ AND ((r.request_status='awaiting_signature' AND l.signature_deadline<=@now)
535
+ OR (r.request_status IN (SELECT value FROM json_each(@chainStates)) AND l.lookup_deadline<=@now))`)
536
+ .all({ owner: this.ownerId, attempt: attemptId ?? null, now: now.toISOString(),
537
+ chainStates: JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES) });
538
+ for (const row of expired) {
539
+ this.transitionRequest(row.attempt_id, row.request_status === "awaiting_signature" ? "request_failed" : "outcome_unknown", now, {
540
+ reason: row.request_status === "awaiting_signature"
541
+ ? "Local signing wait expired. The wallet request may still be open."
542
+ : "No chain result was confirmed within the initial observation window."
543
+ });
544
+ }
545
+ }).immediate();
546
+ }
547
+ recordRequestEvent(request, event, from, now) {
548
+ const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(request.account);
549
+ this.db.prepare(`INSERT INTO review_status_transitions
550
+ (review_session_id,event,attempt_id,domain,from_status,to_status,account_id,reason,transitioned_at)
551
+ VALUES (?,?,?,'request',?,?,?,?,?)`).run(request.reviewSessionId, event, request.attemptId, from ?? null, request.requestStatus, account.id, request.reason ?? null, now.toISOString());
552
+ }
553
+ transitionRequest(id, next, now, options = {}) {
554
+ return this.db.transaction(() => {
555
+ const old = this.request(id), authority = this.authority(id);
556
+ if (!old || !authority || authority.owner_id !== this.ownerId)
557
+ return undefined;
558
+ if (old.requestStatus === next && (!options.signatureVerified || old.signatureVerifiedAt !== undefined))
559
+ return old;
560
+ if (old.requestStatus !== next && !requestTransitions[old.requestStatus].includes(next))
561
+ return old;
562
+ if (next === "completed") {
563
+ const execution = transactionExecutionSummarySchema.parse(options.execution);
564
+ if (execution.attemptId !== id || execution.reviewSessionId !== old.reviewSessionId || execution.planId !== old.planId ||
565
+ execution.txDigest !== old.transactionDigest || execution.chainReceipt.sender !== old.account) {
566
+ throw new WorkflowConflict("Chain result does not match the admitted transaction.");
567
+ }
568
+ const account = this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(old.account);
569
+ this.db.prepare(`INSERT INTO review_executions
570
+ (attempt_id,review_session_id,plan_id,account_id,status,tx_digest,failure_reason,result_json,recorded_at,updated_at)
571
+ VALUES (?,?,?,?,?,?,?,?,?,?)`).run(id, old.reviewSessionId, old.planId, account.id, execution.status, execution.txDigest, execution.failureReason ?? null, JSON.stringify(execution), now.toISOString(), now.toISOString());
572
+ if (options.details) {
573
+ assertNoForbiddenMcpFields(options.details.data);
574
+ const display = options.details.receiptDisplay ? receiptDisplaySchema.parse(options.details.receiptDisplay) : undefined;
575
+ if (display && display.transactionDigest !== old.transactionDigest)
576
+ throw new WorkflowConflict("Receipt display digest differs from the request.");
577
+ this.db.prepare("INSERT INTO live_execution_details VALUES (?,?,?)")
578
+ .run(id, JSON.stringify(options.details.data), display ? JSON.stringify(display) : null);
579
+ }
580
+ }
581
+ this.db.prepare(`UPDATE review_requests SET request_status=?, reason=?, revision=revision+1, updated_at=?,
582
+ signature_verified_at=COALESCE(signature_verified_at,?), submitted_at=COALESCE(submitted_at,?) WHERE attempt_id=? AND revision=?`)
583
+ .run(next, options.reason ?? old.reason ?? null, now.toISOString(), options.signatureVerified ? now.toISOString() : null, next === "submitting" ? now.toISOString() : null, id, old.revision);
584
+ if (next !== "awaiting_signature")
585
+ this.db.prepare("UPDATE live_request_authority SET can_submit=0 WHERE attempt_id=?").run(id);
586
+ const result = this.request(id);
587
+ this.recordRequestEvent(result, options.signatureVerified ? "signature_verified" : next === "completed" ? "chain_result_recorded" : "request_status_changed", old.requestStatus, now);
588
+ this.db.prepare("UPDATE review_sessions SET updated_at=? WHERE id=?").run(now.toISOString(), old.reviewSessionId);
589
+ this.publish(id, !["awaiting_signature", "submitting", "awaiting_chain_result"].includes(next));
590
+ if (old.requestStatus === "awaiting_signature" && next !== old.requestStatus)
591
+ this.publishAvailableWallets();
592
+ if (["stopped", "request_failed", "outcome_unknown", "completed"].includes(next)) {
593
+ // A historical attempt may finish while a newer review is preparing.
594
+ // Clean only the material still owned by this admitted revision.
595
+ for (const table of ["live_transaction_materials", "live_private_review_artifacts"]) {
596
+ this.db.prepare(`DELETE FROM ${table} WHERE review_session_id=? AND EXISTS
597
+ (SELECT 1 FROM live_review_sessions WHERE id=? AND review_revision=? AND preparation_id IS NULL)`)
598
+ .run(old.reviewSessionId, old.reviewSessionId, old.reviewRevision);
599
+ }
600
+ }
601
+ return result;
602
+ }).immediate();
603
+ }
604
+ beginSubmission(id) {
605
+ return this.db.transaction(() => {
606
+ const now = this.clock();
607
+ const request = this.request(id), authority = this.authority(id);
608
+ if (!request || !authority || authority.owner_id !== this.ownerId || authority.can_submit !== 1 ||
609
+ request.requestStatus !== "awaiting_signature" || Date.parse(authority.signature_deadline) <= now.getTime())
610
+ return false;
611
+ const connection = this.connection(authority.connection_id);
612
+ if (!this.isUsableConnection(authority.connection_id, now) || !isOwnedConnectedWallet(connection, this.ownerId, !!this.pendingDisconnect(authority.connection_id)) ||
613
+ connection.connection.revision !== authority.connection_revision || Date.parse(connection.connection.expiresAt) <= now.getTime())
614
+ return false;
615
+ this.db.prepare("UPDATE live_request_authority SET can_submit=0, submit_pending=1, lookup_deadline=? WHERE attempt_id=?")
616
+ .run(new Date(now.getTime() + CHAIN_RECEIPT_LOOKUP_MAX_AGE_MS).toISOString(), id);
617
+ return this.transitionRequest(id, "submitting", now)?.requestStatus === "submitting";
618
+ }).immediate();
619
+ }
620
+ finishSubmission(id) {
621
+ this.db.transaction(() => {
622
+ const now = this.clock();
623
+ this.advanceRequestDeadlines(now, id);
624
+ // Observation may have finished, or its window may have closed, while
625
+ // submit was pending. A late response cannot reopen either outcome.
626
+ if (this.request(id)?.requestStatus === "submitting")
627
+ this.transitionRequest(id, "awaiting_chain_result", now);
628
+ }).immediate();
629
+ }
630
+ settle(id, field, pending) {
631
+ this.db.prepare(`UPDATE live_request_authority SET ${field}=? WHERE attempt_id=? AND owner_id=?`).run(Number(pending), id, this.ownerId);
632
+ }
633
+ stopWaiting(id, now, cause) {
634
+ return this.db.transaction(() => {
635
+ const request = this.request(id), authority = this.authority(id);
636
+ if (!request || !authority || authority.owner_id !== this.ownerId)
637
+ return request;
638
+ if (request.requestStatus === "awaiting_signature")
639
+ return this.transitionRequest(id, cause === "wallet_service_lost" ? "request_failed" : "stopped", now, { reason: interruptionReasons[cause] });
640
+ // Once submitted, connection changes cannot revoke a transaction or the
641
+ // independent lookup of its digest. Only explicit user stopping ends observation.
642
+ if (cause === "user_stop" && authority.observation_stopped === 0 &&
643
+ ["submitting", "awaiting_chain_result", "outcome_unknown"].includes(request.requestStatus)) {
644
+ this.db.prepare("UPDATE live_request_authority SET observation_stopped=1 WHERE attempt_id=?").run(id);
645
+ this.publish(id, false);
646
+ }
647
+ return this.request(id);
648
+ }).immediate();
649
+ }
650
+ resumeObservation(id) {
651
+ this.db.prepare("UPDATE live_request_authority SET observation_stopped=0 WHERE attempt_id=? AND owner_id=?").run(id, this.ownerId);
652
+ }
653
+ invalidateConnectionRequests(connectionId, now, cause) {
654
+ this.db.transaction(() => {
655
+ const rows = this.db.prepare(`SELECT * FROM live_review_sessions v WHERE wallet_connection_id=? AND owner_id=? AND status!='expired'
656
+ AND NOT EXISTS (SELECT 1 FROM review_requests r WHERE r.review_session_id=v.id AND r.review_revision=v.review_revision)`)
657
+ .all(connectionId, this.ownerId);
658
+ for (const row of rows) {
659
+ const state = row.review_state_json ? JSON.parse(row.review_state_json) : undefined;
660
+ // Later session observations cannot rewrite why an already interrupted
661
+ // preparation ended. A new admitted preparation clears this error.
662
+ if (!row.preparation_id && row.preparation_error && !state?.transactionReviewData)
663
+ continue;
664
+ if (state) {
665
+ delete state.transactionReviewData;
666
+ delete state.blockedReason;
667
+ state.status = "refresh_required";
668
+ state.refreshReason = "wallet_connection_changed";
669
+ }
670
+ const status = state ? "refresh_required" : row.status;
671
+ this.db.prepare(`UPDATE live_review_sessions SET status=?,preparation_id=NULL,preparation_error=?,review_state_json=?,
672
+ revision=revision+1,write_contract_version=? WHERE id=?`).run(status, preparationInterruptionReasons[cause], state ? JSON.stringify(state) : null, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, row.id);
673
+ this.db.prepare("UPDATE review_sessions SET current_status=?,updated_at=? WHERE id=?").run(status, now.toISOString(), row.id);
674
+ this.db.prepare(`INSERT INTO review_status_transitions(review_session_id,event,from_status,to_status,reason,transitioned_at)
675
+ VALUES (?,'review_invalidated',?,?,?,?)`).run(row.id, row.status, status, preparationInterruptionReasons[cause], now.toISOString());
676
+ this.publishReview(row.id);
677
+ }
678
+ }).immediate();
679
+ for (const row of this.db.prepare("SELECT attempt_id FROM live_request_authority WHERE connection_id=? AND owner_id=?")
680
+ .all(connectionId, this.ownerId))
681
+ this.stopWaiting(row.attempt_id, now, cause);
682
+ }
683
+ cancelInput(expected) {
684
+ this.db.transaction(() => {
685
+ const now = this.clock();
686
+ const card = this.requireInput(expected, now);
687
+ this.requireAction(card, "cancel", now);
688
+ if (!this.cards.replace(card, { ...card, state: { ...card.state, state: "closed", reason: "cancelled", revision: card.state.revision + 1 } })) {
689
+ throw new WorkflowConflict("Card changed before cancellation.");
690
+ }
691
+ }).immediate();
692
+ }
693
+ beginReviewPreparation(expected, reviewRevision, connection, account) {
694
+ return this.db.transaction(() => {
695
+ const now = this.clock();
696
+ const card = this.requireInput(expected, now);
697
+ if (card.scope !== "review" || typeof card.state.input.reviewSessionId !== "string")
698
+ throw new WorkflowConflict("This card cannot update the review.");
699
+ const id = card.state.input.reviewSessionId;
700
+ const review = this.db.prepare("SELECT * FROM live_review_sessions WHERE id=?").get(id);
701
+ const currentConnection = this.connection(connection.connectionId);
702
+ if (!review || review.preparation_id !== null || review.review_revision !== reviewRevision ||
703
+ review.status === "expired" || Date.parse(review.expires_at) <= now.getTime() ||
704
+ !this.isUsableConnection(connection.connectionId, now) || !isOwnedConnectedWallet(currentConnection, this.ownerId, !!this.pendingDisconnect(connection.connectionId)) ||
705
+ currentConnection.connection.revision !== connection.revision || Date.parse(currentConnection.connection.expiresAt) <= now.getTime() ||
706
+ (review.account && this.busyForAccount(review.account, now)))
707
+ throw new WorkflowConflict("The review could not be updated with the current wallet and account.");
708
+ const readAccount = this.connectionSelection(now).assetReadAccount;
709
+ const selection = reviewPreparationAccount(review.account ?? undefined, readAccount.status === "available" ? readAccount.account : undefined, account);
710
+ if (!selection.allowed)
711
+ throw new WorkflowConflict(selection.message);
712
+ this.requireAction(card, "prepare_review", now);
713
+ if (this.busyForAccount(selection.account, now) || !currentConnection.connection.accounts.includes(selection.account))
714
+ throw new WorkflowConflict("The review could not be updated with the selected wallet account.");
715
+ const preparation = randomUUID();
716
+ this.db.prepare(`UPDATE live_review_sessions SET preparation_id=?,preparation_error=NULL,wallet_connection_id=?,wallet_connection_revision=?,revision=revision+1,write_contract_version=? WHERE id=?`)
717
+ .run(preparation, connection.connectionId, connection.revision, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
718
+ this.publishReview(id);
719
+ return preparation;
720
+ }).immediate();
721
+ }
722
+ isPreparing(id, preparationId) {
723
+ return !!this.db.prepare("SELECT 1 FROM live_review_sessions WHERE id=? AND preparation_id=? AND owner_id=?")
724
+ .get(id, preparationId, this.ownerId);
725
+ }
726
+ failPreparation(id, preparationId, now) {
727
+ this.db.transaction(() => {
728
+ const row = this.db.prepare("SELECT * FROM live_review_sessions WHERE id=? AND preparation_id=? AND owner_id=?")
729
+ .get(id, preparationId, this.ownerId);
730
+ if (!row)
731
+ return;
732
+ const prior = row.review_state_json ? JSON.parse(row.review_state_json) : undefined;
733
+ if (prior && row.status !== "expired") {
734
+ delete prior.transactionReviewData;
735
+ delete prior.blockedReason;
736
+ prior.status = "refresh_required";
737
+ prior.refreshReason = "review_update_failed";
738
+ }
739
+ const status = prior && row.status !== "expired" ? "refresh_required" : row.status;
740
+ this.db.prepare(`UPDATE live_review_sessions SET preparation_id=NULL,preparation_error=?,status=?,review_state_json=?,revision=revision+1,write_contract_version=? WHERE id=?`)
741
+ .run(row.status === "expired" ? "The review expired while it was being updated." : "The review update could not be completed.", status, prior ? JSON.stringify(prior) : null, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, id);
742
+ this.db.prepare("UPDATE review_sessions SET current_status=?,updated_at=? WHERE id=?").run(status, now.toISOString(), id);
743
+ this.db.prepare(`INSERT INTO review_status_transitions(review_session_id,event,from_status,to_status,reason,transitioned_at)
744
+ VALUES (?,'review_update_failed',?,?,?,?)`).run(id, row.status, status, "Review update could not be completed.", now.toISOString());
745
+ this.publishReview(id);
746
+ }).immediate();
747
+ }
748
+ markReviewOpened(reviewSessionId, now) {
749
+ this.db.transaction(() => {
750
+ const changed = this.db.prepare("UPDATE review_sessions SET opened_at=? WHERE id=? AND opened_at IS NULL").run(now.toISOString(), reviewSessionId);
751
+ if (!changed.changes)
752
+ return;
753
+ const row = this.db.prepare("SELECT current_status,account_id FROM review_sessions WHERE id=?").get(reviewSessionId);
754
+ this.db.prepare(`INSERT INTO review_status_transitions(review_session_id,event,from_status,to_status,account_id,transitioned_at)
755
+ VALUES (?,'opened',?,?,?,?)`).run(reviewSessionId, row.current_status, row.current_status, row.account_id, now.toISOString());
756
+ }).immediate();
757
+ }
758
+ recover(now) {
759
+ this.db.transaction(() => {
760
+ const reviews = this.db.prepare("SELECT id,status,account FROM live_review_sessions WHERE owner_id!=?")
761
+ .all(this.ownerId);
762
+ for (const review of reviews) {
763
+ this.db.prepare("DELETE FROM live_transaction_materials WHERE review_session_id=?").run(review.id);
764
+ this.db.prepare("DELETE FROM live_private_review_artifacts WHERE review_session_id=?").run(review.id);
765
+ if (review.status !== "expired") {
766
+ const account = review.account ? this.db.prepare("SELECT id FROM accounts WHERE sui_address=?").get(review.account) : undefined;
767
+ this.db.prepare(`INSERT INTO review_status_transitions(review_session_id,event,from_status,to_status,account_id,reason,transitioned_at)
768
+ VALUES (?,'expired',?,'expired',?,'server_restarted',?)`).run(review.id, review.status, account?.id ?? null, now.toISOString());
769
+ this.db.prepare("UPDATE review_sessions SET current_status='expired',updated_at=? WHERE id=?").run(now.toISOString(), review.id);
770
+ }
771
+ }
772
+ this.db.prepare(`UPDATE live_review_sessions SET owner_id=?,status='expired',preparation_id=NULL,
773
+ revision=revision+1,write_contract_version=? WHERE owner_id!=?`)
774
+ .run(this.ownerId, LIVE_REVIEW_SESSION_WRITE_CONTRACT_VERSION, this.ownerId);
775
+ for (const record of this.connections()) {
776
+ if (record.ownerId === this.ownerId)
777
+ continue;
778
+ const disconnect = this.pendingDisconnect(record.connection.connectionId, record.ownerId);
779
+ if (disconnect || record.connection.status === "awaiting_approval") {
780
+ const next = { ...record.connection, status: disconnect ? "failed" : "stopped", revision: record.connection.revision + 1,
781
+ reason: disconnect ? "Server restarted before wallet disconnection was confirmed. Check the connection in your wallet app."
782
+ : "Server restarted before wallet approval.", updatedAt: now.toISOString() };
783
+ this.db.prepare("UPDATE live_wallet_connections SET owner_id=?,status=?,revision=?,sdk_pending=0,connection_json=? WHERE id=?")
784
+ .run(this.ownerId, next.status, next.revision, JSON.stringify(next), next.connectionId);
785
+ if (disconnect)
786
+ this.publishConnection(next.connectionId, next.status);
787
+ }
788
+ }
789
+ const old = this.db.prepare("SELECT attempt_id FROM live_request_authority WHERE owner_id!=?").all(this.ownerId);
790
+ for (const { attempt_id: id } of old) {
791
+ this.db.prepare("UPDATE live_request_authority SET owner_id=?,can_submit=0,sdk_pending=0,submit_pending=0,lookup_pending=0 WHERE attempt_id=?").run(this.ownerId, id);
792
+ const request = this.request(id);
793
+ if (["awaiting_signature", "submitting", "awaiting_chain_result"].includes(request.requestStatus)) {
794
+ this.transitionRequest(id, "outcome_unknown", now, { reason: "The server restarted. The request will not be sent again." });
795
+ }
796
+ }
797
+ }).immediate();
798
+ }
799
+ }
800
+ export function hasUnsettledWalletWork(db, now = new Date()) {
801
+ return !!db.prepare(`SELECT 1 FROM live_request_authority l JOIN review_requests r ON r.attempt_id=l.attempt_id
802
+ WHERE l.sdk_pending=1 OR l.submit_pending=1 OR l.lookup_pending=1
803
+ OR (r.request_status IN (SELECT value FROM json_each(?)) AND (l.lookup_deadline IS NULL OR l.lookup_deadline>?)) LIMIT 1`)
804
+ .get(JSON.stringify(INITIAL_CHAIN_OBSERVATION_STATUSES), now.toISOString());
805
+ }
806
+ export function readStoredTransactionRequest(db, id) {
807
+ const row = db.prepare(`SELECT r.*, a.sui_address AS account, e.result_json FROM review_requests r
808
+ JOIN accounts a ON a.id=r.account_id LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id
809
+ WHERE r.attempt_id=?`).get(id);
810
+ if (!row)
811
+ return undefined;
812
+ const result = transactionRequestSchema.parse({ attemptId: row.attempt_id, reviewSessionId: row.review_session_id,
813
+ planId: row.plan_id, reviewRevision: row.review_revision, account: row.account, transactionDigest: row.transaction_digest,
814
+ requestStatus: row.request_status, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at,
815
+ ...(row.reason === null ? {} : { reason: row.reason }),
816
+ ...(row.signature_verified_at === null ? {} : { signatureVerifiedAt: row.signature_verified_at }),
817
+ ...(row.submitted_at === null ? {} : { submittedAt: row.submitted_at }),
818
+ ...(row.result_json === null ? {} : { execution: JSON.parse(row.result_json) }) });
819
+ assertNoForbiddenMcpFields(result);
820
+ return result;
821
+ }