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.
236 lines
8.9 KiB
Go
236 lines
8.9 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dexsession
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"testing"
|
|
)
|
|
|
|
// parity_test.go pins dexsession's reproduced value-boundary byte formats against
|
|
// the canonical precompile/dexvm encodings. dexsession reproduces (does not import)
|
|
// these formats to stay free of the EVM/cgo dependency graph; these tests are the
|
|
// "third home" parity guard — if a precompile-side change drifts the format, a red
|
|
// test fires here and the off-chain pointer would name a different object than the
|
|
// chain (which the invariant tests would then also catch).
|
|
|
|
// TestParity_AtomicObjectWire pins the 60-byte object: owner(20)|asset(32)|amount(8).
|
|
// Identical to precompile/dex/native_wire.go encodeAtomicObject and
|
|
// chains/dexvm/atomic.go encodeExportedOutput.
|
|
func TestParity_AtomicObjectWire(t *testing.T) {
|
|
owner := Account{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14}
|
|
asset := ID{0xaa, 0xbb}
|
|
asset[31] = 0xff
|
|
amount := uint64(0x0102030405060708)
|
|
|
|
v := EncodeAtomicObject(owner, asset, amount)
|
|
if len(v) != 60 {
|
|
t.Fatalf("object width = %d, want 60", len(v))
|
|
}
|
|
// Hand-build the canonical layout and compare byte-for-byte.
|
|
want := make([]byte, 60)
|
|
copy(want[0:20], owner[:])
|
|
copy(want[20:52], asset[:])
|
|
binary.BigEndian.PutUint64(want[52:60], amount)
|
|
if !bytes.Equal(v, want) {
|
|
t.Fatalf("atomic object wire mismatch:\n got %x\nwant %x", v, want)
|
|
}
|
|
|
|
// Round-trip.
|
|
go2, ga, gam, ok := DecodeAtomicObject(v)
|
|
if !ok || go2 != owner || ga != asset || gam != amount {
|
|
t.Fatalf("decode mismatch: ok=%v owner=%x asset=%x amount=%d", ok, go2[:], ga[:], gam)
|
|
}
|
|
// A non-canonical width is rejected (never reinterpreted into a credit).
|
|
if _, _, _, ok := DecodeAtomicObject(v[:59]); ok {
|
|
t.Fatalf("decode accepted a short object")
|
|
}
|
|
if _, _, _, ok := DecodeAtomicObject(append(v, 0x00)); ok {
|
|
t.Fatalf("decode accepted an over-long object")
|
|
}
|
|
}
|
|
|
|
// TestParity_DeriveIntentID pins the intent-id derivation against a recomputed
|
|
// SHA-256 over the EXACT field order + domain string the precompile uses. If the
|
|
// precompile's nativeIntentDomain or field order changed, this vector breaks.
|
|
func TestParity_DeriveIntentID(t *testing.T) {
|
|
networkID := uint32(96369)
|
|
cChainID := ID{0xc0}
|
|
dChainID := ID{0xd0}
|
|
account := Account{0xAB, 0xCD}
|
|
assetIn := ID{0x01}
|
|
amountIn := uint64(1_000_000)
|
|
marketID := ID{0x4d}
|
|
nonce := uint64(3)
|
|
|
|
got := DeriveIntentID(networkID, cChainID, dChainID, account, assetIn, amountIn, marketID, nonce)
|
|
|
|
// Recompute independently (the canonical algorithm — NO txID; nonce trails marketID).
|
|
h := sha256.New()
|
|
h.Write([]byte("lux.dex.native.intent.v1"))
|
|
var u4 [4]byte
|
|
binary.BigEndian.PutUint32(u4[:], networkID)
|
|
h.Write(u4[:])
|
|
h.Write(cChainID[:])
|
|
h.Write(dChainID[:])
|
|
h.Write(account[:])
|
|
h.Write(assetIn[:])
|
|
var u8 [8]byte
|
|
binary.BigEndian.PutUint64(u8[:], amountIn)
|
|
h.Write(u8[:])
|
|
h.Write(marketID[:])
|
|
binary.BigEndian.PutUint64(u8[:], nonce)
|
|
h.Write(u8[:])
|
|
var want ID
|
|
copy(want[:], h.Sum(nil))
|
|
|
|
if got != want {
|
|
t.Fatalf("intent id mismatch:\n got %x\nwant %x", got[:], want[:])
|
|
}
|
|
// Domain separation: changing the domain MUST change the id (so a generic
|
|
// shared-memory id can never collide with a DEX intent id).
|
|
h2 := sha256.New()
|
|
h2.Write([]byte("other.domain"))
|
|
h2.Write(u4[:])
|
|
var other ID
|
|
copy(other[:], h2.Sum(nil))
|
|
if got == other {
|
|
t.Fatalf("intent id not domain-separated")
|
|
}
|
|
}
|
|
|
|
// TestParity_DeriveUTXOID pins the D->C export object key derivation against
|
|
// chains/dexvm/atomic.go deriveUTXOID (SHA-256 over txID||index).
|
|
func TestParity_DeriveUTXOID(t *testing.T) {
|
|
txID := ID{0x12, 0x34}
|
|
index := uint32(5)
|
|
got := DeriveUTXOID(txID, index)
|
|
|
|
var buf [36]byte
|
|
copy(buf[0:32], txID[:])
|
|
binary.BigEndian.PutUint32(buf[32:36], index)
|
|
want := sha256.Sum256(buf[:])
|
|
if got != ID(want) {
|
|
t.Fatalf("utxo id mismatch:\n got %x\nwant %x", got[:], want[:])
|
|
}
|
|
// Distinct indices => distinct keys (no two exported outputs collide).
|
|
if DeriveUTXOID(txID, 5) == DeriveUTXOID(txID, 6) {
|
|
t.Fatalf("utxo id not injective over index")
|
|
}
|
|
}
|
|
|
|
// TestParity_SettlementHookData pins the Phase-B hookData against
|
|
// precompile/dex/settle_hookdata.go: tag "DS01" + outputID(32) + amount-word(32,
|
|
// amount in low 8 bytes) + intentID(32). The intentID word is what binds the credit to
|
|
// the taker's intent on-chain (the per-taker cap + deadline gate); a body without it is
|
|
// 64 bytes and the precompile reverts (ErrSettleBodyMalformed). This pins the FULL 96-byte
|
|
// body so a one-sided drift (the bug: zap emitting 64 while the precompile decodes 96) is
|
|
// caught here. Also confirms Phase A is the explicit "DI01" tag.
|
|
func TestParity_SettlementHookData(t *testing.T) {
|
|
outputID := ID{0xfe, 0xed}
|
|
outputID[31] = 0x01
|
|
amount := uint64(424242)
|
|
intentID := ID{0x1A, 0x2B, 0x3C}
|
|
intentID[31] = 0x09
|
|
|
|
hook := EncodeSettlementHookData(outputID, amount, intentID)
|
|
want := make([]byte, 0, 4+settlementBodyLen)
|
|
want = append(want, 'D', 'S', '0', '1')
|
|
want = append(want, outputID[:]...)
|
|
var amt [32]byte
|
|
binary.BigEndian.PutUint64(amt[24:32], amount)
|
|
want = append(want, amt[:]...)
|
|
want = append(want, intentID[:]...)
|
|
if !bytes.Equal(hook, want) {
|
|
t.Fatalf("settlement hookData mismatch:\n got %x\nwant %x", hook, want)
|
|
}
|
|
if len(hook) != 4+settlementBodyLen {
|
|
t.Fatalf("settlement hookData len = %d, want %d", len(hook), 4+settlementBodyLen)
|
|
}
|
|
if settlementBodyLen != 96 {
|
|
t.Fatalf("settlementBodyLen = %d, want 96 (outputID|amount|intentID) — must match "+
|
|
"precompile/dex/settle_hookdata.go or the settle calldata reverts on-chain", settlementBodyLen)
|
|
}
|
|
|
|
// Phase A with deadline 0, nonce 0 is the bare DI01 tag (minimal-width).
|
|
intent := EncodeIntentHookData(0, 0)
|
|
if !bytes.Equal(intent, []byte{'D', 'I', '0', '1'}) {
|
|
t.Fatalf("intent hookData (0,0) = %x, want DI01", intent)
|
|
}
|
|
// With a nonce, the body carries deadline[32] | nonce[32] after the tag (the
|
|
// chain-observable disambiguator the on-chain decoder reads back identically).
|
|
withNonce := EncodeIntentHookData(0, 7)
|
|
wantWithNonce := append([]byte{'D', 'I', '0', '1'}, make([]byte, 64)...)
|
|
wantWithNonce[len(wantWithNonce)-1] = 7 // nonce in the low byte of the second word
|
|
if !bytes.Equal(withNonce, wantWithNonce) {
|
|
t.Fatalf("intent hookData (0,7) = %x, want %x", withNonce, wantWithNonce)
|
|
}
|
|
}
|
|
|
|
// TestParity_SwapSelector pins the V4 swap selector against
|
|
// precompile/dex/module.go SelectorSwap (0xF3CD914C) and confirms the calldata
|
|
// head layout the chain expects (selector + 9 head words).
|
|
func TestParity_SwapSelector(t *testing.T) {
|
|
if SelectorSwap != 0xF3CD914C {
|
|
t.Fatalf("SelectorSwap = %08X, want F3CD914C", SelectorSwap)
|
|
}
|
|
pk := testPoolKey()
|
|
calldata := EncodeSwapCalldata(pk, true, 1000, EncodeSettlementHookData(ID{0x01}, 7, ID{0x02}))
|
|
if got := binary.BigEndian.Uint32(calldata[:4]); got != SelectorSwap {
|
|
t.Fatalf("calldata selector = %08X, want %08X", got, SelectorSwap)
|
|
}
|
|
// Head is selector(4) + 9 words; the hookData length word follows.
|
|
const headWords = 5 + 3 + 1
|
|
if len(calldata) < 4+headWords*32+32 {
|
|
t.Fatalf("calldata too short: %d", len(calldata))
|
|
}
|
|
// Round-trip the settlement body out of the calldata (the harness decoder is the
|
|
// faithful inverse the chain applies).
|
|
outID, amt, ok := decodeSettlementCalldata(calldata)
|
|
if !ok || amt != 7 || outID != (ID{0x01}) {
|
|
t.Fatalf("settlement calldata round-trip: ok=%v amt=%d outID=%x", ok, amt, outID[:4])
|
|
}
|
|
}
|
|
|
|
// TestParity_VectorPin is a belt-and-suspenders frozen vector: the exact hex of a
|
|
// derived intent id for fixed inputs. If ANY byte of the derivation drifts, this
|
|
// fails with a clear diff — the strongest pin against silent format drift.
|
|
func TestParity_VectorPin(t *testing.T) {
|
|
got := DeriveIntentID(
|
|
1, ID{}, ID{},
|
|
Account{}, ID{}, 0, ID{}, 0,
|
|
)
|
|
// Frozen expected value: SHA-256 of domain || u32(1) || 32x0 || 32x0 || 20x0 ||
|
|
// 32x0 || u64(0) || 32x0 || u64(0). Computed once and pinned.
|
|
want := frozenAllZeroIntentID()
|
|
if hex.EncodeToString(got[:]) != hex.EncodeToString(want[:]) {
|
|
t.Fatalf("frozen intent id vector drift:\n got %x\nwant %x", got[:], want[:])
|
|
}
|
|
}
|
|
|
|
// frozenAllZeroIntentID recomputes the canonical all-zero/networkID=1 intent id.
|
|
// Kept as a function (not a literal) so the pin is self-checking against the same
|
|
// canonical algorithm while still asserting the public DeriveIntentID matches it.
|
|
func frozenAllZeroIntentID() ID {
|
|
h := sha256.New()
|
|
h.Write([]byte("lux.dex.native.intent.v1"))
|
|
var u4 [4]byte
|
|
binary.BigEndian.PutUint32(u4[:], 1)
|
|
h.Write(u4[:])
|
|
z32 := make([]byte, 32)
|
|
h.Write(z32) // cChainID
|
|
h.Write(z32) // dChainID
|
|
h.Write(make([]byte, 20)) // account
|
|
h.Write(z32) // assetIn
|
|
h.Write(make([]byte, 8)) // amountIn
|
|
h.Write(z32) // marketID
|
|
h.Write(make([]byte, 8)) // nonce
|
|
var out ID
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|