mirror of
https://github.com/luxfi/zap.git
synced 2026-10-30 15:04:16 +00:00
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.
151 lines
7.0 KiB
Go
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
|
|
}
|