Skip to content

fix: slippage units, unbounded position enumeration, and two misleading guards - #699

Merged
rapcmia merged 5 commits into
developmentfrom
fix/pre-staging-review-followups
Sep 20, 2026
Merged

rapcmia merged 5 commits into
developmentfrom
fix/pre-staging-review-followups

Conversation

@fengtality

@fengtality fengtality commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups from reviewing the commits that landed on development since #683, before staging. Four fixes and one comment correction. Each was verified against a live mainnet node, not just unit tests.

fix(meteora): open a position with the slippage that was asked for

The one that matters. openPosition converted slippagePct to basis points before calling initializePositionAndAddLiquidityByStrategy, which takes a percentage.

The SDK clamps rather than rejects: capSlippagePercentage() caps at 100, and getSlippageMaxAmount(amount, 100) returns U64_MAX. So the conversion did not widen the tolerance, it removed the deposit ceiling. At the template default of slippagePct: 2 the SDK received 200, clamped to 100, and every narrow-range open ran with no slippage protection at all — as did any slippagePct >= 1.

addLiquidity always passed the percentage, and so does the chunked path added in #696, so a range of ≤70 bins was unprotected while a wider one was not. Pre-existing (introduced in 8b57c5b1c), but #696 put both behaviours in one route.

Nothing asserted this before; open-position-slippage-units.test.ts now pins the value each builder receives and that the two agree.

fix(meteora): stop serving pool state frozen at the moment Gateway first saw it

Supersedes #690, whose diagnosis was right and whose fix was one layer too low. Credit to @AzothZephyr, carried as a co-author on the commit.

DLMM.create() reads lbPair once and the SDK never refreshes it again on its own. getDlmmPool cached the instance for the life of the process and called refetchStates() exactly once, at creation, so a long-lived Gateway kept answering from whatever the pool looked like the first time it was touched.

#690 reported the visible half — pool-info returns a fresh price and activeBinId (getActiveBin() fetches from chain, but never writes back) next to a bins window frozen around the creation-time bin, and once the drift passes the window the bins stop containing the active bin at all. Observed live on SOL/USDC 5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6 after hours of uptime: window -5790..-5650 against active bin -5837, re-centered by a restart.

But that is not the only reader of the cached state:

SDK method reads Gateway caller
getBinsAroundActiveBin lbPair.activeId getPoolLiquidity → pool-info bins
initializePositionAndAddLiquidityByStrategy lbPair.activeId → instruction active_id clmm/open-position
addLiquidityByStrategy lbPair.activeId → instruction active_id clmm/add-liquidity

Not swap quotes, despite appearances. swapQuote does read lbPair.activeId and lbPair.vParameters, but clmm/quote-swap calls getBinArrayForSwap first, and that opens with await this.refetchStates() — the SDK refreshes the state immediately before swapQuote reads it. That path was always self-healing. An earlier revision of this description listed it as broken; it was not. The only cost there is that the route now refetches twice per quote, once here and once in the SDK — waste, not a correctness problem, and worth knowing when watching RPC volume.

The two position builders are the reason the refresh moves up to getDlmmPool rather than sitting in getPoolInfo where #690 put it: state older than DLMM_STATE_TTL_MS (5s) is refetched before the instance is handed out, concurrent callers share the one refetch, and a failed refetch propagates rather than quietly returning state the caller has no way to tell is stale. refetchStates() is a single batched getMultipleAccounts, so this is one round trip per pool per window, not per request.

This also fixes an ordering bug in #690's version, which refreshed after getPoolInfo had already read feeInfo and getDynamicFee(). The latter derives from lbPair.vParameters, the volatility accumulator — precisely what drifts with the price movement being detected — so the fixed response still reported dynamicFeePct from stale state. Refreshing inside getDlmmPool puts every read in getPoolInfo after the refresh.

