Skip to content

Release: develop -> main - #4564

Merged
TaprootFreak merged 4 commits into
mainfrom
develop
Aug 1, 2026
Merged

Release: develop -> main#4564
TaprootFreak merged 4 commits into
mainfrom
develop

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* feat(auth): require staff KYC clearance on elevated endpoints

Access to endpoints gated on Admin, Debug, Compliance, Support or RealUnit now
additionally requires an identified natural person behind the calling account:
kycLevel >= 50 and a non-empty verifiedName.

The check lives in RoleGuard, so it covers all 235 elevated gates at once. It is a
property of the endpoint, not of the caller: it applies only when every entry role of a
gate is elevated, so an admin using ordinary customer functionality is unaffected.

StaffKycClearanceService derives the cleared account ids from the DB every minute,
ProcessService primes an in-memory Set from the resulting setting, and RoleGuard reads
it synchronously. This mirrors the existing JWT denylists: the requirement takes effect
on already-issued tokens within one refresh interval, with no DB lookup per request.
The allowlist is deliberately fail-closed - an empty or not-yet-primed Set denies every
elevated endpoint, so a DB or cron outage cannot silently re-open admin access.

Three privilege checks that live in business logic rather than a gate are covered too,
via the new hasStaffAccess helper. All three sit on routes that are not role-gated
(OptionalJwtAuthGuard), so the requirement would otherwise be bypassable: protected KYC
file downloads, ownership-independent access to any customer's transaction history, and
posting official support replies. Privilege checks behind already-gated endpoints (the
isAdmin distinctions in the support note services) are left unchanged - the gate has run
by then.

The three files deciding elevated access are pinned at 100% coverage on all four metrics
via a dedicated CI step, following the existing Frick coverage gate.

* fix(auth): correct import order and a stale service reference

Restores the alphabetical import order in user.module.ts and fixes a comment in
setting.service.ts that referenced a class name that does not exist.

* refactor(auth): filter the verified name in SQL and fix import order

Moves the verifiedName condition out of JS and into the query, per the
"filter in SQL, not JS" rule: TRIM(...) <> '' covers empty and whitespace-only names and
drops NULL on its own, since the comparison yields NULL.

The raw fragment interpolates the alias TypeORM passes in, which is already quoted - an
unquoted camelCase identifier would be folded to lowercase by Postgres and fail at
runtime. Verified against a real Postgres instance: the emitted SQL is
TRIM("..."."verifiedName") <> '' and only genuinely verified staff rows survive, with
NULL, empty and whitespace-only names all filtered out. The unit test pins the rendered
predicate, since mocked repositories never execute the fragment.

Also restores the alphabetical import order in process.service.ts.

* fix(auth): close two gaps in the clearance path

Two holes found in review, both in the path that decides elevated access.

Blank names: Postgres TRIM() with no character argument strips ASCII space only, while
the JS trim() it replaced also strips tabs, newlines and Unicode spaces. A verifiedName
of a single tab or a non-breaking space therefore passed the filter and cleared an
account carrying no identification. The predicate now uses BTRIM with the explicit
character set that trim() strips, spelled out rather than left to [[:space:]], whose
meaning depends on the database locale.

A mocked repository never executes raw SQL, so a new suite runs the predicate against a
real Postgres and pins it to the semantics it replaced: every blank variant is excluded,
padded real names are kept, and the result is asserted to equal the JS filter on every
input. It follows the existing MIGRATION_TEST_PG pattern, so CI runs it against its
throwaway database.

Manual override: the generic PUT /setting/:key route accepted staffKycClearance, which
contradicted this branch's own comment and let a cleared admin write uncleared accounts
into the allowlist, live until the next sync overwrote it. Sync-owned settings are now
rejected there; the sync path writes through setObj and is unaffected.

* refactor(setting): enforce system-managed keys in the service

Moves the system-managed check from the controller into SettingService.set, where it
covers every caller rather than only the HTTP route, and keeps the controller thin as the
contributing guide requires. The sync path writes through setObj, which goes to the
repository directly, so it stays unaffected - now pinned by a test.

Widens the Postgres suite so the character set itself is guarded: the blank fixtures are
derived from the JS runtime rather than from BlankChars, because fixtures generated from
the constant under test would shrink along with it and could never catch a character
dropped from it. Verified by removing one character from BlankChars, which turns the
suite red.

* test(setting): pin the key and value written by the generic setter

Pins key and value on the generic setter instead of only asserting that something was
written - a swapped argument pair would otherwise pass, and this is the only place the
pair is checked since the controller test was folded into the service test.

