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