movehat 0.2.5 → 0.2.7

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.
@@ -13,6 +13,45 @@ import { logger } from "../ui/index.js";
13
13
  // same value here. No corruption, no in-flight-promise memoization
14
14
  // needed.
15
15
  const configCache = new Map();
16
+ // In-flight load deduplication (#47). When two callers hit a cold cache
17
+ // for the same config file concurrently, both would invoke
18
+ // `register()` from `tsx/esm/api` and race on the unregister cleanup.
19
+ // The dedup map ensures only ONE load runs per (path, mtime) burst —
20
+ // concurrent callers await the same Promise. Cleared after the load
21
+ // settles so a later edit (new mtime) can re-trigger a cold load.
22
+ const inFlightLoads = new Map();
23
+ // Hostnames recognized as safe targets for the deterministic test key
24
+ // auto-injection. Any URL whose hostname is not in this set causes the
25
+ // test-key path to be skipped even if the network NAME is 'testnet' or
26
+ // 'local' (#40 — name-only gating was spoofable when users named a
27
+ // production-pointing network 'testnet').
28
+ const TEST_ENDPOINT_HOSTS = new Set([
29
+ "testnet.movementnetwork.xyz",
30
+ "localhost",
31
+ "127.0.0.1",
32
+ "::1",
33
+ ]);
34
+ function isKnownTestEndpoint(url) {
35
+ try {
36
+ return TEST_ENDPOINT_HOSTS.has(new URL(url).hostname.toLowerCase());
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ // Render a URL for log output without exposing userinfo (`user:pass@`)
43
+ // or query strings (`?apiKey=…`). Returns the protocol + host + pathname
44
+ // only, which is enough for the operator to identify the endpoint
45
+ // without leaking embedded credentials to CI logs.
46
+ function sanitizeUrlForLog(url) {
47
+ try {
48
+ const parsed = new URL(url);
49
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
50
+ }
51
+ catch {
52
+ return "<invalid-url>";
53
+ }
54
+ }
16
55
  /**
17
56
  * Loads the user's movehat.config.{ts,js} from the current working directory.
18
57
  *
@@ -47,32 +86,58 @@ export async function loadUserConfig() {
47
86
  if (cached && cached.mtimeMs === mtimeMs) {
48
87
  return cached.config;
49
88
  }
50
- let configModule;
51
- if (configPath.endsWith('.ts')) {
52
- const { register } = await import('tsx/esm/api');
53
- const unregister = register();
54
- try {
55
- const configUrl = pathToFileURL(configPath).href;
56
- configModule = await import(configUrl + '?mtime=' + mtimeMs);
57
- }
58
- finally {
59
- unregister();
60
- }
89
+ // Dedup concurrent cold-cache loads of the same file (#47).
90
+ // Without this, two callers race on tsx's register/unregister and
91
+ // the second may run its `await import()` after the first calls
92
+ // unregister(), removing the loader mid-flight. All callers go
93
+ // through the same `return await loadPromise` below so the outer
94
+ // try/catch wraps a consistent error regardless of who started the
95
+ // load.
96
+ let loadPromise = inFlightLoads.get(configPath);
97
+ if (!loadPromise) {
98
+ loadPromise = doLoadConfig(configPath, mtimeMs);
99
+ inFlightLoads.set(configPath, loadPromise);
100
+ // Clean up after settles. `.catch(() => undefined)` after the
101
+ // finally chain swallows the cleanup-promise rejection so it
102
+ // does not become an unhandled rejection — the actual awaiters
103
+ // still see the original rejection via `return await loadPromise`.
104
+ void loadPromise
105
+ .finally(() => {
106
+ if (inFlightLoads.get(configPath) === loadPromise) {
107
+ inFlightLoads.delete(configPath);
108
+ }
109
+ })
110
+ .catch(() => undefined);
61
111
  }
62
- else {
112
+ return await loadPromise;
113
+ }
114
+ catch (error) {
115
+ throw new Error(`Failed to load configuration file '${configPath}': ${error}`);
116
+ }
117
+ }
118
+ async function doLoadConfig(configPath, mtimeMs) {
119
+ let configModule;
120
+ if (configPath.endsWith('.ts')) {
121
+ const { register } = await import('tsx/esm/api');
122
+ const unregister = register();
123
+ try {
63
124
  const configUrl = pathToFileURL(configPath).href;
64
125
  configModule = await import(configUrl + '?mtime=' + mtimeMs);
65
126
  }
66
- const userConfig = configModule.default;
67
- if (!userConfig.networks || Object.keys(userConfig.networks).length === 0) {
68
- throw new Error("No networks defined in configuration. Add at least one network in the 'networks' field.");
127
+ finally {
128
+ unregister();
69
129
  }
70
- configCache.set(configPath, { mtimeMs, config: userConfig });
71
- return userConfig;
72
130
  }
73
- catch (error) {
74
- throw new Error(`Failed to load configuration file '${configPath}': ${error}`);
131
+ else {
132
+ const configUrl = pathToFileURL(configPath).href;
133
+ configModule = await import(configUrl + '?mtime=' + mtimeMs);
75
134
  }
135
+ const userConfig = configModule.default;
136
+ if (!userConfig.networks || Object.keys(userConfig.networks).length === 0) {
137
+ throw new Error("No networks defined in configuration. Add at least one network in the 'networks' field.");
138
+ }
139
+ configCache.set(configPath, { mtimeMs, config: userConfig });
140
+ return userConfig;
76
141
  }
77
142
  /**
78
143
  * Clear the in-memory config cache. Test-only escape hatch.
@@ -82,6 +147,7 @@ export async function loadUserConfig() {
82
147
  */
83
148
  export function _resetConfigCache() {
84
149
  configCache.clear();
150
+ inFlightLoads.clear();
85
151
  }
86
152
  /**
87
153
  * Resolve configuration for a specific network
@@ -136,15 +202,30 @@ export async function resolveNetworkConfig(userConfig, networkName) {
136
202
  if (accounts.length === 0 && process.env.PRIVATE_KEY) {
137
203
  accounts = [process.env.PRIVATE_KEY];
138
204
  }
139
- // 4. Validate we have at least one account (unless using testnet/local)
205
+ // 4. Validate we have at least one account (unless using testnet/local
206
+ // AND the URL is a recognized test endpoint — name alone is not enough
207
+ // per #40, since a user can name a network 'testnet' but point it at
208
+ // a production RPC and would otherwise inherit the deterministic test
209
+ // key with a production endpoint).
140
210
  if (accounts.length === 0 || !accounts[0]) {
141
- // Special case: Auto-generate test accounts for testing networks
142
- // testnet = public Movement test network (recommended)
143
- // local = local fork server
144
- if (selectedNetwork === "testnet" || selectedNetwork === "local") {
211
+ const isTestNetworkName = selectedNetwork === "testnet" || selectedNetwork === "local";
212
+ const isTestEndpoint = isTestNetworkName && isKnownTestEndpoint(networkConfig.url);
213
+ if (isTestNetworkName && !isTestEndpoint) {
214
+ // Name matches the reserved convention but URL is not a recognized
215
+ // test endpoint — block the deterministic test-key injection. Falls
216
+ // through to the standard "no accounts" throw below so the user gets
217
+ // actionable guidance.
218
+ logger.warning(`Network '${selectedNetwork}' uses a name reserved for testnet/local ` +
219
+ `but '${sanitizeUrlForLog(networkConfig.url)}' is not a recognized test endpoint. ` +
220
+ `Skipping auto-injection of the deterministic test key to protect ` +
221
+ `against accidental production use. Set PRIVATE_KEY explicitly, ` +
222
+ `configure 'accounts' in movehat.config.ts, or rename this network.`);
223
+ }
224
+ if (isTestEndpoint) {
145
225
  // Security: Using a deterministic test account (like Hardhat's default accounts)
146
226
  // This is SAFE because:
147
- // 1. Only used for testnet/local (never mainnet - that throws error below)
227
+ // 1. Only used for testnet/local against a known test endpoint
228
+ // (network NAME + URL allowlist enforced above; bypassed otherwise)
148
229
  // 2. Perfect for transaction simulation (no real funds)
149
230
  // 3. Deterministic = consistent test results
150
231
  const testPrivateKey = "0x0000000000000000000000000000000000000000000000000000000000000001";
@@ -155,8 +236,9 @@ export async function resolveNetworkConfig(userConfig, networkName) {
155
236
  logger.newline();
156
237
  }
157
238
  else {
158
- // For any other network (especially mainnet), REQUIRE explicit configuration
159
- // This prevents accidentally using the test key on production networks
239
+ // For any other network (mainnet, or testnet/local with a non-test
240
+ // URL), REQUIRE explicit configuration. This prevents accidentally
241
+ // using the test key on production networks.
160
242
  throw new Error(`Network '${selectedNetwork}' has no accounts configured.\n` +
161
243
  `\n` +
162
244
  `SECURITY: This network requires explicit account configuration.\n` +
@@ -1,3 +1,4 @@
1
+ import type { Account } from "@aptos-labs/ts-sdk";
1
2
  import type { MovehatRuntime } from "../types/runtime.js";
2
3
  import type { LocalNodeManager } from "../node/LocalNodeManager.js";
3
4
  import type { ForkServer } from "../fork/server.js";
@@ -13,14 +14,41 @@ export type HarnessMode = "local" | "fork" | "live";
13
14
  * a Proxy that synchronously throws {@link HarnessDisposedError} on any
14
15
  * post-`cleanup()` call to one of the deployment / script / view methods.
15
16
  *
16
- * AccountManager note: the underlying account pool is a process-wide
17
- * static (see `core/AccountManager.ts`). Two Harness instances in the
18
- * same process share account labels; this is the same constraint that
19
- * already governs `setupTestFixture`.
17
+ * Account isolation: each Harness owns a per-instance `AccountManager`
18
+ * (see `core/AccountManager.ts`) reachable at `harness.runtime.accountManager`.
19
+ * Two Harness instances in the same process have independent account
20
+ * pools and label maps — `Harness.createLocal({ accountLabels: ["alice"] })`
21
+ * twice produces two DIFFERENT alice accounts.
22
+ *
23
+ * Labeled accounts created during construction (via `accountLabels` in
24
+ * `createLocal`) are snapshotted onto `harness.accounts` at construction
25
+ * time. Late additions via `harness.runtime.accountManager.createAccount(...)`
26
+ * are NOT reflected in the `accounts` Record — that's a snapshot, not a
27
+ * live view. For live mode (`Harness.createLive`), `accounts` is `{}`
28
+ * because the live factory does not create labeled accounts; reach into
29
+ * `harness.runtime.accountManager.*` for advanced operations.
30
+ *
31
+ * The `AccountManager` class-static methods remain available in 0.2.x
32
+ * for backward compatibility (deprecation warning lands in 0.2.7,
33
+ * removal in 0.3.0 — see #270). New code should prefer
34
+ * `harness.accounts.<label>` for the common read path.
20
35
  */
21
36
  export declare class Harness {
22
37
  readonly mode: HarnessMode;
23
38
  readonly runtime: MovehatRuntime;
39
+ /**
40
+ * Labeled accounts snapshotted from `runtime.accountManager` at
41
+ * construction time. For `createLocal`, this is populated from the
42
+ * `accountLabels` option. For `createFork`, populated the same way.
43
+ * For `createLive`, empty (live mode does not create labeled accounts;
44
+ * use `harness.runtime.accountManager.loadAccountFromPrivateKey(...)`
45
+ * for direct key loading).
46
+ *
47
+ * Typed `Readonly` so the snapshot can't be mutated via
48
+ * `harness.accounts.alice = X`. Reach into
49
+ * `harness.runtime.accountManager.*` when you need mutable access.
50
+ */
51
+ readonly accounts: Readonly<Record<string, Account>>;
24
52
  /** @internal */
25
53
  readonly localNode?: LocalNodeManager;
26
54
  /** @internal */
@@ -12,14 +12,41 @@ import { runMoveScript } from "./script.js";
12
12
  * a Proxy that synchronously throws {@link HarnessDisposedError} on any
13
13
  * post-`cleanup()` call to one of the deployment / script / view methods.
14
14
  *
15
- * AccountManager note: the underlying account pool is a process-wide
16
- * static (see `core/AccountManager.ts`). Two Harness instances in the
17
- * same process share account labels; this is the same constraint that
18
- * already governs `setupTestFixture`.
15
+ * Account isolation: each Harness owns a per-instance `AccountManager`
16
+ * (see `core/AccountManager.ts`) reachable at `harness.runtime.accountManager`.
17
+ * Two Harness instances in the same process have independent account
18
+ * pools and label maps — `Harness.createLocal({ accountLabels: ["alice"] })`
19
+ * twice produces two DIFFERENT alice accounts.
20
+ *
21
+ * Labeled accounts created during construction (via `accountLabels` in
22
+ * `createLocal`) are snapshotted onto `harness.accounts` at construction
23
+ * time. Late additions via `harness.runtime.accountManager.createAccount(...)`
24
+ * are NOT reflected in the `accounts` Record — that's a snapshot, not a
25
+ * live view. For live mode (`Harness.createLive`), `accounts` is `{}`
26
+ * because the live factory does not create labeled accounts; reach into
27
+ * `harness.runtime.accountManager.*` for advanced operations.
28
+ *
29
+ * The `AccountManager` class-static methods remain available in 0.2.x
30
+ * for backward compatibility (deprecation warning lands in 0.2.7,
31
+ * removal in 0.3.0 — see #270). New code should prefer
32
+ * `harness.accounts.<label>` for the common read path.
19
33
  */
20
34
  export class Harness {
21
35
  mode;
22
36
  runtime;
37
+ /**
38
+ * Labeled accounts snapshotted from `runtime.accountManager` at
39
+ * construction time. For `createLocal`, this is populated from the
40
+ * `accountLabels` option. For `createFork`, populated the same way.
41
+ * For `createLive`, empty (live mode does not create labeled accounts;
42
+ * use `harness.runtime.accountManager.loadAccountFromPrivateKey(...)`
43
+ * for direct key loading).
44
+ *
45
+ * Typed `Readonly` so the snapshot can't be mutated via
46
+ * `harness.accounts.alice = X`. Reach into
47
+ * `harness.runtime.accountManager.*` when you need mutable access.
48
+ */
49
+ accounts;
23
50
  /** @internal */
24
51
  localNode;
25
52
  /** @internal */
@@ -30,6 +57,7 @@ export class Harness {
30
57
  constructor(init) {
31
58
  this.mode = init.mode;
32
59
  this.runtime = init.runtime;
60
+ this.accounts = init.runtime.accountManager.getLabeledAccounts();
33
61
  if (init.localNode)
34
62
  this.localNode = init.localNode;
35
63
  if (init.forkServer)
@@ -14,8 +14,10 @@ export async function setupTestEnvironment(networkName) {
14
14
  fullnode: config.rpc,
15
15
  });
16
16
  const aptos = new Aptos(aptosConfig);
17
- // Load account using AccountManager
18
- const account = AccountManager.loadAccountFromPrivateKey(config.privateKey);
17
+ // Load account using a throwaway AccountManager instance. This helper
18
+ // returns Aptos+Account directly (no runtime exposure), so a local
19
+ // instance suffices — no shared pool needed.
20
+ const account = new AccountManager().loadAccountFromPrivateKey(config.privateKey);
19
21
  logger.success("Test environment ready");
20
22
  logger.plain(` Account: ${account.accountAddress.toString()}`);
21
23
  logger.plain(` Network: ${config.network}`);
@@ -28,5 +30,6 @@ export async function setupTestEnvironment(networkName) {
28
30
  };
29
31
  }
30
32
  export function createTestAccount() {
31
- return AccountManager.createAccount();
33
+ // Throwaway instance — see setupTestEnvironment for rationale.
34
+ return new AccountManager().createAccount();
32
35
  }
@@ -118,8 +118,12 @@ async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalan
118
118
  // leaks and port 8080 stays bound until the OS reaps it (manifests as
119
119
  // "Movement command failed" on the next test:example run).
120
120
  try {
121
+ // Per-context AccountManager. Threaded into initRuntime below so
122
+ // the runtime exposes the SAME instance — the labels created by
123
+ // createBatch here are visible on `runtime.accountManager`.
124
+ const accountManager = new AccountManager();
121
125
  logger.step(`Generating ${accountLabels.length} test accounts...`);
122
- const accounts = AccountManager.createBatch(accountLabels);
126
+ const accounts = accountManager.createBatch(accountLabels);
123
127
  for (const [label, account] of Object.entries(accounts)) {
124
128
  logger.plain(` ${label}: ${account.accountAddress.toString()}`);
125
129
  }
@@ -129,12 +133,13 @@ async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalan
129
133
  await localNode.fundAccounts(accountsList, defaultBalance);
130
134
  }
131
135
  logger.step("Initializing runtime for local network...");
132
- const deployerPrivateKey = AccountManager.exportPrivateKeys(["deployer"]).deployer;
136
+ const deployerPrivateKey = accountManager.exportPrivateKeys(["deployer"]).deployer;
133
137
  if (!deployerPrivateKey) {
134
138
  throw new Error("Failed to get deployer private key");
135
139
  }
136
140
  const runtime = await initRuntime({
137
141
  network: "local",
142
+ accountManager,
138
143
  configOverride: {
139
144
  networks: {
140
145
  local: {
@@ -256,8 +261,10 @@ async function setupWithFork(options, accountLabels, autoFund, defaultBalance) {
256
261
  // we leak the listener.
257
262
  try {
258
263
  await new Promise((resolve) => setTimeout(resolve, 500));
264
+ // Per-context AccountManager (mirror of setupWithLocalNode pattern).
265
+ const accountManager = new AccountManager();
259
266
  logger.step(`Generating ${accountLabels.length} test accounts...`);
260
- const accounts = AccountManager.createBatch(accountLabels);
267
+ const accounts = accountManager.createBatch(accountLabels);
261
268
  for (const [label, account] of Object.entries(accounts)) {
262
269
  logger.plain(` ${label}: ${account.accountAddress.toString()}`);
263
270
  }
@@ -267,12 +274,13 @@ async function setupWithFork(options, accountLabels, autoFund, defaultBalance) {
267
274
  await forkManager.fundMultipleAccounts(addresses, defaultBalance);
268
275
  }
269
276
  logger.step("Initializing runtime for local network...");
270
- const deployerPrivateKey = AccountManager.exportPrivateKeys(["deployer"]).deployer;
277
+ const deployerPrivateKey = accountManager.exportPrivateKeys(["deployer"]).deployer;
271
278
  if (!deployerPrivateKey) {
272
279
  throw new Error("Failed to get deployer private key");
273
280
  }
274
281
  const runtime = await initRuntime({
275
282
  network: "local",
283
+ accountManager,
276
284
  configOverride: {
277
285
  networks: {
278
286
  local: {
@@ -22,10 +22,11 @@ export interface TestFixture<TModules extends string = string> {
22
22
  /**
23
23
  * Stop the local node / fork server this fixture started.
24
24
  *
25
- * Does **not** clear the shared `AccountManager` pool — clearing it
26
- * would break parallel `setupTestFixture` invocations that share the
27
- * pool. The pool grows for the lifetime of the process; the process
28
- * exit reclaims it.
25
+ * Each fixture owns its own `AccountManager` instance (via
26
+ * `setupLocalTesting` `initRuntime`), so there is no shared pool
27
+ * to clear accounts are garbage-collected when the fixture goes
28
+ * out of scope. Parallel `setupTestFixture` invocations have fully
29
+ * isolated label maps and pools (M9.2, #270).
29
30
  */
30
31
  teardown: () => Promise<void>;
31
32
  }
@@ -1,4 +1,3 @@
1
- import { AccountManager } from "../core/AccountManager.js";
2
1
  import { setupLocalTesting } from "./setupLocalTesting.js";
3
2
  import { logger } from "../ui/index.js";
4
3
  /**
@@ -59,7 +58,7 @@ export async function setupTestFixture(modules, accountLabels = ["alice", "bob"]
59
58
  // the original assembly error unchanged.
60
59
  try {
61
60
  const mh = ctx.runtime;
62
- const labeledAccounts = AccountManager.getLabeledAccounts();
61
+ const labeledAccounts = mh.accountManager.getLabeledAccounts();
63
62
  // any: TestFixture.accounts has a structural shape with required
64
63
  // `deployer/alice/bob` plus a `[key: string]: Account` index. The
65
64
  // builder fills the index dynamically; typing this as the exact
@@ -72,7 +71,7 @@ export async function setupTestFixture(modules, accountLabels = ["alice", "bob"]
72
71
  deployer: labeledAccounts.deployer,
73
72
  };
74
73
  for (const label of accountLabels) {
75
- accounts[label] = labeledAccounts[label] || AccountManager.getOrCreateLabeled(label);
74
+ accounts[label] = labeledAccounts[label] || mh.accountManager.getOrCreateLabeled(label);
76
75
  }
77
76
  const contracts = {};
78
77
  for (const moduleName of modules) {
@@ -119,14 +118,14 @@ export async function setupMinimalFixture(accountLabels = ["alice", "bob"], opti
119
118
  // Same teardown-on-assembly-failure pattern as setupTestFixture.
120
119
  try {
121
120
  const mh = ctx.runtime;
122
- const labeledAccounts = AccountManager.getLabeledAccounts();
121
+ const labeledAccounts = mh.accountManager.getLabeledAccounts();
123
122
  // any: see setupTestFixture above — same dynamic-key builder pattern.
124
123
  const accounts = {
125
124
  // non-null: deployer is unconditionally added to allLabels at L156 above.
126
125
  deployer: labeledAccounts.deployer,
127
126
  };
128
127
  for (const label of accountLabels) {
129
- accounts[label] = labeledAccounts[label] || AccountManager.getOrCreateLabeled(label);
128
+ accounts[label] = labeledAccounts[label] || mh.accountManager.getOrCreateLabeled(label);
130
129
  }
131
130
  logger.newline();
132
131
  logger.success("Minimal fixture ready!");
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from "./helpers/index.js";
2
- export type { MovehatConfig } from "./types/config.js";
2
+ export type { MovehatConfig, NetworkConfig, LocalTestingMode, } from "./types/config.js";
3
3
  export { initRuntime } from "./runtime.js";
4
+ export type { InitRuntimeOptions } from "./runtime.js";
4
5
  export type { MovehatRuntime, NetworkInfo } from "./types/runtime.js";
6
+ export type { ChildProcessAdapter } from "./utils/childProcessAdapter.js";
5
7
  export { ForkManager } from "./fork/manager.js";
6
8
  export { MovementApiClient } from "./fork/api.js";
7
9
  export { ForkStorage } from "./fork/storage.js";
package/dist/runtime.d.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  import { MovehatRuntime } from "./types/runtime.js";
2
2
  import { MovehatUserConfig } from "./types/config.js";
3
+ import { AccountManager } from "./core/AccountManager.js";
3
4
  export interface InitRuntimeOptions {
4
5
  network?: string;
5
6
  accountIndex?: number;
6
7
  configOverride?: Partial<MovehatUserConfig>;
8
+ /**
9
+ * Optional pre-constructed `AccountManager` instance. When supplied,
10
+ * the returned runtime's `accountManager` field is THIS instance —
11
+ * so labeled accounts created BEFORE `initRuntime` are visible on
12
+ * the runtime. When omitted, `initRuntime` constructs a fresh
13
+ * `new AccountManager()` per call.
14
+ *
15
+ * `setupLocalTesting` uses this option to thread one manager through
16
+ * `createBatch` → `exportPrivateKeys` → `initRuntime` so the deployer
17
+ * key it extracts ends up on the same manager the runtime exposes.
18
+ */
19
+ accountManager?: AccountManager;
7
20
  }
8
21
  /**
9
22
  * Initialize the Movehat Runtime Environment.
package/dist/runtime.js CHANGED
@@ -35,9 +35,12 @@ export async function initRuntime(options = {}) {
35
35
  fullnode: config.rpc,
36
36
  });
37
37
  const aptos = new Aptos(aptosConfig);
38
- // Setup accounts using AccountManager
38
+ // Setup accounts using AccountManager. Use the caller-supplied
39
+ // instance when threaded in (preserves labels created before
40
+ // initRuntime); otherwise construct a fresh one.
41
+ const accountManager = options.accountManager ?? new AccountManager();
39
42
  const accountIndex = options.accountIndex || 0;
40
- const accounts = AccountManager.loadAccountsFromConfig(config);
43
+ const accounts = accountManager.loadAccountsFromConfig(config);
41
44
  // Primary account (accounts[0] or selected index)
42
45
  const account = accounts[accountIndex];
43
46
  if (!account) {
@@ -73,10 +76,10 @@ export async function initRuntime(options = {}) {
73
76
  return getDeployedAddress(config.network, moduleName);
74
77
  };
75
78
  const createAccount = () => {
76
- return AccountManager.createAccount();
79
+ return accountManager.createAccount();
77
80
  };
78
81
  const getAccountHelper = (privateKeyHex) => {
79
- return AccountManager.loadAccountFromPrivateKey(privateKeyHex);
82
+ return accountManager.loadAccountFromPrivateKey(privateKeyHex);
80
83
  };
81
84
  const getAccountByIndex = (index) => {
82
85
  const acc = accounts[index];
@@ -95,6 +98,7 @@ export async function initRuntime(options = {}) {
95
98
  aptos,
96
99
  account,
97
100
  accounts,
101
+ accountManager,
98
102
  getContract: getContractHelper,
99
103
  deployContract,
100
104
  getDeployment,
@@ -1,7 +1,7 @@
1
1
  // @ts-nocheck - This is a template file, dependencies are installed in user projects
2
2
  import { describe, it, before, after } from "mocha";
3
3
  import { expect } from "chai";
4
- import { Harness, AccountManager } from "movehat";
4
+ import { Harness } from "movehat";
5
5
 
6
6
  describe("Counter Contract", () => {
7
7
  let harness;
@@ -23,10 +23,11 @@ describe("Counter Contract", () => {
23
23
  autoDeploy: ["counter"],
24
24
  });
25
25
 
26
- const labeled = AccountManager.getLabeledAccounts();
27
- deployer = labeled.deployer;
28
- alice = labeled.alice;
29
- bob = labeled.bob;
26
+ // harness.accounts is a snapshot of the labeled accounts created
27
+ // during Harness construction. Two `Harness.createLocal({ accountLabels:
28
+ // ["alice"] })` calls in the same process now produce DIFFERENT
29
+ // alice accounts (per-Harness isolation introduced in 0.2.7).
30
+ ({ deployer, alice, bob } = harness.accounts);
30
31
 
31
32
  const counterAddr = harness.runtime.getDeploymentAddress("counter");
32
33
  counter = harness.runtime.getContract(counterAddr, "counter");
@@ -2,6 +2,7 @@ import { Aptos, Account } from "@aptos-labs/ts-sdk";
2
2
  import { MovehatConfig } from "./config.js";
3
3
  import { MoveContract } from "../core/contract.js";
4
4
  import { DeploymentInfo } from "../core/deployments.js";
5
+ import type { AccountManager } from "../core/AccountManager.js";
5
6
  import type { ChildProcessAdapter } from "../utils/childProcessAdapter.js";
6
7
  export interface NetworkInfo {
7
8
  name: string;
@@ -14,6 +15,7 @@ export interface MovehatRuntime {
14
15
  aptos: Aptos;
15
16
  account: Account;
16
17
  accounts: Account[];
18
+ accountManager: AccountManager;
17
19
  getContract: (address: string, moduleName: string) => MoveContract;
18
20
  deployContract: (moduleName: string, options?: {
19
21
  packageDir?: string;
@@ -6,9 +6,12 @@
6
6
  /**
7
7
  * Extract the transaction hash from a `movement` CLI subcommand's stdout.
8
8
  *
9
- * Tries the context-bearing pattern first (`transaction hash: 0x…`,
10
- * `txn hash: 0x…`, `hash: 0x…`) and falls back to any 64-char hex
11
- * literal in the buffer. Returns `undefined` if no candidate matches.
9
+ * Only the context-bearing pattern is accepted (`transaction hash: 0x…`,
10
+ * `txn hash: 0x…`, `hash: 0x…`). When the context is absent we log a
11
+ * warning and return `undefined` so callers decide whether to throw —
12
+ * the previous behavior of falling back to "any 64-hex literal" was
13
+ * fragile: a padded module address or state root printed before the
14
+ * actual txhash would silently corrupt the cached deployment record.
12
15
  *
13
16
  * Shared by `core/Publisher.ts` (publish), `harness/codeObject.ts`
14
17
  * (deploy-object / upgrade-object), and `harness/script.ts`
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * @internal — not exported from `src/index.ts`.
5
5
  */
6
+ import { logger } from '../ui/index.js';
6
7
  /**
7
8
  * Extract the transaction hash from a `movement` CLI subcommand's stdout.
8
9
  *
9
- * Tries the context-bearing pattern first (`transaction hash: 0x…`,
10
- * `txn hash: 0x…`, `hash: 0x…`) and falls back to any 64-char hex
11
- * literal in the buffer. Returns `undefined` if no candidate matches.
10
+ * Only the context-bearing pattern is accepted (`transaction hash: 0x…`,
11
+ * `txn hash: 0x…`, `hash: 0x…`). When the context is absent we log a
12
+ * warning and return `undefined` so callers decide whether to throw —
13
+ * the previous behavior of falling back to "any 64-hex literal" was
14
+ * fragile: a padded module address or state root printed before the
15
+ * actual txhash would silently corrupt the cached deployment record.
12
16
  *
13
17
  * Shared by `core/Publisher.ts` (publish), `harness/codeObject.ts`
14
18
  * (deploy-object / upgrade-object), and `harness/script.ts`
@@ -20,6 +24,7 @@ export function parseTxHash(stdout) {
20
24
  const withContext = stdout.match(/(?:transaction\s*(?:hash)?|txn\s*(?:hash)?|hash):\s*(0x[a-fA-F0-9]{64})\b/i);
21
25
  if (withContext?.[1])
22
26
  return withContext[1];
23
- const fallback = stdout.match(/\b(0x[a-fA-F0-9]{64})\b/);
24
- return fallback?.[1];
27
+ logger.warning(`parseTxHash: no contextual 'transaction|txn|hash: 0x…' match in ${stdout.length}-byte CLI output. ` +
28
+ `Returning undefined; the caller decides whether to error.`);
29
+ return undefined;
25
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movehat",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Hardhat-like development framework for Movement L1 smart contracts",
6
6
  "bin": {