Records why U+200B is absent from BlankChars: trim() does not strip it either, so adding
it would make the predicate stricter than the check it replaced. Whether a name made of
invisible characters counts as identification is a question about how verifiedName is
written, not about this gate.
… without a person (#4440)

* fix(liquidity-management): bound the uncertain-order quarantine

An order the venue keeps having no record of stayed UNCERTAIN indefinitely:
the only way out was a manual release through the admin endpoint. Where
nobody performs that release, the order never resolves and the rule behind
it stays blocked, so the venue silently stops being served at all.

Abandon such an order to FAILED once it has been unresolvable for longer
than ABANDON_UNRESOLVED_MINUTES. What makes that safe is not a conclusion
about the order but that a rule replans from the venue's CURRENT balance,
never from the abandoned order: an execution that did happen has already
moved that balance, so the replan sizes itself down rather than duplicating
the request.

Two guards, both fail-closed:
- UNAVAILABLE never abandons, however old the order. It is the absence of
  an answer, not an answer, and waiting cannot turn it into one.
- An order without a creation date never abandons. Util.minutesDiff reads a
  missing date as the epoch and would otherwise abandon instantly.

* fix(liquidity-management): scale the abandon bound to the request kind

One bound for every command was either useless or unsafe. Measured over the
30 days to 2026-07-29 on completed Scrypt orders in prod:

  trades (n=55):      median 9.6s   p95 19.8s   max 57.1s
  withdrawals (n=49): median 7.7min p95 82min   max 5.6h

A trade is done inside a minute, so holding one for hours strands its rule
for no reason — the incident order was a trade and sat for nine. A
withdrawal at 30 minutes is entirely ordinary, so a short bound there would
abandon live transfers and reissue them.

Trades now abandon after 5 minutes (~5x the slowest observed), everything
else after 12 hours (~2x the slowest observed withdrawal). Balances refresh
every minute and the pipeline runs every 10 seconds, so neither bound is
limited by detection speed, only by how long the request may still be alive.

Venue-internal commands are an allowlist (buy, sell, purchase): an
unrecognised or unloaded command falls to the long bound, because being slow
to abandon costs minutes while being fast to abandon a live transfer
duplicates it.

* fix(liquidity-management): bound orders no integration can look up

The bound was applied only after a lookup had actually been made, so it
missed the one case where waiting is provably pointless: an order whose
adapter is no longer registered. No lookup can ever arrive for it, and
without a manual release nothing in this system moves it again — the same
permanent quarantine, reached sooner.

Apply the same bound there. Also corrects a comment that said "for hours",
which stopped being true once trades got a five-minute bound.

* fix(liquidity-management): correct the abandon bound after review

Three defects, all in the abandon path introduced by this PR.

Clock ran from the wrong instant. `unresolvableTooLong` measured from
`created`, a @CreateDateColumn that is never updated. An order can run
normally for a long time and only become UNCERTAIN late, when a completion
check amends or restarts it and that write goes unconfirmed — its `created`
is old by then, so the bound expired on the very first pass while the
replacement request was seconds old and plausibly still arriving. Measures
from `updated` now, which moves with the transition into quarantine.

Allowlist keyed on the command name alone. `sell` on an exchange is a book
match settled in seconds; `sell` on the DEX adapter is an on-chain swap with
a confirmation time, and it was getting the five-minute bound. Keyed on
`system/command` pairs now, listing only the exchange trades actually
measured.

Weakest write had the weakest guard. `abandonUnresolvableOrder` called
`leaveQuarantine` without `expectedRecheckDue`, so an operator release
written between this pass's read and the write was overwritten together with
its audited reason — the one write resting on no evidence outranking the one
resting on a person. Narrowed on the examined release, as
`completeNotSentRelease` already does.

Also withdraws the automatic abandon for orders no integration can look up.
That path never asks a venue, so only the clock would be left, and the safety
argument requires a balance that reflects an execution: a chain balance omits
unconfirmed transactions and a bank balance comes from the last imported
batch. Abandoning there would be guessing with the one class of order nothing
can observe, so it keeps waiting for the manual release instead.

* fix(liquidity-management): match SQL NULL when narrowing on an absent release

The narrowed update never matched a row. TypeORM renders a raw null in a
where object as `= NULL` — invalidWhereValuesBehavior.null defaults to
"ignore", which falls through to an equality — and `x = NULL` is UNKNOWN in
SQL, so it matches nothing, not even the row whose column really is NULL.

That disabled this PR's entire purpose. The abandon path is only ever reached
for an order nobody had released, so the examined value there is always null:
every abandon would have updated zero rows and logged itself as a lost race
while the order stayed quarantined. `completeNotSentRelease` carried the same
defect on its NOT_SENT branch, which reaches it with no release pending.

Uses IsNull() for the null case, matching how the rest of the repo expresses
this. The mocked repo in the unit tests returns a fixed affected count and
cannot see criteria semantics, so the test asserts on the operator itself.

Also corrects the UNCERTAIN status comment, which claimed quarantine is
always time-bounded. Since the previous commit withdrew the automatic abandon
for orders no integration can look up, two cases legitimately wait for a
human: those orders, and a venue that stays unreachable.

* docs(liquidity-management): align the remaining comments with the new bound

Three places still described the pre-change behaviour, one of them
contradicting a comment corrected earlier in the same method.

- resolveUncertainOrders said anything inconclusive "stays put"; it now stays
  put only until the bound for its kind of request runs out.
- The UNRESOLVED enum value said "look again later", implying indefinitely.
- ScryptAdapter.resolveUncertainOrder's docstring said a missing record
  leaves the order quarantined for a human, while its own inline comment
  already said the caller bounds that wait.

Two weaker statements in the same adapter are softened for the same reason:
they describe entry into quarantine, which is unchanged, but read as if
quarantine were the end state.

Also adds the missing regression test for the NOT_SENT caller of the IsNull
narrowing. Asserting on status alone cannot catch a regression there, because
resolveAsNotSent sets FAILED synchronously before the write is attempted, and
the mocked repository returns a fixed affected count.

* docs(liquidity-management): finish aligning comments, cover both allowlist entries

Remaining statements that the abandon bound made untrue, plus two test gaps.

Comments: the abandon docstring said "for hours", which is wrong for the
five-minute trade bound; a stray line break left "The" stranded mid-sentence;
the manual-release docstring claimed an unsent request would block its rule
forever, which now only holds for the cases nothing can observe; three
adapter comments described quarantine as ending only with a human. The
resolveUncertainOrder docstring also said it can only ever confirm a
positive, which its own NOT_SENT branch contradicts — that one predates this
PR but sits in the paragraph already being corrected.

Tests: the bound tests only ever used `sell`, so dropping or mistyping
`scrypt/buy` from the allowlist would have gone unnoticed; they now run over
both entries. And nothing covered a pending release together with an expired
bound — the branch order makes the audited release win, but only a test stops
a later reordering from silently replacing an operator's recorded verdict
with an anonymous clock.

* fix(liquidity-management): report an unaskable order as unavailable, not unresolved

An order with no correlation id was reported as UNRESOLVED, which means the
venue answered and had no record. Nothing was asked: there is no reference to
ask about. The distinction now decides an order's fate — the caller may
abandon an UNRESOLVED order once its bound expires, and would have recorded
"the venue has had no record of it" for a lookup that never ran, against this
PR's own rule that a row must not claim an observation nobody made.

UNAVAILABLE is exactly this case by its own definition: the venue could not
be asked at all, and such an order is never abandoned automatically. Only
orders predating the reserve-before-send guarantee can reach it, and those
are the ones that most need a person to look.

Also fixes a docstring this PR made worse two commits ago: "an absence can
only ever confirm a positive" states the opposite of what the code does. An
absence confirms nothing — that is the whole point of the paragraph.

* test(liquidity-management): expect an unaskable order to be unavailable

The existing case asserted UNRESOLVED for an order with no reference, without
saying why. It was written when the distinction had no consequence: nothing
acted on UNRESOLVED, so either value behaved the same.

This PR gives it one — UNRESOLVED now permits an automatic abandon — so the
old expectation locks in the behaviour the previous commit corrected. Replaced
by the documented case, covering both an undefined and a null reference, and
asserting that no lookup is attempted at all.

* fix(liquidity-management): do not abandon an order with references left unasked

The lookup stops at the newest reference when the venue cannot see it, and
deliberately does not fall through to the one it replaced — a hit there would
report SENT on a superseded reference. But it reported that stop as
UNRESOLVED, an answer, and the bound may abandon an order on an answer. The
predecessor of an invisible replacement is very often the live one, so an
order with an open attempt at the venue could have been failed after five
minutes and its rule sent to plan again. An existing test documents exactly
this shape: predecessor still NEW at the venue.

It now reports UNAVAILABLE whenever references were left unasked, which the
caller never abandons on. A single unknown reference still reports UNRESOLVED
— nothing was skipped there, so the answer is complete.

Also repairs a side effect of the previous commit: with an unaskable order
reporting UNAVAILABLE, a hand-verified release stopped taking effect
immediately and instead waited out the full unreachable-venue hour. Nothing
went out under a reference, so no lookup can ever answer — the same dead end
as an order with no integration, and released the same way.

Two smaller review points: the it.each table left its parameter implicitly
any, and the allowlist hardcoded the system name that already exists as an
enum value. The command halves stay literal because their enums live in the
adapters, which import this entity.

* fix(liquidity-management): count only references that can still be live as unasked

The previous commit counted every remaining reference as unasked, including
ones already superseded. That creates the permanent quarantine this PR exists
to end: once a replacement has been adopted, the reference it replaced stays
in the list forever, so if the venue later loses sight of the adopted one,
the dead predecessor makes every lookup incomplete and the order can never be
abandoned — worse than before this PR, where the bound would have applied.

Only references at least as current as the adopted one count now, matching
how adoptLiveReplacement already separates claimed replacements from
established ones.

Also completes two comments: UNAVAILABLE has three causes, not one, and only
two of them were named. A complete answer may time out; an incomplete one
never does, whatever caused it.

* feat(liquidity-management): bound the unanswered outcomes too

Waiting for an operator was still the end state whenever the venue could not
be asked, or not completely: no reference to ask with, an unreachable venue,
a lookup that stopped with a reference left unasked. Where nobody performs
the manual release, that is not caution — it is the same permanent quarantine
this PR set out to remove, reached by a different door, and the rule behind
the order dies with it just as surely.

These outcomes now expire as well, on their own much longer clock: 24 hours,
over four times the slowest order ever observed at this venue. By then
nothing can still be in flight, so the balance the rule replans from already
reflects whatever really happened — the same argument that makes the short
bound safe, with far more room.

The recorded reason says the venue was never heard from, never that nothing
was sent, so the row still claims only what was observed.

One case still waits for a person, and deliberately: an order no integration
can look up at all. There the venue is never asked and the balance a replan
would read is not live either, so neither half of the safety argument holds.

* fix(liquidity-management): finish a lookup that would otherwise never complete

The 24-hour bound did not hold for one of the three cases it covered. When a
claimed replacement never reached the venue, it is absent on every pass — so
the lookup stopped at it every time and never reached the reference it
replaced. Waiting could not fix that: the absence is structural, not
transient. Meanwhile these are GTC limit orders with no expiry, so the
unchecked predecessor can sit open in the book indefinitely, and abandoning
after 24 hours would have planned those funds a second time.

The measurements behind the bounds cannot see this either. They describe
orders that completed, so an order that never becomes observable is by
construction absent from the sample — the numbers are a floor, not a ceiling.

Past the age at which this venue already treats an order as lost, the
invisible replacement is now recorded as spent and the lookup carries on to
the reference that may actually be live. That turns a question which never
completed into one that does, which is the only way such an order ever gets a
real answer rather than a guess.

Review points from the same round: the two abandon paths were identical
except for their reason, so they are one method taking it as an argument; the
timeout map and the entity mutator no longer name only the case they started
with; a JSDoc had come to sit above the wrong method; and the new predicates
now have direct entity tests covering both bounds and the missing-timestamp
guard.

* fix(liquidity-management): apply the same age rule to the write path

The previous commit repaired the reconciliation lookup but not the completion
check, and the two disagreed. Reconciliation would walk past a claimed
replacement the venue never showed and resolve the order to its predecessor;
the very next completion check blocked on that same invisible claim, refused
every write, and quarantined the order again. The order oscillated between
the two states indefinitely — and because each return to quarantine refreshes
`updated`, both abandon bounds were reset every cycle, so the safety net
meant to end exactly this was disabled by the loop itself.

adoptLiveReplacement now steps past a claim under the same condition the
lookup uses: the venue ANSWERED and still does not show it, past the age at
which this integration already treats an order as lost. A claim the venue
could not be asked about keeps blocking however old it is — silence is not an
answer, and no clock turns it into one.

Also drops a call to recordSpentCorrelationId that could never do anything:
its argument always came from the order's own reference list, which is the
one case the entity guards against. The commit message claiming otherwise was
wrong; the correctness came from continuing the loop, not from that line. The
"keeping it quarantined" warning now only logs on the path that actually
keeps it quarantined, and the comment claiming an explicit rejection is the
only way to reach an older reference names the timeout path too.

* feat(liquidity-management): cancel before giving up, instead of timing it out

Everything about abandoning a quarantined order was an estimate of when its
request could no longer execute. That estimate is what three rounds of review
kept breaking: the measurements behind it only describe orders that finished,
Scrypt trades are GTC and expire never, and the clock the two code paths
shared was a whole-row timestamp that any unrelated write reset — so the order
oscillated instead of ending.

None of that is necessary. What makes abandoning dangerous is not the passage
of time but the possibility of a live request, and that possibility can be
removed rather than waited out: cancel every reference the order ever sent,
and only give up once the venue confirms none of them can execute. A cancel is
the opposite of a re-send and cannot create anything, which makes it the one
write that is always safe against an outcome nobody observed.

The order is abandoned when the venue settles all of its references, and keeps
waiting when it will not — unreachable, or reporting one in some other state.
That is the same conservative answer as before, but reached for a stated
reason instead of an assumed one, and it no longer depends on any clock.

What this removes: the 24-hour unanswered bound, the reference-left-unasked
classification, the age exception in adoptLiveReplacement, and the tests built
on them. The two measured bounds stay — they still decide when it is worth
trying to clean up at all, not whether cleaning up is safe.

Scrypt only. Adapters that cannot cancel omit the new hook and their orders
keep waiting for a person, unchanged.

* fix(liquidity-management): read what a cancellation actually established

The cancellation had two outcomes where the venue has three, and each gap was
a way to lose money or to stall forever.

A partially filled order cancels with a terminal Canceled status AND a
non-zero fill — the protocol says so explicitly. Reading only the status
called that "nothing can execute" and abandoned the order, dropping a real
fill and handing the rule back funds that were already spent. A fill is now
recognised whatever the final status says, and the order is pointed at the
reference that executed so the ordinary completion path books it instead.

A refused cancellation arrives as an execution report, not as an error —
this venue sends no separate reject message. So the branch meant to catch
"there is no such order" was unreachable, and the incident case this PR
exists for would never have been settled at all. It is now read off ExecType
and CxlRejReason, and only UnknownOrder counts: every other reason settles
nothing.

An order with no reference ran the loop zero times and fell through to "all
settled" — abandoned on a confirmation nobody gave. It now refuses without
asking anything, since there is nothing to ask about.

Also adds the tests this contract had none of, and corrects the comments left
describing the two-tier bound that no longer exists: age only decides when
cleaning up is worth attempting, and the venue decides whether giving up is
safe.

* test(liquidity-management): fix fixtures for the new cancellation contract

The cancel now returns the venue's execution report rather than a boolean, so
a mock that resolved undefined made the amend path read a field off nothing
and take the unconfirmed branch. And cancelOutstanding derives the trade pair
from the rule, which the shared uncertain-order fixture does not carry.

* fix(liquidity-management): file a cancellation under the order it settles

The fill was still unbookable, one layer further down. This venue tags a
cancel confirmation with the CANCEL request's id and names the cancelled
order only in OrigClOrdID — while every lookup here is keyed on ClOrdID
alone. So the terminal state was filed under an id nothing ever asks about,
and the cancelled order kept answering with whatever non-terminal report
preceded it. Pointing the row at a reference that had executed therefore led
nowhere: reconciliation saw the stale report, handed the order back, and the
completion check found the same stale report again.

The confirmation is now filed under the order it settles.

Even then the fill was unreachable: the reconciliation lookup stops at the
first reference the venue does not show, newest first, and a claimed
replacement that never arrived is exactly such a reference. It would stop
there on every pass and never reach the older one the cancellation had just
identified as executed. The stop now applies only to references NEWER than
the one the row names — the current reference is authoritative, and a
cancellation that found a fill points at it deliberately.

An unreadable filled size no longer counts as zero; it settles nothing, since
concluding "nothing filled" from a value that could not be parsed is how a
real fill gets dropped. The rejection branch in the catch is gone — this
venue answers a refused cancel with a report, not an exception, so it was
unreachable and contradicted the branch that does the work.

Adds seven direct tests for the classification itself, which until now was
only ever mocked, and completes the fixture that the type checker could not
see through.

* fix(exchange): file only a real cancellation under the cancelled reference

The previous commit filed every response tagged with the cancelled order's id,
refusals included. A refusal comes back on the same channel carrying the
order's last known state, and for a reference the venue never had that reads
as a live New order — so caching it invented one. The next lookup found that
phantom, called the order sent against a reference that had never executed,
and pointed the row away from the one that actually filled. Same lost fill as
before, one layer further down.

Only a terminal Canceled says anything about the order being cancelled, and
that covers both outcomes the caller distinguishes: nothing filled, and
partially filled. Refusals stay uncached, so a reference the venue never had
keeps reporting nothing — which is what lets the newer-reference guard in the
reconciliation lookup do its job.

Adds the two tests that would have caught it: a refusal must leave the cache
untouched, and a cancellation that finds a fill must still name the executed
reference after the next reconciliation pass.

* feat(liquidity-management): treat an executed reference as settled too

The whole tangle came from one wrong assumption: that a reference which
filled is a reason to hold on to the order. It is not. The question this path
asks is whether a reference can still execute, and one that already ran
answers it just as finally as one that was called off.

Holding on was also pointless, because the fill is in the venue's balance and
that balance is what the rule replans from — it plans for what is actually
left, not for what this row believed. So there is nothing to rescue and
nothing to guess: cancel everything, give up, replan.

That removes the part which produced a critical finding in five consecutive
review rounds — rerouting the row onto the executed reference, and with it
the need to remember which references the venue never had. Gone with it: the
reroute, the newer-than-current guard in the lookup, and the chained test
that only ever existed to cover them.

Two findings from the same round, both real:
- The cancel waiter accepted the first report mentioning the order, including
  a non-terminal PendingCancel. Since it unsubscribes on its first match, the
  real terminal report that follows would never have been seen. PendingCancel
  is now skipped.
- resolveUncertainOrders described itself as only ever observing, which
  stopped being true when this PR gave it a cancel.

* fix(exchange): require a terminal state before calling a reference executed

The fill was read before the status, so a REFUSED cancel could be classified
as executed. A refusal carries the order's last known state — a partially
filled order that could not be cancelled reports a fill while staying wide
open — and since the caller now treats executed like settled, that would have
let it walk away from a reference still able to trade. The same double
execution this path exists to prevent, re-entered through the door the
previous simplification opened.

A fill on its own says nothing; only a terminal state says nothing more is
coming. Executed now requires Canceled or Filled, and a non-terminal report
with a fill settles nothing.

Also records which reference filled on the order before abandoning it. The
money side is carried independently by the venue's own transaction sync, but
an abandoned order books no output and cannot say for itself what happened —
naming the reference is what lets the two be tied together afterwards.

Documents the one assumption that stays an assumption: the replan reads a
pushed balance, so a just-landed fill may briefly not be in it. Seconds in
practice, but a window rather than a guarantee, and the comment said
otherwise.

Corrects the enum, the docstring and the log line that still described the
executed case as one that must be completed rather than abandoned.

* fix(exchange): decide what counts as terminal in one place

The cancellation check restated which states are final and left one out. A
rejected order is as unable to trade as a cancelled one, but it fell through
both branches and settled nothing — so an order that provably could not
execute would have stayed quarantined for want of being recognised. Exactly
the class of permanent hang this PR removes, reintroduced beside it.

This venue already answers that question in one place, and that answer
includes rejected. Reusing it means a second list cannot drift from the first.

Adds the two cases that were missing: a rejected reference with nothing
filled settles, one with a fill counts as executed — same as cancelled and
filled.

* docs(liquidity-management): align three statements with what the code now does

All three were true when written and were made false by changes in this PR.

The entity header still claimed abandoning is safe because the replan reads
the CURRENT balance — stated as settled fact, while the adapter's own
cancellation now honestly calls that a window rather than a guarantee. It
names both things that actually carry it now: the venue's confirmation, and a
replan against the balance, with the freshness caveat pointed at where it is
described.

The no-reference comment said such orders resolve on a bound rather than a
verdict. They do not: with no reference there is nothing to cancel either, so
the automatic route cannot confirm anything about them and they wait for a
person.

And abandoning is no longer reserved for orders the venue never accounted for
— it is reached just as well when a reference executed and nothing is left
outstanding.

* docs(exchange): state the UnknownOrder reading as the inference it is

The comment asserted that UnknownOrder means there is no such order, so
nothing under that reference can execute. The protocol spec lists the reason
without defining it, so that cannot be read off the value: "never existed" and
"not processed yet" are indistinguishable from it alone.

The reading itself stays — it is what lets the incident case resolve at all,
and every alternative leaves the order waiting forever. But it now says what
it rests on instead of claiming certainty: the caller only cancels a reference
it has already failed to find through a separate status lookup, and only after
the order has outlived the window in which its request could still be live.
Two independent negative answers plus that age are the strongest evidence this
protocol offers, which is a different claim from proof.

* fix(exchange): treat a missing filled size as missing, not as zero

Number('') is 0, not NaN, so an empty or whitespace CumQty passed the finite
check and read as an untouched order — settling it. The comment two lines
above promises the opposite: that an unreadable quantity settles nothing.
Emptiness is now caught separately, with tests for both forms.

Also corrects five statements that this PR had made untrue:

The entity's abandon mutator still said "an order the venue never accounted
for" — the fourth instance of a sentence corrected three times elsewhere, and
wrong for the same reason: abandoning now also happens when a reference
executed.

The abandon docstring claimed to rest on nothing but the clock, four lines
above stating correctly that it is reached only after the venue confirmed
nothing can execute. The clock decides when cleaning up is worth attempting;
the confirmation is what permits it. Same wording corrected in the sibling
docstring and in the test comment that echoed it.

The UNRESOLVED enum value said the bound alone gives an order up. It does not
— without the cancellation's confirmation the order stays quarantined.

And "every reference the row ever put on the wire" overstates the source: a
reference is reserved before it is sent, so the list can contain one that
never left.

* fix(exchange): a refusal that contradicts itself settles nothing

The UnknownOrder branch returned settled unconditionally, while the terminal
branch right above it checks the filled quantity before deciding. That
inconsistency mattered: a refused cancel carries the order's last known state,
and the neighbouring test proves such a refusal really can arrive with a
filled quantity attached.

A report claiming the venue has no record of an order while reporting a fill
on it disagrees with itself. Nothing may be concluded from that — least of all
that nothing can execute, which is the one conclusion that lets the caller
walk away. It now settles nothing and the order keeps waiting.

* docs: say what the cancellation evidence actually covers

The UnknownOrder comment claimed two independent negative answers per
reference. It gets one: the status lookup stops at the first reference the
venue does not show, while the cancellation asks about every reference of the
order — so for all but one of them the refusal is the only negative answer
there is. Age plus one refusal is still the strongest evidence this protocol
offers, but that is a weaker claim than the one that stood there.

Four statements corrected in the same class, all of them versions of
sentences already fixed elsewhere in this PR and missed at these spots:

- The reference-list helper still said "actually put on the wire", one line
  from the caller where exactly that wording was corrected — a reference is
  reserved before it is sent.
- The interface contract for cancelOutstanding had the same wording, making it
  narrower than what its only implementation documents.
- A comment still had the bound abandoning an order by itself; it triggers a
  cancellation attempt, and only that attempt's confirmation abandons.
- An inline comment still called the abandon write "resting on no evidence at
  all", four lines below a docstring that had already been corrected to say it
  rests on the venue's confirmation.

* fix(exchange): check the contradiction before the status decides

The self-contradiction guard added last commit sat behind the terminal-state
check, so it only caught the non-terminal shape. A refusal claiming the venue
has no record while reporting a fill is contradictory whatever status rides
along — with a terminal one attached it went straight through as executed,
which is exactly the permission to give the order up that the guard exists to
withhold. It now runs first.

The cancellation is also no longer cached when its filled quantity is
unreadable. Readers derive that value with `parseFloat(...) || 0`, so such an
entry would quietly claim nothing was filled on every later lookup — the same
reading the classification itself was changed to avoid.

And three statements the previous commit claimed to have corrected but did
not: its edit script aborted partway and I only chased the one failure it
reported instead of checking whether the rest had run. The interface contract
still said "put on the wire", and two comments still had the bound giving an
order up by itself rather than triggering a cancellation whose confirmation
does. Corrected now, with a grep over the whole diff to confirm no further
instances remain.

* test(exchange): cover the matcher and a non-numeric filled size

The PendingCancel skip added earlier had no test at all: every cancellation
test mocks the transport directly and hands back a finished report, which
routes around the matcher entirely. This one captures the matcher and drives
it, asserting it ignores the interim state and takes the terminal report that
follows — the waiter resolves on its first match and stops listening, so
accepting PendingCancel would freeze it as the answer.

Also adds a genuinely non-numeric filled size. Empty and whitespace were
covered, 'abc' was not.

* fix(liquidity-management): ask every reference before deciding

cancelOutstanding promised to cancel everything the order could still have
live, then left the loop at the first reference the venue would not settle.
Those it skipped are precisely the ones that can sit open in the book while
the newest keeps refusing — so the method did the opposite of what it says
whenever it mattered most. It now asks about every reference and decides
afterwards; one refusal still keeps the whole order quarantined.

The existing test hid this: its unconfirmed reference happened to be last in
iteration order, so both got called anyway. The new one puts it first.

Also sharpens the interface contract on what "confirmed" covers. An accepted
cancellation or a terminal order settles the question outright; a refusal
saying the venue has no such order is an inference from its own words, not a
statement about execution. The contract said CONFIRMED for all three, which
overstates the weakest of them.

* docs: correct the bound's role everywhere, and say where it does not reach

The previous commit claimed a grep over the whole diff had confirmed no
further instances of "the bound gives the order up". It had not — the grep
matched the exact wording I had just changed, not the pattern. Five more
places said it, in three different phrasings. Searched by pattern this time,
with the result verified as empty afterwards.

More useful than the wording: the transfer bound does not reach Scrypt
withdrawals at all. It was derived from measured withdrawal runtimes, but
reaching it only triggers a cancellation attempt, and the one venue that
implements cancellation refuses it for withdrawals outright — there is no such
thing as cancelling one there. So those still wait for a person, and the value
governs the other transfer kinds and any venue that gains a cancellable
withdrawal later. Said so at the constant, and renamed the test that asserted
this to stop implying a venue can do what Scrypt cannot.

Also: the abandon write does rest on evidence — the venue's cancellation —
just not on evidence about whether the request was ever sent. And the cache
test now covers all four unreadable shapes instead of only the empty string.

* fix(liquidity-management): stop calling an inference a confirmation

The reason written into the order and the log said the venue "confirmed" that
nothing can execute. It does not always confirm that: a refusal saying it has
no such order settles the question by inference, which the code says plainly
where that inference is made. The interface contract was corrected for exactly
this distinction two commits ago and the downstream message was not, so an
operator reading the reason later would lose the difference between an
accepted cancellation and something concluded from the venue's wording. Both
now say "answered", which covers all three outcomes honestly.

Four smaller consistency fixes in the same class: two docstrings, an enum
comment and a test title said "every reference the order ever sent", excluding
the reserved-but-never-sent case that cancelOutstanding explicitly covers; a
trade test title still credited the bound alone with abandoning, while its
transfer twin had been corrected for that; and a test comment still said the
caller may abandon once the bound expires, without the settled cancellation
that actually permits it.

* fix(exchange): never file a cleanup cancellation under the order it cancelled

A confirmed cancellation was cached under the cancelled order's own id. That
made a later status lookup read the order as terminally cancelled — and a
cleanup cancellation says nothing about the order as a whole: its sibling
references may still be unsettled and live. The lookup would then report the
order as known, take it out of quarantine, and the completion check would open
a replacement beside a reference that can still trade. The double execution
this whole path exists to prevent, reachable in a plain multi-reference order.

The aliasing only ever existed to make the EXECUTED reroute work, and that
reroute is gone — cancelling now settles a reference rather than adopting it.
So this is dead code with a live hazard, and removing it restores what the
method did before this PR: report the outcome, cache nothing.

The test that demanded the aliasing now demands its absence.

* test(exchange): restore the amend-propagation guard lost to a regex

My delete regex in the previous commit took more than the one test it was
aimed at. I restored the block brace and the helper it had swallowed, but not
this test — it guards that an unconfirmed amend is propagated as such and that
nothing is cancelled along the way, behaviour that is unchanged and still
thrown in two places. Its loss also orphaned an import, which eslint reports
as a warning; `npm run lint` exits zero on warnings, so my own gate check
called that green while CONTRIBUTING requires none.

Restored verbatim from the parent commit. Verified by diffing every test title
against that commit: only the one intentional reversal differs, and eslint
with --max-warnings 0 is clean.

* style(exchange): keep these two files on the CRLF endings this repository uses

Restores what my Python edit scripts had silently normalised to LF. It is not
cosmetic: with every line counted as changed the real diff was unreadable and
the branch could not take a thirteen-line change to the same region.

* fix(liquidity-management): keep the venue-lookup cooldown inside the abandon bound

#4438 landed on develop while this branch was open and throttles the very loop
this branch extends: a quarantined order's venue lookup now waits a tenth of the
order's age, capped at thirty minutes, and skips the pass entirely while that
wait runs. The skip happens before the branch that gives an expired order up, so
the throttle silently governed the deadline too — a trade quarantined when it
was already hours old would draw the full thirty-minute interval and be
abandoned six times its own five-minute bound too late, with nothing anywhere
saying the bound had moved.

The interval is now also capped at what is left of the order's own bound, held at
the cooldown floor once that has passed. A wait reaching past the deadline it has
to keep cannot do its job; below the floor, a cancellation the venue will not
confirm would retry on every ten-second tick, which is what #4438 exists to
prevent. Both bounds keep their intent and the tighter one wins.

The remaining time comes from the entity, from the same private accessor as the
deadline itself, so the two can never disagree about which bound applies. Only
the direction carrying the safety is asserted: an order past its bound reports
nothing left. The converse cannot hold at the single instant where elapsed equals
the bound, and a non-negative duration cannot express that — it costs one
cooldown floor, never a missed abandonment.

Verified by mutation: removing the new cap leaves the abandonment sitting for the
full thirty minutes and turns the test red. #4438's own cap tests are untouched —
their fixture sets no `updated`, so no deadline constrains them — and a transfer,
whose bound is twelve hours, still waits the full cap.

* fix(exchange): refuse a negative filled size instead of reading it as untouched

A cumulative filled size below zero is finite and parses cleanly, so it passed
the unreadable-value guard and then lost to `filled > 0` — reported as an order
nothing had ever traded, which settles the cancellation and lets the caller walk
away from the reference. Same class as the `Number('') === 0` case the guard
above it already names: a value that cannot mean what it says must not be
compared as though it could.

Fails closed as UNCONFIRMED. Verified by mutation: dropping the check turns the
two new cases green again, which is what makes them worth having.

* test(liquidity-management): type the new cooldown fixtures through the shared helper

`tsc` rejected what jest had happily transpiled: an inline action literal is
checked against LiquidityManagementAction inside Partial<LiquidityManagementOrder>
and lacks paramMap, created and updated. The file already has agedOrder(minutes,
command) for exactly this, with the cast the rest of the suite uses, so both new
tests now go through it instead of building their own fixture.

My local gate had missed it because I ran jest and eslint but not tsc. Re-verified
by mutation after the rewrite: removing the cap still turns the bound test red.

* fix(liquidity-management): freeze the deadline cap at the last lookup

The cap was read fresh on every tick, so it shrank while the wait it bounds
grew — and the two met halfway. A five-minute bound was therefore re-asked at
2.5 minutes, then 3.75, then 4.4: a geometric series of full history fetches
before a deadline that had not moved, which is the cost #4438 exists to avoid.
The abandonment also landed late, around 5.75 minutes, because each of those
lookups restamped the cooldown.

Now measured as the time that was left when the last lookup finished. Adding
the elapsed wait back to what is left now yields exactly that figure —
bound − (attempt − updated) — so the cap holds still between lookups and
needs nothing stored beyond the stamp already kept. One lookup, and the
abandonment lands on the bound rather than past it.

Verified by mutation, both directions: re-reading the cap on each tick turns
the new test red, and removing the cap altogether turns two red.

* fix(liquidity-management): stop reporting a stuck quarantine write as a race

A missed compare-and-set was logged as "already resolved elsewhere, skipping",
which is one of two possible causes and the harmless one. The other is a
narrowing that matched no row and never will: then the same line appears on
every pass while the order does not move at all, and the wording makes a
permanent block read like routine concurrency.

Found the hard way in production. A release timestamp written by hand in SQL
carried microsecond precision; a JavaScript Date carries milliseconds, so the
value the code loaded could not match the row it came from. The order sat behind
this message, once per tick, until the precision was corrected — and the log
said it had been resolved elsewhere the whole time.

The line now names both causes, says that repetition means the second one, and
is a warning: a race is rare and self-correcting, while the other case is a
silent permanent block and this is the only trace it leaves. Which is the exact
failure mode this branch exists to end, so it should not be introduced by the
branch's own bookkeeping.

No extra query — re-reading the row to tell the cases apart consumed a call the
surrounding tests sequence their mocks on, which is too much machinery for a log
line. Verified by mutation: restoring the old wording turns the new test red.

* fix(liquidity-management): give the order up at its bound, not a cooldown later

Two review lanes found the same defect independently, and between them the whole
of it: the quarantine bound was reliably overshot by up to a minute.

Half of it was an off-by-one between the two halves of one clock. The deadline
having arrived is what lets a reconciliation pass run at all; being *past* the
bound is what lets that pass give the order up. With a strict `>` the instant
the bound was exactly met satisfied the first and not the second, so the pass
ran, abandoned nothing, and re-stamped its cooldown — a five-minute bound gave up
at six. Now `>=`, and the two turn over together. Asserted with the clock held
still, because anything built from a live `Date.now()` is already a fraction past
the boundary, where `>` and `>=` agree and the bug is invisible.

The other half was the cap's own floor. A lookup running shortly before the
deadline has less than a floor's worth of time left, and imposing a full interval
on it scheduled the next pass after the deadline — the overshoot the cap exists
to prevent, caused by the cap. The floor now applies only once the deadline has
passed, where there is nothing left to protect and an unconfirmable cancellation
must still not retry every tick.

Making that distinction possible is why `msUntilAbandonable()` became
`abandonableAt()`. A remaining duration has to be clamped at zero, and that clamp
erases precisely what a throttle needs after the deadline: whether the wait it is
about to impose began before it. An absolute instant carries the sign, so the
lookup's headroom is a subtraction rather than a reconstruction — and the comment
explaining the reconstruction is gone with it.

Verified by mutation, three ways: restoring the floor on an open deadline turns
the new boundary test red, `>` turns three red, and dropping the cap turns three
red. My first attempt at the floor half was not caught by its own test — the
scenario never produced a lookup close to the deadline — so the test now reaches
it the way production does, via an order that enters the pass already near its
bound with no cooldown recorded.

* refactor(liquidity-management): follow the method naming rule, fix a stale doc link

CONTRIBUTING requires methods to be verbs, with the documented exception being
boolean getters on `is`/`has`. `abandonableAt()` is neither, and it returns a
Date — so `getAbandonableAt()`. The entity's other additions here are already
verbs or read as statements; this was the one value getter, and the only method
of its shape in any entity, so there was no house pattern arguing the other way.

The JSDoc above `unresolvableTooLong()` still linked `{@link msUntilAbandonable}`,
a method the previous commit removed. A dead link in the one comment explaining
why the two halves of that clock must agree is worse than no link.

* refactor(liquidity-management): apply the naming rule to the helper too

`abandonBoundMinutes()` is a noun phrase, exactly what the previous commit
renamed `abandonableAt()` for. Applying the rule to the public getter and not to
the private helper beside it would have left the file arguing with itself. The
entity spec's describe block also still carried the old name.

The local variable holding the returned instant keeps its noun name: the rule
governs methods, and `abandonableAt` is what that value is.

* docs: stop promising a bound the code deliberately does not give

Three statements claimed more than the code does. The bound was described as
"how long before it is abandoned", the quarantine as ending "not indefinitely",
and every outcome at an askable venue as "bounded". None of that holds: reaching
the bound starts a cancellation, and the order is given up only if the venue
settles every reference it claimed. Where an integration cannot cancel, or the
venue will not answer, the order stays quarantined past the bound — which the
same comments then said correctly a few lines further down, so they contradicted
themselves.

The claim these bounds actually support is narrower and worth stating exactly:
they end the assumption that somebody will eventually look, not the quarantine.

The catch in `cancelOrder` said anything reaching it is a transport failure. It
starts with a trade-pair lookup, so a missing pair or configuration lands there
too, having sent nothing. The comment now says what the handler really knows —
that it cannot tell which failures got as far as writing, which is precisely why
it drops the cached report for all of them.

Comments only; no statement, condition or value changed.

* docs: finish the pass, and check the whole class rather than the reports

Three more statements of the same kind the previous commit corrected, each
contradicted by a comment within a few lines of it:

- The UNCERTAIN enum said an order with no venue record "is abandoned to FAILED"
  once past the window, four lines above its own correct note that only the
  venue's answer permits FAILED.
- `completeNotSentRelease` said the other exit means an unreleased order "cannot
  block its rule forever", against the corrected text 150 lines up in the same
  file saying it can, and that this release is then the only way out.
- SETTLED was documented as "the certainty the caller needs" in both the enum and
  the service, while the constant it rests on says the unknown-order reading is an
  inference and not a documented guarantee. Both now separate the two qualities of
  answer instead of flattening them.

Fixing only what was reported is what left these behind twice, so this time the
whole diff was swept for the pattern — every new comment claiming a guarantee.
The three above were real. The remaining matches are sound: one is asserted by
the test it appears in, one is hedged with "in practice" and exists precisely to
explain why the code does not rely on it, and the rest describe the failure this
branch fixes rather than promising anything about it.

Comments only; no statement, condition or value changed.

* docs: correct three claims, including one where the review was right and I was not

The review's first two rounds on this pattern each left something behind, and one
of the leftovers was a call I had made myself:

- The UNCERTAIN enum said an order past the window "has its references cancelled".
  Still too much: a Scrypt withdrawal returns false from cancelOutstanding without
  asking anything, and an adapter that cannot cancel omits the method entirely. It
  now says an attempt is made where the adapter supports one.
- The entity said an askable venue leaves no outcome waiting for an operator. The
  attempt is a cancellation; a venue that will not confirm one holds the order
  indefinitely, and a request that cannot be cancelled is never attempted. Stated
  as what it is: those orders get an attempt, not a bound.
- `abandonUncertainOrder` claimed its release marker is "always null in practice,
  because a pending release routes to an earlier branch". I had reviewed that claim
  in the previous commit's sweep and passed it. It is wrong: this branch has no
  release condition of its own, so an order released while the venue was
  unreachable reaches it with the marker set. The compare-and-set was already
  correct — it narrows on the value actually read — but the comment explained it
  with a premise that does not hold.

Checked what the third one implied and it is fine: the abandonment prefixes the
existing message rather than replacing it, so an operator's audited reason and
ticket survive. Said so in the comment, since the question now arises there.

Comments only; no statement, condition or value changed.

* docs(liquidity-management): correct what this test says the abandon would do

The comment said the abandon would overwrite the operator's audited reason with
an anonymous one. It would not — both exits prefix the existing message rather
than replacing it, which I established while correcting the production comment in
the previous commit and then failed to carry over to here, leaving the two saying
opposite things about the same code.

What actually differs is the verdict each exit adds: the release states the venue
confirmed the request never arrived, the abandon only that nothing is left to
execute, and just one of them rests on somebody having checked. That is what the
branch order protects and what the assertion is worth having for.

* fix(liquidity-management): give quarantined Scrypt withdrawals an automatic exit

A quarantined withdrawal had no way out but an operator: cancelOutstanding
returned false for WITHDRAW without asking anything, because Scrypt has no
cancel operation for withdrawals. Trades resolved themselves, withdrawals
waited for a person who may never come — and withdrawals are roughly half of
what this venue does.

The exit cannot rest on a cancellation, so it rests on confirmed absence: the
venue returns its full transaction history and this reference is not in it.
That is weaker than "the request never arrived" and is worded as such — the
order is abandoned, never released as not-sent, because nobody observed a
send.

What carries the safety is the consistency gate, not the age bound. A fresh
history counts only when it is non-empty and every already-known transaction
inside the order's window reappears in it. Without that, a truncated reply
that merely omits the reference would read as "does not exist", the rule
would replan, and withdraw() would run again — it only checks
minAmount > balance and then takes min(maxAmount, balance), so with enough
balance left a second payout goes through. A failed gate warns with the
missing references named, so a permanently broken fetch is visible instead of
becoming a silent wait.

cancelOutstanding now returns the reason to record instead of a boolean, so
an abandoned withdrawal does not carry the trade wording claiming every
reference was answered for.

Reaching quarantine without a reference is an invariant break since
reserve-before-send, not a planned wait: logged as an error rather than
silently held. Verified against production — no Scrypt order is in Uncertain,
and every one without a reference is Failed or NotProcessable, so there is no
legacy population to migrate.

Trades whose references the venue will not settle keep waiting, deliberately:
that wait is on the venue, not on a person. While Scrypt is silent nothing
can be planned against it anyway, and the next pass abandons the order within
seconds of it answering. Releasing earlier would be guessing, and an onFail
chain could then buy the same funds on a second venue while a zombie order
still sits in the book.

* fix(liquidity-management): close the remaining paths where Scrypt waits for a person

Review found three more states a quarantined Scrypt order could only leave by
hand, plus a gate that did not hold what it promised.

The consistency gate was only as good as the local cache it compared against.
With that cache empty — process restart, failed warm-up, catch-up not through
yet — it passed without checking anything, so a truncated history that merely
omitted the reference would have read as confirmed absence. It now requires an
anchor: at least one known reference inside the window, plus the newest known
transaction overall, so an empty time window cannot hollow it out either.
Missing anchor means no exit and a warning, which only defers — warm-up refills
the cache within seconds. The test that accepted the unsafe state now demands
the safe one.

An order whose command is no longer registered was skipped on every pass: the
factory hands out an integration only for a supported command, and
Scrypt/sell-if-deficit still exists in production as an action the code
dropped. Reconciliation now resolves by system alone, because what matters for
resolving a quarantined order is who can ask the venue, not whether the command
is still executable. Executing still requires a registered command.
Cancellation for such an order sends nothing — it asks after every reference
and gives up only where the venue reports all of them terminal.

An order without a reference was a dead end, needlessly: the reference is
derived from the order id alone, so it can be reconstructed and the venue asked
as usual. The invariant-break error stays, it is just no longer the only
response.

A missing `updated` switched the deadline off entirely, which turned a defensive
guard into a permanent wait. It falls back to `created` — always older, so the
bound expires earlier, which is safe because abandoning still needs the venue's
confirmation; the clock only decides when a cleanup attempt is worth making.
Without either timestamp the order still stays put, or the bound would run from
the epoch and give up at once. A test asserted the old behaviour by name
("never abandons an order whose quarantine timestamp is missing") and was
guarding the very defect; its claim is inverted rather than dropped.

Two comments promised automation the code does not deliver for systems other
than Scrypt.

* fix(exchange): make the absence proof survive a venue that answers incompletely

The consistency gate compared the fresh history against the local cache — but
both come from the same client, and fetchAll treats a missing `next` as
end-of-history without checking cursor or sequence. A truncated pagination
therefore looks complete, and the same missing slice is absent from cache and
reply alike, so the gate passed on a common-mode failure. The newest-known
anchor did not help: it guards recency, while the failure drops an older row.

An old anchor is added alongside it. The oldest cached reference stamped before
the order's own window must reappear in the fresh reply, which forces the answer
to span that window and exposes a suffix cut in either direction. What this
cannot see is stated where the check lives rather than glossed over: a hole in
the middle of history, missing from cache and reply alike, has nothing local to
be compared against.

The cache snapshot was taken before the fetch and never re-read, so a withdrawal
arriving as a live push while the bulk fetch was open was invisible to both
snapshot and reply — and would have been reported absent. The live map is now
re-read after the await.

The anchor requirement introduced a way to wait forever: the cache is filled by
the boot warm-up and by reconnects only, and a failed warm-up had no retry. On a
stable socket the cache would then stay empty, every reconciliation would defer,
and the withdrawal would sit there — the forbidden combination, reintroduced by
its own fix. A warm-up that fails now triggers the existing catch-up instead of
waiting for a reconnect, and only a real rejection counts: an empty history is a
successful warm-up.

Cancelling for a command that is no longer registered was passive, waiting for a
terminal status that may never come. The premise was wrong: getTradePair matches
a pair in either direction and returns the same symbol, and a cancel reads only
that symbol, never the side. With a tradeAsset in the params such an order takes
the ordinary cancel path and gains the UnknownOrder inference with it. Only
without a determinable symbol does the passive branch remain.

* fix(exchange): drop the absence gate, it bought a non-risk with a forbidden wait

The gate guarded against a second withdrawal. That is not a risk worth
guarding: Scrypt withdrawal destinations are exclusively DFX-owned addresses,
so a repeated payout moves funds between our own accounts — an internal
rebooking, not a loss and not a compliance incident.

What it cost instead was the one thing that is not allowed. Every anchor it
required can be structurally absent: no cached row carrying a ClReqID, no row
older than the order, nothing inside the order's window, or simply an order
older than the 365-day cache bound. In each of those cases the check returned
false on every pass, forever, and the withdrawal waited for someone to refill a
cache by hand. Review found two such paths; both came from the gate itself.

So the gate goes, and with it roughly 120 lines. What remains reacts to an
absent answer rather than to a missing local anchor — a fetch that throws, or an
empty history — plus the one positive observation that costs nothing: if the
reference turns up in the live cache while the bulk fetch is open, absence is
not confirmed. A truncated venue reply can now cause a second withdrawal, which
is written where the check lives, because it is a decision and not an oversight.

Two defects surfaced underneath and are fixed on their own merits. Balance
transactions without a readable timestamp were dropped from the cache
unconditionally — `new Date(undefined) >= x` is always false — while the sibling
function five lines above guards exactly that case; the rows discarded were
withdrawals we later look for. And the passive path for an unregistered command
treated "the venue does not know this reference" like "the venue could not be
asked", so it never settled; the active path infers precisely that. Only a
lookup that could not be performed keeps the order waiting now.

* fix(exchange): close the last two states a Scrypt order could not leave alone

An empty transaction history was treated as an answer we could not use. It is
the opposite: on a fresh or long-idle account — the stream carries only deposits
and withdrawals, never trades — the venue answers successfully with zero rows,
and a reference cannot be in a list that has none. The branch turned that into a
permanent wait, and the warm-up retry cannot break it, because an empty result
counts as a *successful* warm-up. Two of my own comments contradicted each other
on exactly this point; the one at the check claimed a total integration failure
that recovery would repair, three lines from the constructor saying an empty
array triggers nothing. A real outage throws and is caught above, so the two
similar-looking cases are handled differently on purpose. That is now written
where the check is.

The second state was a trade of a command the code no longer has. Cancelling
needs a symbol, the order carried none that could be trusted, and Scrypt orders
run until cancelled — so nothing would ever end it. The premise was wrong:
ScryptOrderInfo already names the symbol a reference lives under, and a cancel
reads nothing but that symbol. The venue therefore tells us how to cancel its own
order, and every reference it can still show is cancellable.

So the symbol derivation goes. It was not only unnecessary but unsound: a
tradeAsset left in a stale paramMap is no evidence of what the command once did,
"SELL versus everything else" is not a side inference for a command that by
definition is not SELL, and the pair lookup reads live security configuration
that a delisted pair no longer matches — while the order sits open at the venue
under its original symbol. One path now covers every unsupported command: ask,
then cancel under the symbol the venue hands back, evaluated exactly as the
known-command path evaluates a cancel. `null` still means the venue has no
record; only `undefined` — could not be asked — keeps the order waiting, and
waiting on the venue is allowed.

cancelIfOutstanding keeps its behaviour byte for byte; it and the new
symbol-taking variant share one core rather than a second copy of the guards.

* fix(liquidity-management): do not advance an order past the check that would finish it

Making reconciliation resolve by system alone let an unregistered command be
observed again — that part was right and stays. What it also did was let such an
order back into IN_PROGRESS on a venue-confirmed SENT, and that was a trap of my
own making. The normal completion path uses the strict lookup, which returns
nothing for an unregistered command, and the result was dereferenced unchecked:
every pass threw a TypeError that the surrounding catch merely logged. The order
sat in IN_PROGRESS, where the automatic abandon path does not reach and the
manual endpoint refuses it for no longer being quarantined. Worse than the state
it replaced, because quarantine at least has an exit.

Taking the loose lookup into the completion path instead would not have helped:
checkCompletion answers `false` for a command it does not know, so the order
would hang just as long without even an error to show for it. The exit has to run
through quarantine, which is where the cancel path now works.

So the rule is split by what a caller can actually do. Observing is allowed for
anyone who can ask the venue — reconciliation stays command-independent.
Advancing out of quarantine requires whoever can also finish the job, so a
venue-side SENT for a vanished command stays quarantined and leaves by the
ordinary cancel/abandon route.

Second layer, for any system rather than this one: a running order whose adapter
is gone now raises an unknown-outcome error instead of a TypeError, so the
existing handler quarantines it. That turns a silent permanent loop into the
state from which both the automatic and the manual exit apply.

The docstring claimed a confirmed order goes back to IN_PROGRESS and the normal
check takes over, three lines above a comment describing the removed-command case
as covered. Both were true separately and wrong togeth…
TaprootFreak
TaprootFreak previously approved these changes Aug 1, 2026
* Name the caller in the log line of a rejected request

A rejected request is logged with its reason but with nothing about who sent it. On an endpoint
that runs without authentication there is then no way to tell a partner integration, one of our
own apps and a third-party script apart, which leaves a recurring rejection without an owner who
could fix it.

The WARN line now carries the X-Client value, the requesting site (Origin, or the origin of
Referer - never its path or query, which can hold personal data or tokens) and the user agent.
All three are client-supplied and unauthenticated, so they are a diagnostic hint, never an
identity, and nothing is gated on them.

Each part is masked to the same standard as the rest of the logs, capped per header, and stripped
of anything that could break the line, so a crafted header cannot forge a log entry of its own.

* Log the values a rejected request sent

A validation message names the field and the values it accepts, not the value that arrived. A
client sending one wrong constant is therefore visible as a steady rejection count and stays
unidentifiable: the same line is produced whether the field was missing, misspelled or set to a
value from a neighbouring enum.

The global ValidationPipe now raises a ValidationFailedException that carries the raw
ValidationError[] alongside the response, and the exception filter renders the rejected values
into its WARN line. The response is unchanged - the exception is built by the base factory and
only re-raised, which a test pins field by field against the stock pipe.

The rendering treats every value as untrusted input: redacted by field name, masked by value
pattern, bounded in count, depth and length, and reduced to a summary for structured or oversized
values, so a request body cannot be turned into a log dump through a validation failure.

* Render a rejected value only where the field declares what it accepts

The first pass rendered any scalar that failed validation and relied on a field-name denylist to
hold back the sensitive ones. Review found three fields it does not hold back: `apiKey` and
`masterKey` are not matched by it, `accountNumber` is not either because the list anchors `number`
exactly, and a rejected `webhookUrl` or `redirectUri` would have carried its query string - the
place a webhook credential lives - into the log verbatim.

Widening the list would have been the wrong repair. It has to grow with every DTO that ever
carries a credential, and the field it has not heard of yet is the one that leaks.

The value is now rendered only where the constraint that failed declares a closed set of literals
(`isEnum`, `isIn`). That is the case the log line cannot be read without, because one client
sending one wrong constant looks exactly like another sending a different one, and it is bounded
by what the field declares rather than by what happened to arrive. Every other field keeps its
shape and loses its content: `<string(43)>`, `<number>`, `<array(2)>`. Missing, null and empty
stay visible for every field - there is nothing to disclose, and it is what separates a client
that never sent the field from one that sent it wrong. The name-based redaction stays as a second
layer over the fields it does cover.

Also moves the oversize rule into `maskLogValue`, so a caller cannot pay for masking a string it
is about to cut away; types the exception body as `Record<string, unknown>` rather than `any`; and
points the neighbouring payment-info spec at the pipe `main.ts` now installs, whose comment this
change had made inaccurate.

* Pin the two files this change completes in the coverage ratchet

The gate's advisory step named them on this branch and not on the base: the new DTO spec is what
carries `get-buy-quote.dto.ts` and `xor.validator.ts` to 100% on all four metrics. Unpinned they
would stay unguarded, so a later change could erode the coverage this pull request created without
turning the gate red.

* Say what the literal-domain rule bounds, and what it does not

The comment read as if declaring a closed set of values also bounded the value that arrives there.
It does not: the declaration decides which fields may show their content at all, and what is then
shown is still untrusted input, held only by the masking and the length cap.

* Say what holds a rendered value, and cover the case that showed it did not

The comment claimed that what gets rendered "stays masked and capped". That is true of a string,
and only of a string: a number or a boolean is written out as it is - which is harmless, because
being one is already the bound, but the sentence did not say so.

The gap was possible because no test covered a non-string under a closed set of values, although
the repository has such a field: `noExecutionVerified` is `@IsBoolean() @isin([true])`. Adds one.

* Note the field name in the rule the previous commit already applied to it

That commit put the field name through the same rendering as the value - a precaution, since no
DTO today validates through a client-keyed object - but said only what it did to the value. Names
it, and closes the paragraph it had left mid-line.

* Reduce the Origin header to its origin as well

Only the referring URL was cut down to its origin. `Origin` was passed through as it arrived - on
the reasoning that it carries nothing else - and that is the reasoning this whole line is built to
distrust: the header comes from the client, and a value that is not what it is supposed to be is
exactly the one that must not reach the log with a query string on it. A crafted
`Origin: https://example.com/x?token=…` went into the WARN line as sent, which is what the comment
above it says cannot happen.

Both headers now go through the same reduction, and an unparsable value is dropped either way.

Two comments were left behind by the previous commit and are corrected with it: the summary over
`describeRejectedValues` and the one in the exception filter both still claimed that everything
rendered is masked and capped, which holds for a string and not for a number or a boolean. And the
field name goes through `maskLogValue`, not through the whole of `renderValue` - "the same
rendering as the value" overstated it.

* Pick the first header that yields an origin, not the first one that is there

Reducing `Origin` to its origin cost two things that were not meant to go: `Origin: null` - what a
browser sends for an opaque origin, a sandboxed frame or a redirect across sites - is not a URL,
so it fell into the catch and vanished, although having no origin to name is itself worth the
line. And an `Origin` the client filled with something unparsable now suppressed the `Referer`
too, because the choice was made on which header was present rather than on which one produced
something: a caller that could have been named ended up as no caller at all.

The headers are now tried in order and the first one that yields an origin wins, with the opaque
literal kept as it is. Both cases are pinned by a test.

Also drops a completeness claim from the neighbouring DTO spec: it demonstrates two rejections,
it does not enumerate the ones that DTO can produce.

* Pin that an opaque origin beats a referer, like any other origin does

The rule is that the first header to yield an origin wins, and `null` is one - so a request that
carries both an opaque `Origin` and a usable `Referer` is logged as opaque. That is intended and
now written down in the comment, but nothing held it: the test covered the opaque header alone.

* Make the origin cap test reach the cap

The oversized origin it sent was not a URL, so `callerOrigin` dropped it before the cap could
apply: the assertion that nothing floods the line held for a reason the test did not intend, and
the origin cap itself was never exercised. It now sends a parsable URL that is too long and checks
where it gets cut.

* Cut a capped value between characters, not between code units

`slice` counts UTF-16 units, so a value whose character straddles the cap lost half of a surrogate
pair: the stray half went into the log right before the ellipsis and reaches a UTF-8 transport as
a replacement character. Every caller inherits it - both caller headers and every rendered field
value - since none of them filter what a client can send.

The cut now walks characters. Pinned by a test that puts an emoji on the boundary.

* Name the unit of a reported length, and the factory's return type

`maskLogValue` reports an oversized value as `<N chars>`, but N is `String.length`
- UTF-16 code units, not characters. 257 astral characters are reported as 514.
That is the very distinction the previous commit drew for the cut, so the label
is renamed rather than the count converted: the number is the one `MAX_STRING`
is compared against, and counting characters would mean walking the oversized
string this branch exists to avoid.

The same inaccuracy exists in `redact` and `format` (`<... N chars ...>`,
`...(N chars)`), which are untouched here: those are shipped log formats and
outside what this branch changes.

`DetailedValidationPipe.createExceptionFactory` gets the explicit return type
CONTRIBUTING.md asks for, matching the base signature.

* Keep the whole warning on one line, and mask an IBAN by value

Two gaps the caller markers and the rejected values were already closed against,
but the line they join was not:

The reason is masked, but not collapsed. Ninety-nine of the exceptions that
produce it interpolate a value into their message, and some of those values come
from the request (`Invalid address for ${field}: ${address}`), so a line break in
one of them ends the log line and starts a second one that looks like a log entry
of its own. It now goes through the same collapse and the same character-safe cut
as everything else on the line - `slice` would have halved a surrogate pair at
the cap the same way it did before the previous commit.

Neither the name-based nor the value-based redaction covered an IBAN that is not
under a field named for one. The rejected value of a field with a closed set of
literals is rendered as it arrived, so a client putting an account number into
such a field put it in the log; in the request trace the same value under any key
the name list does not know reached it too. `maskValue` masks it by shape now, in
one run or in the groups of four it is printed in, bounded so a transaction hash
is still left intact.

`singleLine` and `capCharacters` come out of `maskLogValue` for this, which
leaves one definition of "cannot break a log line" in the file. `format` gets it
as well: `JSON.stringify` escapes the control characters but leaves U+2028 and
U+2029, which is the one way the request trace could still be split.

* Drop the IBAN pattern, and bound the work the cap and the scan do

The IBAN pattern is withdrawn. Matching an account number by shape needs the
country to know where it ends: without it the grouped form either stops short of
a 32-character IBAN and leaves the last group readable, or runs on and swallows
the words behind it - both were measured, and the way out of both is a
country-length table inside a log formatter. A value-shape list is also open by
construction: an account number is one shape, a document number and a card are
others, and the one that is not in the list yet is the one that leaks. What is
left is the rule the branch already had - the value is only rendered where the
field declares a closed set of literals, and it is masked by field name there.

`capCharacters` walks to the cap instead of materializing the value first. It is
reached with an exception message now, and one of those can be as large as the
body it interpolated a value from; spreading that to render 500 characters cost a
multiple of the message in heap. The reason is cut to a scan length before it is
masked for the same reason, wide enough past the cap that a pattern starting
inside it is still seen whole.

`format` cuts on a character boundary too - it was the one section left that
could halve a surrogate pair - and reports the section length in the unit it
counts, as the per-string cap already does.

Two comments claimed more than the code does: not every string on a trace line
goes through the collapse, only the free-form values do.

* Close the three ways the log line still let something through

The client header was the one value on a trace line that was interpolated as it
arrived. A header can carry U+0085, which `LINE_BREAKING` covers and no other
step did, so it is rendered like every other value from the caller now, capped
to a name rather than a payload.

The reason's scan cut could expose what it split. Masking only shortens, so the
part of a message that survives to the log can come from well past the visible
cap - and a pattern that straddled the cut arrives halved, is no longer
recognized, and its head is then among the characters that survive. A pattern
length is read past what is kept and dropped again, which leaves only patterns
that ended before the cut, and those were seen whole. The length comes from the
patterns themselves, next to where they are declared.

Reading the message can throw: the response body is whatever the thrower put
there, and an array element that cannot be turned into a string takes `join`
with it - before the response is sent. The line loses its reason instead.

`format` cuts to its own budget again. That budget is in code units and
`capCharacters` counts characters, so an astral section came out at twice it;
the cut moves off a surrogate pair rather than counting past it.

* Mask before collapsing, and stop trying to bound the scan

Masking ran after the collapse, so a control character sitting inside a pattern
took the pattern with it: the collapse turned it into a space, the pattern no
longer matched, and an address was logged in full. It was measured on both the
reason and the client header. Masking runs first now, on the value as it arrived.

The scan margin is withdrawn. It cut the message before masking so the work was
bounded, and every version of it cut somewhere a pattern could be open - a
pattern crossing the cut arrives halved, is no longer recognized, and masking
what precedes it shortens the text enough to pull its head into view. Two
attempts at a margin produced two ways for that to happen. The message is masked
whole again, as it was before this branch; what is left is the cap, which is what
this branch actually needed to bound.

The request target was the last free-form value that reached a line as it
arrived. `maskUrl` collapses it too, which is one line where the query is already
being dropped, and makes the sentence about this file true.

The response now goes out before the line is written. Everything the line renders
comes from the request or from the thrower, and reading either can throw - which
left the caller with no response rather than with a line missing a detail. An
unreadable exception body gets the generic one instead of none.

Two comments claimed more than they had to: a message *can* interpolate a request
value, and what the field-name masking covers is stated without a claim about
every DTO in the repository.

* Let the field say its rejected value may be logged

Rendering the value wherever the constraint declares a closed set of literals was
the wrong rule, and it is the one thing this branch does that `develop` does not:
`develop` logs no rejected values at all. A constraint bounds what is accepted,
not what a client sends, and a rejected value is by definition outside it - so
`paymentMethod` rendered an account number, a card number, a passport number and
an API key just as readily as it rendered a wrong constant. Every attempt to
catch those by shape has failed in a new way, because that list is open.

`@LogRejectedValue()` closes it from the other side. The field declares that its
wrong values are constants of the program, and only a field that says so has its
value rendered; everything else keeps its shape and loses its content, including
every field that exists today. The two payment-method fields and the personal
IBAN provider carry the marker, which is what the endpoint this started from
needs.

An error without the object it came from renders nothing, so a value cannot
arrive through a `ValidationError` that never passed a DTO.

`maskLogValue` masks a value whole when removing the control characters is what
reveals a pattern in it. A control character splits the pattern so it no longer
matches, and the collapse then puts the halves next to each other in the line; a
single value is not a sentence, so masking more of it than the pattern costs
nothing.

Two comments still said more than they had to: the response is already sent by
the time the reason is read, and what is masked by value are the three patterns
declared above, not personal data in general.

* Render a declared constant, never the string that arrived

The marker said a field's value may be shown; it did not say which values, so a
client could still put an account number, a card number or an API key into a
field that carries it and have it logged in full. The marker now carries the set
those values may come from, and only a value in that set is rendered - matched
without regard to case, and written out of the set rather than out of the
request, so what reaches the line is a constant of this program either way.

For a field taking the fiat payment methods that set is the full payment-method
union: its crypto member is exactly what a client sends there by mistake, which
is the case these lines exist for. The personal IBAN provider loses the marker
again - there is no wider set its wrong values come from, so it would never
render anything.

With that, nothing client-composed reaches the line and the masking is no longer
what protects it, which is what every attempt so far had rested on.

`singleLine` removes what could break a line instead of replacing it, and runs
before the masking everywhere: a character placed inside a pattern used to leave
the pattern split around whatever replaced it, so the masking no longer saw it -
in the reason, in the request target and in the trace bodies, not only where the
per-value guard reached. Removing it puts the pattern back together first. That
guard is gone with it.

* Give the provider its values back, and keep the reading honest

The personal IBAN provider was dropped from the declarations on the grounds that
no wider set exists for it. That was wrong: the matching ignores case, so the
field's own values are exactly what makes `frick` readable as `Frick` - the
mistake this line is for. It declares them again.

A numeric enum object carries its reverse mapping, so `Object.values` on one
returns the member names alongside the values and a request sending a name would
have matched. Only the values are taken now.

A declared constant is written by this code rather than by the request, but it
goes through the same rendering as everything else on the line - there is no
reason for the one value on it that is not checked to be the one a hand wrote.

The status is resolved before anything else is read and falls back to a server
error when the exception cannot answer or answers with something Express will not
send; the request is read after the response has gone out, since the response
never needed it.

Two comments described the replaced behaviour rather than the removal that took
its place.

* Let a declared value past the field name, and mask on both sides of the removal

The provider declaration was inert. `personalIbanProvider` carries `iban` in its
name, so the name-based redaction answered first and the value never reached the
declaration - the fix landed and changed nothing. A declared match now comes
first: what it renders is a constant of this program, and the field's name says
nothing about a value that was never the client's.

Removing what breaks a line breaks a pattern in the other direction. It joins
what stood on either side, so an address followed by a control character and one
more digit comes out as one run, where it no longer ends on a word boundary and
the masking no longer sees it - the mirror image of the case the removal was
introduced for. Both passes run now, around the removal rather than on one side
of it, in the one place that renders free-form text for a line. A second pass
cannot invent a match: what the first leaves behind carries no alphanumerics.

The response body is the generic one whenever the status being sent is not the
one the exception names, so a replaced status no longer ships a body that
contradicts it, and a message that cannot be read falls back to the status rather
than out of the method.

The line is written inside its own guard. The response is already out by then, so
a failure to describe it - the logger included, which is the one thing that could
not report it - ends there instead of travelling back to the caller.

1xx leaves the accepted range: it is not a final response, so it is not one this
can send.
A staff member who holds the role but is not KYC-cleared got the generic "Forbidden
resource", which is indistinguishable from a removed role. Neither the person nor any
tooling in front of the API could tell that the fix is to complete an identification.

RoleGuard now throws StaffKycRequiredException: HTTP 403 with a machine-readable
code STAFF_KYC_REQUIRED and a message naming the actual requirement. This follows the
existing TFA_REQUIRED pattern, so clients branch on the code instead of matching prose.
A wrong ROLE still yields the generic 403 - the two cases need different actions and
must stay distinguishable.

The protected KYC file route checked both conditions through one predicate and reported
"Requires admin or compliance role" even when the role was fine; it now reports each
case separately.

JwtUserActiveGuard calls the guard programmatically, but only with UserRole.USER, which
is never elevated - so it still receives a boolean.
@TaprootFreak
TaprootFreak merged commit 4bec116 into main Aug 1, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant