@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,1068 @@
1
+ import { decideReviewEvaluation, needsReviewMaterial } from "../session/reviewValidity.js";
2
+ import { workflowEligibility } from "../session/workflowState.js";
3
+ import { SessionStoreError } from "../session/sessionErrors.js";
4
+ import { z } from "zod";
5
+ import { TRANSACTION_REQUEST_STATUSES, transactionRequestStatusSchema } from "../session/transactionRequest.js";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, resolve } from "node:path";
10
+ import Database from "better-sqlite3";
11
+ import { SqliteWalletWorkflowStore, readStoredTransactionRequest } from "../session/sqliteWalletWorkflowStore.js";
12
+ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
13
+ import { parseLifecycleValidatedReviewState } from "../action/reviewStateValidation.js";
14
+ import { actionPlanSchema } from "../action/schemas.js";
15
+ import { parseSuiAddress } from "../suiAddress.js";
16
+ import { SqlitePreferencesRepository } from "../preferences/sqlitePreferencesRepository.js";
17
+ import { SqliteTransactionMaterialStore } from "../session/sqliteTransactionMaterialStore.js";
18
+ import { SqliteCardRecordStore } from "../session/sqliteCardStore.js";
19
+ import { sameHandle } from "../session/transactionMaterialStore.js";
20
+ import { SqliteSessionRecordStore, SqlitePrivateReviewArtifactStore, createSqliteSettingsRecordStore, sessionFromLiveReviewSessionRow, insertLiveReviewSessionRow, updateLiveReviewSessionRow } from "../session/sqliteSessionStore.js";
21
+ import { SqliteLocalDataService } from "./localDataService.js";
22
+ import { buildExternalActivityCoverageResult } from "./externalActivityCoverage.js";
23
+ import { buildExternalActivityTransactionStreamResult, canonicalExternalActivityTransactions } from "./externalActivityTransactionStream.js";
24
+ import { ActivityStoreReadError, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS, REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD } from "./activityStore.js";
25
+ import { assertCurrentDatabaseFormat, configureDatabase, initializeDatabase } from "./sqliteActivityStoreSchema.js";
26
+ import { ActivityStoreError } from "./sqliteActivityStoreTypes.js";
27
+ import { EXTERNAL_ACTIVITY_RELATIONSHIPS, EXTERNAL_ACTIVITY_STATUSES, INTERNAL_SESSION_STATUSES, REVIEW_STATE_STATUSES, asAccountSource, asInternalSessionStatus, asReviewTransitionEvent, asString, assertDateRange, coinMetadataCacheRecordFromRow, countMap, emptyExternalActivitySummaryStats, emptyReviewFunnelSummary, externalActivityScanFromRow, externalActivitySummaryResult, externalActivityTransactionFromRow, extractRequestedIntent, normalizeExternalActivityLimit, normalizeListLimit, nullableSeconds, parseActionPlanEvidence, parseEvidenceJson, parseIsoTimestamp, parseOptionalIsoTimestamp, reasonForReviewState, reviewActivityListResult, reviewActivityRowFromStorage, reviewFunnelResult, reviewSessionWhere, serializeExternalActivityTransactionDetail, serializeJson, serializeOptionalJson } from "./sqliteActivityStoreRows.js";
28
+ export { ActivityStoreError };
29
+ const ACTIVE_ACCOUNT_SINGLETON_ID = 1;
30
+ export const DATA_DIR_ENV = "SUI_MCP_DATA_DIR";
31
+ export const ACTIVITY_DATABASE_FILENAME = "sui-mcp.sqlite";
32
+ // Best-effort permission hardening. The owner-only data directory is the primary
33
+ // protection; failures (e.g. on Windows, which ignores POSIX modes) are non-fatal.
34
+ function restrictPathPermissions(path, mode) {
35
+ try {
36
+ chmodSync(path, mode);
37
+ }
38
+ catch {
39
+ // Non-fatal: the 0700 directory remains the primary protection.
40
+ }
41
+ }
42
+ export class SqliteActivityStore {
43
+ db;
44
+ validateAdapterLifecycle;
45
+ constructor(options) {
46
+ this.validateAdapterLifecycle = options.validateAdapterLifecycle;
47
+ const dataDirectory = dirname(options.databasePath);
48
+ try {
49
+ mkdirSync(dataDirectory, { recursive: true, mode: 0o700 });
50
+ }
51
+ catch {
52
+ throw new ActivityStoreError(`Could not create the local activity data directory. Check directory permissions or set ${DATA_DIR_ENV}.`);
53
+ }
54
+ if (options.databasePath !== ":memory:" && existsSync(options.databasePath)) {
55
+ const existing = new Database(options.databasePath, { readonly: true, fileMustExist: true });
56
+ try {
57
+ assertCurrentDatabaseFormat(existing);
58
+ }
59
+ finally {
60
+ existing.close();
61
+ }
62
+ }
63
+ const database = new Database(options.databasePath);
64
+ this.db = options.guardDatabase?.(database) ?? database;
65
+ try {
66
+ initializeDatabase(this.db);
67
+ configureDatabase(this.db);
68
+ }
69
+ catch (error) {
70
+ this.db.close();
71
+ throw error;
72
+ }
73
+ // This database persists unsigned transaction material (Option B), so restrict it to
74
+ // the owner. The 0700 directory is the primary protection (it also covers the WAL/SHM
75
+ // sidecars and blocks other OS users); the 0600 file is belt-and-suspenders. Best-effort
76
+ // because some platforms (e.g. Windows) ignore POSIX modes.
77
+ restrictPathPermissions(dataDirectory, 0o700);
78
+ restrictPathPermissions(options.databasePath, 0o600);
79
+ }
80
+ async upsertAccount(address, source, now = new Date()) {
81
+ return this.upsertAccountSync(address, source, now.toISOString());
82
+ }
83
+ async getKnownAccount(address) {
84
+ const normalized = parseSuiAddress(address);
85
+ if (!normalized) {
86
+ throw new ActivityStoreReadError("input_invalid", "Invalid account address", { field: "account" });
87
+ }
88
+ return this.getAccountByAddressSync(normalized);
89
+ }
90
+ async setActiveAccount(address, source, now = new Date(), wallet) {
91
+ return this.setActiveAccountSync(address, source, now, wallet);
92
+ }
93
+ setActiveAccountSync(address, source, now, wallet) {
94
+ const timestamp = now.toISOString();
95
+ return this.db.transaction(() => {
96
+ const account = this.upsertAccountSync(address, source, timestamp);
97
+ this.db
98
+ .prepare(`INSERT INTO active_account_context (id, account_id, source, set_at, wallet_name, wallet_id)
99
+ VALUES (?, ?, ?, ?, ?, ?)
100
+ ON CONFLICT(id) DO UPDATE SET
101
+ account_id = excluded.account_id,
102
+ source = excluded.source,
103
+ set_at = excluded.set_at,
104
+ wallet_name = excluded.wallet_name,
105
+ wallet_id = excluded.wallet_id`)
106
+ .run(ACTIVE_ACCOUNT_SINGLETON_ID, account.id, source, timestamp, wallet?.name ?? null, wallet?.id ?? null);
107
+ return {
108
+ accountId: account.id,
109
+ address: account.address,
110
+ source,
111
+ setAt: timestamp,
112
+ ...(wallet?.name ? { walletName: wallet.name } : {}),
113
+ ...(wallet?.id ? { walletId: wallet.id } : {})
114
+ };
115
+ })();
116
+ }
117
+ async getActiveAccount() {
118
+ return this.getActiveAccountSync();
119
+ }
120
+ async clearActiveAccount(now = new Date()) {
121
+ this.db
122
+ .prepare(`INSERT INTO active_account_context (id, account_id, source, set_at)
123
+ VALUES (?, NULL, 'cleared', ?)
124
+ ON CONFLICT(id) DO UPDATE SET
125
+ account_id = NULL,
126
+ source = 'cleared',
127
+ set_at = excluded.set_at`)
128
+ .run(ACTIVE_ACCOUNT_SINGLETON_ID, now.toISOString());
129
+ }
130
+ async recordReviewSession(input) {
131
+ const plan = parseActionPlanEvidence(input.plan);
132
+ const planJson = serializeJson(plan);
133
+ const intentJson = serializeOptionalJson(extractRequestedIntent(plan));
134
+ this.db
135
+ .transaction(() => {
136
+ this.db
137
+ .prepare(`INSERT INTO review_sessions
138
+ (id, plan_id, action_kind, adapter_id, protocol, current_status,
139
+ plan_json, intent_json, created_at, updated_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
141
+ .run(input.reviewSessionId, plan.id, plan.actionKind, plan.adapterId, plan.protocol, input.currentStatus, planJson, intentJson, input.createdAt, input.createdAt);
142
+ this.insertReviewTransition({
143
+ reviewSessionId: input.reviewSessionId,
144
+ event: "created",
145
+ toStatus: input.currentStatus,
146
+ transitionedAt: input.createdAt
147
+ });
148
+ })();
149
+ }
150
+ async recordReviewSessionWithLiveSession(input, live) {
151
+ const plan = parseActionPlanEvidence(input.plan);
152
+ const planJson = serializeJson(plan);
153
+ const intentJson = serializeOptionalJson(extractRequestedIntent(plan));
154
+ return this.runLiveReviewSessionMutation(live, () => {
155
+ this.db
156
+ .prepare(`INSERT INTO review_sessions
157
+ (id, plan_id, action_kind, adapter_id, protocol, current_status,
158
+ plan_json, intent_json, created_at, updated_at)
159
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
160
+ .run(input.reviewSessionId, plan.id, plan.actionKind, plan.adapterId, plan.protocol, input.currentStatus, planJson, intentJson, input.createdAt, input.createdAt);
161
+ this.insertReviewTransition({
162
+ reviewSessionId: input.reviewSessionId,
163
+ event: "created",
164
+ toStatus: input.currentStatus,
165
+ transitionedAt: input.createdAt
166
+ });
167
+ });
168
+ }
169
+ async recordReviewTransition(input) {
170
+ this.db
171
+ .transaction(() => {
172
+ const accountId = input.account
173
+ ? this.upsertAccountSync(input.account, "review_execution", input.transitionedAt).id
174
+ : null;
175
+ if (accountId !== null) {
176
+ this.assertReviewSessionAccount(input.reviewSessionId, accountId);
177
+ }
178
+ this.insertReviewTransition({ ...input, accountId });
179
+ this.db
180
+ .prepare(`UPDATE review_sessions
181
+ SET current_status = ?, account_id = COALESCE(account_id, ?), updated_at = ?
182
+ WHERE id = ?`)
183
+ .run(input.toStatus, accountId, input.transitionedAt, input.reviewSessionId);
184
+ })();
185
+ }
186
+ async recordReviewTransitionWithLiveSession(input, live) {
187
+ return this.recordReviewTransitionWithLiveSessionSync(input, live);
188
+ }
189
+ recordReviewTransitionWithLiveSessionSync(input, live) {
190
+ return this.runLiveReviewSessionMutation(live, () => {
191
+ const accountId = input.account
192
+ ? this.upsertAccountSync(input.account, "review_execution", input.transitionedAt).id
193
+ : null;
194
+ if (accountId !== null) {
195
+ this.assertReviewSessionAccount(input.reviewSessionId, accountId);
196
+ if (input.event === "wallet_connected") {
197
+ this.assertActiveAccountSync(accountId, input.reviewSessionId);
198
+ }
199
+ }
200
+ this.insertReviewTransition({ ...input, accountId });
201
+ this.db
202
+ .prepare(`UPDATE review_sessions
203
+ SET current_status = ?, account_id = COALESCE(account_id, ?), updated_at = ?
204
+ WHERE id = ?`)
205
+ .run(input.toStatus, accountId, input.transitionedAt, input.reviewSessionId);
206
+ });
207
+ }
208
+ async recordReviewStateSnapshot(input) {
209
+ const parsedState = parseLifecycleValidatedReviewState(input.state, this.validateAdapterLifecycle);
210
+ const stateJson = serializeJson(parsedState);
211
+ this.db
212
+ .transaction(() => {
213
+ const account = this.upsertAccountSync(parsedState.account, "review_execution", input.recordedAt);
214
+ this.assertReviewSessionAccount(input.reviewSessionId, account.id);
215
+ this.db
216
+ .prepare(`INSERT INTO review_state_snapshots
217
+ (review_session_id, plan_id, account_id, status, blocked_reason, refresh_reason,
218
+ state_json, updated_at, recorded_at, review_revision)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
220
+ .run(input.reviewSessionId, parsedState.planId, account.id, parsedState.status, "blockedReason" in parsedState ? parsedState.blockedReason : null, "refreshReason" in parsedState ? parsedState.refreshReason : null, stateJson, parsedState.updatedAt, input.recordedAt, input.reviewRevision);
221
+ this.insertReviewTransition({
222
+ reviewSessionId: input.reviewSessionId,
223
+ event: "state_computed",
224
+ fromStatus: input.fromStatus,
225
+ toStatus: parsedState.status,
226
+ accountId: account.id,
227
+ reason: reasonForReviewState(parsedState),
228
+ transitionedAt: input.recordedAt
229
+ });
230
+ this.db
231
+ .prepare(`UPDATE review_sessions
232
+ SET current_status = ?, account_id = ?, updated_at = ?
233
+ WHERE id = ?`)
234
+ .run(parsedState.status, account.id, input.recordedAt, input.reviewSessionId);
235
+ })();
236
+ }
237
+ async recordReviewStateSnapshotWithLiveSession(input, live) {
238
+ return this.recordReviewStateSnapshotWithLiveSessionSync(input, live);
239
+ }
240
+ recordReviewStateSnapshotWithLiveSessionSync(input, live) {
241
+ const parsedState = parseLifecycleValidatedReviewState(input.state, this.validateAdapterLifecycle);
242
+ const stateJson = serializeJson(parsedState);
243
+ return this.runLiveReviewSessionMutation(live, () => {
244
+ if (live.publication) {
245
+ const { material: checked, clock } = live.publication;
246
+ const at = clock(), handle = checked.artifacts.transactionMaterial;
247
+ const material = handle && this.createTransactionMaterialStore().getTransactionMaterial(handle, at);
248
+ const connection = live.expected?.walletConnectionId ? this.db.prepare("SELECT revision,status,owner_id,connection_json FROM live_wallet_connections WHERE id=?")
249
+ .get(live.expected.walletConnectionId) : undefined;
250
+ if (Date.parse(live.next.expiresAt) <= at.getTime() || !handle || !material || !sameHandle(material, handle) ||
251
+ !Buffer.from(material.transactionBytes).equals(checked.transactionBytes) || !connection || connection.status !== "connected" ||
252
+ connection.owner_id !== live.expected?.ownerId || connection.revision !== live.expected?.walletConnectionRevision ||
253
+ Date.parse(JSON.parse(connection.connection_json).expiresAt) <= at.getTime()) {
254
+ throw new SessionStoreError("invalid_session_transition", "Review material or wallet selection changed before publication.");
255
+ }
256
+ }
257
+ const account = this.upsertAccountSync(parsedState.account, "review_execution", input.recordedAt);
258
+ this.assertReviewSessionAccount(input.reviewSessionId, account.id);
259
+ this.db
260
+ .prepare(`INSERT INTO review_state_snapshots
261
+ (review_session_id, plan_id, account_id, status, blocked_reason, refresh_reason,
262
+ state_json, updated_at, recorded_at, review_revision)
263
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
264
+ .run(input.reviewSessionId, parsedState.planId, account.id, parsedState.status, "blockedReason" in parsedState ? parsedState.blockedReason : null, "refreshReason" in parsedState ? parsedState.refreshReason : null, stateJson, parsedState.updatedAt, input.recordedAt, input.reviewRevision);
265
+ this.insertReviewTransition({
266
+ reviewSessionId: input.reviewSessionId,
267
+ event: "state_computed",
268
+ fromStatus: input.fromStatus,
269
+ toStatus: parsedState.status,
270
+ accountId: account.id,
271
+ reason: reasonForReviewState(parsedState),
272
+ transitionedAt: input.recordedAt
273
+ });
274
+ this.db
275
+ .prepare(`UPDATE review_sessions
276
+ SET current_status = ?, account_id = ?, updated_at = ?
277
+ WHERE id = ?`)
278
+ .run(parsedState.status, account.id, input.recordedAt, input.reviewSessionId);
279
+ });
280
+ }
281
+ async listReviewActivity(filter) {
282
+ const from = parseOptionalIsoTimestamp(filter.from, "from"), to = parseOptionalIsoTimestamp(filter.to, "to");
283
+ assertDateRange(from, to);
284
+ const limit = normalizeListLimit(filter.limit), scope = this.resolveReviewActivityScope(filter);
285
+ if (scope.accountId === undefined)
286
+ return reviewActivityListResult(scope, from, to, [], false, 0);
287
+ const base = reviewSessionWhere(scope.accountId, from, to, filter.reviewStatus);
288
+ let where = base.whereSql;
289
+ const params = [...base.params];
290
+ if (filter.requestStatus !== undefined) {
291
+ where += " AND r.request_status=?";
292
+ params.push(transactionRequestStatusSchema.parse(filter.requestStatus));
293
+ }
294
+ if (filter.executionStatus !== undefined) {
295
+ where += " AND e.status=?";
296
+ params.push(z.enum(["success", "failure"]).parse(filter.executionStatus));
297
+ }
298
+ const joins = `FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id
299
+ LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id AND r.account_id=rs.account_id
300
+ LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id`;
301
+ const count = this.db.prepare(`SELECT COUNT(*) AS count ${joins} ${where}`).get(...params).count;
302
+ const rows = this.db.prepare(`SELECT rs.id AS review_session_id, rs.plan_id, rs.action_kind, rs.adapter_id, rs.protocol,
303
+ rs.current_status, rs.current_attempt_id, a.sui_address AS account, rs.created_at, rs.updated_at,
304
+ e.status AS execution_status, e.tx_digest,
305
+ (SELECT COUNT(*) FROM review_state_snapshots s WHERE s.review_session_id=rs.id) AS snapshot_count,
306
+ (SELECT COUNT(*) FROM review_status_transitions t WHERE t.review_session_id=rs.id) AS transition_count
307
+ ${joins} ${where} ORDER BY rs.created_at DESC,rs.id DESC LIMIT ?`).all(...params, limit + 1);
308
+ const activities = rows.slice(0, limit).map((row) => {
309
+ const request = row.current_attempt_id ? this.readRequestEvidence(row.current_attempt_id, row.review_session_id, row.account) : undefined;
310
+ return reviewActivityRowFromStorage(row, request);
311
+ });
312
+ return reviewActivityListResult(scope, from, to, activities, rows.length > limit, count);
313
+ }
314
+ async summarizeReviewFunnel(filter) {
315
+ const from = parseOptionalIsoTimestamp(filter.from, "from"), to = parseOptionalIsoTimestamp(filter.to, "to");
316
+ assertDateRange(from, to);
317
+ const scope = this.resolveReviewActivityScope(filter), summary = emptyReviewFunnelSummary();
318
+ if (scope.accountId === undefined)
319
+ return reviewFunnelResult(scope, from, to, summary, 0);
320
+ const { whereSql: where, params } = reviewSessionWhere(scope.accountId, from, to);
321
+ const totals = this.db.prepare(`SELECT COUNT(*) AS total,
322
+ SUM(CASE WHEN rs.opened_at IS NOT NULL THEN 1 ELSE 0 END) AS opened,
323
+ SUM(CASE WHEN r.attempt_id IS NULL THEN 1 ELSE 0 END) AS without_request,
324
+ SUM(CASE WHEN e.attempt_id IS NULL THEN 1 ELSE 0 END) AS without_execution,
325
+ SUM(CASE WHEN rs.current_status='expired' AND NOT EXISTS (SELECT 1 FROM review_executions x WHERE x.review_session_id=rs.id) THEN 1 ELSE 0 END) AS expired_without_execution
326
+ FROM review_sessions rs LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id
327
+ LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id ${where}`).get(...params);
328
+ summary.total = totals.total;
329
+ summary.opened = totals.opened ?? 0;
330
+ summary.withoutRequest = totals.without_request ?? 0;
331
+ summary.withoutExecutionResult = totals.without_execution ?? 0;
332
+ summary.expiredWithoutExecutionResult = totals.expired_without_execution ?? 0;
333
+ const eventCounts = this.db.prepare(`SELECT t.event AS key,COUNT(DISTINCT t.review_session_id) AS count
334
+ FROM review_status_transitions t JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='review' GROUP BY t.event`)
335
+ .all(...params);
336
+ summary.walletConnected = eventCounts.find((row) => row.key === "wallet_connected")?.count ?? 0;
337
+ summary.stateComputed = eventCounts.find((row) => row.key === "state_computed")?.count ?? 0;
338
+ summary.reviewStatusCounts = countMap(INTERNAL_SESSION_STATUSES, this.db.prepare(`SELECT rs.current_status AS key,COUNT(*) AS count FROM review_sessions rs ${where} GROUP BY rs.current_status`).all(...params));
339
+ const requestCounts = countMap(TRANSACTION_REQUEST_STATUSES, this.db.prepare(`SELECT r.request_status AS key,COUNT(*) AS count FROM review_sessions rs
340
+ JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id ${where} GROUP BY r.request_status`).all(...params));
341
+ summary.requestStatusCounts = TRANSACTION_REQUEST_STATUSES.map((requestStatus) => ({ requestStatus, count: requestCounts[requestStatus] }));
342
+ summary.executionStatusCounts = countMap(["success", "failure"], this.db.prepare(`SELECT e.status AS key,COUNT(*) AS count FROM review_sessions rs
343
+ JOIN review_executions e ON e.attempt_id=rs.current_attempt_id AND e.review_session_id=rs.id ${where} GROUP BY e.status`).all(...params));
344
+ summary.everReachedReviewStateCounts = countMap(REVIEW_STATE_STATUSES, this.db.prepare(`SELECT t.to_status AS key,COUNT(DISTINCT rs.id) AS count
345
+ FROM review_status_transitions t JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='review'
346
+ AND t.to_status IN ('ready_for_wallet_review','blocked','refresh_required') GROUP BY t.to_status`).all(...params));
347
+ summary.everAwaitedChainResult = this.db.prepare(`SELECT COUNT(DISTINCT rs.id) AS count FROM review_status_transitions t
348
+ JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='request' AND t.to_status='awaiting_chain_result'`).get(...params).count;
349
+ const timing = this.db.prepare(`SELECT AVG((julianday(first.verified_at)-julianday(rs.created_at))*86400.0) AS created,
350
+ AVG(CASE WHEN rs.opened_at IS NULL THEN NULL ELSE (julianday(first.verified_at)-julianday(rs.opened_at))*86400.0 END) AS opened
351
+ FROM review_sessions rs JOIN (SELECT review_session_id,MIN(signature_verified_at) AS verified_at FROM review_requests
352
+ WHERE signature_verified_at IS NOT NULL GROUP BY review_session_id) first ON first.review_session_id=rs.id ${where}`)
353
+ .get(...params);
354
+ summary.avgCreatedToSignatureVerifiedSeconds = nullableSeconds(timing.created);
355
+ summary.avgOpenedToSignatureVerifiedSeconds = nullableSeconds(timing.opened);
356
+ const inconsistent = this.db.prepare(`SELECT rs.id FROM review_sessions rs LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id
357
+ LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id ${where} AND
358
+ ((rs.current_attempt_id IS NOT NULL AND (r.attempt_id IS NULL OR r.review_session_id!=rs.id OR r.account_id!=rs.account_id)) OR
359
+ (r.request_status='completed' AND e.attempt_id IS NULL) OR (e.attempt_id IS NOT NULL AND
360
+ (r.request_status!='completed' OR e.tx_digest!=r.transaction_digest OR json_valid(e.result_json)=0))) LIMIT 1`).get(...params);
361
+ if (inconsistent)
362
+ throw new ActivityStoreReadError("internal_error", "Stored review request evidence is inconsistent.", { reason: "invalid_stored_evidence" });
363
+ // Aggregation must not turn a malformed current result into a valid count.
364
+ for (const row of this.db.prepare(`SELECT rs.id,rs.current_attempt_id,a.sui_address AS account
365
+ FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id ${where} AND rs.current_attempt_id IS NOT NULL`)
366
+ .all(...params)) {
367
+ this.readRequestEvidence(row.current_attempt_id, row.id, row.account);
368
+ }
369
+ return reviewFunnelResult(scope, from, to, summary, summary.total);
370
+ }
371
+ readRequestEvidence(id, reviewSessionId, account) {
372
+ try {
373
+ const request = readStoredTransactionRequest(this.db, id);
374
+ if (!request || request.reviewSessionId !== reviewSessionId || request.account !== account)
375
+ throw new Error("Request identity mismatch");
376
+ return request;
377
+ }
378
+ catch {
379
+ throw new ActivityStoreReadError("internal_error", "Stored review request evidence is invalid.", { reason: "invalid_stored_evidence", reviewSessionId });
380
+ }
381
+ }
382
+ async getReviewSessionDetail(input) {
383
+ const scope = this.resolveReviewActivityScope({ account: input.account });
384
+ const row = scope.accountId === undefined ? undefined : this.db.prepare(`SELECT rs.*, a.sui_address AS account
385
+ FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id WHERE rs.id=? AND rs.account_id=?`)
386
+ .get(input.reviewSessionId, scope.accountId);
387
+ if (!row)
388
+ throw new ActivityStoreReadError("session_not_found", "Review session not found", { reviewSessionId: input.reviewSessionId });
389
+ const snapshots = this.db.prepare(`SELECT s.*,a.sui_address AS account FROM review_state_snapshots s JOIN accounts a ON a.id=s.account_id
390
+ WHERE s.review_session_id=? AND s.account_id=? ORDER BY s.recorded_at,s.id LIMIT ?`)
391
+ .all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
392
+ const transitions = this.db.prepare(`SELECT t.*,a.sui_address AS account FROM review_status_transitions t LEFT JOIN accounts a ON a.id=t.account_id
393
+ WHERE t.review_session_id=? AND (t.account_id IS NULL OR t.account_id=?) ORDER BY t.transitioned_at,t.id LIMIT ?`)
394
+ .all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
395
+ const requestIds = this.db.prepare(`SELECT attempt_id FROM review_requests WHERE review_session_id=? AND account_id=? ORDER BY created_at,attempt_id LIMIT ?`)
396
+ .all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
397
+ const requestCount = this.db.prepare("SELECT COUNT(*) AS count FROM review_requests WHERE review_session_id=? AND account_id=?")
398
+ .get(input.reviewSessionId, scope.accountId).count;
399
+ const recordCount = this.db.prepare("SELECT COUNT(*) AS count FROM review_sessions WHERE account_id=?").get(scope.accountId).count;
400
+ const request = row.current_attempt_id ? this.readRequestEvidence(row.current_attempt_id, row.id, row.account) : undefined;
401
+ return {
402
+ dataScope: { account: scope.account, recordCount }, accountSource: scope.accountSource,
403
+ lowSampleWarning: recordCount < REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD, lowSampleThreshold: REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD,
404
+ session: { reviewSessionId: row.id, planId: row.plan_id, actionKind: row.action_kind, adapterId: row.adapter_id,
405
+ protocol: row.protocol, reviewStatus: asInternalSessionStatus(row.current_status), account: row.account,
406
+ createdAt: row.created_at, updatedAt: row.updated_at, ...(request ? { currentAttemptId: request.attemptId } : {}) },
407
+ planJson: parseEvidenceJson(row.plan_json, row.id, "plan_json", actionPlanSchema),
408
+ ...(row.intent_json === null ? {} : { intentJson: parseEvidenceJson(row.intent_json, row.id, "intent_json") }),
409
+ stateSnapshots: snapshots.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map((snapshot) => ({
410
+ id: snapshot.id, reviewRevision: snapshot.review_revision, planId: snapshot.plan_id, account: snapshot.account, status: snapshot.status,
411
+ blockedReason: snapshot.blocked_reason ?? undefined, refreshReason: snapshot.refresh_reason ?? undefined,
412
+ stateJson: this.parseReviewStateEvidenceJson(snapshot.state_json, row.id, "state_json"),
413
+ updatedAt: snapshot.updated_at, recordedAt: snapshot.recorded_at
414
+ })),
415
+ transitions: transitions.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map((event) => ({
416
+ id: event.id, event: asReviewTransitionEvent(event.event), domain: event.domain,
417
+ ...(event.attempt_id === null ? {} : { attemptId: event.attempt_id }),
418
+ fromStatus: event.from_status ?? undefined, toStatus: event.to_status, isNoOp: event.from_status === event.to_status,
419
+ account: event.account ?? undefined, reason: event.reason ?? undefined, transitionedAt: event.transitioned_at
420
+ })),
421
+ ...(request ? { request } : {}), requestCount,
422
+ requests: requestIds.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map(({ attempt_id }) => this.readRequestEvidence(attempt_id, row.id, row.account)),
423
+ truncated: { activities: false, snapshots: snapshots.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS,
424
+ transitions: transitions.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS, requests: requestIds.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS }
425
+ };
426
+ }
427
+ async recordExternalActivityScan(input) {
428
+ try {
429
+ assertNoForbiddenMcpFields(input);
430
+ }
431
+ catch {
432
+ throw new ActivityStoreReadError("input_invalid", "External activity scan contains forbidden fields", {
433
+ reason: "forbidden_field"
434
+ });
435
+ }
436
+ const account = await this.getKnownAccount(input.account);
437
+ if (!account) {
438
+ throw new ActivityStoreReadError("input_invalid", "External activity scan account is not a known wallet", {
439
+ reason: "account_not_known"
440
+ });
441
+ }
442
+ const fetchedAt = parseIsoTimestamp(input.fetchedAt, "fetchedAt");
443
+ const fromTimestamp = parseOptionalIsoTimestamp(input.fromTimestamp, "from");
444
+ const toTimestamp = parseOptionalIsoTimestamp(input.toTimestamp, "to");
445
+ assertDateRange(fromTimestamp, toTimestamp);
446
+ const record = this.db.transaction(() => {
447
+ this.db
448
+ .prepare(`INSERT INTO external_activity_scans
449
+ (scan_id, kind, account_id, relationship, input_digest, from_checkpoint, to_checkpoint,
450
+ from_timestamp, to_timestamp, limit_count, request_cursor, response_cursor, endpoint_host,
451
+ chain_identifier, fetched_at, stored_count, skipped_count, has_more, window_complete,
452
+ incomplete_reason)
453
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
454
+ .run(input.scanId, input.kind, account.id, input.relationship, input.inputDigest ?? null, input.fromCheckpoint ?? null, input.toCheckpoint ?? null, fromTimestamp ?? null, toTimestamp ?? null, input.limit, input.requestCursor ?? null, input.responseCursor ?? null, input.endpointHost, input.chainIdentifier, fetchedAt, 0, input.transactions.length, input.hasMore ? 1 : 0, input.windowComplete === null ? null : input.windowComplete ? 1 : 0, input.incompleteReason ?? null);
455
+ let storedCount = 0;
456
+ const skippedCount = input.skippedCount ?? 0;
457
+ const upsert = this.db.prepare(`INSERT INTO external_activity_transactions
458
+ (account_id, digest, relationship, checkpoint, timestamp, status, known_sender_account_id,
459
+ first_scan_id, last_scan_id, first_fetched_at, last_fetched_at, detail_json)
460
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
461
+ ON CONFLICT(account_id, digest, relationship) DO UPDATE SET
462
+ checkpoint = COALESCE(excluded.checkpoint, external_activity_transactions.checkpoint),
463
+ timestamp = COALESCE(excluded.timestamp, external_activity_transactions.timestamp),
464
+ status = excluded.status,
465
+ known_sender_account_id = COALESCE(excluded.known_sender_account_id, external_activity_transactions.known_sender_account_id),
466
+ last_scan_id = excluded.last_scan_id,
467
+ last_fetched_at = excluded.last_fetched_at,
468
+ detail_json = COALESCE(excluded.detail_json, external_activity_transactions.detail_json)`);
469
+ for (const transaction of input.transactions) {
470
+ if (transaction.knownSenderAccountId !== undefined && !this.accountIdExists(transaction.knownSenderAccountId)) {
471
+ throw new ActivityStoreReadError("input_invalid", "External activity sender account is not known", {
472
+ reason: "sender_account_not_known"
473
+ });
474
+ }
475
+ upsert.run(account.id, transaction.digest, transaction.relationship, transaction.checkpoint ?? null, transaction.timestamp ? parseIsoTimestamp(transaction.timestamp, "transaction.timestamp") : null, transaction.status, transaction.knownSenderAccountId ?? null, input.scanId, input.scanId, fetchedAt, fetchedAt, transaction.details === undefined
476
+ ? null
477
+ : serializeExternalActivityTransactionDetail(transaction.details, account.address));
478
+ storedCount += 1;
479
+ }
480
+ this.db
481
+ .prepare(`UPDATE external_activity_scans
482
+ SET stored_count = ?, skipped_count = ?
483
+ WHERE scan_id = ?`)
484
+ .run(storedCount, skippedCount + input.transactions.length - storedCount, input.scanId);
485
+ return this.externalActivityScanById(input.scanId);
486
+ })();
487
+ if (!record) {
488
+ throw new ActivityStoreReadError("internal_error", "External activity scan was not recorded", {
489
+ scanId: input.scanId
490
+ });
491
+ }
492
+ return record;
493
+ }
494
+ async getExternalActivityCoverage(filter) {
495
+ const from = parseIsoTimestamp(filter.from, "from");
496
+ const to = parseIsoTimestamp(filter.to, "to");
497
+ assertDateRange(from, to);
498
+ assertNonEmptyHalfOpenRange(from, to);
499
+ const scope = this.resolveReviewActivityScope(filter);
500
+ if (scope.accountId === undefined) {
501
+ return buildExternalActivityCoverageResult({
502
+ scope,
503
+ from,
504
+ to,
505
+ scans: [],
506
+ scanCount: 0,
507
+ storedTransactionCount: 0
508
+ });
509
+ }
510
+ const scanWhereSql = `
511
+ WHERE eas.account_id = ?
512
+ AND (eas.from_timestamp IS NULL OR eas.from_timestamp < ?)
513
+ AND (eas.to_timestamp IS NULL OR eas.to_timestamp > ?)`;
514
+ const scanParams = [scope.accountId, to, from];
515
+ const scanCount = this.db
516
+ .prepare(`SELECT COUNT(*) AS count FROM external_activity_scans eas ${scanWhereSql}`)
517
+ .get(...scanParams).count;
518
+ const scanRows = this.db
519
+ .prepare(`SELECT eas.scan_id, eas.kind, eas.account_id, a.sui_address AS account, eas.relationship,
520
+ eas.input_digest, eas.from_checkpoint, eas.to_checkpoint, eas.from_timestamp,
521
+ eas.to_timestamp, eas.limit_count, eas.request_cursor, eas.response_cursor,
522
+ eas.endpoint_host, eas.chain_identifier, eas.fetched_at, eas.stored_count,
523
+ eas.skipped_count, eas.has_more, eas.window_complete, eas.incomplete_reason
524
+ FROM external_activity_scans eas
525
+ JOIN accounts a ON a.id = eas.account_id
526
+ ${scanWhereSql}
527
+ ORDER BY eas.fetched_at DESC, eas.scan_id DESC
528
+ LIMIT ?`)
529
+ .all(...scanParams, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS + 1);
530
+ const scansTruncated = scanRows.length > EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS;
531
+ const scans = scanRows
532
+ .slice(0, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS)
533
+ .map(externalActivityScanFromRow);
534
+ const transactionWhereSql = "WHERE eat.account_id = ? AND eat.timestamp >= ? AND eat.timestamp < ?";
535
+ const transactionParams = [scope.accountId, from, to];
536
+ const canonicalTransactions = canonicalExternalActivityTransactions(this.externalActivityTransactions(transactionWhereSql, [...transactionParams]));
537
+ const storedTransactionCount = canonicalTransactions.length;
538
+ const storedTransactionRange = canonicalTransactions.length === 0
539
+ ? undefined
540
+ : externalActivityTransactionRangeFromRecords(canonicalTransactions);
541
+ return buildExternalActivityCoverageResult({
542
+ scope,
543
+ from,
544
+ to,
545
+ scans,
546
+ scanCount,
547
+ storedTransactionCount,
548
+ storedTransactionRange,
549
+ scansTruncated
550
+ });
551
+ }
552
+ async listExternalActivityEffectTransactions(filter) {
553
+ const from = parseIsoTimestamp(filter.from, "from");
554
+ const to = parseIsoTimestamp(filter.to, "to");
555
+ assertDateRange(from, to);
556
+ assertNonEmptyHalfOpenRange(from, to);
557
+ const limit = normalizeExternalActivityLimit(filter.limit);
558
+ const scope = this.resolveReviewActivityScope(filter);
559
+ if (scope.accountId === undefined) {
560
+ return buildExternalActivityTransactionStreamResult({
561
+ scope,
562
+ from,
563
+ to,
564
+ transactions: [],
565
+ truncated: false,
566
+ transactionCount: 0
567
+ });
568
+ }
569
+ const whereSql = "WHERE eat.account_id = ? AND eat.timestamp >= ? AND eat.timestamp < ?";
570
+ const canonicalTransactions = canonicalExternalActivityTransactions(this.externalActivityTransactions(whereSql, [scope.accountId, from, to]));
571
+ return buildExternalActivityTransactionStreamResult({
572
+ scope,
573
+ from,
574
+ to,
575
+ transactions: canonicalTransactions.slice(0, limit),
576
+ truncated: canonicalTransactions.length > limit,
577
+ transactionCount: canonicalTransactions.length
578
+ });
579
+ }
580
+ async summarizeExternalActivity(filter) {
581
+ const from = parseOptionalIsoTimestamp(filter.from, "from");
582
+ const to = parseOptionalIsoTimestamp(filter.to, "to");
583
+ assertDateRange(from, to);
584
+ const limit = normalizeExternalActivityLimit(filter.limit);
585
+ const scope = this.resolveReviewActivityScope(filter);
586
+ if (scope.accountId === undefined) {
587
+ return externalActivitySummaryResult(scope, from, to, [], false, emptyExternalActivitySummaryStats());
588
+ }
589
+ const where = ["eat.account_id = ?"];
590
+ const params = [scope.accountId];
591
+ if (from !== undefined) {
592
+ where.push("eat.timestamp >= ?");
593
+ params.push(from);
594
+ }
595
+ if (to !== undefined) {
596
+ where.push("eat.timestamp <= ?");
597
+ params.push(to);
598
+ }
599
+ const whereSql = `WHERE ${where.join(" AND ")}`;
600
+ const total = this.db
601
+ .prepare(`SELECT COUNT(*) AS count FROM external_activity_transactions eat ${whereSql}`)
602
+ .get(...params).count;
603
+ const statusCounts = countMap(EXTERNAL_ACTIVITY_STATUSES, this.db
604
+ .prepare(`SELECT eat.status AS key, COUNT(*) AS count FROM external_activity_transactions eat ${whereSql} GROUP BY eat.status`)
605
+ .all(...params));
606
+ const relationshipCounts = countMap(EXTERNAL_ACTIVITY_RELATIONSHIPS, this.db
607
+ .prepare(`SELECT eat.relationship AS key, COUNT(*) AS count FROM external_activity_transactions eat ${whereSql} GROUP BY eat.relationship`)
608
+ .all(...params));
609
+ const timestampRow = this.db
610
+ .prepare(`SELECT MIN(eat.timestamp) AS earliest_timestamp, MAX(eat.timestamp) AS latest_timestamp
611
+ FROM external_activity_transactions eat
612
+ ${whereSql}`)
613
+ .get(...params);
614
+ const rows = this.db
615
+ .prepare(`SELECT eat.account_id, a.sui_address AS account, eat.digest, eat.relationship,
616
+ eat.checkpoint, eat.timestamp, eat.status, eat.known_sender_account_id,
617
+ eat.first_scan_id, eat.last_scan_id, eat.first_fetched_at, eat.last_fetched_at,
618
+ last_scan.incomplete_reason AS last_scan_incomplete_reason,
619
+ eat.detail_json
620
+ FROM external_activity_transactions eat
621
+ JOIN accounts a ON a.id = eat.account_id
622
+ LEFT JOIN external_activity_scans last_scan ON last_scan.scan_id = eat.last_scan_id
623
+ ${whereSql}
624
+ ORDER BY
625
+ CASE WHEN eat.checkpoint IS NULL THEN 0 ELSE 1 END DESC,
626
+ CAST(eat.checkpoint AS INTEGER) DESC,
627
+ COALESCE(eat.timestamp, '') DESC,
628
+ eat.digest DESC
629
+ LIMIT ?`)
630
+ .all(...params, limit + 1);
631
+ return externalActivitySummaryResult(scope, from, to, rows.slice(0, limit).map(externalActivityTransactionFromRow), rows.length > limit, {
632
+ transactionCount: total,
633
+ statusCounts,
634
+ relationshipCounts,
635
+ earliestTimestamp: timestampRow.earliest_timestamp ?? undefined,
636
+ latestTimestamp: timestampRow.latest_timestamp ?? undefined
637
+ });
638
+ }
639
+ close() {
640
+ this.db.close();
641
+ }
642
+ createPreferencesRepository() {
643
+ return new SqlitePreferencesRepository(this.db);
644
+ }
645
+ createLocalDataService(options) {
646
+ return new SqliteLocalDataService(this.db, options, this.validateAdapterLifecycle);
647
+ }
648
+ createCoinMetadataCache() {
649
+ return new SqliteCoinMetadataCache(this.db);
650
+ }
651
+ createTransactionMaterialStore() {
652
+ return new SqliteTransactionMaterialStore(this.db);
653
+ }
654
+ createSessionRecordStore() {
655
+ return new SqliteSessionRecordStore(this.db, { usesActivityStoreLiveSessionMutations: true });
656
+ }
657
+ createCardRecordStore() {
658
+ return new SqliteCardRecordStore(this.db);
659
+ }
660
+ createPrivateReviewArtifactStore() {
661
+ return new SqlitePrivateReviewArtifactStore(this.db);
662
+ }
663
+ createWalletWorkflowStore(ownerId, clock = () => new Date()) {
664
+ const records = new SqliteWalletWorkflowStore(this.db, ownerId, (address, id, name, now) => this.setActiveAccountSync(address, "wallet_connection", now, { id, name }), clock, this.createTransactionMaterialStore(), (input, at) => this.evaluateWorkflowState(input, records, ownerId, at === undefined ? clock : () => at), () => this.getActiveAccountSync());
665
+ return records;
666
+ }
667
+ finalizeReviewEvaluation(candidate, clock) {
668
+ return this.db.transaction(() => this.finalizeReviewEvaluationAt(candidate, clock())).immediate();
669
+ }
670
+ finalizeReviewEvaluationAt(candidate, now) {
671
+ const row = this.liveReviewSessionRow(candidate.session.id);
672
+ if (!row)
673
+ throw new SessionStoreError("session_not_found", "The saved review is unavailable.");
674
+ const current = sessionFromLiveReviewSessionRow(row);
675
+ const artifacts = this.createPrivateReviewArtifactStore().get(current.id);
676
+ const admitted = this.createSessionRecordStore().hasAdmittedRevision(current.id, current.reviewRevision);
677
+ const material = !admitted && !current.preparationId && artifacts?.transactionMaterial
678
+ ? this.createTransactionMaterialStore().getTransactionMaterial(artifacts.transactionMaterial, now) : undefined;
679
+ if (!admitted && !current.preparationId && artifacts && !material && !needsReviewMaterial(current, admitted)) {
680
+ this.createTransactionMaterialStore().deleteReviewSessionTransactionMaterials(current.id);
681
+ this.createPrivateReviewArtifactStore().delete(current.id);
682
+ }
683
+ const next = decideReviewEvaluation(candidate, current, row.revision, artifacts, material, admitted, now);
684
+ if (next === current)
685
+ return { session: current, events: [] };
686
+ const live = { expected: current, next, deleteTransactionMaterials: true };
687
+ const committed = next.status === "expired"
688
+ ? this.recordReviewTransitionWithLiveSessionSync({ reviewSessionId: current.id, event: "expired", fromStatus: current.status,
689
+ toStatus: next.status, transitionedAt: now.toISOString() }, live)
690
+ : this.recordReviewStateSnapshotWithLiveSessionSync({ reviewSessionId: current.id, fromStatus: current.status,
691
+ state: next.reviewState, reviewRevision: next.reviewRevision, recordedAt: now.toISOString() }, live);
692
+ if (!committed)
693
+ throw new SessionStoreError("invalid_session_transition", "Review state changed before evaluation committed.");
694
+ return { session: next, events: next.status === "expired" ? [] : [{ type: "state.computed", sessionId: next.id,
695
+ status: next.status, reason: "private_review_artifacts_refresh_required", at: now.toISOString() }] };
696
+ }
697
+ evaluateWorkflowState(input, records, ownerId, clock) {
698
+ return this.db.transaction(() => {
699
+ const now = clock();
700
+ const cards = this.createCardRecordStore();
701
+ let record = input.expectedCard && records.currentCard(input.expectedCard);
702
+ const reviewId = record?.state.kind === "review" ? String(record.state.input.reviewSessionId) : input.reviewSessionId;
703
+ const target = reviewId ? records.reviewReadTarget(reviewId, record) : undefined;
704
+ if (input.readTarget && (!target || input.readTarget.reviewSessionId !== target.reviewSessionId ||
705
+ input.readTarget.attemptId !== target.attemptId || !input.readTarget.walletDependent && target.walletDependent)) {
706
+ throw new SessionStoreError("session_mismatch", "The review target changed while its state was being read. Check this review again.");
707
+ }
708
+ let evaluated;
709
+ if (reviewId) {
710
+ if (!input.candidate || input.candidate.session.id !== reviewId)
711
+ throw new SessionStoreError("session_not_found", "The saved review is unavailable.");
712
+ evaluated = this.finalizeReviewEvaluationAt(input.candidate, now);
713
+ if (input.uiObservation && record?.scope === "review")
714
+ records.markReviewOpened(reviewId, now);
715
+ }
716
+ if (!target || target.walletDependent) {
717
+ records.advanceRequestDeadlines(now);
718
+ records.expireConnections(now);
719
+ }
720
+ else
721
+ records.advanceRequestDeadlines(now, target.attemptId);
722
+ if (record)
723
+ record = cards.evaluate(record, () => now).record;
724
+ const session = evaluated?.session;
725
+ const request = target?.attemptId ? records.request(target.attemptId) : undefined;
726
+ const authority = request && records.authority(request.attemptId);
727
+ const details = request?.execution && records.executionDetails(request.attemptId);
728
+ const connection = record?.state.kind === "connect" && record.operationId ? records.connection(record.operationId) : undefined;
729
+ const storedAccount = this.getActiveAccountSync(), activeAccount = storedAccount?.address, targetAccount = session?.account ?? activeAccount;
730
+ const facts = { evaluatedAt: now.toISOString(), ownerId, record, session, request, authority,
731
+ hasReviewInput: !!session && session.status !== "expired" && Date.parse(session.expiresAt) > now.getTime() &&
732
+ cards.hasReviewInput(session.id, ownerId, now),
733
+ walletAvailability: input.walletAvailability, walletObservation: input.walletObservation,
734
+ runtimeRecovery: record && records.walletRecovery(record), activeAccount, activeAccountConnectionId: storedAccount?.walletId,
735
+ walletDependent: !target || target.walletDependent,
736
+ connections: records.connectionViews(),
737
+ connection: connection ? records.connectionView(connection.connection) : undefined,
738
+ boundReview: request && (record?.operationId || record?.scope === "review_manage") ? records.requestReview(request.attemptId) : undefined,
739
+ busyForAccount: !!targetAccount && records.busyForAccount(targetAccount, now),
740
+ receipt: details ? details.data : undefined, receiptDisplay: details ? details.receiptDisplay : undefined };
741
+ return { ...facts, ...workflowEligibility(facts), events: evaluated?.events ?? [] };
742
+ }).immediate();
743
+ }
744
+ createSettingsRecordStore() {
745
+ return createSqliteSettingsRecordStore(this.db);
746
+ }
747
+ runLiveReviewSessionMutation(live, writeActivity) {
748
+ const stale = new Error("live review session changed before commit");
749
+ try {
750
+ const commit = this.db.transaction(() => {
751
+ const current = this.liveReviewSessionRow(live.next.id);
752
+ if (live.expected ? !current || !isDeepStrictEqual(sessionFromLiveReviewSessionRow(current), live.expected) : !!current)
753
+ throw stale;
754
+ writeActivity();
755
+ if (!this.applyLiveReviewSessionMutation(live)) {
756
+ throw stale;
757
+ }
758
+ });
759
+ commit.immediate();
760
+ return true;
761
+ }
762
+ catch (error) {
763
+ if (error === stale) {
764
+ return false;
765
+ }
766
+ throw error;
767
+ }
768
+ }
769
+ applyLiveReviewSessionMutation(live) {
770
+ if (!live.expected) {
771
+ if (this.liveReviewSessionRow(live.next.id)) {
772
+ return false;
773
+ }
774
+ insertLiveReviewSessionRow(this.db, live.next);
775
+ this.applyLiveReviewSessionSideEffects(live);
776
+ return true;
777
+ }
778
+ const row = this.liveReviewSessionRow(live.next.id);
779
+ if (!row || !isDeepStrictEqual(sessionFromLiveReviewSessionRow(row), live.expected)) {
780
+ return false;
781
+ }
782
+ if (!updateLiveReviewSessionRow(this.db, row.revision, live.next)) {
783
+ return false;
784
+ }
785
+ this.applyLiveReviewSessionSideEffects(live);
786
+ return true;
787
+ }
788
+ applyLiveReviewSessionSideEffects(live) {
789
+ this.db.prepare(`UPDATE live_read_cards SET revision=revision+1 WHERE kind='review' AND state='ready'
790
+ AND json_extract(input_json,'$.reviewSessionId')=?`).run(live.next.id);
791
+ if (live.deleteTransactionMaterials) {
792
+ this.db.prepare(`DELETE FROM live_transaction_materials WHERE review_session_id = ?`).run(live.next.id);
793
+ this.db.prepare(`DELETE FROM live_private_review_artifacts WHERE review_session_id = ?`).run(live.next.id);
794
+ }
795
+ if (live.privateArtifactsJson === null) {
796
+ this.db.prepare(`DELETE FROM live_private_review_artifacts WHERE review_session_id = ?`).run(live.next.id);
797
+ }
798
+ else if (live.privateArtifactsJson !== undefined) {
799
+ this.db
800
+ .prepare(`INSERT INTO live_private_review_artifacts (review_session_id, artifacts_json)
801
+ VALUES (?, ?)
802
+ ON CONFLICT(review_session_id) DO UPDATE SET artifacts_json = excluded.artifacts_json`)
803
+ .run(live.next.id, live.privateArtifactsJson);
804
+ }
805
+ }
806
+ liveReviewSessionRow(id) {
807
+ return this.db
808
+ .prepare(`SELECT * FROM live_review_sessions WHERE id = ?`)
809
+ .get(id);
810
+ }
811
+ upsertAccountSync(address, source, timestamp) {
812
+ const normalized = parseSuiAddress(address);
813
+ if (!normalized) {
814
+ throw new ActivityStoreError("Invalid Sui account address");
815
+ }
816
+ this.db
817
+ .prepare(`INSERT INTO accounts (sui_address, first_seen_at, last_used_at, first_source, last_source)
818
+ VALUES (?, ?, ?, ?, ?)
819
+ ON CONFLICT(sui_address) DO UPDATE SET
820
+ last_used_at = excluded.last_used_at,
821
+ last_source = excluded.last_source`)
822
+ .run(normalized, timestamp, timestamp, source, source);
823
+ const row = this.db
824
+ .prepare(`SELECT id, sui_address, first_seen_at, last_used_at, first_source, last_source
825
+ FROM accounts
826
+ WHERE sui_address = ?`)
827
+ .get(normalized);
828
+ if (!row) {
829
+ throw new ActivityStoreError(`Account was not recorded: ${normalized}`);
830
+ }
831
+ return {
832
+ id: row.id,
833
+ address: asString(row.sui_address),
834
+ firstSeenAt: asString(row.first_seen_at),
835
+ lastUsedAt: asString(row.last_used_at),
836
+ firstSource: asAccountSource(row.first_source),
837
+ lastSource: asAccountSource(row.last_source)
838
+ };
839
+ }
840
+ parseReviewStateEvidenceJson(value, reviewSessionId, evidenceField) {
841
+ const parsed = parseEvidenceJson(value, reviewSessionId, evidenceField);
842
+ try {
843
+ return parseLifecycleValidatedReviewState(parsed, this.validateAdapterLifecycle);
844
+ }
845
+ catch {
846
+ throw new ActivityStoreReadError("internal_error", "Malformed activity JSON evidence", {
847
+ reviewSessionId,
848
+ evidenceField
849
+ });
850
+ }
851
+ }
852
+ getAccountByAddressSync(address) {
853
+ const row = this.db
854
+ .prepare(`SELECT id, sui_address, first_seen_at, last_used_at, first_source, last_source
855
+ FROM accounts
856
+ WHERE sui_address = ?`)
857
+ .get(address);
858
+ return row
859
+ ? {
860
+ id: row.id,
861
+ address: asString(row.sui_address),
862
+ firstSeenAt: asString(row.first_seen_at),
863
+ lastUsedAt: asString(row.last_used_at),
864
+ firstSource: asAccountSource(row.first_source),
865
+ lastSource: asAccountSource(row.last_source)
866
+ }
867
+ : undefined;
868
+ }
869
+ accountIdExists(accountId) {
870
+ const row = this.db.prepare("SELECT id FROM accounts WHERE id = ?").get(accountId);
871
+ return row !== undefined;
872
+ }
873
+ externalActivityScanById(scanId) {
874
+ const row = this.db
875
+ .prepare(`SELECT eas.scan_id, eas.kind, eas.account_id, a.sui_address AS account, eas.relationship,
876
+ eas.input_digest, eas.from_checkpoint, eas.to_checkpoint, eas.from_timestamp,
877
+ eas.to_timestamp, eas.limit_count, eas.request_cursor, eas.response_cursor,
878
+ eas.endpoint_host, eas.chain_identifier, eas.fetched_at, eas.stored_count,
879
+ eas.skipped_count, eas.has_more, eas.window_complete, eas.incomplete_reason
880
+ FROM external_activity_scans eas
881
+ JOIN accounts a ON a.id = eas.account_id
882
+ WHERE eas.scan_id = ?`)
883
+ .get(scanId);
884
+ return row ? externalActivityScanFromRow(row) : undefined;
885
+ }
886
+ externalActivityTransactions(whereSql, params) {
887
+ const rows = this.db
888
+ .prepare(`SELECT eat.account_id, a.sui_address AS account, eat.digest, eat.relationship,
889
+ eat.checkpoint, eat.timestamp, eat.status, eat.known_sender_account_id,
890
+ eat.first_scan_id, eat.last_scan_id, eat.first_fetched_at, eat.last_fetched_at,
891
+ last_scan.incomplete_reason AS last_scan_incomplete_reason,
892
+ eat.detail_json
893
+ FROM external_activity_transactions eat
894
+ JOIN accounts a ON a.id = eat.account_id
895
+ LEFT JOIN external_activity_scans last_scan ON last_scan.scan_id = eat.last_scan_id
896
+ ${whereSql}
897
+ ORDER BY
898
+ CASE WHEN eat.checkpoint IS NULL THEN 0 ELSE 1 END DESC,
899
+ CAST(eat.checkpoint AS INTEGER) DESC,
900
+ COALESCE(eat.timestamp, '') DESC,
901
+ eat.digest DESC`)
902
+ .all(...params);
903
+ return rows.map(externalActivityTransactionFromRow);
904
+ }
905
+ insertReviewTransition(input) {
906
+ this.db
907
+ .prepare(`INSERT INTO review_status_transitions
908
+ (review_session_id, event, from_status, to_status, account_id, reason, transitioned_at)
909
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
910
+ .run(input.reviewSessionId, input.event, input.fromStatus ?? null, input.toStatus, input.accountId ?? null, input.reason ?? null, input.transitionedAt);
911
+ }
912
+ resolveReviewActivityScope(filter) {
913
+ if (filter.account) {
914
+ const normalized = parseSuiAddress(filter.account);
915
+ if (!normalized) {
916
+ throw new ActivityStoreReadError("input_invalid", "Invalid account filter", { field: "account" });
917
+ }
918
+ const row = this.db
919
+ .prepare("SELECT id FROM accounts WHERE sui_address = ?")
920
+ .get(normalized);
921
+ return {
922
+ account: normalized,
923
+ accountId: row?.id,
924
+ accountSource: "explicit_filter"
925
+ };
926
+ }
927
+ const active = this.getActiveAccountSync();
928
+ if (!active) {
929
+ throw new ActivityStoreReadError("active_account_not_set", "Active account read context is not set", {
930
+ action: "connect_wallet_connection"
931
+ });
932
+ }
933
+ return {
934
+ account: active.address,
935
+ accountId: active.accountId,
936
+ accountSource: "active_account_context"
937
+ };
938
+ }
939
+ getActiveAccountSync() {
940
+ const row = this.db
941
+ .prepare(`SELECT a.id AS account_id, a.sui_address AS address, c.source AS source, c.set_at AS set_at,
942
+ c.wallet_name AS wallet_name, c.wallet_id AS wallet_id
943
+ FROM active_account_context c
944
+ JOIN accounts a ON a.id = c.account_id
945
+ WHERE c.id = ? AND c.account_id IS NOT NULL`)
946
+ .get(ACTIVE_ACCOUNT_SINGLETON_ID);
947
+ return row
948
+ ? {
949
+ accountId: row.account_id,
950
+ address: asString(row.address),
951
+ source: "wallet_connection",
952
+ setAt: asString(row.set_at),
953
+ ...(row.wallet_name ? { walletName: row.wallet_name } : {}),
954
+ ...(row.wallet_id ? { walletId: row.wallet_id } : {})
955
+ }
956
+ : undefined;
957
+ }
958
+ assertReviewSessionAccount(reviewSessionId, accountId) {
959
+ const row = this.db
960
+ .prepare("SELECT account_id FROM review_sessions WHERE id = ?")
961
+ .get(reviewSessionId);
962
+ if (!row) {
963
+ throw new ActivityStoreError(`Review session not found: ${reviewSessionId}`);
964
+ }
965
+ if (row.account_id !== null && row.account_id !== accountId) {
966
+ throw new ActivityStoreError(`Review session already belongs to a different account: ${reviewSessionId}`);
967
+ }
968
+ }
969
+ assertActiveAccountSync(accountId, reviewSessionId) {
970
+ const row = this.db
971
+ .prepare("SELECT account_id FROM active_account_context WHERE id = ? AND source = 'wallet_connection'")
972
+ .get(ACTIVE_ACCOUNT_SINGLETON_ID);
973
+ if (!row || row.account_id !== accountId) {
974
+ throw new ActivityStoreError(`Review session active account changed before commit: ${reviewSessionId}`);
975
+ }
976
+ }
977
+ }
978
+ function assertNonEmptyHalfOpenRange(from, to) {
979
+ if (from >= to) {
980
+ throw new ActivityStoreReadError("input_invalid", "from must be before to for a non-empty half-open range", {
981
+ from,
982
+ to
983
+ });
984
+ }
985
+ }
986
+ function externalActivityTransactionRangeFromRecords(rows) {
987
+ const timestamps = rows.flatMap((row) => row.timestamp ?? []).sort();
988
+ const checkpoints = rows.flatMap((row) => row.checkpoint ?? []).sort(compareIntegerStrings);
989
+ return {
990
+ earliestTimestamp: timestamps[0],
991
+ latestTimestamp: timestamps.at(-1),
992
+ earliestCheckpoint: checkpoints[0],
993
+ latestCheckpoint: checkpoints.at(-1)
994
+ };
995
+ }
996
+ function compareIntegerStrings(a, b) {
997
+ const delta = BigInt(a) - BigInt(b);
998
+ if (delta < 0n)
999
+ return -1;
1000
+ if (delta > 0n)
1001
+ return 1;
1002
+ return 0;
1003
+ }
1004
+ class SqliteCoinMetadataCache {
1005
+ db;
1006
+ constructor(db) {
1007
+ this.db = db;
1008
+ }
1009
+ async getCoinMetadata(input) {
1010
+ const row = this.db
1011
+ .prepare(`SELECT coin_type, chain_identifier, decimals, symbol, name, fetched_at, expires_at
1012
+ FROM coin_metadata_cache
1013
+ WHERE coin_type = ? AND chain_identifier = ?`)
1014
+ .get(input.coinType, input.chainIdentifier);
1015
+ if (!row) {
1016
+ return { status: "miss" };
1017
+ }
1018
+ const record = coinMetadataCacheRecordFromRow(row);
1019
+ return record.expiresAt > input.now.toISOString()
1020
+ ? { status: "hit", record }
1021
+ : { status: "expired", record };
1022
+ }
1023
+ async setCoinMetadata(record) {
1024
+ this.db
1025
+ .prepare(`INSERT INTO coin_metadata_cache
1026
+ (coin_type, chain_identifier, decimals, symbol, name, fetched_at, expires_at)
1027
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1028
+ ON CONFLICT(coin_type, chain_identifier) DO UPDATE SET
1029
+ decimals = excluded.decimals,
1030
+ symbol = excluded.symbol,
1031
+ name = excluded.name,
1032
+ fetched_at = excluded.fetched_at,
1033
+ expires_at = excluded.expires_at`)
1034
+ .run(record.coinType, record.chainIdentifier, record.decimals, record.symbol, record.name, record.fetchedAt, record.expiresAt);
1035
+ }
1036
+ }
1037
+ export function resolveActivityDatabasePath(env = process.env, platform = process.platform) {
1038
+ const configured = env[DATA_DIR_ENV];
1039
+ const dataDir = configured && configured.trim() ? configured : defaultDataDir(env, platform);
1040
+ if (dataDir.includes("\0")) {
1041
+ throw new ActivityStoreError(`${DATA_DIR_ENV} must not contain null bytes`);
1042
+ }
1043
+ return resolve(dataDir, ACTIVITY_DATABASE_FILENAME);
1044
+ }
1045
+ export function assertSqliteEngineAvailable() {
1046
+ const db = new Database(":memory:");
1047
+ try {
1048
+ db.exec("CREATE TABLE engine_check (id INTEGER PRIMARY KEY)");
1049
+ db.prepare("INSERT INTO engine_check (id) VALUES (?)").run(1);
1050
+ const row = db.prepare("SELECT id FROM engine_check").get();
1051
+ if (row?.id !== 1) {
1052
+ throw new ActivityStoreError("better-sqlite3 smoke query failed");
1053
+ }
1054
+ }
1055
+ finally {
1056
+ db.close();
1057
+ }
1058
+ }
1059
+ function defaultDataDir(env, platform) {
1060
+ const home = homedir();
1061
+ if (platform === "darwin") {
1062
+ return resolve(home, "Library", "Application Support", "sui-mcp");
1063
+ }
1064
+ if (platform === "win32") {
1065
+ return resolve(env.APPDATA ?? resolve(home, "AppData", "Roaming"), "sui-mcp");
1066
+ }
1067
+ return resolve(env.XDG_DATA_HOME ?? resolve(home, ".local", "share"), "sui-mcp");
1068
+ }