Skip to content

scripts: load generator for arctic-1 and atlantic-2 - #3850

Open
kollegian wants to merge 6 commits into
mainfrom
scripts/load-generator
Open

scripts: load generator for arctic-1 and atlantic-2#3850
kollegian wants to merge 6 commits into
mainfrom
scripts/load-generator

Conversation

@kollegian

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

A replayer for arctic-1 and atlantic-2. Replays pacific 1 load onto the testnets.

Testing performed to validate your change

No prod code change.

@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Isolated to integration test tooling and testnets, but EXECUTE=1 submits real transactions and relies on mnemonics/RPC configuration; large new surface with vendored GPL contracts.

Overview
Adds a new integration_test/load_generator TypeScript package that captures read-only Pacific-1 block segments and replays newly signed, bounded equivalent load on arctic-1 or atlantic-2, with EXECUTE=0 dry-run by default and integration-test README pointing to it.

The runner covers capture/validate, schema-v4 fixture deploy (canonical SushiSwap V2 plus deterministic DeFi-shaped contracts), worker provisioning, timed/buffered continuous replay, TPS and gas/calldata caps, and Prometheus/Grafana observability. Pacific traffic is classified and translated (semantic swaps/lending/staking/etc. onto fixtures; unknown EVM via CallGraphHarness, ProfileLoadHarness, or SyntheticCreationHarness; Cosmos bank-shaped or skipped privileged traffic) rather than rebroadcasting mainnet signatures or calling Pacific addresses.

Hardhat compiles multi-version Solidity (0.6.12 Sushi + 0.8.x fixtures), ships GPL third-party notices, and adds fixture tests for Sushi V2, synthetic CREATE/CREATE2, and delegatecall/callback paths.

Reviewed by Cursor Bugbot for commit 078e86a. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 6, 2026, 12:18 PM

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 089374e. Configure here.

callType > 4 ||
(i == 0 && depth != 0) ||
(i != 0 && depth > previousDepth + 1)
) revert MalformedSpec();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call graph bounds mismatch

Medium Severity

CallGraphHarness caps depth at 8 and frames at 64, but capture config allows TRACE_MAX_DEPTH up to 32 and TRACE_MAX_FRAMES up to 256. encodeCallGraphHarness clamps depth with Math.min(8, ...) instead of truncating, which flattens deep trees into invalid specs that revert in _children, and oversized frame lists are still submitted instead of falling back to ProfileLoadHarness.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 089374e. Configure here.

seidroid[bot]
seidroid Bot previously requested changes Aug 4, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-structured, thoroughly documented standalone load-generator package with no production code changes and good defensive design (dry-run defaults, manifest/bytecode verification, bounded gas/calldata/value). One blocking safety gap: the replay executor never verifies the target Cosmos chain ID, so a misconfigured TARGET_COSMOS_RPC can broadcast load to an unintended network despite the README claiming otherwise.

Findings: 1 blocking | 13 non-blocking | 8 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor second-opinion review file (cursor-review.md) is empty — that pass produced no output. Codex's review had two findings, both confirmed and reflected below.
  • Nothing in CI runs this package. .github/workflows/ and the Makefile contain no reference to integration_test/load_generator, so npm run typecheck, npm test (mocha), and npm run test:fixtures (hardhat) are never executed automatically. For ~5k lines of new TS plus a 720-line spec suite, the tests will silently rot. Consider a workflow gated on paths: integration_test/load_generator/**.
  • README.md:9 states "EVM and Cosmos chain IDs, user/deployment manifests, and deployed bytecode are verified before submission." That is accurate for replay:users but not for replay:run — update the claim (or, preferably, close the gap flagged inline in runReplay.ts).
  • I could not execute the suite in the review environment (node_modules absent, deps not installed), so the assertions in test/core.spec.ts, test/corpusCleanup.spec.ts, and test/observability.spec.ts were reviewed by reading only. I did verify the hand-rolled SetCodeTx/SetCodeAuthorization protobuf decoders in evmCorrelation.ts field-by-field against proto/eth/tx.proto, and association.ts against proto/evm/query.proto — all field numbers and wire types match.
  • traceCapture.ts normalizeCallTrace bounds visit recursion by maxDepth, but the countSubtree helper it calls on truncation recurses over the entire remaining subtree with no depth bound. A pathologically deep captured call trace could overflow the stack and fail a whole segment. Low likelihood; an explicit depth cap or iterative walk would remove it.
  • Consider adding a test for the Cosmos adapter (cosmosAdapters.ts). test/core.spec.ts covers the EVM adapters, config parsing, trace normalization, scheduling, and correlation well, but buildCosmosReplay — including privileged-pattern detection and the MsgSend amount-bounding fallback — has no unit coverage.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.


const provider = new ethers.JsonRpcProvider(target.evmRpcUrl);
provider.pollingInterval = 200;
await verifyTargetRpc(target, provider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] The target Cosmos chain ID is never verified before the Cosmos lane broadcasts.

verifyTargetRpc only checks evmChainId against the EVM provider. The Cosmos lane goes through cosmosClient() (line 937), which calls SigningStargateClient.connectWithSigner(rpcUrl, ...) — cosmjs auto-detects the chain ID from the node and signs with it, so there is no local mismatch guard anywhere on this path.

That this is an oversight rather than a decision is clear from provisionUsers.ts:60-66, which does exactly the missing check:

const cosmosChainId = await admin.getChainId();
if (cosmosChainId !== target.cosmosChainId) { /* refuse */ }

and from README.md:9, which promises "EVM and Cosmos chain IDs … are verified before submission."

Failure scenario: an operator sets TARGET_NETWORK=arctic-1 but leaves a stale/typo'd TARGET_COSMOS_RPC pointing at a pacific-1 endpoint (the two default hostnames in config.ts differ by one label). The EVM check passes against the correct arctic-1 EVM RPC, so startup succeeds; every Cosmos-lane transaction — bank sends and shaped privileged traffic — is then signed for pacific-1 and broadcast to mainnet, funded by the same mnemonic. Because MAX_TPS defaults to 25 and runs default to 2 hours, this is sustained rather than a single stray tx.

Suggest asserting the Cosmos chain ID once at startup, next to verifyTargetRpc, so the run refuses to begin rather than discovering the mismatch per-worker.

await source.verifyChain();
await fs.mkdir(OUTPUT_DIRECTORY, { recursive: true });
const existingManifest = await readOptionalJson<CaptureManifest>(MANIFEST_PATH);
if (existingManifest?.complete) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This early return short-circuits validateCaptureManifest() and never compares the manifest's range against START_BLOCK/END_BLOCK.

A complete: true manifest is accepted unconditionally, so reusing a REPLAY_DIR after changing MAINNET_RPC/COSMOS_RPC, SEGMENT_BLOCKS, or the requested block range silently reports "Capture already complete" and exits 0 — downstream replay:run then replays stale data captured under different settings. The incomplete-manifest branch below (line 94-97) has the inverse problem: it validates the source/segment size but then adopts manifest.source.firstBlock/lastBlock, discarding a changed START_BLOCK/END_BLOCK without warning.

Suggest calling validateCaptureManifest(existingManifest) before the complete check, and rejecting (or at least logging) when an explicit START_BLOCK/END_BLOCK disagrees with the recorded range.

params: [address, toHex(blockTag)],
}));
const responses = await this.rpcBatch<string>(requests);
responses.forEach((response, index) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Batch responses are correlated to requests by array position, but nothing guarantees the array is complete.

rpcBatch sorts by id and checks for error entries, but never asserts body.length === requests.length. If an endpoint or intermediary proxy omits a single entry, responses[index] no longer lines up with batch[index], and every subsequent address gets the next address's code hash — silently written into the segment file with no error. attachCreationRuntime (line 417) has the same shape, mis-assigning deployedRuntimeCodeBytes/deployedRuntimeCodeHash across contract creations.

Those values feed adapter routing (recipientCodeHash picks the ERC4626 target in evmAdapters.ts:381-390) and encodeSyntheticCreationHarness, so corruption is silent and persistent in the corpus.

Suggest either validating the length in rpcBatch, or keying results by response.id (each request's id is already captured in the request objects) instead of relying on positional alignment.

const isUnmatched = record.fidelity !== 'semantic';
if (isUnmatched) this.unmatched++;
const line = `${JSON.stringify(record)}\n`;
this.queue = this.queue.then(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] A single appendFile failure permanently poisons this promise chain.

this.queue is reassigned to the chained promise with no .catch, so once it rejects, every later record() returns a promise that rejects without running its callback, and flush() rejects forever. Since record() is awaited in the hot loop (runReplay.ts:423, 472) and again in the finally block (line 565), one transient ENOSPC/EMFILE aborts the whole replay run and the finally rejection can mask the original error.

Suggest terminating the chain with a .catch that logs and increments a dropped-records counter, so audit-write failures degrade the audit trail rather than the run:

this.queue = this.queue.then(async () => { /* ... */ }).catch(error => {
    console.error('Bucket audit write failed:', error);
});

Related nit: initialize() writes to auditPath/unmatchedPath without mkdir -p on the parent, so a user-supplied BUCKET_AUDIT_PATH in a non-existent directory fails at startup (the default path works only because replayDirectory already exists).

targetTransactionBytes,
});
} catch (error) {
if (error instanceof TimeoutError) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] A timed-out Cosmos broadcast is counted as both submitted and rejected.

The comment correctly notes the node accepted the transaction and only the poll window elapsed, but control falls through to metrics.rejected++ / recordOutcome('cosmos', 'rejected') on line 873-874, and the audit record is written with outcome 'rejected'. So submitted + rejected double-counts these, and the rejected metric — the main signal for "the target chain refused our load" — is inflated by transactions that were in fact accepted.

Suggest an early return after recording submitted (plus a distinct timeout outcome or reason in the audit record) so the two cases stay distinguishable on the dashboard.

return body.sort((a, b) => a.id - b.id);
} catch (error) {
lastError = error;
await retryDelay(attempt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] retryDelay(attempt) also runs after the final attempt, so a permanently failing endpoint sleeps an extra ~15s before throwing (and again in cosmosRpcBatch, line 665). traceRpc gets this right with if (attempt + 1 < this.traceMaxRetries) — worth matching that guard here for consistency. With maxRetries defaulting to 10 this adds a pointless delay to every hard failure.

"version": "1.0.0",
"private": true,
"description": "Capture Pacific traffic and safely replay equivalent load on Sei testnets.",
"license": "MIT",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] "license": "MIT" declares a single license for a package that ships GPL-3.0-only Solidity sources under contracts/uniswapv2/ and contracts/mocks/WETH9.sol, plus their compiled artifacts under vendor/sushiswap-v2/.