This is sequenced with the slippage fix above, which is what makes it urgent. getAndCapMaxActiveBinSlippage derives the on-chain active_id tolerance from the same slippage argument: ceil(slippage / (binStep / 100)). Passing basis points gave a 2% open on a binStep-20 pool a tolerance of ceil(200 / 0.2) = 1000 bins — wide enough to absorb any amount of drift silently. With the percentage restored that tolerance is ceil(2 / 0.2) = 10 bins, and the ~47 bins of drift #690 measured would start reverting on-chain. Landing the slippage fix without this one converts a silent misplacement into a failed open.

Known residual, pre-existing and not addressed here: the DLMM instance is shared and mutable, so a refresh triggered by one request can move lbPair.activeId under another that is mid-flight between getDlmmPool and its builder call. Flagged by Greptile on this PR. The SDK already does exactly this on every clmm/quote-swap, via the refetchStates() at the top of getBinArrayForSwap, so it is not a new class of hazard — but bounding staleness does make it reachable on more routes. Fixing it properly means per-request instances or copy-on-read, a larger change than this PR should carry.

fix(raydium): list positions without pulling the wallet's whole token ledger

getPositionsForWalletAddress asked for every token account the address owns and filtered locally. Now that the route is read-only and accepts any address, that is unbounded — getParsedTokenAccountsByOwner on an exchange-scale address returns ~200MB and the request timed out at 90s.

Switching to unparsed accounts only moved the failure (84s, V8 max string length), so the filtering moves to the node: memcmp on the balance, dataSlice down to the mint.

before after
5Q544fKr… (very large) timeout at 90s, then crash at 84s 200 in 4.3s
wallet with real positions — 36 positions in 3.0s

Checked that this does not quietly require a paid provider: the default public api.mainnet-beta.solana.com serves the filtered query fine — it is unfiltered getProgramAccounts on the token program that providers refuse. Token-2022 deliberately omits the dataSize: 165 filter, since extensions push those accounts past 165 bytes.

fix(clmm): stop rejecting a correct position list, and name the real conflict

positionsOwned compared positions.length against the starting balanceOf. With both continue paths removed, the loop runs exactly numPositions times and each pass either pushes once or throws — the two can never differ, so the guard was unreachable.

Re-reading balanceOf instead, which is what this PR originally did, turned out to be the wrong instrument. Greptile flagged one hole in it; looking properly found a worse one pointing the other way.

Enumeration is EnumerableSet-backed — confirmed against the mainnet position manager, where tokenOfOwnerByIndex(owner, balanceOf) reverts with EnumerableSet: index out of bounds, that set's own error. It appends on add and swap-and-pops on remove, and the two cases are nothing alike:

mid-scan change what the loop returns what a count check did
position added indices 0..N-1 untouched — a correct snapshot of the set as of the request saw N+1 ≠ N and threw it away
position removed the index walks off the end, or the tokenId stops existing — the pass throws already redundant, except for a removal landing after the last read

So it converted correct answers into 500s to cover a narrow tail window, and for a wallet that opens positions while polling this route — a rebalancing bot, which is the workload — the discarded case was the common one. The check is gone.

What remains is the removal the loop already catches, which was surfacing as:

Failed to read position 12 of 20 for wallet 0x…:
execution reverted: EnumerableSet: index out of bounds

A 500 that reads like a Gateway bug and tells the caller nothing. Both revert strings (index out of bounds, Invalid token ID) are now answered with a 409 naming what happened and that the request is worth repeating. Anything else still fails loudly.

This also shrinks the same-count churn Greptile raised: it needs a removal, and a removal almost always trips the loop first, so the silent duplicate-and-miss now needs both halves to land after the loop's last read. Closing that entirely needs atomicity rather than arithmetic — one blockTag across every read — still deliberately not done, because a pinned read against the load-balanced public RPCs Gateway defaults to can land on a node that has not yet seen that block. Recorded in the comment at the site.

Uniswap and PancakeSwap both; the changed region is byte-identical between them.

docs(clmm): say what the tick bitmap actually saves

Comment only. #695's comment claimed the bitmap turns hundreds of calls into dozens and keeps requests under the rate limit. Measured over a 401-bin window on mainnet:

