Files
zeekay b3fcc13f30 dexsession: settlement byte-identity (intentID word) + scope route off the on-chain hookData (swap-seam r2)
THE SETTLE CALLDATA REVERTED (byte-identity). EncodeSettlementHookData emitted a 64-byte
Phase-B body (outputID|amount), but the precompile's decodeSettlementBody requires 96
(outputID|amount|intentID) — the intentID word binds the credit to the taker's intent record
(the per-taker cap + deadline gate). So every settle tx built off this calldata REVERTED
on-chain (ErrSettleBodyMalformed). Add the intentID word so the body is byte-identical with
precompile/dex/settle_hookdata.go; settlementBodyLen is now 96. The DExportRef already carries
IntentID, so the production importSettlement path and every ResolveExport supplies it.
calldata_parity_test pins the full 96-byte body against a hardcoded vector.

THE MULTI-HOP ROUTE REVERTED (RT01 scoping). prepareRouteLocal signed an on-chain hookData of
DI01|RT01|hopCount|path. The precompile has NO route-marker awareness: it classifies the DI01
tag as a Phase-A intent, then decodeIntentBody rejects the RT01 body width (not in {0,32,64})
-> every multi-hop swap REVERTED on-chain. Fix by scoping the route OFF the on-chain calldata:
the on-chain leg is now a PLAIN DI01 intent on the entry market (nonce=CallIndex, matching the
id this session derives — DeriveIntentID binds the entry market + nonce, NOT the path, so the
id is unchanged). The PATH travels to the D router over the ZAP control plane (the RouteRequest
envelope, buildRouteRequest/NotifyRoute), where it belongs — it is D-matcher orchestration that
moves no C-side value (one input object, one final output object regardless of hop count).
Removed EncodeRouteIntentHookData/DecodeRouteIntentHookData (the on-chain RT01 wire the
precompile never supported — a bug generator); the path's one canonical wire is the
RouteRequest envelope. Reworked the route parity test to pin the new property (on-chain route
leg is a precompile-accepted DI01 body carrying no RT01/DS01 tag) and the multi-hop e2e test to
read the path from the session (control plane), not the signed calldata.

CGO_ENABLED=0 pure-Go (GOWORK=off); full dexsession suite green.
2026-06-21 22:39:02 -07:00

