11 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 84587ddb0f Resolve the market before a swap can name a halt
SettleSwap took the caller's PoolKey and never asked whether that pool
existed, so key.ID() fed the halt slot, the volume shard and the fill
event alike. A fabricated key names a slot nobody set, and the swap
settles while the real market is halted.

Resolving once above the phase switch and passing the resolved poolID
down means no phase -- including one added later -- can key on anything
the registry has not admitted. checkHalt no longer accepts a key, so it
cannot derive one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 02:41:14 -07:00
zeekay 5ab320e270 dex, fhe: finish the refactor the merge half-applied
collectPosition's calldata is a fixed 156 bytes now — claimID, asset,
positionID, and the 60-byte claim — so the two tests that built the old
128-byte layout were refusing on length before reaching what they meant to
exercise. The reentrancy test carries a real claim; the amount-domain test
becomes a width sweep, because there is no longer an amount word to put out of
range: the amount is the uint64 inside the claim, which a caller cannot widen.
ErrLPCollectBadAmount goes with it, having become a sentinel nothing returns.

The swap arm's write witness moves 13 -> 10. One custody pot and no per-order
escrow is less state, and the witness exists to make that a decision rather
than a drift.

fhe's derived gas comments still stated the products of the old wall-clock
figures — 180M and 936M against constants that now compute 300M and 2.88G.
2026-08-28 20:19:27 -07:00
zeekayandHanzo Dev 566a8d89e9 dex: the two version stamps did not move when the object they version did
The cross-chain object shrank from the braided 118-byte form to the 60-byte
funding claim. Both stamps that exist to mark exactly that event stayed where
they were.

The staged-op record's leading version byte is still 1, and the comment above it
says an unknown version must fail the flush rather than silently mis-decode
value. Today the old record is refused, but by WIDTH — 183 bytes where 125 are
expected — which is a coincidence, not a statement. Two layouts that happen to
agree on width decode into each other. Bump it to 2.

This does NOT rescue a v0.19.8 staged Put; that record was fatal before and is
fatal now, and it should be. What changes is that the refusal is a version
mismatch instead of a width mismatch. Nothing has ever flushed a staged Put on
any live network, so the set of records this migrates is empty — which is the
only moment the stamp can be corrected at no cost.

The object commitment's domain is still ...import.object.v1 across the same
change. A declaration is a hash plus a promise about what was hashed, and the
domain is where that promise lives; leaving it behind lets a commitment from one
era be presented as a commitment from another. The previous object change
rotated it for this reason. Rotate it to v2.

Also fixes the collectPosition calldata comment, which still specified
object[69] with the braided field list, three widths behind the code beneath it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 21:34:50 -07:00
zeekayandHanzo Dev d544607475 dex: a collect took whose-record from the calldata, so it took it from the attacker
collectPosition binds the claim to the caller — Import checks the object's
recorded beneficiary, so the value can only ever pay the party it names. The
positionID rode in beside it and was checked against nothing.

The record it names is somebody's property. A stranger holding a legitimate
claim of their own, for any amount, could name any LP's live record and the
bookkeeping would faithfully apply their collect to it. A collect that reaches
the record's withdrawable marks it Cancelled, which is terminal: the LP's value
is still on D, the record that brings it back is closed, and
requestPositionWithdraw refuses it as already closed. The only way out is
committing new money.

The comment above the function explained why it is not a gate — the record
follows the value, custody backs the credit, the claim is consumable once — and
that is all true and all about the VALUE. Whose record the value lands in is a
different question, and nothing was answering it.

So the owner is a parameter. There is no way to call it without saying whose
record this is, and a live record belonging to someone else is refused rather
than silently mutated. A record that is absent or already fully collected still
stays where it is: that tolerance was deliberate and is untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 21:32:02 -07:00
zeekayandHanzo Dev e0a77cceaf dex: the comments described a seam that no longer exists
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 17:44:38 -07:00
zeekayandHanzo Dev 0b433110ac dex: the funding rail carries value, and nothing else
Move once, trade many. Value crosses the C<->D boundary twice in its life; in
between, D owns it and every order, match and trade is a local balance movement.
So the crossing has exactly one job — move ownership exactly once — and the
object shrinks to what that job needs:

    beneficiary(20) | asset(32) | amount(8)   = 60 bytes

Out of the wire: the 49-byte market/side/limit/size tail, the `spent` execution
witness, and the lane byte selecting a trading pot. Out of the precompile: the
per-order escrow with its principal cap and deadline, the taker-authenticated
price floor, the deadline reclaim and its selector, the shared-memory discovery
trait, and the .v2 domain rotation that existed only because the tail had
changed the object's width.

