@deeeed/metamask-harness 0.23.0 → 0.24.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 +25 -0
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/metro-config.cjs +93 -0
- package/adapters/mobile/start-metro.sh +11 -0
- package/dist/recipe-security.js +13 -2
- package/library/actions/core/perps/_controller.mjs +138 -5
- package/library/actions/core/perps/assert_orders.mjs +174 -10
- package/library/actions/core/perps/assert_positions.mjs +20 -2
- package/library/actions/core/perps/close_orders.mjs +24 -5
- package/library/actions/core/perps/close_positions.mjs +22 -8
- package/library/actions/core/perps/edit_order.mjs +331 -0
- package/library/actions/core/perps/place_order.mjs +192 -43
- package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
- package/library/actions/mobile/platform/bridge.mjs +22 -16
- package/library/manifests/core.action-manifest.json +468 -27
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.24.0 - 2026-07-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `metamask.perps.edit_order` drives `PerpsController.editOrder()` on the `core` adapter, verifying that a known resting target was replaced by one new order matching the requested attributes rather than trusting the acknowledgement.
|
|
10
|
+
- `expect_error` asserts that a `core` perps operation is REJECTED with a given typed error instead of succeeding. The comparison is exact and code-only: actions attach the controller's error code as data, and a rejection carrying no code does NOT match, so an unrelated failure whose message happens to mention the code cannot satisfy a refusal proof. Exposed only on the five actions with a real controller-refusal boundary (`place_order`, `edit_order`, `update_position_tpsl`, `close_orders`, `close_positions`); on read, assert and ensure actions every throw is argument validation or a postcondition failure, which can carry no code, so the parameter is not advertised there.
|
|
11
|
+
- `expect_count` on `metamask.perps.assert_orders` and `assert_positions` pins the exact number of matching items, which the default at-least-one check cannot do.
|
|
12
|
+
- Attached TP/SL assertions verify that one parent links to live children of the requested TP/SL family, market, side and trigger price, and record those attributes in evidence.
|
|
13
|
+
- Relative price and size inputs on `place_order` and `update_position_tpsl`: `take_profit_offset_pct` / `stop_loss_offset_pct` derive a price from live mid, and `take_profit_fraction` / `stop_loss_fraction` derive a size from the live position, rounded to the market's size precision. An absolute value still wins when supplied.
|
|
14
|
+
- `notional` on `edit_order` derives a size when the resting order cannot be read.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Optional params left unset by a recipe template arrive as an empty string; the `core` perps actions treated that as a supplied value, so an order with no TP/SL could be rejected as having an invalid TP/SL size. Empty now means absent and falls through to a supplied alias.
|
|
19
|
+
- Batch order-cancel and position-close failures retain the failed controller result instead of being flattened, including partial failures.
|
|
20
|
+
- `edit_order` accepts only resting `GTC` and `ALO` replacements; `IOC` cannot satisfy its resting-order postcondition.
|
|
21
|
+
- `place_order` no longer discards a `time_in_force` or a stray trigger price on a non-trigger placement. Dropping a field the caller set is wrong on its own terms, and it also hid the controller's refusal of it; both are forwarded and the controller decides.
|
|
22
|
+
- `place_order` and `edit_order` reject a blank or unrecognised `side` instead of treating anything that is not `short` as a long, which let an unset template place a real order on an assumed side.
|
|
23
|
+
|
|
24
|
+
## 0.23.1 - 2026-07-29
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Mobile recipe evidence no longer triggers Metro Fast Refresh: harness-managed Metro ignores checkout-local `temp` artifacts, and screenshot scratch files stay outside the watched project.
|
|
29
|
+
|
|
5
30
|
## 0.23.0 - 2026-07-28
|
|
6
31
|
|
|
7
32
|
### Added
|
package/adapters/manifest.json
CHANGED
|
@@ -42,6 +42,14 @@
|
|
|
42
42
|
"inputs": "--target --port --log --pid-file [--workers] [--clear]",
|
|
43
43
|
"outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
|
|
44
44
|
},
|
|
45
|
+
{
|
|
46
|
+
"id": "mobile/metro-config",
|
|
47
|
+
"entry": "adapters/mobile/metro-config.cjs",
|
|
48
|
+
"kind": "module",
|
|
49
|
+
"purpose": "Preserve the product Metro config while excluding harness-owned checkout temp artifacts.",
|
|
50
|
+
"inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
|
|
51
|
+
"outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
|
|
52
|
+
},
|
|
45
53
|
{
|
|
46
54
|
"id": "mobile/stop-metro",
|
|
47
55
|
"entry": "adapters/mobile/stop-metro.sh",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { createRequire } = require('node:module');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { pathToFileURL } = require('node:url');
|
|
7
|
+
|
|
8
|
+
function escapeRegExp(value) {
|
|
9
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function appendBlockList(blockList, pattern) {
|
|
13
|
+
if (Array.isArray(blockList)) return [...blockList, pattern];
|
|
14
|
+
return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function blockListFlags(blockList) {
|
|
18
|
+
const first = Array.isArray(blockList) ? blockList[0] : blockList;
|
|
19
|
+
return first instanceof RegExp ? first.flags : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveExpoConfigLoader(projectRoot) {
|
|
23
|
+
const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
|
|
24
|
+
let requireUtilsPath;
|
|
25
|
+
try {
|
|
26
|
+
requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code !== 'MODULE_NOT_FOUND') throw error;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const { loadModuleSync } = requireFromProject(requireUtilsPath);
|
|
32
|
+
return typeof loadModuleSync === 'function' ? loadModuleSync : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadProductConfig(configPath, baseConfig, projectRoot) {
|
|
36
|
+
let loaded;
|
|
37
|
+
const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
|
|
38
|
+
if (expoConfigLoader) {
|
|
39
|
+
loaded = expoConfigLoader(configPath);
|
|
40
|
+
} else {
|
|
41
|
+
try {
|
|
42
|
+
loaded = require(configPath);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
|
|
45
|
+
loaded = await import(pathToFileURL(configPath).href);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const moduleValue = await loaded;
|
|
50
|
+
const exported =
|
|
51
|
+
moduleValue?.__esModule ||
|
|
52
|
+
moduleValue?.[Symbol.toStringTag] === 'Module'
|
|
53
|
+
? moduleValue.default
|
|
54
|
+
: moduleValue;
|
|
55
|
+
const config = await exported;
|
|
56
|
+
return typeof config === 'function' ? config(baseConfig) : config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = async function harnessMetroConfig(baseConfig) {
|
|
60
|
+
const projectRoot = path.resolve(
|
|
61
|
+
process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
|
|
62
|
+
);
|
|
63
|
+
const productConfigPath = path.resolve(
|
|
64
|
+
process.env.MM_HARNESS_METRO_BASE_CONFIG ||
|
|
65
|
+
path.join(projectRoot, 'metro.config.js'),
|
|
66
|
+
);
|
|
67
|
+
let productConfig = baseConfig;
|
|
68
|
+
if (fs.existsSync(productConfigPath)) {
|
|
69
|
+
productConfig = await loadProductConfig(
|
|
70
|
+
productConfigPath,
|
|
71
|
+
baseConfig,
|
|
72
|
+
projectRoot,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tempRoot = path.join(projectRoot, 'temp');
|
|
77
|
+
const existingBlockList =
|
|
78
|
+
productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
|
|
79
|
+
const tempPattern = new RegExp(
|
|
80
|
+
`^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
|
|
81
|
+
blockListFlags(existingBlockList),
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
...productConfig,
|
|
85
|
+
resolver: {
|
|
86
|
+
...productConfig.resolver,
|
|
87
|
+
blockList: appendBlockList(
|
|
88
|
+
existingBlockList,
|
|
89
|
+
tempPattern,
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
};
|
|
@@ -49,6 +49,7 @@ fi
|
|
|
49
49
|
# only ever inspect or signal a Metro listening on $PORT.
|
|
50
50
|
# shellcheck disable=SC1091
|
|
51
51
|
. "$SCRIPT_DIR/lib/metro-listener.sh"
|
|
52
|
+
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID} MM_HARNESS_METRO_PROJECT_ROOT EXPO_OVERRIDE_METRO_CONFIG"
|
|
52
53
|
|
|
53
54
|
# RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
|
|
54
55
|
# sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
|
|
@@ -98,6 +99,13 @@ set +a
|
|
|
98
99
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
99
100
|
export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
|
|
100
101
|
export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
|
|
102
|
+
export MM_HARNESS_METRO_PROJECT_ROOT="$(printf '%q' "$TARGET")"
|
|
103
|
+
if [ -n "\${EXPO_OVERRIDE_METRO_CONFIG:-}" ]; then
|
|
104
|
+
export MM_HARNESS_METRO_BASE_CONFIG="\$EXPO_OVERRIDE_METRO_CONFIG"
|
|
105
|
+
else
|
|
106
|
+
export MM_HARNESS_METRO_BASE_CONFIG=$(printf '%q' "$TARGET/metro.config.js")
|
|
107
|
+
fi
|
|
108
|
+
export EXPO_OVERRIDE_METRO_CONFIG="$(printf '%q' "$SCRIPT_DIR/metro-config.cjs")"
|
|
101
109
|
yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
102
110
|
EOF
|
|
103
111
|
chmod +x "$runner"
|
|
@@ -184,6 +192,9 @@ if ! start_metro_tmux; then
|
|
|
184
192
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
185
193
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
186
194
|
export METRO_MAX_WORKERS="${METRO_WORKERS}"
|
|
195
|
+
export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
|
|
196
|
+
export MM_HARNESS_METRO_BASE_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
|
|
197
|
+
export EXPO_OVERRIDE_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
|
|
187
198
|
launcher_args=(
|
|
188
199
|
--target "$TARGET"
|
|
189
200
|
--port "$PORT"
|
package/dist/recipe-security.js
CHANGED
|
@@ -9,7 +9,14 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
9
9
|
"metamask.perps.read_orders",
|
|
10
10
|
"metamask.perps.read_account",
|
|
11
11
|
"metamask.perps.assert_positions",
|
|
12
|
-
"metamask.perps.assert_orders"
|
|
12
|
+
"metamask.perps.assert_orders",
|
|
13
|
+
// Both only read the collector's JSONL and export/assert over it.
|
|
14
|
+
// `metamask.analytics.start_capture` is deliberately NOT listed: it spawns a
|
|
15
|
+
// detached HTTP listener, so it falls through to 'arbitrary-code' and needs
|
|
16
|
+
// explicit approval. Classifying a process-spawning action as read-only to
|
|
17
|
+
// save an approval prompt would defeat the point of this allowlist.
|
|
18
|
+
"metamask.analytics.read_events",
|
|
19
|
+
"metamask.analytics.assert_events"
|
|
13
20
|
]);
|
|
14
21
|
const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
15
22
|
"metamask.wallet.setup",
|
|
@@ -18,16 +25,20 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
18
25
|
"metamask.perps.close_positions",
|
|
19
26
|
"metamask.perps.close_orders",
|
|
20
27
|
"metamask.perps.place_order",
|
|
28
|
+
"metamask.perps.edit_order",
|
|
21
29
|
"metamask.perps.update_position_tpsl",
|
|
22
30
|
"metamask.perps.ensure_positions",
|
|
23
31
|
"metamask.perps.ensure_orders",
|
|
24
32
|
"metamask.perps.start_state",
|
|
25
|
-
"metamask.perps.teardown_state"
|
|
33
|
+
"metamask.perps.teardown_state",
|
|
34
|
+
// Flips MetaMetrics consent in the wallet, so app-mutation, not read-only.
|
|
35
|
+
"metamask.analytics.set_consent"
|
|
26
36
|
]);
|
|
27
37
|
const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
28
38
|
"metamask.perps.close_positions",
|
|
29
39
|
"metamask.perps.close_orders",
|
|
30
40
|
"metamask.perps.place_order",
|
|
41
|
+
"metamask.perps.edit_order",
|
|
31
42
|
"metamask.perps.update_position_tpsl",
|
|
32
43
|
"metamask.perps.ensure_positions",
|
|
33
44
|
"metamask.perps.ensure_orders",
|
|
@@ -618,6 +618,60 @@ export function redactPosition(position) {
|
|
|
618
618
|
};
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Wrap a controller rejection so its typed code survives as data.
|
|
623
|
+
*
|
|
624
|
+
* `expect_error` used to match by substring against the message, which meant a
|
|
625
|
+
* message that merely mentioned a code — or contained a longer code with the
|
|
626
|
+
* expected one as a prefix — matched wrongly. Carrying the code on the error
|
|
627
|
+
* lets the comparison be exact.
|
|
628
|
+
*
|
|
629
|
+
* @param params - Rejection details.
|
|
630
|
+
* @param params.action - Action name, for the message.
|
|
631
|
+
* @param params.detail - Human context for the message.
|
|
632
|
+
* @param params.code - The controller's typed error code.
|
|
633
|
+
* @returns An error carrying `perpsErrorCode`.
|
|
634
|
+
*/
|
|
635
|
+
export function controllerRejection(params) {
|
|
636
|
+
const { action, detail, code } = params;
|
|
637
|
+
const error = new Error(`${action} was rejected: ${code}${detail ? ` (${detail})` : ''}`);
|
|
638
|
+
error.perpsErrorCode = code;
|
|
639
|
+
return error;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Read an optional node param, treating an unset template as absent.
|
|
644
|
+
*
|
|
645
|
+
* Recipes are static JSON, so an optional parameter that a caller leaves at its
|
|
646
|
+
* default arrives as an empty string rather than being omitted. Passing that
|
|
647
|
+
* through as a set-but-empty value is how a plain order ends up carrying an
|
|
648
|
+
* empty TP/SL size, which the controller then rejects as invalid. Whitespace is
|
|
649
|
+
* trimmed for the same reason.
|
|
650
|
+
*
|
|
651
|
+
* @param node - The recipe node.
|
|
652
|
+
* @param snake - snake_case param name.
|
|
653
|
+
* @param camel - camelCase alias.
|
|
654
|
+
* @returns The value as a string, or undefined when the node did not set it.
|
|
655
|
+
*/
|
|
656
|
+
export function optionalParam(node, snake, camel) {
|
|
657
|
+
const normalize = (value) => {
|
|
658
|
+
if (value === undefined || value === null) {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
const text = String(value).trim();
|
|
662
|
+
return text.length === 0 ? undefined : text;
|
|
663
|
+
};
|
|
664
|
+
return normalize(node?.[snake]) ?? normalize(node?.[camel]);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function optionalBooleanParam(node, snake, camel) {
|
|
668
|
+
const value = optionalParam(node, snake, camel);
|
|
669
|
+
if (value === undefined) return undefined;
|
|
670
|
+
if (value.toLowerCase() === 'true') return true;
|
|
671
|
+
if (value.toLowerCase() === 'false') return false;
|
|
672
|
+
throw new Error(`${snake} must be true or false; got ${value}.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
621
675
|
export function redactOrder(order) {
|
|
622
676
|
return {
|
|
623
677
|
coin: order.coin ?? order.symbol ?? null,
|
|
@@ -691,16 +745,95 @@ async function disconnectController(exitCode) {
|
|
|
691
745
|
}
|
|
692
746
|
}
|
|
693
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Read the rejection a node expects, if any.
|
|
750
|
+
*
|
|
751
|
+
* @param input - Adapter input (node.expect_error / node.expectError).
|
|
752
|
+
* @returns The expected error token, or undefined when the node expects success.
|
|
753
|
+
*/
|
|
754
|
+
function expectedErrorFor(input) {
|
|
755
|
+
const node = input?.node ?? {};
|
|
756
|
+
for (const key of ['expect_error', 'expectError']) {
|
|
757
|
+
const value = node[key];
|
|
758
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
759
|
+
return value.trim();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Run a Core perps adapter, honouring an expected rejection.
|
|
767
|
+
*
|
|
768
|
+
* Most nodes assert that an operation succeeds. A controller's refusals are part
|
|
769
|
+
* of its contract too — that a partial size below the asset's precision, or a
|
|
770
|
+
* TP/SL linkage the venue cannot express, is rejected with a typed error and
|
|
771
|
+
* before any side effect. Without `expect_error` those can only be proven by a
|
|
772
|
+
* bespoke script, because every action here throws on failure.
|
|
773
|
+
*
|
|
774
|
+
* With `expect_error` set, a throw whose message carries the expected token is
|
|
775
|
+
* the passing outcome: the rejection is written out as evidence and the process
|
|
776
|
+
* exits 0. Anything else fails — a different error, or the operation succeeding
|
|
777
|
+
* when it should have been refused. The success case is checked explicitly
|
|
778
|
+
* rather than left to fall through, so a contract that silently stops rejecting
|
|
779
|
+
* is caught rather than reported as a pass.
|
|
780
|
+
*
|
|
781
|
+
* Actions surface a controller's `{ success: false, error }` result by throwing
|
|
782
|
+
* with the code in the message (see place_order.mjs), so a substring match
|
|
783
|
+
* covers both that shape and a genuine throw.
|
|
784
|
+
*
|
|
785
|
+
* When the param is absent this is byte-identical to the previous behaviour.
|
|
786
|
+
*
|
|
787
|
+
* @param callback - The adapter body, receiving the loaded input.
|
|
788
|
+
*/
|
|
694
789
|
export async function runAdapter(callback) {
|
|
695
790
|
const input = await loadInput();
|
|
791
|
+
const expectedError = expectedErrorFor(input);
|
|
696
792
|
let exitCode = 0;
|
|
697
793
|
try {
|
|
698
|
-
|
|
794
|
+
const output = await callback(input);
|
|
795
|
+
if (expectedError) {
|
|
796
|
+
// Not thrown: a rejection asserted here must not be satisfied by the
|
|
797
|
+
// message this branch would itself produce.
|
|
798
|
+
exitCode = 1;
|
|
799
|
+
process.stderr.write(
|
|
800
|
+
`[core/perps] ${input.action} expected rejection ${expectedError}, but it succeeded.\n`,
|
|
801
|
+
);
|
|
802
|
+
} else {
|
|
803
|
+
await writeOutput(input, output);
|
|
804
|
+
}
|
|
699
805
|
} catch (error) {
|
|
700
|
-
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
806
|
+
const message = error?.message ?? String(error);
|
|
807
|
+
// A typed proof requires the code as data. Matching on the message was a
|
|
808
|
+
// weaker check wearing the same name: an adapter's own error, or any text
|
|
809
|
+
// that merely mentioned a code, could satisfy it. Only a rejection the
|
|
810
|
+
// action tagged with the controller's code counts, compared exactly — so a
|
|
811
|
+
// proof cannot pass on a failure the controller never issued.
|
|
812
|
+
const code = error?.perpsErrorCode;
|
|
813
|
+
if (expectedError && code === expectedError) {
|
|
814
|
+
await writeOutput(input, {
|
|
815
|
+
action: input.action,
|
|
816
|
+
source: 'expect_error',
|
|
817
|
+
rejected: true,
|
|
818
|
+
matched: true,
|
|
819
|
+
matchedOn: 'code',
|
|
820
|
+
expectedError,
|
|
821
|
+
actualErrorCode: code,
|
|
822
|
+
actualError: message,
|
|
823
|
+
});
|
|
824
|
+
} else {
|
|
825
|
+
exitCode = 1;
|
|
826
|
+
if (expectedError) {
|
|
827
|
+
process.stderr.write(
|
|
828
|
+
code === undefined
|
|
829
|
+
? `[core/perps] ${input.action} expected rejection ${expectedError}, but the failure carries no controller error code — it did not come from the controller.\n`
|
|
830
|
+
: `[core/perps] ${input.action} expected rejection ${expectedError}, but the controller rejected with ${code}.\n`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
// Surface the failure; still tear down so a half-open WebSocket from a
|
|
834
|
+
// failed write doesn't leave the process wedged open.
|
|
835
|
+
process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
|
|
836
|
+
}
|
|
704
837
|
} finally {
|
|
705
838
|
await disconnectController(exitCode);
|
|
706
839
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getCoreController,
|
|
3
3
|
isDirectRun,
|
|
4
|
+
optionalParam,
|
|
4
5
|
redactOrder,
|
|
5
6
|
requireExplicitSelection,
|
|
6
7
|
runAdapter,
|
|
@@ -19,6 +20,123 @@ import {
|
|
|
19
20
|
// expect_size (proves a partial TP/SL quantity). They use the controller's own
|
|
20
21
|
// field names (triggerOrderType, triggerPrice, orderType, reduceOnly, size).
|
|
21
22
|
|
|
23
|
+
// A count proves only how many orders exist, never that one is the linked TP/SL
|
|
24
|
+
// the caller asked for: two unrelated orders satisfy `expect_count: 2` just as
|
|
25
|
+
// well as a parent and its child. So assert the LINK the controller exposes —
|
|
26
|
+
// the parent's own takeProfitOrderId / stopLossOrderId — and that the id it
|
|
27
|
+
// names is really resting as a reduce-only trigger.
|
|
28
|
+
export function expectedChildLinks(input) {
|
|
29
|
+
const node = input.node ?? {};
|
|
30
|
+
const wanted = [];
|
|
31
|
+
const truthy = (value) =>
|
|
32
|
+
value !== undefined && value !== null && String(value).trim() !== '' &&
|
|
33
|
+
String(value).toLowerCase() !== 'false';
|
|
34
|
+
if (truthy(optionalParam(node, 'expect_take_profit_child', 'expectTakeProfitChild'))) {
|
|
35
|
+
wanted.push({ label: 'take profit', idField: 'takeProfitOrderId' });
|
|
36
|
+
}
|
|
37
|
+
if (truthy(optionalParam(node, 'expect_stop_loss_child', 'expectStopLossChild'))) {
|
|
38
|
+
wanted.push({ label: 'stop loss', idField: 'stopLossOrderId' });
|
|
39
|
+
}
|
|
40
|
+
return wanted;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assertChildLinks(matching, allOrders, wanted) {
|
|
44
|
+
const resolved = [];
|
|
45
|
+
if (wanted.length === 0) return resolved;
|
|
46
|
+
|
|
47
|
+
const hasLink = (order, idField) =>
|
|
48
|
+
order[idField] !== undefined && String(order[idField]).trim() !== '';
|
|
49
|
+
const parent = matching.find((order) =>
|
|
50
|
+
wanted.every(({ idField }) => hasLink(order, idField)),
|
|
51
|
+
);
|
|
52
|
+
if (!parent) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Expected one matching order to expose ${wanted.map(({ idField }) => idField).join(' and ')}, but none did. Matching orders: ${JSON.stringify(matching.map((order) => ({ id: order.orderId, takeProfitOrderId: order.takeProfitOrderId, stopLossOrderId: order.stopLossOrderId })))}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalizedSide = (order) => {
|
|
59
|
+
const value = String(order.side ?? order.dir ?? '').toLowerCase();
|
|
60
|
+
if (value === 'buy' || value === 'b' || value.includes('long')) return 'buy';
|
|
61
|
+
if (value === 'sell' || value === 'a' || value.includes('short')) return 'sell';
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
const parentSymbol = parent.symbol ?? parent.coin;
|
|
65
|
+
const parentSide = normalizedSide(parent);
|
|
66
|
+
|
|
67
|
+
for (const { label, idField } of wanted) {
|
|
68
|
+
const isTakeProfit = idField === 'takeProfitOrderId';
|
|
69
|
+
const priceField = isTakeProfit ? 'takeProfitPrice' : 'stopLossPrice';
|
|
70
|
+
const triggerFamily = isTakeProfit ? 'take_profit_' : 'stop_';
|
|
71
|
+
const childId = String(parent[idField]);
|
|
72
|
+
const child = allOrders.find(
|
|
73
|
+
(order) => String(order.orderId ?? order.oid) === childId,
|
|
74
|
+
);
|
|
75
|
+
if (!child) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Order ${parent.orderId} names ${idField}=${childId} as its ${label} child, but no such order is live — the child was dropped after being linked.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (child.reduceOnly !== true) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`The ${label} child ${childId} is live but not reduce-only, so it would open exposure rather than close it.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (child.isTrigger !== true) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`The ${label} child ${childId} is live but is not a trigger order, so it cannot fire as a ${label}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const childType = String(child.triggerOrderType ?? '').toLowerCase();
|
|
91
|
+
if (!childType.startsWith(triggerFamily)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`The ${label} child ${childId} has triggerOrderType=${JSON.stringify(child.triggerOrderType)}, expected the ${triggerFamily} family.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const childSymbol = child.symbol ?? child.coin;
|
|
97
|
+
if (parentSymbol === undefined || childSymbol !== parentSymbol) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`The ${label} child ${childId} is on ${childSymbol ?? 'an unknown market'}, expected ${parentSymbol ?? 'the parent market'}.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const childSide = normalizedSide(child);
|
|
103
|
+
if (
|
|
104
|
+
parentSide === undefined ||
|
|
105
|
+
childSide === undefined ||
|
|
106
|
+
childSide === parentSide
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`The ${label} child ${childId} has side=${child.side ?? child.dir ?? 'unknown'}, expected the opposite of parent side=${parent.side ?? parent.dir ?? 'unknown'}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const parentPrice = parent[priceField];
|
|
113
|
+
const childPrice = child.triggerPrice ?? child.triggerPx;
|
|
114
|
+
if (
|
|
115
|
+
parentPrice === undefined ||
|
|
116
|
+
childPrice === undefined ||
|
|
117
|
+
Number(parentPrice) !== Number(childPrice)
|
|
118
|
+
) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`The ${label} child ${childId} has triggerPrice=${JSON.stringify(childPrice)}, expected the parent's ${priceField}=${JSON.stringify(parentPrice)}.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
resolved.push({
|
|
124
|
+
link: idField,
|
|
125
|
+
parentOrderId: String(parent.orderId ?? parent.oid),
|
|
126
|
+
childOrderId: childId,
|
|
127
|
+
child: {
|
|
128
|
+
symbol: childSymbol,
|
|
129
|
+
side: childSide,
|
|
130
|
+
triggerOrderType: child.triggerOrderType,
|
|
131
|
+
triggerPrice: childPrice,
|
|
132
|
+
size: child.size ?? child.sz,
|
|
133
|
+
reduceOnly: child.reduceOnly,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return resolved;
|
|
138
|
+
}
|
|
139
|
+
|
|
22
140
|
export function expectedOpen(input) {
|
|
23
141
|
if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
|
|
24
142
|
const state = String(input.node.state).toLowerCase();
|
|
@@ -40,23 +158,42 @@ export function expectedOpen(input) {
|
|
|
40
158
|
* @returns True when only trigger orders should be considered.
|
|
41
159
|
*/
|
|
42
160
|
export function onlyTriggerOrders(input) {
|
|
43
|
-
const value =
|
|
161
|
+
const value = optionalParam(
|
|
162
|
+
input.node ?? {},
|
|
163
|
+
'only_trigger_orders',
|
|
164
|
+
'onlyTriggerOrders',
|
|
165
|
+
);
|
|
44
166
|
return value === true || String(value).toLowerCase() === 'true';
|
|
45
167
|
}
|
|
46
168
|
|
|
47
169
|
export function expectedTriggerData(input) {
|
|
48
170
|
const node = input.node ?? {};
|
|
171
|
+
const pick = (snake, camel) => {
|
|
172
|
+
const normalize = (value) =>
|
|
173
|
+
typeof value === 'string' && value.trim().length === 0
|
|
174
|
+
? undefined
|
|
175
|
+
: value ?? undefined;
|
|
176
|
+
return normalize(node[snake]) ?? normalize(node[camel]);
|
|
177
|
+
};
|
|
49
178
|
const expectations = {
|
|
50
|
-
triggerOrderType:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
179
|
+
triggerOrderType: pick(
|
|
180
|
+
'expect_trigger_order_type',
|
|
181
|
+
'expectTriggerOrderType',
|
|
182
|
+
),
|
|
183
|
+
triggerPrice: pick('expect_trigger_price', 'expectTriggerPrice'),
|
|
184
|
+
orderType: pick('expect_execution', 'expectExecution'),
|
|
185
|
+
reduceOnly: pick('expect_reduce_only', 'expectReduceOnly'),
|
|
186
|
+
size: pick('expect_size', 'expectSize'),
|
|
56
187
|
};
|
|
188
|
+
// A blank expectation is an unset recipe template, not a demand that the
|
|
189
|
+
// value equal ''. Keeping it turned an unused param into an assertion against
|
|
190
|
+
// an empty string — which for a price compares as 0.
|
|
57
191
|
return Object.fromEntries(
|
|
58
192
|
Object.entries(expectations).filter(
|
|
59
|
-
([, value]) =>
|
|
193
|
+
([, value]) =>
|
|
194
|
+
value !== undefined &&
|
|
195
|
+
value !== null &&
|
|
196
|
+
!(typeof value === 'string' && value.trim().length === 0),
|
|
60
197
|
),
|
|
61
198
|
);
|
|
62
199
|
}
|
|
@@ -103,8 +240,15 @@ export function assertTriggerData(orders, expectations) {
|
|
|
103
240
|
export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
104
241
|
requireExplicitSelection(input);
|
|
105
242
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
106
|
-
const timeoutMs = Number(
|
|
243
|
+
const timeoutMs = Number(
|
|
244
|
+
optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 0,
|
|
245
|
+
);
|
|
107
246
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
247
|
+
const expectedCount = optionalParam(
|
|
248
|
+
input.node ?? {},
|
|
249
|
+
'expect_count',
|
|
250
|
+
'expectCount',
|
|
251
|
+
);
|
|
108
252
|
let orders;
|
|
109
253
|
let matching;
|
|
110
254
|
while (true) {
|
|
@@ -118,7 +262,13 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
118
262
|
// applied to an unrelated parent order resting alongside them.
|
|
119
263
|
matching = matching.filter((order) => order.isTrigger === true);
|
|
120
264
|
}
|
|
121
|
-
|
|
265
|
+
// When an exact count is demanded, stopping at the first positive result
|
|
266
|
+
// reads a mid-replace moment as final: the previous order may not have been
|
|
267
|
+
// swept yet, or the new one not yet placed. Wait for the count itself.
|
|
268
|
+
const reached = expectedCount === undefined
|
|
269
|
+
? matching.length > 0
|
|
270
|
+
: matching.length === Number(expectedCount);
|
|
271
|
+
if (expectOpen ? reached : matching.length === 0) break;
|
|
122
272
|
if (Date.now() >= deadline) break;
|
|
123
273
|
await new Promise((resolve) =>
|
|
124
274
|
setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
|
|
@@ -130,9 +280,20 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
130
280
|
throw new Error('Expected at least one matching open Perps order, but found none.');
|
|
131
281
|
}
|
|
132
282
|
|
|
283
|
+
// "at least one" cannot distinguish a clean replace from one that added a
|
|
284
|
+
// second order beside the first, so a caller can pin the exact count.
|
|
285
|
+
if (expectedCount !== undefined && matching.length !== Number(expectedCount)) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Expected exactly ${expectedCount} matching open Perps order(s), but found ${matching.length}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
133
291
|
const triggerExpectations = expectedTriggerData(input);
|
|
292
|
+
const childLinks = expectedChildLinks(input);
|
|
293
|
+
let resolvedChildLinks = [];
|
|
134
294
|
if (expectOpen) {
|
|
135
295
|
assertTriggerData(matching, triggerExpectations);
|
|
296
|
+
resolvedChildLinks = assertChildLinks(matching, orders, childLinks);
|
|
136
297
|
}
|
|
137
298
|
if (!expectOpen && hasOrder) {
|
|
138
299
|
throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
|
|
@@ -146,7 +307,10 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
146
307
|
expectedOpen: expectOpen,
|
|
147
308
|
expectedTrigger:
|
|
148
309
|
Object.keys(triggerExpectations).length === 0 ? null : triggerExpectations,
|
|
310
|
+
expectedChildLinks: childLinks.map((link) => link.idField),
|
|
311
|
+
resolvedChildLinks,
|
|
149
312
|
matchingCount: matching.length,
|
|
313
|
+
expectedCount: expectedCount === undefined ? null : Number(expectedCount),
|
|
150
314
|
orders: matching.map(redactOrder),
|
|
151
315
|
proofPath: 'perps-controller-getOpenOrders',
|
|
152
316
|
};
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
getCoreController,
|
|
3
3
|
isDirectRun,
|
|
4
4
|
redactPosition,
|
|
5
|
+
optionalParam,
|
|
5
6
|
requireExplicitSelection,
|
|
6
7
|
runAdapter,
|
|
7
8
|
selectedItems,
|
|
@@ -22,8 +23,15 @@ export function expectedOpen(input) {
|
|
|
22
23
|
export async function assertPositions(input, expectOpen = expectedOpen(input)) {
|
|
23
24
|
requireExplicitSelection(input);
|
|
24
25
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
25
|
-
const timeoutMs = Number(
|
|
26
|
+
const timeoutMs = Number(
|
|
27
|
+
optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 0,
|
|
28
|
+
);
|
|
26
29
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
30
|
+
const expectedCount = optionalParam(
|
|
31
|
+
input.node ?? {},
|
|
32
|
+
'expect_count',
|
|
33
|
+
'expectCount',
|
|
34
|
+
);
|
|
27
35
|
let positions;
|
|
28
36
|
let matching;
|
|
29
37
|
while (true) {
|
|
@@ -32,7 +40,10 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
|
|
|
32
40
|
userAddress: accountAddress,
|
|
33
41
|
});
|
|
34
42
|
matching = selectedItems(input, positions);
|
|
35
|
-
|
|
43
|
+
const reached = expectedCount === undefined
|
|
44
|
+
? matching.length > 0
|
|
45
|
+
: matching.length === Number(expectedCount);
|
|
46
|
+
if (expectOpen ? reached : matching.length === 0) break;
|
|
36
47
|
if (Date.now() >= deadline) break;
|
|
37
48
|
await new Promise((resolve) =>
|
|
38
49
|
setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
|
|
@@ -43,6 +54,12 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
|
|
|
43
54
|
if (expectOpen && !hasPosition) {
|
|
44
55
|
throw new Error('Expected at least one matching open Perps position, but found none.');
|
|
45
56
|
}
|
|
57
|
+
|
|
58
|
+
if (expectedCount !== undefined && matching.length !== Number(expectedCount)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Expected exactly ${expectedCount} matching open Perps position(s), but found ${matching.length}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
46
63
|
if (!expectOpen && hasPosition) {
|
|
47
64
|
throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
|
|
48
65
|
}
|
|
@@ -54,6 +71,7 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
|
|
|
54
71
|
account: accountAddress,
|
|
55
72
|
expectedOpen: expectOpen,
|
|
56
73
|
matchingCount: matching.length,
|
|
74
|
+
expectedCount: expectedCount === undefined ? null : Number(expectedCount),
|
|
57
75
|
positions: matching.map(redactPosition),
|
|
58
76
|
proofPath: 'perps-controller-getPositions',
|
|
59
77
|
};
|