pool initialized fewer calls
WETH/USDC 0.3% 400/402 0%
WETH/USDC 0.05% 396/402 1%
WBTC/WETH 0.3% 311/402 22%
USDC/USDT 0.01% 100/402 75%

True only where it is least needed — dense pools are the ones most likely to be queried, and binCount=401 against a public endpoint still 429s. The batching and backoff are what keep the request survivable; the bitmap is a win on sparse pools and a no-op on dense ones.

Separately, I confirmed #695's rewrite is behaviour-preserving: running the old brute-force read and the new bitmap path side by side against live WETH/USDC 0.05% gave byte-identical liquidityNet across all 402 boundaries.

Testing

  • pnpm test — 168/168 suites, 1500/1500 tests
  • pnpm typecheck clean, pnpm lint 0 errors
  • Live mainnet verification for the slippage, raydium and bitmap items, as described above.
  • The stale-state fix is covered by unit tests only on my side (dlmm-state-refresh.test.ts: no refetch inside the TTL, state follows the active bin once past it, one refetch serves concurrent callers, a failed refetch surfaces without wedging the pool). The bug itself was observed live by @AzothZephyr in fix(meteora): refresh cached DLMM state when active bin drifts #690, and their narrower version ran on a live mainnet instance from 2026-08-26; the broadened placement here has not had that soak. Worth a look on a live instance before staging, specifically for RPC volume on the hot quote-swap path.

Closes #690.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because cached Meteora pool state can still mutate midway through an in-flight quote or position operation.

Findings

  1. P1 Shared Pool State Race ▶

Summary

This PR corrects slippage units for Meteora position creation, periodically refreshes cached DLMM state, bounds Raydium position discovery through RPC-side filtering, improves concurrent position-enumeration errors, and corrects CLMM bitmap documentation.

  • Passes Meteora slippage consistently as a percentage.
  • Refreshes cached DLMM state after a five-second TTL and coalesces concurrent refreshes.
  • Filters Raydium token accounts by owner and unit balance before transferring account data.
  • Returns conflicts when EVM position enumeration detects a concurrent removal.
  • Adds focused regression coverage for the changed behaviors.
Diagram
sequenceDiagram
  participant A as Request A
  participant C as Cached DLMM
  participant B as Request B
  participant R as RPC
  A->>C: getDlmmPool()
  C-->>A: shared mutable instance
  A->>A: begin quote or position work
  B->>C: getDlmmPool() after TTL
  C->>R: refetchStates()
  R-->>C: replace lbPair state
  C-->>B: refreshed shared instance
  A->>C: continue builder call
  Note over A,C: Request A may combine state from two snapshots
Loading

Reviews (7) · Last reviewed commit: "fix(clmm): report a concurrent position ..."