ONE CUSTODY POT. seamReserve and committedPositions were the swap lane and the
LP lane of one quantity — "value C holds because D owns it" — and the lane byte
was the only thing keeping them apart. Splitting them is what created the
cross-lane drain the byte then had to defend against. One number per asset, one
credit path, nothing to cross.

WHAT REPLACES THE PER-ORDER CAP AND THE PRICE FLOOR: D refuses an over-debit on
export, C refuses a credit custody cannot back, a claim is consumable exactly
once, and Block.Verify proves the carried bytes are the recorded object. Those
are properties of the value. The cap and the floor were properties of an order,
which is why the order had to ride the object for them to be checkable at all.

The claim id is one derivation for both directions and both repos:
SHA-256(domain | source | dest | sourceTx | index). It replaces DeriveOrderID,
DerivePositionCommitID and deriveSeamOutputID, which were three spellings of the
same fact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 17:33:14 -07:00
zeekayandHanzo Dev f8ca4a37be wip: C-side funding rail un-braid (build green)
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 17:23:58 -07:00
zeekayandHanzo Dev 2391e31aaa dex: the seam's unit of work is an order, not an intent
Order -> Match -> Execution -> Trade is the vocabulary; "intent" was the old
name for what an Order is. Renames the identifiers across the 0x9999 settle
precompile so the code reads in the settled terms.

Values that live on a chain keep their bytes, because renaming one of those is
a migration and not a rename:

  - "lux.dex.native.intent.v1" and "lux.dex.native.intent.pending.v2" are hash
    INPUTS; a byte changed there moves every derived order id and the pending
    trait both repos must agree on.
  - settleStateNamespace + "intent" is an on-disk key prefix; renaming it
    orphans the submitted-order replay set.
  - reclaimIntent(bytes32), IntentSubmitted(...) and IntentReclaimed(...) are
    the deployed ABI; the signature is what hashes, so a rename would move a
    selector and two topics.

Each of those now carries one line saying why the literal does not track the
identifier that names it, so the inconsistency is not "fixed" later.

TestOrderID_CrossRepoGolden still reads 5b0e8d38... and
TestSeamPendingTrait_MatchesDChainGolden still reads 5f10f9ad..., which is the
evidence no derivation input moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 16:13:01 -07:00
zeekayandHanzo Dev fd1cc0904e dex: carry the cross-chain object in the transaction, not shared memory
A settled swap could never be synced past. Both C-side consume paths
(ImportSettlement, ImportPositionCollect) read the D->C object from shared
memory DURING EVM execution, while the consuming Remove landed at block
Accept. A node re-executing that block — bootstrapping, state-syncing,
re-tracing — found the object gone, computed a reverted receipt where the
network had computed a successful one, and died on the receipt root. The
seam settled live and wedged every node that tried to follow.

Move the object's 69 bytes into the transaction (Phase-B hookData, and the
collectPosition calldata) so execution is a pure function of the block and
replays byte-identically forever. Both rails now go through ONE binding
primitive, bindImportObject, which also DECLARES the consumption as a log
committing to the object's hash. The host authenticates that declaration
against shared memory at Block.Verify, so a forged object costs the
producer a block instead of forking a receipt root.

The ship rule did not weaken, it moved: a C balance is still credited only
by consuming a real D->C object. What changed is where that is proven.

Also:
- amount is no longer restated on the wire; it IS the object's amount, so
  there is one declaration of the fact instead of two to cross-check.
- the retired DS01 tag is refused BY NAME. The fallback for an unknown tag
  is Phase A, so a stale settle would otherwise have been silently re-read
  as an intent and LOCKED the sender's input — the one mixed-fleet outcome
  worse than a revert.
- the zero-amount refusal moved into the single binding point, so an absent
  object zero-padded to canonical width reports the same clear error on
  both rails rather than surfacing as a cross-rail violation.

336 tests pass, 0 fail.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 06:40:23 -07:00
zeekay 7432a1e09f dex: close Red H1 cross-rail D->C consumption + per-object/owner collect bound (FIX 1-5)
Rail-bind the C<->D atomic object to close H1 in BOTH directions, plus the
LP-collect per-object/per-owner bound, lifecycle, seam liveness seed, and the
AssetAddr-trust hardening. Targeted fixes on the existing seam + LP rail; no
redesign. Wire goes 60->61 bytes (rail tag leads); unreleased branch, markets:0,
no live objects exist on the old wire.