151 lines
7.0 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"crypto/sha256"
"encoding/binary"
)
// v4liquidity.go reproduces the 0x9999 modifyLiquidity CALLDATA byte format and the
// route-intent path encoding, as pure functions over values — the same "three homes"
// discipline calldata.go uses for the swap selector. The precompile (luxfi/precompile/
// dex) is the authority on these encodings; dexsession reproduces them so a session
// can hand a wallet the exact bytes to sign. v4 parity tests pin every constant.
//
// WHY THESE BELONG WITH THE SWAP CALLDATA: the V4 ABI has exactly two value-moving
// kernels at 0x9999 — swap (SelectorSwap, calldata.go) and modifyLiquidity
// (SelectorModifyLiquidity, here). The 0x9996 position facade (mint/increase/
// decrease/collect/burn/placeLimit/cancelLimit) ALL route into 0x9999
// modifyLiquidity (+delta to add/commit, -delta to remove/collect/cancel), so the
// liquidity/collect/cancel sessions build modifyLiquidity calldata directly and let
// the ONE kernel hold custody. The CREDIT side of any remove (collect/decrease/
// cancel) is the SAME DS01 Phase-B settlement as a swap (calldata.go), because every
// D->C export — swap output, collected fees, withdrawn liquidity, cancelled-order
// refund — is consumed by the one settlement path. There is exactly ONE credit path.
//
// These are VALUES, not authority: emitting calldata reserves nothing and credits
// nothing; only the user signing it and the chain consuming the resulting atomic
// object move value.
// SelectorModifyLiquidity is the V4 modifyLiquidity selector — IDENTICAL to
// precompile/dex/module.go SelectorModifyLiquidity (0x5A6BCFDA):
//
// modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)
//
// The ModifyLiquidityParams tuple is (tickLower int24, tickUpper int24, liquidityDelta
// int256, salt bytes32). A POSITIVE liquidityDelta adds (commit C->D funds); a
// NEGATIVE delta removes (collect/decrease/cancel -> D->C export -> DS01 credit).
const SelectorModifyLiquidity uint32 = 0x5A6BCFDA
// ModifyLiquidityArgs are the V4 ModifyLiquidityParams tuple fields. LiquidityDelta
// is signed: >0 add, <0 remove. The session sets the sign per action (commit vs
// collect/cancel); the SIGN is what distinguishes a funding C->D commit from a
// value-returning D->C removal — both ride this one selector, exactly as the
// position facade does.
type ModifyLiquidityArgs struct {
TickLower int32 // int24
TickUpper int32 // int24
LiquidityDelta int64 // int256 (we carry the int64 magnitude+sign; ABI sign-extends)
Salt ID // bytes32 position salt
}
// EncodeModifyLiquidityCalldata builds the full 0x9999 modifyLiquidity calldata:
//
// selector(4) || PoolKey(5 words) || ModifyParams(4 words: tickLower, tickUpper,
// liquidityDelta, salt) || offset(1 word) || hookData(length word + padded bytes)
//
// Standard Solidity ABI encoding of the modifyLiquidity signature. The dynamic
// `bytes hookData` is tail-encoded with a head offset word. hookData selects the
// phase exactly as for swap: empty / DI01 for the commit intent, DS01 for settling a
// removal's D->C export (though a removal's settlement is built via the swap-shaped
// EncodeSwapCalldata + DS01, since the credit kernel is the swap path — see
// v4session.go collect/cancel).
func EncodeModifyLiquidityCalldata(pk PoolKeyArgs, args ModifyLiquidityArgs, hookData []byte) []byte {
// Head: selector + 5 PoolKey words + 4 ModifyParams words + 1 hookData offset
// word = 4 + 10*32. Tail: hookData length word + padded data.
const headWords = 5 + 4 + 1
head := make([]byte, 4+headWords*32)
binary.BigEndian.PutUint32(head[0:4], SelectorModifyLiquidity)
off := 4
put := func(w abiWord) {
copy(head[off:off+32], w[:])
off += 32
}
// PoolKey tuple (inline — fixed-size members).
put(wordAddr(pk.Currency0))
put(wordAddr(pk.Currency1))
put(wordU24(pk.Fee))
put(wordI24(pk.TickSpacing))
put(wordAddr(pk.Hooks))
// ModifyLiquidityParams tuple (inline — fixed-size members).
put(wordI24(args.TickLower))
put(wordI24(args.TickUpper))
put(wordI256FromInt64(args.LiquidityDelta)) // signed: +add / -remove
put(abiWord(args.Salt))
// hookData dynamic offset: bytes start right after the 10 head words.
put(wordU256FromU64(uint64(headWords * 32)))
tail := encodeDynamicBytes(hookData)
return append(head, tail...)
}
// wordI256FromInt64 encodes a signed int64 into a two's-complement int256 ABI word
// (sign-extended to 32 bytes). Used for the signed liquidityDelta.
func wordI256FromInt64(v int64) abiWord {
var w abiWord
u := uint64(v)
binary.BigEndian.PutUint64(w[24:32], u)
if v < 0 {
for i := 0; i < 24; i++ {
w[i] = 0xff
}
}
return w
}
// ----------------------------------------------------------------------------
// Route-intent path encoding.
// ----------------------------------------------------------------------------
//
// A V4 route's ON-CHAIN leg is a PLAIN SWAP INTENT (DI01 / Phase A) on the ENTRY market:
// it locks the single input and creates ONE C->D input object, identically to a normal
// single-market intent. The multi-market PATH is NOT carried in the signed on-chain
// hookData — it travels to the D-Chain router over the ZAP CONTROL PLANE (the RouteRequest
// envelope: buildRouteRequest / NotifyRoute, MsgRoutePrepare). Two reasons this is the
// correct seam:
//
// 1. The on-chain precompile has NO route-marker awareness. Its decodeIntentBody accepts
// only a DI01 body of width {0, 32, 64} (empty / deadline / deadline+nonce); a route
// body (a marker + hop count + path) is a different width and REVERTS on-chain. So an
// on-chain RT01 body would brick every multi-hop swap. (This was the bug.)
// 2. The path is D-matcher orchestration that moves NO C-side value, and the on-chain
// intent id (DeriveIntentID) binds the ENTRY market + nonce, NOT the path — so a plain
// DI01 intent on the entry market yields the SAME id the route session computed. The
// path therefore does not belong in the consensus-visible calldata.
//
// This is the structural reason multi-hop "stays on D": there is ONE C->D object (the
// input) and ONE D->C object (the final output / refund). Intermediate hops are D-internal
// matching state; they never create a C-side object, so the money plane sees exactly
// 1-in / 1-out regardless of hop count. The path's canonical wire is the RouteRequest
// envelope (v4route.go) — there is exactly ONE place the path is serialized for the venue,
// and it is not the on-chain hookData.
// DeriveRoutePathHash hashes a route path (the ordered marketID list) into a scope
// PoolKeyHash. Two routes with different paths derive different session ids, so a
// route capability is confined to its exact path.
func DeriveRoutePathHash(path []ID) ID {
h := sha256.New()
h.Write([]byte("lux.dex.v4.routepath.v1"))
var c [4]byte
binary.BigEndian.PutUint32(c[:], uint32(len(path)))
h.Write(c[:])
for _, m := range path {
h.Write(m[:])
}
var out ID
copy(out[:], h.Sum(nil))
return out
}