Comment thread src/connectors/uniswap/clmm-routes/positionsOwned.ts Outdated
@rapcmia rapcmia self-assigned this Sep 18, 2026
@rapcmia rapcmia moved this to Under Review in Pull Request Board Sep 18, 2026
Comment on lines +132 to +134
if (cachedPool) {
await this.refreshDlmmPoolState(poolAddress, cachedPool);
return cachedPool;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Shared Pool State Race

The TTL refresh mutates the shared cached DLMM instance after earlier callers may already have received it. A long-running quote or position request can read the active bin, await other RPC work, and then have another request refresh lbPair before the builder runs. The first request can therefore select swap arrays or calculate a position range from one pool snapshot, but build against another. Pool state needs to remain consistent for the full read-and-build operation.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct that the shared instance is mutated under in-flight readers, but this is pre-existing rather than introduced here — and the SDK is the one doing it on the hottest route.

getBinArrayForSwap opens with await this.refetchStates() (@meteora-ag/dlmm/dist/index.js:15358), so every clmm/quote-swap has always refreshed the shared lbPair mid-request, on every call, long before this change. Bounding staleness makes it reachable on more routes; it is not a new class of hazard.

Severity is also lower than the comment implies, and inverted on one of the two builders:

  • open-position / add-liquidity — a mid-flight refresh makes the active_id baked into the instruction fresher, so it is closer to the value the program validates against within max_active_bin_slippage. That direction helps rather than hurts.
  • quote-swap — the one place it could bite is a refresh landing during getBinArrayForSwap's own RPC, leaving arrays selected for one active bin and swapQuote iterating from another. A bin array spans 70 bins, so the move has to be large to escape the fetched window.

Chasing this down did catch a real error in the description, now fixed: it listed swapQuote among the readers left stale by the cache. It never was, because of that same refetchStates(). The three genuinely affected readers are getBinsAroundActiveBin and the two position builders.

Keeping it as a documented residual. Closing it properly means per-request instances or copy-on-read — the shared mutable SDK object is the root cause, and a better refresh policy cannot fix it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That’s a fair correction. The shared mutable-instance race is pre-existing, and getBinArrayForSwap() already refreshes the SDK state on the quote path, so this change does not introduce a new class of inconsistency there. The position-builder case is also not uniformly harmful: a refresh makes the baked active_id fresher, while the remaining quote risk requires the active bin to move enough during getBinArrayForSwap() to invalidate the fetched array window. I agree this should remain a documented residual rather than a blocking finding.

@fengtality
fengtality force-pushed the fix/pre-staging-review-followups branch from 3ab9078 to e7dcefa Compare September 18, 2026 21:35
fengtality and others added 5 commits September 18, 2026 22:13
openPosition converted slippagePct to basis points before handing it to
initializePositionAndAddLiquidityByStrategy. That builder takes a percentage.

The SDK defends itself against a nonsense value rather than rejecting it:
capSlippagePercentage() clamps to 100, and getSlippageMaxAmount(amount, 100)
returns U64_MAX. So the conversion did not merely widen the tolerance, it
removed the deposit ceiling entirely. At the template default of slippagePct 2
the SDK was handed 200, clamped to 100, and every narrow-range open ran with
no slippage protection at all; any slippagePct >= 1 did the same.

addLiquidity always passed the percentage, and so does the chunked path added
for wide ranges, which left the two halves of this one route disagreeing: a
range of 70 bins or fewer was unprotected, a wider one was not.

The conversion is gone. Nothing else read slippageBps.

Covered by open-position-slippage-units.test.ts, which pins the value passed
to each builder and that the two agree - nothing asserted this before, which
is how a unit mismatch survived in a route that spends the caller's money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ledger

getPositionsForWalletAddress asked the node for every token account the
address owns and sifted them here. Since the route became read-only and takes
any address, that is unbounded: getParsedTokenAccountsByOwner renders each
account as verbose JSON, and on an exchange-scale address the response is
about 200MB. The request timed out at 90 seconds without reading a position.

Asking for the unparsed accounts instead is roughly a tenth the bytes and not
enough - the same address then failed at 84 seconds on V8's maximum string
length, which is a worse error for the same reason.

So the filtering moves to the node. A memcmp on the balance returns only the
accounts holding exactly one unit, and dataSlice trims each reply to the mint.
That address now answers in about 3 seconds with 1011 candidates, and a wallet
with real positions returns 36 of them in 3 seconds.

The filters are what make this affordable. An unfiltered getProgramAccounts on
the token program is what providers refuse; this one is served by the default
public endpoint as well as the paid ones, so it does not quietly require a
particular provider.

Two details worth keeping:

  - Token-2022 deliberately has no dataSize filter. Those accounts grow past
    165 bytes with each extension, so pinning the size would skip every
    position NFT that carries one.
  - The decimals-0 half of the old NFT test is gone, because it meant reading
    every mint. A dust balance of one raw unit now reaches the PDA lookup,
    which is what decides whether a mint is really a position anyway.

The per-position reads run eight at a time rather than one after another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment on computeV3BinDistribution claimed the bitmap turns hundreds of
calls into dozens and keeps a request small enough not to draw a 429. Measured
over a 401-bin window on mainnet, that is true only where it is least needed:

  WETH/USDC 0.3%   400/402 boundaries initialized    0% fewer calls
  WETH/USDC 0.05%  396/402                           1%
  WBTC/WETH 0.3%   311/402                          22%
  USDC/USDT 0.01%  100/402                          75%

On a dense pool nearly every boundary carries liquidity, so there is almost
nothing to skip - and dense pools are the ones most likely to be asked for. A
binCount of 401 against a public endpoint still returns 429, as it should.

What keeps the request survivable is the batching and backoff below, and what
makes a rate-limited read safe is that it now propagates instead of being
recorded as zero liquidity. The bitmap is a real win on sparse pools and a
no-op on dense ones. Saying so here means the next person debugging a 429 does
not start from a wrong premise.

Comment only; no behaviour change.

Also folds in the import reorder that `pnpm lint` (which runs with --fix)
applies to quote-position-transaction-count.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rst saw it

DLMM.create() reads lbPair once and the SDK never refreshes it again on its own.
getDlmmPool cached the instance for the life of the process and called
refetchStates() exactly once, at creation, so a long-lived Gateway kept answering
from whatever the pool looked like the first time it was touched.

Three readers go through that cached state with nothing refreshing it:

  - getBinsAroundActiveBin centers the pool-info bins window on lbPair.activeId
  - initializePositionAndAddLiquidityByStrategy bakes it into the open-position
    instruction as active_id
  - addLiquidityByStrategy does the same for add-liquidity

Not swap quotes, despite appearances. swapQuote does read lbPair.activeId and
lbPair.vParameters, but clmm/quote-swap calls getBinArrayForSwap first, and that
opens with `await this.refetchStates()` - so the SDK refreshes the state
immediately before swapQuote reads it. That path was always self-healing. The
cost is that it now refetches twice per quote, once here and once in the SDK,
which is waste rather than a correctness problem.

#690 reported the visible reader: pool-info returned a fresh price and
activeBinId (getActiveBin fetches from chain, but never writes back) next to a
bins window frozen around the creation-time bin, and once the drift passed the
window the bins no longer contained the active bin at all. Observed live on
SOL/USDC 5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6 after hours of uptime:
window -5790..-5650 against active bin -5837, and a restart re-centered it.

The two position builders read the same stale id and are the reason this sits in
getDlmmPool rather than in getPoolInfo, where #690 put it. Cached state older
than DLMM_STATE_TTL_MS (5s) is refetched before the instance is handed out,
concurrent callers share the one refetch, and a failed refetch propagates instead
of quietly returning state the caller has no way to tell is stale.
refetchStates() is a single batched getMultipleAccounts, so this is one round
trip per pool per window, not per request.

This also fixes an ordering bug in #690's version, which refreshed after
getPoolInfo had already read feeInfo and getDynamicFee() - the latter derives
from lbPair.vParameters, the volatility accumulator, so the fixed response still
reported dynamicFeePct from stale state. Refreshing inside getDlmmPool puts every
read in getPoolInfo after the refresh.

Sequenced with the slippage fix in this PR, which is what makes it urgent.
getAndCapMaxActiveBinSlippage derives the on-chain active_id tolerance from the
same slippage argument: ceil(slippage / (binStep / 100)). Passing basis points
gave a 2% open on a binStep-20 pool a tolerance of ceil(200 / 0.2) = 1000 bins,
wide enough to absorb any amount of drift silently. With the percentage restored
that tolerance is ceil(2 / 0.2) = 10 bins, and the ~47 bins of drift #690
measured would start reverting on-chain instead.

Known residual, pre-existing and not addressed here: the DLMM instance is shared
and mutable, so a refresh triggered by one request can move lbPair.activeId under
another that is mid-flight between getDlmmPool and its builder call. The SDK
already does exactly this on every clmm/quote-swap, via the refetchStates() at
the top of getBinArrayForSwap, so this is not a new class of hazard - but
bounding staleness does make it reachable on more routes. Fixing it properly
means per-request instances or copy-on-read, which is a larger change than this
PR should carry.

Covered by dlmm-state-refresh.test.ts: no refetch inside the TTL, state follows
the active bin once past it, one refetch serves concurrent callers, and a failed
refetch surfaces without wedging the pool.

Co-Authored-By: azothzephyr <azothzephyr@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
positionsOwned guarded against the wallet changing during enumeration by
comparing positions.length with the balanceOf it started from. That check could
never fire: the loop runs exactly numPositions times and, since the two
`continue` paths were removed, each pass either pushes once or throws - so the
two numbers always agree and the guard read as protection that was not there.

The hazard it described is real, but a count is the wrong instrument for it, and
re-reading balanceOf at the end - which is what this branch tried first - is
worse than nothing.

Enumeration is EnumerableSet-backed. Confirmed against the mainnet position
manager: tokenOfOwnerByIndex(owner, balanceOf) reverts with "EnumerableSet: index
out of bounds", that set's own error. It appends on add and swap-and-pops on
remove, and those two cases are nothing alike.

A position ADDED mid-scan appends, so indices 0..N-1 are untouched and the loop
reads exactly the set that existed when the request arrived. The list is a
correct snapshot. A count check sees N+1 against N and throws it away. For a
wallet that opens positions while polling this route - a rebalancing bot, which
is the workload - that is the common case, not an edge.

A position REMOVED mid-scan is already caught one level up, by the loop: the
index walks off the end, or the tokenId stops existing before positions() is
asked about it, and either way the pass throws.

So there is no final check here now. What the loop catches was being reported as

  Failed to read position 12 of 20 for wallet 0x...:
  execution reverted: EnumerableSet: index out of bounds

- a 500 that reads like a Gateway bug and tells the caller nothing. Both revert
strings are now recognised and answered with a 409 naming what happened and that
the request is worth repeating. Anything else still fails loudly.

What none of this catches is a change landing after the loop's last read. That
window is narrow, and closing it needs atomicity rather than arithmetic - one
blockTag across every read - which is deliberately not done: a pinned read
against the load-balanced public RPCs Gateway defaults to can land on a node that
has not yet seen that block, trading a rare stale list for a routine failed
request. Recorded in the comment at the site.

Same change in both connectors, which carry the same loop.
positions-owned-completeness.test.ts covers the add returning its snapshot (and
balanceOf never being re-read), both revert strings reporting 409, and an
unrelated failure still reporting 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fengtality
fengtality force-pushed the fix/pre-staging-review-followups branch from e7dcefa to 9724900 Compare September 19, 2026 05:13

@rapcmia rapcmia left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Meteora deposit with a small price range, opened a SOL/USDC position at 2% slippage and it went through in one transaction, with the deposit staying within the amounts requested.
  • Meteora deposit with a wide price range, the same request over a wider range completed across three transactions with the same slippage applied, so both halves of the route now agree.
  • Meteora pool data refresh; two pool reads either side of a real price move showed the bin window following the active bin, on a Gateway left running the whole time.
  • Meteora refresh logging, Gateway recorded the same price move in its log about 43 minutes into uptime, matching what the route returned.
  • Raydium position list on a very large wallet, the address the PR used returned in about 7 seconds where it previously timed out at 90 seconds.
  • Raydium position list on a wallet with a position, a real position came back complete and identical across two calls, confirming the new filter still finds genuine positions.
  • Position list tested on both connectors this PR changes. Uniswap on Arbitrum returned all 46 position records, PancakeSwap on BSC all 9, both identical across repeated calls, including one position still holding funds.

@rapcmia rapcmia added this to the v2.17 milestone Sep 20, 2026
@rapcmia
rapcmia merged commit b66f4d9 into development Sep 20, 2026
6 checks passed
@rapcmia
rapcmia deleted the fix/pre-staging-review-followups branch September 20, 2026 12:03
@rapcmia rapcmia mentioned this pull request Sep 20, 2026
2 tasks
@rapcmia rapcmia moved this from Under Review to Development 2.17.0 in Pull Request Board Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development 2.18.0

Development

Successfully merging this pull request may close these issues.

2 participants