@deeeed/metamask-harness 0.38.1 → 0.40.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/CHANGELOG.md +58 -0
- package/adapters/extension/lib/playwright-cdp.cjs +17 -1
- package/adapters/extension/sidepanel-toggle.sh +135 -13
- package/dist/adapters/mobile/release-artifact.js +65 -0
- package/dist/adapters.js +27 -17
- package/dist/command-contract.js +10 -2
- package/dist/commands/call.js +7 -4
- package/dist/commands/launch/index.js +55 -3
- package/dist/commands/parse-args.js +6 -0
- package/dist/commands/run-engine.js +75 -39
- package/dist/funding-execution-context.js +1386 -0
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +170 -3
- package/dist/mm-harness-cli.js +8 -2
- package/dist/recipe-security.js +5 -12
- package/dist/runner.js +102 -6
- package/docs/RECIPES.md +74 -1
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
- package/library/actions/extension/assets/open_details.mjs +30 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
- package/library/actions/extension/networks/add_chainlist.mjs +4 -0
- package/library/actions/extension/networks/add_custom.mjs +4 -0
- package/library/actions/extension/networks/custom_network.mjs +48 -1
- package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
- package/library/actions/extension/perps/close_visible_position.mjs +299 -37
- package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
- package/library/actions/extension/perps/edit_margin.mjs +285 -44
- package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
- package/library/actions/extension/perps/perps.mjs +1029 -363
- package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
- package/library/actions/extension/perps/read_snapshot.mjs +124 -3
- package/library/actions/extension/perps/read_visible_state.mjs +63 -5
- package/library/actions/extension/perps/search_markets.mjs +32 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
- package/library/actions/extension/perps/select_market_filter.mjs +48 -4
- package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
- package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
- package/library/actions/extension/platform/cdp.mjs +88 -9
- package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
- package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
- package/library/actions/extension/ui/navigate.mjs +45 -5
- package/library/actions/extension/wallet/import.mjs +70 -29
- package/library/actions/extension/wallet/lock.mjs +62 -5
- package/library/actions/extension/wallet/visible-session.mjs +218 -0
- package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
- package/library/actions/mobile/assets/open_details.mjs +43 -0
- package/library/actions/mobile/assets/read_details.mjs +96 -0
- package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
- package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
- package/library/actions/mobile/networks/add_custom.mjs +125 -0
- package/library/actions/mobile/networks/network-management.mjs +132 -0
- package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
- package/library/actions/mobile/networks/remove_custom.mjs +100 -0
- package/library/actions/mobile/perps/search_markets.mjs +46 -0
- package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
- package/library/actions/mobile/platform/native-session-name.mjs +14 -0
- package/library/actions/mobile/platform/native-session.mjs +59 -0
- package/library/actions/mobile/platform/observe-ui.mjs +9 -1
- package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
- package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
- package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
- package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
- package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
- package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
- package/library/actions/mobile/ui/native-navigation.mjs +120 -23
- package/library/actions/mobile/ui/navigate.mjs +13 -2
- package/library/actions/mobile/wallet/import.mjs +17 -10
- package/library/actions/mobile/wallet/lock.mjs +14 -0
- package/library/actions/mobile/wallet/native-ui.mjs +88 -15
- package/library/actions/shared/perps/visible-state.mjs +33 -11
- package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
- package/library/manifests/extension.action-manifest.json +1707 -1241
- package/library/manifests/mobile.action-manifest.json +308 -1
- package/library/recipes/assets/release-token-details.recipe.json +5 -4
- package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
- package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
- package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
- package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
- package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
- package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
- package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
- package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
- package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
- package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
- package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
- package/library/recipes/perps/release-market-details.recipe.json +1 -1
- package/library/recipes/perps/release-market-search.recipe.json +8 -4
- package/library/recipes/perps/release-watchlist.recipe.json +31 -19
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
- package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
- package/library/recipes/wallet/import.recipe.json +4 -14
- package/package.json +1 -1
- package/library/actions/extension/assets/finish_send.mjs +0 -128
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
- package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
- package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
- package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
- package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
- package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
- package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
readdir,
|
|
11
|
+
realpath,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
18
|
+
const EXECUTION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
19
|
+
const NODE_ID = /^(?=.{1,256}$)[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})*$/u;
|
|
20
|
+
const BASE_UNITS = /^[1-9][0-9]{0,29}$/u;
|
|
21
|
+
const ORIGIN = /^chrome-extension:\/\/[a-p]{32}$/u;
|
|
22
|
+
|
|
23
|
+
function canonical(value) {
|
|
24
|
+
return `${JSON.stringify(value)}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function digest(value) {
|
|
28
|
+
return createHash('sha256').update(value).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recoveryError(receipt, detail) {
|
|
32
|
+
return new Error(
|
|
33
|
+
`External mutation outcome is unknown (${receipt.sha256}): ${detail}. ` +
|
|
34
|
+
'Next: do not rerun this mutation; inspect the visible position/order and activity first.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ExternalMutationNotDispatchedError extends Error {}
|
|
39
|
+
|
|
40
|
+
export function externalMutationNotDispatched(message) {
|
|
41
|
+
return new ExternalMutationNotDispatchedError(message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function withExternalMutationRecovery(receipt, callback) {
|
|
45
|
+
try {
|
|
46
|
+
return await callback();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
49
|
+
if (/External mutation outcome is unknown[\s\S]*Next: do not rerun this mutation;/u.test(message)) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
throw recoveryError(receipt, message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function requireDirectory(directory, label) {
|
|
57
|
+
const entry = await lstat(directory);
|
|
58
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
59
|
+
throw new Error(`${label} must be a regular directory.`);
|
|
60
|
+
}
|
|
61
|
+
if (await realpath(directory) !== path.resolve(directory)) {
|
|
62
|
+
throw new Error(`${label} must not contain symbolic-link path components.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function receiptDestination(input, io = {}) {
|
|
67
|
+
const configuredRoot = input.context?.artifactsDir;
|
|
68
|
+
const nodeId = String(input.context?.nodeId ?? '');
|
|
69
|
+
if (typeof configuredRoot !== 'string' || configuredRoot.trim() === '' || !path.isAbsolute(configuredRoot)) {
|
|
70
|
+
throw new Error('External mutation receipt requires a nonempty absolute trusted artifacts directory.');
|
|
71
|
+
}
|
|
72
|
+
const artifactsRoot = path.resolve(configuredRoot);
|
|
73
|
+
if (artifactsRoot !== configuredRoot || !NODE_ID.test(nodeId)) {
|
|
74
|
+
throw new Error('External mutation receipt requires a trusted artifacts directory and bounded node ID.');
|
|
75
|
+
}
|
|
76
|
+
await requireDirectory(artifactsRoot, 'External mutation artifacts root');
|
|
77
|
+
const directory = path.join(artifactsRoot, 'mutation-receipts');
|
|
78
|
+
let created = false;
|
|
79
|
+
try {
|
|
80
|
+
await mkdir(directory, { mode: 0o700 });
|
|
81
|
+
created = true;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
84
|
+
}
|
|
85
|
+
await requireDirectory(directory, 'External mutation receipt directory');
|
|
86
|
+
await chmod(directory, 0o700);
|
|
87
|
+
if (created) await durableDirectorySync(artifactsRoot, io);
|
|
88
|
+
return {
|
|
89
|
+
absolute: path.join(directory, `${digest(nodeId)}.json`),
|
|
90
|
+
directory,
|
|
91
|
+
relative: `mutation-receipts/${digest(nodeId)}.json`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function executionMarkerDestination(input, identity) {
|
|
96
|
+
const configuredRoot = input.context?.trustedMutation?.executionLedgerRoot;
|
|
97
|
+
const executionClaimSha256 = String(input.context?.trustedMutation?.executionClaimSha256 ?? '');
|
|
98
|
+
if (typeof configuredRoot !== 'string' || !path.isAbsolute(configuredRoot)
|
|
99
|
+
|| path.resolve(configuredRoot) !== configuredRoot || !SHA256.test(executionClaimSha256)) {
|
|
100
|
+
throw new Error('External mutation requires a trusted validator-owned execution ledger.');
|
|
101
|
+
}
|
|
102
|
+
await requireDirectory(configuredRoot, 'External mutation execution ledger');
|
|
103
|
+
return {
|
|
104
|
+
absolute: path.join(configuredRoot, `${digest(identity.executionId)}.json`),
|
|
105
|
+
directory: configuredRoot,
|
|
106
|
+
relative: `funding-execution-starts/${digest(identity.executionId)}.json`,
|
|
107
|
+
executionClaimSha256,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function executionMarkerRecord(identity, receipt, executionClaimSha256) {
|
|
112
|
+
const initialReceipt = {
|
|
113
|
+
schemaVersion: 1,
|
|
114
|
+
kind: 'external-mutation-receipt',
|
|
115
|
+
state: 'outcome-unknown',
|
|
116
|
+
identity,
|
|
117
|
+
facts: receipt.record?.facts ?? {},
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
kind: 'external-mutation-start',
|
|
122
|
+
executionClaimSha256,
|
|
123
|
+
identity,
|
|
124
|
+
receipt: { path: receipt.relativePath, sha256: digest(canonical(initialReceipt)) },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertMatchingExecutionMarker(marker, expected) {
|
|
129
|
+
if (canonical(marker.record) !== canonical(expected)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'External mutation execution marker does not match its receipt and trusted claim. ' +
|
|
132
|
+
'Next: do not dispatch; create a fresh consent request, claim, and artifact root.',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function publishExecutionMarker(destination, record, receipt, io = {}) {
|
|
138
|
+
const staged = await stagedRecord(destination.directory, record);
|
|
139
|
+
let published = null;
|
|
140
|
+
let failure = null;
|
|
141
|
+
try {
|
|
142
|
+
try {
|
|
143
|
+
await (io.markerLink ?? link)(staged.stagedPath, destination.absolute);
|
|
144
|
+
published = await readPublished(destination);
|
|
145
|
+
if (!published) throw new Error('the execution marker could not be reread');
|
|
146
|
+
assertMatchingExecutionMarker(published, record);
|
|
147
|
+
await (io.markerSyncDirectory ?? syncDirectory)(destination.directory);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code === 'EEXIST') {
|
|
150
|
+
published = await readPublished(destination);
|
|
151
|
+
if (!published) failure = error;
|
|
152
|
+
else assertMatchingExecutionMarker(published, record);
|
|
153
|
+
} else {
|
|
154
|
+
failure = error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
failure = error;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await (io.markerRm ?? rm)(staged.stagingDirectory, { recursive: true, force: true });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
failure ??= error;
|
|
164
|
+
}
|
|
165
|
+
if (failure) {
|
|
166
|
+
throw recoveryError(receipt, `the validator-owned execution marker was not durably published (${failure instanceof Error ? failure.message : String(failure)})`);
|
|
167
|
+
}
|
|
168
|
+
return published;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function decimalBaseUnits(value, decimals = 6) {
|
|
172
|
+
const match = /^(0|[1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(String(value ?? ''));
|
|
173
|
+
if (!match) throw new Error('External mutation amount must be a positive decimal with at most six places.');
|
|
174
|
+
const result = `${match[1]}${String(match[2] ?? '').padEnd(decimals, '0')}`.replace(/^0+(?=\d)/u, '');
|
|
175
|
+
if (!BASE_UNITS.test(result)) throw new Error('External mutation amount must be positive.');
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function trustedString(value, label, pattern) {
|
|
180
|
+
const text = String(value ?? '');
|
|
181
|
+
if (!pattern.test(text)) throw new Error(`Trusted mutation ${label} is invalid.`);
|
|
182
|
+
return text;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function trustedExternalMutationIdentity(input, page, operation) {
|
|
186
|
+
const trusted = input.context?.trustedMutation;
|
|
187
|
+
if (!trusted || typeof trusted !== 'object' || Array.isArray(trusted)) {
|
|
188
|
+
throw new Error('External mutation requires the runner-verified trustedMutation context.');
|
|
189
|
+
}
|
|
190
|
+
const runtime = page?.runtimeIdentity;
|
|
191
|
+
if (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) {
|
|
192
|
+
throw new Error('External mutation requires the current verified Extension runtime identity.');
|
|
193
|
+
}
|
|
194
|
+
if (runtime.runtimeSessionId !== trusted.runtimeSessionId
|
|
195
|
+
|| runtime.cdpPort !== trusted.cdpPort
|
|
196
|
+
|| runtime.cdpTargetId !== trusted.cdpTargetId
|
|
197
|
+
|| runtime.extensionOrigin !== trusted.extensionOrigin) {
|
|
198
|
+
throw new Error('External mutation current runtime does not match the independently validated funding evidence.');
|
|
199
|
+
}
|
|
200
|
+
const operationName = String(operation.operation ?? '');
|
|
201
|
+
const amount = String(operation.amount ?? '');
|
|
202
|
+
const amountBaseUnits = decimalBaseUnits(amount);
|
|
203
|
+
const authorizationCount = Number(operation.authorizationCount ?? 1);
|
|
204
|
+
if (!Number.isInteger(authorizationCount) || authorizationCount < 1 || authorizationCount > 2) {
|
|
205
|
+
throw new Error('External mutation authorization count is invalid.');
|
|
206
|
+
}
|
|
207
|
+
const authorized = Array.isArray(trusted.operations)
|
|
208
|
+
? trusted.operations.filter((entry) => entry?.operation === operationName
|
|
209
|
+
&& entry?.unit === 'usdc-base-units'
|
|
210
|
+
&& entry?.amountBaseUnits === amountBaseUnits)
|
|
211
|
+
: [];
|
|
212
|
+
if (authorized.length !== 1 || authorized[0]?.count !== authorizationCount) {
|
|
213
|
+
throw new Error('External mutation operation is not uniquely authorized by the trusted funding request.');
|
|
214
|
+
}
|
|
215
|
+
const nodeId = trustedString(input.context?.nodeId, 'nodeId', NODE_ID);
|
|
216
|
+
const runId = trustedString(trusted.runId, 'runId', SHA256);
|
|
217
|
+
const value = {
|
|
218
|
+
action: trustedString(input.action, 'action', /^metamask\.perps\.[a-z_]+$/u),
|
|
219
|
+
nodeId,
|
|
220
|
+
executionId: digest(`${runId}\0${nodeId}`),
|
|
221
|
+
runId,
|
|
222
|
+
rootRecipeSha256: trustedString(trusted.rootRecipeSha256, 'rootRecipeSha256', SHA256),
|
|
223
|
+
executionPlanSha256: trustedString(trusted.executionPlanSha256, 'executionPlanSha256', SHA256),
|
|
224
|
+
artifactsIdentitySha256: trustedString(trusted.artifactsIdentitySha256, 'artifactsIdentitySha256', SHA256),
|
|
225
|
+
activeRecipeSha256: trustedString(trusted.recipeSha256, 'recipeSha256', SHA256),
|
|
226
|
+
fundingRequestSha256: trustedString(trusted.requestSha256, 'requestSha256', SHA256),
|
|
227
|
+
fundingPreflightSha256: trustedString(trusted.preflightSha256, 'preflightSha256', SHA256),
|
|
228
|
+
fundingReleaseProofSha256: trustedString(trusted.releaseProofSha256, 'releaseProofSha256', SHA256),
|
|
229
|
+
fundingBalanceProofSha256: trustedString(trusted.balanceProofSha256, 'balanceProofSha256', SHA256),
|
|
230
|
+
fundingRequirementsSha256: trustedString(trusted.fundingRequirementsSha256, 'fundingRequirementsSha256', SHA256),
|
|
231
|
+
runtimeArtifactSha256: trustedString(trusted.runtimeArtifactSha256, 'runtimeArtifactSha256', SHA256),
|
|
232
|
+
runtimeTreeSha256: trustedString(trusted.runtimeTreeSha256, 'runtimeTreeSha256', SHA256),
|
|
233
|
+
selectedIdentitySha256: trustedString(trusted.selectedIdentitySha256, 'selectedIdentitySha256', SHA256),
|
|
234
|
+
cdpPort: Number(runtime.cdpPort),
|
|
235
|
+
cdpTargetId: trustedString(runtime.cdpTargetId, 'cdpTargetId', EXECUTION_ID),
|
|
236
|
+
runtimeSessionId: trustedString(runtime.runtimeSessionId, 'runtimeSessionId', EXECUTION_ID),
|
|
237
|
+
extensionOrigin: trustedString(runtime.extensionOrigin, 'extensionOrigin', ORIGIN),
|
|
238
|
+
operation: operationName,
|
|
239
|
+
...(operation.provider == null ? {} : { provider: String(operation.provider).toLowerCase() }),
|
|
240
|
+
...(operation.network == null ? {} : { network: String(operation.network).toLowerCase() }),
|
|
241
|
+
market: String(operation.market ?? '').toUpperCase(),
|
|
242
|
+
side: String(operation.side ?? '').toLowerCase(),
|
|
243
|
+
amount,
|
|
244
|
+
amountBaseUnits,
|
|
245
|
+
leverage: Number(operation.leverage),
|
|
246
|
+
orderType: String(operation.orderType ?? ''),
|
|
247
|
+
positionMode: String(operation.positionMode ?? ''),
|
|
248
|
+
limitPrice: operation.limitPrice == null ? null : String(operation.limitPrice),
|
|
249
|
+
offsetPct: operation.offsetPct == null ? null : String(operation.offsetPct),
|
|
250
|
+
partialCloseAmount: operation.partialCloseAmount == null ? null : String(operation.partialCloseAmount),
|
|
251
|
+
visibleOrderId: operation.visibleOrderId == null ? null : String(operation.visibleOrderId),
|
|
252
|
+
externalOrderId: operation.externalOrderId == null ? null : String(operation.externalOrderId),
|
|
253
|
+
roundingToleranceBaseUnits: String(operation.roundingToleranceBaseUnits ?? '0'),
|
|
254
|
+
...(operation.positionAmountBaseUnits == null ? {} : {
|
|
255
|
+
positionAmountBaseUnits: String(operation.positionAmountBaseUnits),
|
|
256
|
+
}),
|
|
257
|
+
...(operation.marginAction == null ? {} : { marginAction: String(operation.marginAction) }),
|
|
258
|
+
...(operation.takeProfitPrice == null ? {} : { takeProfitPrice: String(operation.takeProfitPrice) }),
|
|
259
|
+
...(operation.stopLossPrice == null ? {} : { stopLossPrice: String(operation.stopLossPrice) }),
|
|
260
|
+
...(operation.positionSize == null ? {} : { positionSize: String(operation.positionSize) }),
|
|
261
|
+
...(operation.cleanupOnly == null ? {} : { cleanupOnly: operation.cleanupOnly === true }),
|
|
262
|
+
};
|
|
263
|
+
if (!Number.isInteger(value.cdpPort) || value.cdpPort < 1 || value.cdpPort > 65535) {
|
|
264
|
+
throw new Error('Trusted mutation cdpPort is invalid.');
|
|
265
|
+
}
|
|
266
|
+
return validateIdentity(input, value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function assertTrustedExternalMutationTarget(input, page, identity) {
|
|
270
|
+
const trustedTargetId = trustedString(
|
|
271
|
+
input.context?.trustedMutation?.cdpTargetId,
|
|
272
|
+
'cdpTargetId',
|
|
273
|
+
EXECUTION_ID,
|
|
274
|
+
);
|
|
275
|
+
const currentTargetId = trustedString(page?.runtimeIdentity?.cdpTargetId, 'cdpTargetId', EXECUTION_ID);
|
|
276
|
+
if (currentTargetId !== trustedTargetId || currentTargetId !== identity?.cdpTargetId) {
|
|
277
|
+
throw new Error('External mutation current CDP target does not match its trusted wallet proof.');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function trustedExternalMutationBudget(input, operation) {
|
|
282
|
+
const trusted = input.context?.trustedMutation;
|
|
283
|
+
const matches = Array.isArray(trusted?.budgets)
|
|
284
|
+
? trusted.budgets.filter((entry) => entry?.operation === operation
|
|
285
|
+
&& entry?.unit === 'usdc-base-units')
|
|
286
|
+
: [];
|
|
287
|
+
if (matches.length !== 1 || !BASE_UNITS.test(String(matches[0]?.maxBaseUnits ?? ''))) {
|
|
288
|
+
throw new Error('External mutation requires one runner-verified operation budget.');
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
maxBaseUnits: String(matches[0].maxBaseUnits),
|
|
292
|
+
maxCount: Number(matches[0].maxCount),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function trustedExternalMutationFundingRequirement(input) {
|
|
297
|
+
const trusted = input.context?.trustedMutation;
|
|
298
|
+
const requirements = Array.isArray(trusted?.requirements) ? trusted.requirements : [];
|
|
299
|
+
if (requirements.length !== 1) {
|
|
300
|
+
throw new Error('External mutation requires one runner-verified visible funding requirement.');
|
|
301
|
+
}
|
|
302
|
+
const requirement = requirements[0];
|
|
303
|
+
if (requirement?.kind !== 'metric' || requirement?.id !== 'perps-available-margin-usdc'
|
|
304
|
+
|| !BASE_UNITS.test(String(requirement?.minBaseUnits ?? ''))
|
|
305
|
+
|| !BASE_UNITS.test(String(requirement?.actualBaseUnits ?? ''))
|
|
306
|
+
|| !SHA256.test(String(requirement?.evidenceSha256 ?? ''))
|
|
307
|
+
|| BigInt(requirement.actualBaseUnits) < BigInt(requirement.minBaseUnits)
|
|
308
|
+
|| digest(canonical(requirements)) !== trusted.fundingRequirementsSha256) {
|
|
309
|
+
throw new Error('External mutation visible funding requirement is invalid.');
|
|
310
|
+
}
|
|
311
|
+
return { ...requirement };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function trustedExternalMutationPlanIsExact(input, operation, count = 1) {
|
|
315
|
+
const operations = input.context?.trustedMutation?.operations;
|
|
316
|
+
return Array.isArray(operations)
|
|
317
|
+
&& operations.length === 1
|
|
318
|
+
&& operations[0]?.operation === operation
|
|
319
|
+
&& operations[0]?.unit === 'usdc-base-units'
|
|
320
|
+
&& operations[0]?.count === count;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function stagedRecord(directory, record) {
|
|
324
|
+
const stagingDirectory = await mkdtemp(path.join(directory, '.mutation-receipt-'));
|
|
325
|
+
await chmod(stagingDirectory, 0o700);
|
|
326
|
+
const stagedPath = path.join(stagingDirectory, 'receipt.json');
|
|
327
|
+
const bytes = canonical(record);
|
|
328
|
+
const handle = await open(stagedPath, 'wx', 0o600);
|
|
329
|
+
try {
|
|
330
|
+
await handle.writeFile(bytes);
|
|
331
|
+
await handle.sync();
|
|
332
|
+
} finally {
|
|
333
|
+
await handle.close();
|
|
334
|
+
}
|
|
335
|
+
return { bytes, stagedPath, stagingDirectory };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function syncDirectory(directory) {
|
|
339
|
+
const handle = await open(directory, 'r');
|
|
340
|
+
try {
|
|
341
|
+
await handle.sync();
|
|
342
|
+
} finally {
|
|
343
|
+
await handle.close();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function durableDirectorySync(directory, io) {
|
|
348
|
+
return (io?.syncDirectory ?? syncDirectory)(directory);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function unreadableReceiptError(detail) {
|
|
352
|
+
return recoveryError({ sha256: 'unreadable-receipt' }, detail);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readPublished(destination) {
|
|
356
|
+
let entry;
|
|
357
|
+
try {
|
|
358
|
+
entry = await lstat(destination.absolute);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error?.code === 'ENOENT') return null;
|
|
361
|
+
throw unreadableReceiptError('the persisted receipt could not be inspected');
|
|
362
|
+
}
|
|
363
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
364
|
+
throw unreadableReceiptError('the persisted receipt is unsafe');
|
|
365
|
+
}
|
|
366
|
+
let bytes;
|
|
367
|
+
try {
|
|
368
|
+
bytes = await readFile(destination.absolute, 'utf8');
|
|
369
|
+
} catch {
|
|
370
|
+
throw unreadableReceiptError('the persisted receipt could not be read');
|
|
371
|
+
}
|
|
372
|
+
let record;
|
|
373
|
+
try {
|
|
374
|
+
record = JSON.parse(bytes);
|
|
375
|
+
} catch {
|
|
376
|
+
throw unreadableReceiptError(`the persisted receipt JSON is invalid (${digest(bytes)})`);
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
record,
|
|
380
|
+
relativePath: destination.relative,
|
|
381
|
+
sha256: digest(bytes),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function validateIdentity(input, identity) {
|
|
386
|
+
const nodeId = String(input.context?.nodeId ?? '');
|
|
387
|
+
const value = {
|
|
388
|
+
action: String(input.action ?? ''),
|
|
389
|
+
nodeId,
|
|
390
|
+
executionId: String(identity.executionId ?? ''),
|
|
391
|
+
fundingRequestSha256: String(identity.fundingRequestSha256 ?? '').toLowerCase(),
|
|
392
|
+
fundingPreflightSha256: String(identity.fundingPreflightSha256 ?? '').toLowerCase(),
|
|
393
|
+
fundingReleaseProofSha256: String(identity.fundingReleaseProofSha256 ?? '').toLowerCase(),
|
|
394
|
+
fundingBalanceProofSha256: String(identity.fundingBalanceProofSha256 ?? '').toLowerCase(),
|
|
395
|
+
fundingRequirementsSha256: String(identity.fundingRequirementsSha256 ?? '').toLowerCase(),
|
|
396
|
+
runtimeArtifactSha256: String(identity.runtimeArtifactSha256 ?? '').toLowerCase(),
|
|
397
|
+
runtimeTreeSha256: String(identity.runtimeTreeSha256 ?? '').toLowerCase(),
|
|
398
|
+
runId: String(identity.runId ?? '').toLowerCase(),
|
|
399
|
+
rootRecipeSha256: String(identity.rootRecipeSha256 ?? '').toLowerCase(),
|
|
400
|
+
executionPlanSha256: String(identity.executionPlanSha256 ?? '').toLowerCase(),
|
|
401
|
+
artifactsIdentitySha256: String(identity.artifactsIdentitySha256 ?? '').toLowerCase(),
|
|
402
|
+
activeRecipeSha256: String(identity.activeRecipeSha256 ?? '').toLowerCase(),
|
|
403
|
+
selectedIdentitySha256: String(identity.selectedIdentitySha256 ?? '').toLowerCase(),
|
|
404
|
+
cdpPort: Number(identity.cdpPort),
|
|
405
|
+
cdpTargetId: String(identity.cdpTargetId ?? ''),
|
|
406
|
+
runtimeSessionId: String(identity.runtimeSessionId ?? ''),
|
|
407
|
+
extensionOrigin: String(identity.extensionOrigin ?? ''),
|
|
408
|
+
operation: String(identity.operation ?? ''),
|
|
409
|
+
...(identity.provider == null ? {} : { provider: String(identity.provider).toLowerCase() }),
|
|
410
|
+
...(identity.network == null ? {} : { network: String(identity.network).toLowerCase() }),
|
|
411
|
+
market: String(identity.market ?? '').toUpperCase(),
|
|
412
|
+
side: String(identity.side ?? '').toLowerCase(),
|
|
413
|
+
amount: String(identity.amount ?? ''),
|
|
414
|
+
amountBaseUnits: String(identity.amountBaseUnits ?? ''),
|
|
415
|
+
leverage: Number(identity.leverage),
|
|
416
|
+
orderType: String(identity.orderType ?? ''),
|
|
417
|
+
positionMode: String(identity.positionMode ?? ''),
|
|
418
|
+
limitPrice: identity.limitPrice == null ? null : String(identity.limitPrice),
|
|
419
|
+
offsetPct: identity.offsetPct == null ? null : String(identity.offsetPct),
|
|
420
|
+
partialCloseAmount: identity.partialCloseAmount == null ? null : String(identity.partialCloseAmount),
|
|
421
|
+
visibleOrderId: identity.visibleOrderId == null ? null : String(identity.visibleOrderId),
|
|
422
|
+
externalOrderId: identity.externalOrderId == null ? null : String(identity.externalOrderId),
|
|
423
|
+
roundingToleranceBaseUnits: String(identity.roundingToleranceBaseUnits ?? ''),
|
|
424
|
+
...(identity.positionAmountBaseUnits == null ? {} : {
|
|
425
|
+
positionAmountBaseUnits: String(identity.positionAmountBaseUnits),
|
|
426
|
+
}),
|
|
427
|
+
...(identity.marginAction == null ? {} : { marginAction: String(identity.marginAction) }),
|
|
428
|
+
...(identity.takeProfitPrice == null ? {} : { takeProfitPrice: String(identity.takeProfitPrice) }),
|
|
429
|
+
...(identity.stopLossPrice == null ? {} : { stopLossPrice: String(identity.stopLossPrice) }),
|
|
430
|
+
...(identity.positionSize == null ? {} : { positionSize: String(identity.positionSize) }),
|
|
431
|
+
...(identity.cleanupOnly == null ? {} : { cleanupOnly: identity.cleanupOnly === true }),
|
|
432
|
+
};
|
|
433
|
+
if (!NODE_ID.test(value.nodeId) || !EXECUTION_ID.test(value.executionId)) {
|
|
434
|
+
throw new Error('External mutation receipt requires bounded node and execution IDs.');
|
|
435
|
+
}
|
|
436
|
+
if (![value.fundingRequestSha256, value.fundingPreflightSha256,
|
|
437
|
+
value.fundingReleaseProofSha256, value.fundingBalanceProofSha256,
|
|
438
|
+
value.fundingRequirementsSha256, value.runtimeArtifactSha256, value.runtimeTreeSha256, value.runId,
|
|
439
|
+
value.rootRecipeSha256, value.executionPlanSha256, value.artifactsIdentitySha256,
|
|
440
|
+
value.activeRecipeSha256, value.selectedIdentitySha256]
|
|
441
|
+
.every((entry) => SHA256.test(entry)) || !BASE_UNITS.test(value.amountBaseUnits)) {
|
|
442
|
+
throw new Error('External mutation receipt requires trusted funding, recipe, run, wallet, and amount identities.');
|
|
443
|
+
}
|
|
444
|
+
if (!/^metamask\.perps\.[a-z_]+$/u.test(value.action)
|
|
445
|
+
|| !/^perps-[a-z-]+$/u.test(value.operation)
|
|
446
|
+
|| !/^[A-Z0-9:_-]{2,20}$/u.test(value.market)
|
|
447
|
+
|| !/^(?:long|short)$/u.test(value.side)
|
|
448
|
+
|| !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(value.amount)
|
|
449
|
+
|| !Number.isInteger(value.cdpPort) || value.cdpPort < 1 || value.cdpPort > 65535
|
|
450
|
+
|| !EXECUTION_ID.test(value.cdpTargetId) || !EXECUTION_ID.test(value.runtimeSessionId)
|
|
451
|
+
|| !ORIGIN.test(value.extensionOrigin)) {
|
|
452
|
+
throw new Error('External mutation receipt identity is invalid.');
|
|
453
|
+
}
|
|
454
|
+
if (!Number.isInteger(value.leverage) || value.leverage < 1 || value.leverage > 100
|
|
455
|
+
|| !/^[a-z][a-z0-9_-]{0,31}$/u.test(value.provider)
|
|
456
|
+
|| !/^(?:mainnet|testnet)$/u.test(value.network)
|
|
457
|
+
|| !/^(?:market|limit|close|margin|tpsl)$/u.test(value.orderType)
|
|
458
|
+
|| !/^(?:new|modify|close)$/u.test(value.positionMode)
|
|
459
|
+
|| !/^(?:0|[1-9][0-9]{0,29})$/u.test(value.roundingToleranceBaseUnits)
|
|
460
|
+
|| (value.limitPrice !== null && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(value.limitPrice))
|
|
461
|
+
|| (value.offsetPct !== null && !/^-?[0-9]+(?:\.[0-9]{1,6})?$/u.test(value.offsetPct))
|
|
462
|
+
|| (value.partialCloseAmount !== null && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(value.partialCloseAmount))) {
|
|
463
|
+
throw new Error('External mutation receipt operation parameters are invalid.');
|
|
464
|
+
}
|
|
465
|
+
if ((value.positionAmountBaseUnits != null && !BASE_UNITS.test(value.positionAmountBaseUnits))
|
|
466
|
+
|| (value.marginAction != null && !/^(?:add|remove)$/u.test(value.marginAction))
|
|
467
|
+
|| (value.takeProfitPrice != null && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(value.takeProfitPrice))
|
|
468
|
+
|| (value.stopLossPrice != null && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(value.stopLossPrice))) {
|
|
469
|
+
throw new Error('External mutation receipt position parameters are invalid.');
|
|
470
|
+
}
|
|
471
|
+
if ((value.positionSize != null
|
|
472
|
+
&& (!/^[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u.test(value.positionSize)
|
|
473
|
+
|| Number.parseFloat(value.positionSize) <= 0))
|
|
474
|
+
|| (value.cleanupOnly != null && value.cleanupOnly !== true)) {
|
|
475
|
+
throw new Error('External mutation receipt cleanup position identity is invalid.');
|
|
476
|
+
}
|
|
477
|
+
if (value.visibleOrderId !== null && !/^order-card-[A-Za-z0-9._:-]{1,180}$/u.test(value.visibleOrderId)) {
|
|
478
|
+
throw new Error('External mutation receipt visible order identity is invalid.');
|
|
479
|
+
}
|
|
480
|
+
if (value.externalOrderId !== null && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,180}$/u.test(value.externalOrderId)) {
|
|
481
|
+
throw new Error('External mutation receipt external order identity is invalid.');
|
|
482
|
+
}
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function assertMatchingIdentity(receipt, identity) {
|
|
487
|
+
if (canonical(receipt.record.identity) !== canonical(identity)) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`External mutation receipt identity mismatch (${receipt.sha256}). ` +
|
|
490
|
+
'Next: do not submit; inspect the existing receipt and visible position/order.',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function readExternalMutationReceipt(input, rawIdentity) {
|
|
496
|
+
const identity = validateIdentity(input, rawIdentity);
|
|
497
|
+
const destination = await receiptDestination(input);
|
|
498
|
+
const receipt = await readPublished(destination);
|
|
499
|
+
if (!receipt) return null;
|
|
500
|
+
assertMatchingIdentity(receipt, identity);
|
|
501
|
+
return receipt;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function readPriorMutationReceipt(input, currentIdentity, action, identityFields = {}) {
|
|
505
|
+
const destination = await receiptDestination(input);
|
|
506
|
+
const names = (await readdir(destination.directory)).filter((name) => /^[a-f0-9]{64}\.json$/u.test(name));
|
|
507
|
+
if (names.length > 100) throw new Error('External mutation receipt directory exceeds its bounded file count.');
|
|
508
|
+
const matches = [];
|
|
509
|
+
for (const name of names) {
|
|
510
|
+
const absolute = path.join(destination.directory, name);
|
|
511
|
+
const receipt = await readPublished({ absolute, directory: destination.directory, relative: `mutation-receipts/${name}` });
|
|
512
|
+
if (!receipt?.record?.identity || receipt.record.identity.action !== action
|
|
513
|
+
|| !Object.entries(identityFields).every(([field, value]) => receipt.record.identity[field] === value)) continue;
|
|
514
|
+
const prior = validateIdentity({
|
|
515
|
+
...input,
|
|
516
|
+
action,
|
|
517
|
+
context: { ...input.context, nodeId: receipt.record.identity.nodeId },
|
|
518
|
+
}, receipt.record.identity);
|
|
519
|
+
const stableFields = [
|
|
520
|
+
'runId', 'rootRecipeSha256', 'fundingRequestSha256', 'fundingPreflightSha256',
|
|
521
|
+
'fundingReleaseProofSha256', 'fundingBalanceProofSha256',
|
|
522
|
+
'fundingRequirementsSha256', 'runtimeArtifactSha256', 'runtimeTreeSha256',
|
|
523
|
+
'executionPlanSha256', 'artifactsIdentitySha256',
|
|
524
|
+
'selectedIdentitySha256', 'cdpPort', 'cdpTargetId', 'runtimeSessionId', 'extensionOrigin', 'market', 'side',
|
|
525
|
+
'provider', 'network',
|
|
526
|
+
];
|
|
527
|
+
if (stableFields.every((field) => prior[field] === currentIdentity[field])) matches.push(receipt);
|
|
528
|
+
}
|
|
529
|
+
if (matches.length !== 1) {
|
|
530
|
+
throw new Error(`Expected exactly one prior ${action} receipt in this trusted run, found ${matches.length}.`);
|
|
531
|
+
}
|
|
532
|
+
return matches[0];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function beginExternalMutation(input, rawIdentity, io = {}, facts = {}) {
|
|
536
|
+
const identity = validateIdentity(input, rawIdentity);
|
|
537
|
+
const factsBytes = canonical(facts);
|
|
538
|
+
if (!facts || typeof facts !== 'object' || Array.isArray(facts) || factsBytes.length > 4096
|
|
539
|
+
|| /secret|password|srp|mnemonic|private.?key/iu.test(factsBytes)) {
|
|
540
|
+
throw new Error('External mutation receipt facts must be a small secret-free object.');
|
|
541
|
+
}
|
|
542
|
+
const destination = await receiptDestination(input, io);
|
|
543
|
+
const markerDestination = await executionMarkerDestination(input, identity);
|
|
544
|
+
const existingMarker = await readPublished(markerDestination);
|
|
545
|
+
const existing = await readPublished(destination);
|
|
546
|
+
if (existingMarker && !existing) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
'External mutation was previously started but its receipt is missing. ' +
|
|
549
|
+
'Next: do not dispatch; create a fresh consent request, claim, and artifact root.',
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (existing) {
|
|
553
|
+
assertMatchingIdentity(existing, identity);
|
|
554
|
+
const expectedMarker = executionMarkerRecord(identity, existing, markerDestination.executionClaimSha256);
|
|
555
|
+
if (existingMarker) assertMatchingExecutionMarker(existingMarker, expectedMarker);
|
|
556
|
+
else await publishExecutionMarker(markerDestination, expectedMarker, existing, io);
|
|
557
|
+
if (existing.record?.state === 'not-dispatched') {
|
|
558
|
+
throw new Error(
|
|
559
|
+
'External mutation is terminally known not dispatched. Next: create a fresh consent request, claim, and artifact root.',
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return { ...existing, resumed: true };
|
|
563
|
+
}
|
|
564
|
+
const record = {
|
|
565
|
+
schemaVersion: 1,
|
|
566
|
+
kind: 'external-mutation-receipt',
|
|
567
|
+
state: 'outcome-unknown',
|
|
568
|
+
identity,
|
|
569
|
+
facts,
|
|
570
|
+
};
|
|
571
|
+
const staged = await stagedRecord(destination.directory, record);
|
|
572
|
+
let published = null;
|
|
573
|
+
let failure = null;
|
|
574
|
+
try {
|
|
575
|
+
try {
|
|
576
|
+
await (io.link ?? link)(staged.stagedPath, destination.absolute);
|
|
577
|
+
published = {
|
|
578
|
+
record,
|
|
579
|
+
relativePath: destination.relative,
|
|
580
|
+
sha256: digest(staged.bytes),
|
|
581
|
+
resumed: false,
|
|
582
|
+
};
|
|
583
|
+
await durableDirectorySync(destination.directory, io);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (published) {
|
|
586
|
+
failure = recoveryError(published, `the outcome-unknown receipt was published but not durably synced (${error instanceof Error ? error.message : String(error)})`);
|
|
587
|
+
} else if (error?.code === 'EEXIST') {
|
|
588
|
+
const raced = await readPublished(destination);
|
|
589
|
+
if (!raced) failure = error;
|
|
590
|
+
else {
|
|
591
|
+
assertMatchingIdentity(raced, identity);
|
|
592
|
+
published = { ...raced, resumed: true };
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
failure = error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
failure = error;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
await (io.rm ?? rm)(staged.stagingDirectory, { recursive: true, force: true });
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (published) {
|
|
605
|
+
failure = recoveryError(published, `receipt staging cleanup failed (${error instanceof Error ? error.message : String(error)})`);
|
|
606
|
+
} else if (!failure) {
|
|
607
|
+
failure = error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (failure) throw failure;
|
|
611
|
+
const marker = executionMarkerRecord(identity, published, markerDestination.executionClaimSha256);
|
|
612
|
+
await publishExecutionMarker(markerDestination, marker, published, io);
|
|
613
|
+
return published;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function markExternalMutationNotDispatched(input, receipt, reason, io = {}) {
|
|
617
|
+
const destination = await receiptDestination(input, io);
|
|
618
|
+
const current = await readPublished(destination);
|
|
619
|
+
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
620
|
+
throw recoveryError(receipt, 'the receipt changed before the known-not-dispatched state was recorded');
|
|
621
|
+
}
|
|
622
|
+
const reasonText = reason instanceof Error ? reason.message : String(reason);
|
|
623
|
+
if (!reasonText || reasonText.length > 1024 || /secret|password|srp|mnemonic|private.?key/iu.test(reasonText)) {
|
|
624
|
+
throw recoveryError(receipt, 'the known-not-dispatched reason is unsafe');
|
|
625
|
+
}
|
|
626
|
+
const record = {
|
|
627
|
+
...current.record,
|
|
628
|
+
state: 'not-dispatched',
|
|
629
|
+
notDispatchedReasonSha256: digest(reasonText),
|
|
630
|
+
};
|
|
631
|
+
const staged = await stagedRecord(destination.directory, record);
|
|
632
|
+
try {
|
|
633
|
+
await (io.rename ?? rename)(staged.stagedPath, destination.absolute);
|
|
634
|
+
await durableDirectorySync(destination.directory, io);
|
|
635
|
+
} finally {
|
|
636
|
+
await (io.rm ?? rm)(staged.stagingDirectory, { recursive: true, force: true });
|
|
637
|
+
}
|
|
638
|
+
const published = await readPublished(destination);
|
|
639
|
+
if (!published || published.record?.state !== 'not-dispatched') {
|
|
640
|
+
throw recoveryError(receipt, 'the known-not-dispatched receipt could not be reread');
|
|
641
|
+
}
|
|
642
|
+
return published;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function completeExternalMutation(input, receipt, observation, io = {}) {
|
|
646
|
+
return withExternalMutationRecovery(receipt, async () => {
|
|
647
|
+
const destination = await receiptDestination(input);
|
|
648
|
+
const current = await readPublished(destination);
|
|
649
|
+
if (!current || current.sha256 !== receipt.sha256) {
|
|
650
|
+
throw new Error('the persisted receipt changed before completion');
|
|
651
|
+
}
|
|
652
|
+
const record = {
|
|
653
|
+
...current.record,
|
|
654
|
+
state: 'succeeded',
|
|
655
|
+
observationSha256: digest(canonical(observation)),
|
|
656
|
+
observation,
|
|
657
|
+
};
|
|
658
|
+
if (canonical(observation).length > 8192
|
|
659
|
+
|| /secret|password|srp|mnemonic|private.?key/iu.test(canonical(observation))) {
|
|
660
|
+
throw new Error('External mutation observation must be small and secret-free.');
|
|
661
|
+
}
|
|
662
|
+
const staged = await stagedRecord(destination.directory, record);
|
|
663
|
+
let renamed = false;
|
|
664
|
+
let failure = null;
|
|
665
|
+
try {
|
|
666
|
+
const entry = await lstat(destination.absolute);
|
|
667
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
668
|
+
throw new Error('the receipt destination became unsafe');
|
|
669
|
+
}
|
|
670
|
+
await (io.rename ?? rename)(staged.stagedPath, destination.absolute);
|
|
671
|
+
renamed = true;
|
|
672
|
+
await durableDirectorySync(destination.directory, io);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
failure = error;
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
await (io.rm ?? rm)(staged.stagingDirectory, { recursive: true, force: true });
|
|
678
|
+
} catch (error) {
|
|
679
|
+
failure ??= error;
|
|
680
|
+
}
|
|
681
|
+
if (failure) throw failure;
|
|
682
|
+
if (!renamed) throw new Error('the completed receipt was not published');
|
|
683
|
+
const published = io.readPublished
|
|
684
|
+
? await io.readPublished(destination, readPublished)
|
|
685
|
+
: await readPublished(destination);
|
|
686
|
+
if (!published || published.record?.state !== 'succeeded') {
|
|
687
|
+
throw new Error('the completed receipt could not be reread');
|
|
688
|
+
}
|
|
689
|
+
return published;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function recordExternalMutationCleanupCandidate(input, receipt, candidate, io = {}) {
|
|
694
|
+
return withExternalMutationRecovery(receipt, async () => {
|
|
695
|
+
const destination = await receiptDestination(input);
|
|
696
|
+
const current = await readPublished(destination);
|
|
697
|
+
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
698
|
+
throw new Error('the outcome-unknown receipt changed before cleanup classification');
|
|
699
|
+
}
|
|
700
|
+
if (!Array.isArray(current.record?.facts?.baselineOrderIds)
|
|
701
|
+
|| current.record.facts.baselineOrderIds.length !== 0
|
|
702
|
+
|| !candidate || typeof candidate !== 'object' || Array.isArray(candidate)
|
|
703
|
+
|| !/^order-card-[A-Za-z0-9._:-]{1,180}$/u.test(String(candidate.orderId ?? ''))
|
|
704
|
+
|| ![candidate.observedNotionalBaseUnits, candidate.requestedNotionalBaseUnits, candidate.overageBaseUnits]
|
|
705
|
+
.every((value) => BASE_UNITS.test(String(value)))) {
|
|
706
|
+
throw new Error('the cleanup candidate is not bound to one new visible order and bounded notional facts');
|
|
707
|
+
}
|
|
708
|
+
if (BigInt(candidate.observedNotionalBaseUnits) <= BigInt(candidate.requestedNotionalBaseUnits)
|
|
709
|
+
|| BigInt(candidate.overageBaseUnits)
|
|
710
|
+
!== BigInt(candidate.observedNotionalBaseUnits) - BigInt(candidate.requestedNotionalBaseUnits)) {
|
|
711
|
+
throw new Error('the cleanup candidate does not prove a positive policy overage');
|
|
712
|
+
}
|
|
713
|
+
const record = {
|
|
714
|
+
...current.record,
|
|
715
|
+
state: 'observed-policy-violation',
|
|
716
|
+
cleanupCandidate: candidate,
|
|
717
|
+
};
|
|
718
|
+
const bytes = canonical(record);
|
|
719
|
+
if (bytes.length > 12_288 || /secret|password|srp|mnemonic|private.?key/iu.test(bytes)) {
|
|
720
|
+
throw new Error('the cleanup candidate receipt is unsafe');
|
|
721
|
+
}
|
|
722
|
+
const staged = await stagedRecord(destination.directory, record);
|
|
723
|
+
try {
|
|
724
|
+
await (io.rename ?? rename)(staged.stagedPath, destination.absolute);
|
|
725
|
+
await durableDirectorySync(destination.directory, io);
|
|
726
|
+
} finally {
|
|
727
|
+
await (io.rm ?? rm)(staged.stagingDirectory, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
const published = await readPublished(destination);
|
|
730
|
+
if (!published || published.record?.state !== 'observed-policy-violation') {
|
|
731
|
+
throw new Error('the cleanup candidate receipt could not be reread');
|
|
732
|
+
}
|
|
733
|
+
return published;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo }) {
|
|
738
|
+
const receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
|
|
739
|
+
if (receipt.record?.state === 'observed-policy-violation') {
|
|
740
|
+
throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
|
|
741
|
+
}
|
|
742
|
+
if (receipt.record?.state !== 'outcome-unknown' && receipt.record?.state !== 'succeeded') {
|
|
743
|
+
throw new Error('External mutation receipt state is unsafe.');
|
|
744
|
+
}
|
|
745
|
+
if (!receipt.resumed) {
|
|
746
|
+
try {
|
|
747
|
+
await dispatch();
|
|
748
|
+
} catch (error) {
|
|
749
|
+
if (error instanceof ExternalMutationNotDispatchedError) {
|
|
750
|
+
await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
|
|
751
|
+
throw new Error(
|
|
752
|
+
`External mutation was safely not dispatched (${error.message}). ` +
|
|
753
|
+
'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
let observation;
|
|
760
|
+
try {
|
|
761
|
+
observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
|
|
762
|
+
} catch (error) {
|
|
763
|
+
throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
|
|
764
|
+
}
|
|
765
|
+
if (!observation || observation.success !== true) {
|
|
766
|
+
throw recoveryError(receipt, 'the matching visible postcondition was not proven');
|
|
767
|
+
}
|
|
768
|
+
const completed = receipt.record.state === 'succeeded'
|
|
769
|
+
? receipt
|
|
770
|
+
: await completeExternalMutation(input, receipt, observation, receiptIo);
|
|
771
|
+
return {
|
|
772
|
+
receipt: {
|
|
773
|
+
path: completed.relativePath,
|
|
774
|
+
sha256: completed.sha256,
|
|
775
|
+
state: completed.record.state,
|
|
776
|
+
},
|
|
777
|
+
resumed: receipt.resumed,
|
|
778
|
+
recovery: null,
|
|
779
|
+
observation,
|
|
780
|
+
};
|
|
781
|
+
}
|