FIX 1 (HIGH, H1 both directions): the D->C object now carries a rail tag
(native_wire.go: rail(1)|owner(20)|asset(32)|amount(8)). ImportSettlement
consumes ONLY railSwap (debits seamReserve); ImportPositionCollect consumes ONLY
railLP (debits committedPositions). SubmitSwapIntent stamps railSwap;
SubmitPositionCommit stamps railLP; dexvm executeWithdraw stamps railLP, fill
exports default railSwap. executeImport binds the credited rail to the consumed
UTXO's recorded rail (symmetric to asset/owner). Plus a per-object gate:
collectPosition names a positionID; ImportPositionCollect binds the recorded
owner to THAT record (Open/Closing) and bounds the credit by its LockedAmt.
  H1-A closed: TestRED_LP_CollectObjectCannotBeSwapSettled (railLP -> swap settle
  reverts ErrSettleWrongRail; seamReserve untouched).
  H1-B closed: TestRED_LP_SwapObjectCannotBeCollected (railSwap -> collect reverts
  ErrLPCollectWrongRail; committedPositions untouched).
  Per-object gate: TestRED_LP_CollectMustNameAnOpenPosition; dexvm
  TestNativeRail_ImportRejectsRailMismatch (+ matching-rail positive control).

FIX 2 (MED): collect is bounded by the recorded owner's OWN committed reserve
(the named record's LockedAmt + loadLockedReserve), decremented on collect.
  TestRED_LP_CollectBoundToOwnPositionPrincipal: an over-export for X cannot draw
  on Y's committed principal in the shared pot.

FIX 3 (LOW): collect drives the record Open->Closing->Closed by remaining
LockedAmt (Closed at 0); re-commit while Closing is forbidden
(ErrLPCommitWhileClosing). ownerHasOpenPosition removed (dead).
  TestRED_LP_LifecycleClosedOnFullCollect.

FIX 4 (MED, liveness): seedSeamReserve(address,uint256) operator-gated selector
funds the swap rail's counterparty backing (production path, not test-only), so a
real market's first matched swap settles; creditPositionFee(bytes32,address,
uint256) is the symmetric per-owner LP fee credit. Both value-backed
(observed-delta native / transferFrom) and protocolFeeController-gated.
  TestFIX4_FirstFillRevertsWithoutSeed / OperatorSeedBacksFirstFill /
  SeedIsOperatorGatedAndValueBacked / FeeCreditIsOperatorGated.

FIX 5 (LOW): creditSettlementOutput / creditPositionCollect derive the transfer
token from the recorded asset (assetAddress(recAsset)); claim.AssetAddr is
ignored for the transfer. Corrected the custodyGuardKey9999 comment.
  TestRED_FIX5_CreditTokenDerivedFromRecordedAsset.

Ship rule (fixed): a C credit comes ONLY from consuming a D->C object OF THE
MATCHING RAIL; a unit is CSpendable XOR DCommitted across BOTH rails. All prior
gates kept (AccountID/AssetID full width, observed-delta, one-time, nonReentrant,
CEI, 4-pot conservation realHolding == settleVault+makerLockedVault+seamReserve+
committedPositions). Pure Go, luxfi only.

CGO_ENABLED=0 GOWORK=off go build ./dex/... + go test ./dex/ green.
2026-06-21 02:59:07 -07:00
zeekay 0666a222bb dex: LP D-committed liquidity on the native C<->D atomic seam (0x9999 modifyLiquidity commit + 0x9996/0x9999 collect)
Replace the prior C-side-only maker lock (makerLockedVault available->locked
reclassification that kept funds on C) with the CTO-locked committed-liquidity
model: an LP add COMMITS funds to a funded D position via a C->D atomic object
(DL01); collect/decrease/burn/cancel return funds via a consumed D->C object.

Two value states per asset, never both: CSpendable (EVM balance) <-> DCommitted
(committedPositions pot backing a live D position). Plus DPendingCollect -> CSettled.

- native_wire.go: DerivePositionCommitID (own positionCommitDomain, disjoint id space)
- native_state.go: committedPositions pot (4th orthogonal vault claim)
- native_dchain_client.go: SubmitPositionCommit (C->D DL01) + ImportPositionCollect
  (D->C, credits from committedPositions, rail-gated by ownerHasOpenPosition)
- position_commit.go: the LP-rail kernel (runSettleModifyLiquidity commit/withdraw,
  runSettleCollectPosition, position lifecycle Open/Closing/Closed)
- settle_module.go: collectPosition selector wired into 0x9999
- position_module.go: 0x9996 collect emits a D->C collect request (no longer Unsupported)
- settle_stm.go: Block-STM access set updated to committedPositions + staging seq

Ship rule: D position funded ONLY by consuming a C->D object; C credited (collect)
ONLY by consuming a D->C object; never CSpendable && DCommitted. All prior red-found
gates kept (full AccountID/AssetID, owner/asset/amount bind, one-time import,
callIndex, observed-delta ERC-20, nonReentrant, conservation invariant extended to
realHolding == settleVault + makerLockedVault + seamReserve + committedPositions).

10 LP tests + 3 rewritten surface tests; dex 325 pass.
2026-06-21 02:15:41 -07:00