THIRD_PARTY_NOTICES.md documents the situation well and the package is private: true (so never published), which limits the practical impact. Still, the metadata contradicts the notices file. Consider "license": "SEE LICENSE IN THIRD_PARTY_NOTICES.md" — or dropping the field entirely for a private package — so automated license scanners don't record this tree as pure MIT.

minBufferMinutes: positiveNumber(env, 'MIN_BUFFER_MINUTES', 5),
resumeBufferMinutes: positiveNumber(env, 'RESUME_BUFFER_MINUTES', 20),
metricsPort: nonNegativeInteger(env, 'METRICS_PORT', 9465),
metricsHost: string(env, 'METRICS_HOST', '0.0.0.0'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] METRICS_HOST defaults to 0.0.0.0, so the Prometheus endpoint binds every interface by default. The README explains this is needed for the bundled host.docker.internal scrape, and the endpoint is read-only, so this is a reasonable trade-off — but 127.0.0.1 as the default (documented as "set to 0.0.0.0 when using the bundled Docker stack") would be the safer direction, especially since these runs happen on shared/remote load-generation hosts.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.66%. Comparing base (a8f5cd1) to head (078e86a).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3850      +/-   ##
==========================================
- Coverage   61.74%   60.66%   -1.08%     
==========================================
  Files        2381     2269     -112     
  Lines      201667   188665   -13002     
==========================================
- Hits       124513   114460   -10053     
+ Misses      66074    64127    -1947     
+ Partials    11080    10078    -1002     
Flag Coverage Δ
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.
see 195 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread integration_test/load_generator/src/runReplay.ts
Comment thread integration_test/load_generator/src/replay/evmAdapters.ts

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A large, self-contained Pacific→testnet replay load generator added under integration_test/load_generator/; no production chain code is touched, the safety posture is good (EXECUTE gating, chain-id verification on both lanes, bounded gas/value/calldata, privileged-message filtering, GPL provenance documented), and there is real unit coverage. Findings are non-blocking correctness/hygiene issues — deadline overshoot in paced replay, capture-manifest reuse validation gaps, a CREATE2 salt that collides when the same corpus is replayed twice, MAX_TPS being a pre-flight check rather than a runtime limiter, and no CI job that actually runs the new tests.

Findings: 0 blocking | 15 non-blocking | 10 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • No CI wiring: nothing in .github/workflows/ or the root Makefile references integration_test/load_generator, so npm test (~1000 lines of new mocha specs), npm run typecheck, and hardhat compile never run in CI. Consider a small workflow gated on path filters so these specs don't silently rot.
  • The Cursor second-opinion pass (cursor-review.md) produced no output — this review merges only my findings with Codex's.
  • MAX_TPS reads like a runtime rate limit but is only a pre-flight check over the already-captured corpus (validatePeakTps); at runtime the only bound is WORKER_COUNT * MAX_PENDING_PER_LANE in-flight, with overflow dropped. Worth stating explicitly in README.md/.env.example so operators don't size runs assuming a throttle.
  • package.json declares "license": "MIT" while the repo root is Apache-2.0 and this same package tree ships GPL-3.0 sources under contracts/uniswapv2/ and contracts/mocks/WETH9.sol. The package is private: true and THIRD_PARTY_NOTICES.md correctly disclaims relicensing, so this is only a metadata inaccuracy — but it's the one machine-readable license statement in the package.
  • runBuffered.ts defaults (BUFFER_START_MODE=latest, CLEANUP_CONSUMED_SEGMENTS=1 for buffered mode) mean a plain npm run replay:start:buffered renames the existing corpus to <dir>-archive-<ts> and then deletes every segment file in it. That's re-capturable data and it is logged, but a first-time operator reusing the default REPLAY_DIR loses a prior capture; consider requiring an explicit opt-in for the delete step.
  • 10 suggestion(s)/nit(s) flagged inline on specific lines.

firstTimestamp ??= block.timestamp;
const targetElapsedMs =
((block.timestamp - firstTimestamp) * 1_000) / TIME_SCALE;
await sleepUntil(replayStartedAt + pausedMilliseconds + targetElapsedMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] sleepUntil is unbounded by runDeadline, and the deadline is not re-checked after it returns. The check at lines 400-405 happens before the sleep, so a large source timestamp gap (source-chain stall) or TIME_SCALE < 1 lets a RUN_DURATION_SECONDS-bounded run sleep well past its deadline and then still submit the entire next block plus wait for inclusion at line 525. sleepBounded(..., runDeadline) already exists and is used on the buffer-pause paths — using it here and re-testing the deadline afterwards would make the bound hold. (Also raised by Codex.)

gasBurn,
initcodeBytes,
useCreate2,
ethers.zeroPadValue(ethers.toBeHex(seed), 32),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The CREATE2 salt is derived purely from seed, which is context.sequence — a per-process counter starting at 0. The synthetic initcode is a pure function of runtimeBytes/requestedInitcodeBytes, so replaying the same corpus a second time against the same testnet reproduces identical (harness, salt, keccak(initcode)) triples. create2 then returns address(0) because the account already has code, and SyntheticCreationHarness.deploy reverts with CreationFailed. Every creation-shape transaction whose source used CREATE2 fails on any re-replay (REPLAY_FROM_START=1, or a fresh run over a retained corpus), silently degrading into included_failed rather than an obvious error. Mixing something run-unique into the salt (the worker address, the audit run id, or block.prevrandao inside the harness) would make it idempotent across runs.

);
}

function validateCaptureManifest(manifest: CaptureManifest): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] validateCaptureManifest checks the schema, network, both RPC URLs, segmentBlocks, and START_BLOCK/END_BLOCK when explicitly set — but not captureId, recordMinutes, or tipLagBlocks. Combined with the early return at line 85 for complete: true manifests and the fixed default REPLAY_DIR (runtime/replay/pacific-1/pacific-1-20m), re-running with e.g. RECORD_MINUTES=60 reports "Capture already complete" and hands back the old 20-minute corpus. The printed block range is the only hint. Including requestedMinutes/tipLagBlocks in the comparison (or refusing to reuse a directory whose captureId differs) would fail loudly instead. (Also raised by Codex.)

peakCheckedThroughBlock =
newSegments[newSegments.length - 1].source.lastBlock;
const peak = scaledPeakTps(newSegments);
if (peak > MAX_TPS) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The comment claims "worker-queue backpressure bounds the overflow", but the mechanism at lines 431 and 475 is a drop, not backpressure: when evmPending/cosmosPending hits MAX_PENDING_PER_LANE, the transaction is recorded as a queueFull skip and discarded. So in follow mode an over-MAX_TPS burst is neither throttled nor rejected — the excess source load is thrown away. It is at least attributed (worker_queue_full skip reason, pacific_replay_skipped_transactions_total), so this is a fidelity concern rather than a target-safety one, but the wording oversells it. Either reword to "excess is dropped and audited" or add a real throttle so MAX_TPS means the same thing before and after the run starts. (Codex rated this P1; I read it as a documented tradeoff rather than a defect, hence the lower severity.)

const liquidHash = context.deployment.codeHashes?.liquidStakingProxy;
const hashChoosesLiquid =
source.recipientCodeHash !== undefined &&
BigInt(source.recipientCodeHash) % 2n === 0n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] hashChoosesLiquid (even code hash ⇒ liquid staking) is evaluated as a peer of the exact liquidStakingProxy hash match rather than as a fallback, so it can override an exact match against the other candidate: a source transaction whose recipientCodeHash happens to equal codeHashes.strategyVaultProxy and is even routes to liquidStakingProxy. The comment describes exact hash → parity-of-hash → sequence parity as an ordered ladder; the code doesn't implement that order. Checking codeHashes.strategyVaultProxy explicitly before falling back to the parity heuristic would match the stated intent.

}

recordTraceProfile(source: ReplayEvmTransaction): void {
this.traceAvailability.inc({ availability: source.trace?.availability ?? 'not_captured' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The Prometheus label uses 'not_captured' here while traceAuditFields in runReplay.ts:1283 writes 'not-captured' into the bucket audit for the same condition. Correlating dashboards against the JSONL audit means normalizing the spelling by hand; pick one.

minBufferMinutes: positiveNumber(env, 'MIN_BUFFER_MINUTES', 5),
resumeBufferMinutes: positiveNumber(env, 'RESUME_BUFFER_MINUTES', 20),
metricsPort: nonNegativeInteger(env, 'METRICS_PORT', 9465),
metricsHost: string(env, 'METRICS_HOST', '0.0.0.0'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] METRICS_HOST defaults to 0.0.0.0, so the unauthenticated /metrics and /healthz endpoints are exposed on every interface of whatever host runs the replay. The scrape config only needs host.docker.internal, which resolves via extra_hosts: host-gateway — binding 127.0.0.1 by default and requiring an explicit override for remote scraping would be a safer default for a tool operators run on shared boxes.

}

function optionalPositiveInteger(env: Environment, key: string): number | undefined {
if (!env[key]) return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] if (!env[key]) return undefined treats START_BLOCK=0 as unset rather than rejecting it, and a whitespace-only value falls through to positiveInteger(env, key, 1) where string() trims to '' and returns the fallback — so START_BLOCK=" " silently becomes block 1. Trimming first and erroring on a present-but-unparseable value would match the strictness of the other helpers in this file.

- prometheus
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-replay}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Grafana falls back to admin/replay and both services publish on all interfaces (3000:3000, 9090:9090). Fine for a laptop, less so for the shared hosts these long replay runs tend to live on — consider 127.0.0.1:3000:3000 and requiring GRAFANA_ADMIN_PASSWORD rather than defaulting it.

} finally {
await bucketAudit.flush();
for (const worker of workers) worker.cosmosClient?.disconnect();
await liveMetrics.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The cleanup block disconnects the Cosmos clients and closes the metrics server but never calls provider.destroy() on the ethers.JsonRpcProvider created at line 176. With pollingInterval = 200 the provider can keep a timer alive, so a successful run may not exit on its own (the failure path calls process.exit(1), which masks it). Adding provider.destroy() here makes termination deterministic.

@seidroid
seidroid Bot dismissed their stale review August 6, 2026 11:11

Superseded: latest AI review found no blocking issues.

Comment thread integration_test/load_generator/src/association.ts

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large, self-contained TypeScript load generator under integration_test/load_generator with no production code changes; the design is careful (chain-ID/manifest/bytecode preflights, bounded adapters, continuity validation, audit trail, unit tests). I found no security or app-hash concerns, but several robustness and accounting issues in the replay loop, capture finalization, and corpus reads that are worth fixing before this is run unattended for hours.

Findings: 0 blocking | 14 non-blocking | 9 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • No CI wiring: npm run typecheck, npm test (900+ lines of unit tests) and npm run test:fixtures are not invoked by any workflow (.github/workflows/* only reference integration_test/dapp_tests, runner, rpc_tests, precompile_tests). These tests will silently rot. Consider a small workflow gated on integration_test/load_generator/**.
  • The Cursor second-opinion file (cursor-review.md) is empty, so that pass contributed no findings; the Codex file contained a single medium finding, which is folded into the inline comment on src/capture.ts:180.
  • EIP-7702 replay installs a delegation on the worker EOA (worker.wallet.authorize({address: profileHarness})) and never revokes it, so after one type-4 replay the worker accounts stay permanently delegated to ProfileLoadHarness on the shared testnet. Subsequent nativeTransfer replays to a delegated recipient also pay the extra delegated-account access on top of intrinsic gas against the hard-coded gasLimit: 30_000n — it still fits today, but the headroom is thin and the state change outlives the run. Worth documenting in the README safety section (or resetting delegation at shutdown).
  • Memory growth in follow mode: readReplaySegments parses/caches every segment in the directory on each poll, and pendingBlocks flatMaps all of them per iteration. Buffered mode bounds this via CLEANUP_CONSUMED_SEGMENTS=1, but a manually driven FOLLOW_SEGMENTS=1 npm run replay:run (cleanup defaults to 0 there) will grow the in-memory corpus for the whole run — with TRACE_CAPTURE_MODE=full segments are not small.
  • signCosmosToTargetSize can call client.sign() up to 6 times per Cosmos transaction (each performing an account query) purely to pad the memo to an exact byte target. Bounded, but it is the most expensive part of the Cosmos lane; consider computing the memo length from the first signed size only, or capping the retry at 2.
  • 9 suggestion(s)/nit(s) flagged inline on specific lines.

metrics.submitted++;
liveMetrics.recordOutcome('evm', 'submitted');
try {
const receipt = await response.wait();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] response.wait() has no timeout, so a broadcast transaction that never gets mined (mempool eviction, underpriced after a fee spike — fees only refresh every 60s) leaves this job pending forever. That wedges the whole run, not just the lane: worker.evmPending never decrements, and the per-block barrier await Promise.allSettled([...active]) (line 533) never resolves, so the loop stops re-checking runDeadline. Because SIGINT/SIGTERM are handled by setting stopRequested (only read between entries/blocks), the process also can't be stopped without SIGKILL — including by runBuffered's currentChild?.kill('SIGTERM'). Suggest response.wait(1, <timeoutMs>) and treating the timeout like the Cosmos lane's poll_timeout outcome (submitted, not rejected).

targetCalldataBytes: built.producedCalldataBytes,
});
} catch (error) {
metrics.rejected++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] A transaction that was successfully broadcast (already counted in metrics.submitted on line 699) but whose wait() fails for a non-status-0 reason lands here and is also counted as rejected, and liveMetrics.recordOutcome('evm','rejected') fires after 'submitted'. That inflates rejected for transactions that may still be included, and the nonce is resynced from pending for a nonce that was in fact consumed. Consider distinguishing "broadcast failed" from "receipt unavailable" (the latter mirrors the Cosmos poll_timeout bucket).

}

const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1_000);
const segmentFiles = (await fs.readdir(OUTPUT_DIRECTORY))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Agreeing with the Codex finding, with a narrower blast radius than stated: finalization globs every SEGMENT_FILENAME match in the output directory, so totals.canonicalTransactions/sourceBytes (and segmentFiles) can include segments outside the requested [start, end], while totals.blocks on line 211 is computed as end - start + 1 — the two halves of totals can describe different ranges. validateCaptureManifest and validateAgainstCheckpoint do block most reuse paths, so the reachable case is a directory that already holds out-of-range segments (e.g. one the buffered collector appended to, since collectContinuously writes segments without updating capture-manifest.json) and an incomplete manifest. Cheap fix: filter the globbed files to those whose parsed first/last block falls inside [start, end] and assert contiguity before writing complete: true.

}
return segments;
} catch (error) {
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] allowMissing swallows any ENOENT raised anywhere in the try, including a readFile for a segment that was unlinked between readdir and the read. That is a live race: runBuffered's collector polls this directory while the replay:run child is deleting consumed segments via cleanupConsumedReplaySegments. The result is [] rather than "the corpus minus one file", which the caller then reports as "Initial replay buffer disappeared" and backs off for up to 30s. Suggest scoping the ENOENT tolerance to the readdir (directory absent) and skipping just the file that vanished during the per-file read.

while (!stopping) {
try {
const segments = await readReplaySegments(REPLAY_DIRECTORY, true, segmentCache);
if (segments.length === 0) throw new Error('Initial replay buffer disappeared');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The collector's cursor is derived purely from the last segment file on disk, so it depends on the replay child leaving one behind. With RETAIN_COMPLETED_SEGMENTS=0 (an accepted config value — nonNegativeInteger) the child can legitimately empty the directory, and this throw then repeats forever with exponential backoff: collection never resumes even though capture-checkpoint.json holds exactly the continuity data needed (nextCollectHeight, lastCollectedEvmHash, lastCollectedCosmosHash). Suggest falling back to the checkpoint here, or refusing RETAIN_COMPLETED_SEGMENTS=0 in buffered mode.

context: CosmosAdapterContext,
): BuiltCosmosReplay {
if (source.isEvm) {
return skipped(source, 'Wrapped EVM transaction is replayed through the EVM lane');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This reason is misleading for one real case. An ante-failed EVM wrapper is captured with isEvm: true but no attached evm (see the if (!evm) continue path in pacificSource.captureSegment), so replayEntriesForBlock routes it to the Cosmos lane with evm: undefined — it is skipped here and is not replayed on the EVM lane either. Consider distinguishing source.evm ? 'replayed through the EVM lane' : 'wrapped EVM transaction with no linked EVM entry (ante-failed)' so the unbucketed audit doesn't attribute these to the other lane.

{ amount: coins('21000', 'usei'), gas: '200000' },
`associate ${target.network} replay user`,
);
client.disconnect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] client.disconnect() is not in a finally, so a throwing signAndBroadcast (or the explicit throw on line 120) leaks the Tendermint client for that user; with mapConcurrent at 5 and 100 users a mid-run failure leaks several. Also worth noting: the association-buffer funding pass on line 75 tops up every user below ASSOCIATION_BUFFER_USEI before checking who actually needs associating, so already-associated users get an unnecessary send.

return orderRpcBatchResponses(requests, body);
} catch (error) {
lastError = error;
await retryDelay(attempt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] retryDelay(attempt) runs unconditionally, including after the final attempt, so a hard failure sleeps up to ~15s before rethrowing (same in cosmosRpcBatch). With maxRetries: 10 and exponential backoff a permanently failing batch takes ~100s to surface. traceRpc already guards this correctly (if (attempt + 1 < this.traceMaxRetries)); mirroring that here would be consistent.

transaction.receipt.contractAddress,
);
if (creations.length === 0) return;
const responses = await this.rpcBatch<string>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This eth_getCode batch is unchunked, unlike fetchRecipientCodeHashes which caps batches at 50. A block with many successful contract creations issues one oversized JSON-RPC batch array, which some gateways reject outright (and a rejection fails the whole segment after the full retry ladder). Reusing the same chunk(..., 50) loop would make the two paths consistent.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A large, well-documented, self-contained TypeScript load generator under integration_test/load_generator/ that changes no production code. I found no blocking correctness or security defects — the hand-rolled SetCodeTx/SetCodeAuthorization protobuf decoders match proto/eth/tx.proto exactly, chain-ID/manifest/bytecode guards are in place, secrets are env-only and .env is gitignored, and the Docker observability stack binds to localhost with a required Grafana password. Remaining notes are hardening, CI wiring, and docs.

Findings: 0 blocking | 12 non-blocking | 7 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), so only Codex's two findings were available to merge. Both of Codex's points are real but non-blocking; see the inline comments on src/runReplay.ts:178 and :309.
  • The new test suites are not wired into CI. npm test (mocha, ~1,100 lines across test/core.spec.ts, test/corpusCleanup.spec.ts, test/observability.spec.ts), npm run typecheck, and npm run test:fixtures are documented in the README's Verification section but no workflow under .github/workflows/ references integration_test/load_generator. Consider a small job (Node setup + npm ci + typecheck + test) so regressions in the adapters and config parsing are caught — right now the tests are effectively documentation.
  • vendor/sushiswap-v2/PROVENANCE.json records sha256/gitBlob for every vendored Solidity file and artifact, but nothing verifies them. Only the three bytecode hashes (pairInitCodeHash, factoryCreationCodeHash, routerCreationCodeHash) are checked, and only at deploy time in validateSushiArtifacts. A unit test that hashes the vendored files against the manifest would make the provenance claims enforceable rather than aspirational.
  • README.md (Metrics section) tells operators to "keep METRICS_HOST=0.0.0.0" when using the bundled Docker stack. That exposes the unauthenticated /metrics endpoint (which leaks target network, time scale, and privileged mode via pacific_replay_run_info) on every interface. .env.example is appropriately cautious about this; the README should match — suggest documenting the Docker bridge gateway address or adding an explicit firewall caveat.
  • No prompt-injection or instruction-like content was found in the diff, commit messages, or PR description.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

cosmosVerifier.disconnect();
}
await verifyDeploymentCode(deployment, provider);
const users = { ...manifest, users: manifest.users.slice(0, WORKER_COUNT) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The two-user precondition on line 166 checks the full manifest, but the working set is sliced to WORKER_COUNT here. With WORKER_COUNT=1 against a 100-user manifest the check passes and the run proceeds with a single worker, at which point every worker-to-worker translation degenerates into a self-transfer: recipient = users[(workerIndex + 1) % users.length] resolves back to worker in buildEvmReplay (native + ERC20 transfer) and in buildCosmosReplay's bank paths. The generated load changes materially and silently.

Suggest validating after the slice, e.g. move the check to if (users.users.length < 2) throw new Error('At least two replay users are required') — or reject WORKER_COUNT < 2 in loadReplayConfig. (Raised by Codex as P2; agreed.)

if (newSegments.length > 0) {
peakCheckedThroughBlock = newSegments[newSegments.length - 1].source.lastBlock;
const peak = scaledPeakTps(newSegments);
if (peak > MAX_TPS) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] MAX_TPS is a hard precondition for the initially selected corpus (validatePeakTps throws) but only a warning for segments discovered during follow mode. The comment is honest that excess is dropped, and in practice the per-worker evmQueue/cosmosQueue serialization plus the per-source-block Promise.allSettled barrier do bound in-flight work — so this isn't unbounded. The gap is that admission is governed by MAX_PENDING_PER_LANE × WORKER_COUNT, which is unrelated to the configured MAX_TPS ceiling, so a continuous run can sustain a rate well above it while excess is recorded as queueFull skips.

Worth either deriving the queue bounds from MAX_TPS, or renaming/redocumenting MAX_TPS as a startup-only corpus check so operators don't read it as a live rate limiter. (Raised by Codex as P1; I'd rate it lower than P1 given the queue-level backpressure, but the point stands.)


while (!stopRequested && (runDeadline === undefined || Date.now() < runDeadline)) {
const availableSegments = FOLLOW_SEGMENTS
? await readReplaySegments(replayDirectory, false, segmentCache)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] segmentCache is unbounded and every parsed segment stays resident for the life of the process. Eviction in readReplaySegments only happens when the underlying file disappears, and cleanupSegments requires both FOLLOW_SEGMENTS and CLEANUP_CONSUMED_SEGMENTS — the latter defaults to false for replay:run (it is only true by default in loadBufferedConfig, and runContinuousReplay forwards it). So a standalone FOLLOW_SEGMENTS=1 run without cleanup accumulates every 200-block segment (with trace payloads) in memory for the whole RUN_DURATION_HOURS, and additionally re-flatMaps all of them into pendingBlocks on each poll.

A size/age cap on the cache, or dropping cache entries for segments below nextSourceBlock, would make the non-buffered follow path safe for long runs.

if (cached) return { file, segment: cached };
try {
const parsed = JSON.parse(
await fs.readFile(path.join(directory, file), 'utf8'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] There's a latent TOCTOU here: readdir and readFile are separate steps, and when allowMissing is false an ENOENT from a segment removed in between propagates out. runReplay's follow loop calls this with allowMissing = false, so a concurrent deleter would take the whole replay down via main().catchprocess.exit(1).

Today the only deleter is cleanupConsumedReplaySegments running in that same process (the buffered supervisor reads with allowMissing = true), so this isn't reachable as wired. It becomes reachable the moment two processes share a REPLAY_DIR. Treating a mid-read ENOENT as "file vanished, skip it" regardless of allowMissing would remove the sharp edge — the allowMissing flag would then only govern a missing directory.

const evm = entry.evm;
metrics.offered++;
if (evm) {
const built = buildEvmReplay(evm, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Unlike executeEvm/executeCosmos, which wrap the adapter call in try/catch and record an adapterError bucket, the dry-run path has no per-transaction isolation: a single throw from buildEvmReplay (e.g. fitCalldata when natural semantic calldata exceeds MAX_CALLDATA_BYTES, or encodeCallGraphHarness) aborts the entire classification pass with Fatal:.

Not reachable with current defaults — natural semantic calldata is ≤ ~68 bytes and MAX_CALLDATA_BYTES has a floor of 260 via minimumInteger — but since dry-run classification is the primary triage tool for a new corpus, losing the whole report to one anomalous source tx is a poor failure mode. A try/catch that counts an adapterError and continues would match the execute path.

const isPrivileged = types.some(type =>
PRIVILEGED_PATTERNS.some(pattern => type.includes(pattern)),
);
if (isPrivileged && context.privilegedMode !== 'shape') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] privilegedMode is optional and this comparison makes an omitted value behave as skip, while loadReplayConfig's PRIVILEGED_REPLAY_MODE default is shape. The two defaults disagree.

Harmless today (runReplay always passes it explicitly, and the tests exercise both modes deliberately), but it's the kind of divergence that produces a confusing corpus diff if a future caller forgets the field. Making the property required on CosmosAdapterContext, or defaulting it to 'shape' here, would keep one source of truth.

BUFFER_START_MODE=latest
INITIAL_BUFFER_BLOCKS=200
# Buffered mode deletes consumed capture segments and keeps the newest completed one.
CLEANUP_CONSUMED_SEGMENTS=1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The comment scopes this to "Buffered mode", but .env is loaded by every command via dotenv/config, and loadReplayConfig reads the same key. A user who copies this template and later runs replay:run directly with FOLLOW_SEGMENTS=1 gets segment deletion too — which reads as contradicting the README's "Direct replay:run leaves segments untouched unless cleanup is explicitly enabled."

Suggest wording it as "Deletes consumed capture segments in any follow-mode run; keeps the newest completed one."

Comment on lines +400 to +410
const lane: ReplayLane = evm ? 'evm' : 'cosmos';
liveMetrics.recordOffered(lane);
const currentSequence = sequence++;
if (evm) {
const worker = workers[evmCursor++ % workers.length];
if (worker.evmPending >= MAX_PENDING_PER_LANE) {
recordSkip(metrics, 'EVM worker queue full', liveMetrics, 'evm');
await bucketAudit.record(
evmAuditRecord(
evm,
entry.sourceCosmosHash,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The queue-full admission check compares a per-worker counter (worker.evmPending/worker.cosmosPending) against MAX_PENDING_PER_LANE at runReplay.ts:405 and :452, but work is dispatched round-robin across all WORKER_COUNT workers, so the number of transactions the lane will admit/queue before back-pressuring is MAX_PENDING_PER_LANE * WORKER_COUNT, not MAX_PENDING_PER_LANE as the name and README imply. The genuinely lane-wide pendingFor(lane) aggregate (lines 292-296) is computed but only ever fed to metrics, never consulted for admission — tuning this knob down does not tighten the backlog bound the way an operator would expect.

Extended reasoning...

The mismatch: runReplay.ts maintains both a per-worker pending counter (worker.evmPending/worker.cosmosPending, declared at lines 90-91) and a true per-lane aggregator, pendingFor(lane) (lines 292-296), which sums that counter across every worker. The admission checks at lines 405 and 452 compare the per-worker counter against MAX_PENDING_PER_LANE, while pendingFor(lane) is only ever passed to liveMetrics.setPending (lines 423, 446, 469, 489) — it never gates admission. Because evmCursor++ % workers.length (line 404) and cosmosCursor++ % workers.length (line 451) dispatch round-robin, consecutive entries land on distinct workers, each independently allowed up to MAX_PENDING_PER_LANE admitted jobs. The result is that the lane-wide admission ceiling is MAX_PENDING_PER_LANE * WORKER_COUNT (2 * 20 = 40 with the .env.example defaults), not MAX_PENDING_PER_LANE — a 20x looser bound than the name and the README's listing of MAX_PENDING_PER_LANE as one of the 'important bounds' for target-chain load would suggest to an operator tuning it down.\n\nWhy this is a backlog/admission bound, not a chain-concurrency bound (addressing the refutation): one verifier correctly pointed out that each worker's evmQueue/cosmosQueue is a serial promise chain (job = worker.evmQueue.then(() => executeEvm(...)), line 424; similarly line 470 for Cosmos) — a queued job does not begin broadcasting until the prior job on that worker fully resolves (including its receipt wait). So the number of transactions actively broadcast and awaiting on-chain confirmation at any instant is bounded by WORKER_COUNT (one per worker), not MAX_PENDING_PER_LANE * WORKER_COUNT. The original bug description's framing of 'up to 40 concurrent unconfirmed EVM transactions' overstates this specific claim, and I don't think that particular consequence is accurate as stated.\n\nWhat is accurate, and is the actual defect: the admitted-but-not-yet-settled job count per lane — i.e. how many transactions the loop will accept into a worker's queue (running + waiting their turn) before it starts dropping ('worker queue full', lines 406/453) — is bounded by MAX_PENDING_PER_LANE * WORKER_COUNT, not MAX_PENDING_PER_LANE. Within a single block, up to 40 transactions can be admitted and queued (even though at most 20 are ever broadcasting simultaneously) before the per-block barrier at line 497 (await Promise.allSettled([...active])) forces the loop to wait for all of them to settle. So the practical effect of the bug is that the backpressure/skip threshold — the thing an operator is actually trying to control by setting MAX_PENDING_PER_LANE low — is 20x looser than its name suggests, even though the peak instantaneous on-chain broadcast concurrency is separately capped by WORKER_COUNT regardless of this bug.\n\nConcrete walkthrough: with MAX_PENDING_PER_LANE=2 and WORKER_COUNT=20 (the .env.example defaults), suppose a block contains 40 EVM-lane entries. Cursor round-robin assigns exactly 2 entries to each of the 20 workers. For every worker: entry 1 increments evmPending to 1 and starts executing immediately (worker.evmQueue was empty); entry 2 increments evmPending to 2 and queues behind it. All 40 entries are admitted — none hit the worker.evmPending >= MAX_PENDING_PER_LANE skip path — because the check only ever sees a per-worker count of at most 2, never the lane-wide total of 40 that pendingFor('evm') would report. Only when a 41st entry would land on an already-saturated worker does a skip occur. Had the check used pendingFor('evm') >= MAX_PENDING_PER_LANE instead, admission would have stopped after the 2nd entry lane-wide, matching the documented intent of the knob.\n\nFix: either replace the per-worker check with pendingFor(lane) >= MAX_PENDING_PER_LANE (true per-lane enforcement, matching the name), or rename the constant/README wording to reflect that it is actually a per-worker queue-depth limit (e.g. MAX_PENDING_PER_WORKER) so the semantics match what's enforced.\n\nSeverity: this is a naming/semantics mismatch in a standalone testnet load-generation tool (integration_test/load_generator, per the PR description "No prod code change") — nothing crashes, corrupts data, or causes incorrect on-chain behavior, and per-worker queue-depth bounding is itself a defensible design. Marking as nit, consistent with all three confirming verifiers.

@kollegian
kollegian added this pull request to the merge queue Aug 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 6, 2026
@kollegian
kollegian added this pull request to the merge queue Aug 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 6, 2026
@kollegian
kollegian added this pull request to the merge queue Aug 6, 2026
@kollegian
kollegian removed this pull request from the merge queue due to a manual request Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants