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

180 lines
6.8 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"sync"
)
// venue_test.go provides two Venue implementations for the suite:
//
// honestVenue — a well-behaved D-Chain stand-in. quote reads a configured book;
// notify records the intent; watch returns Pending until the test
// marks a commit (modeling a D block); resolveExport points at the
// object the test placed in the ledger.
// maliciousVenue — lies about EVERYTHING: fabricates quotes, claims PhaseCommitted
// with bogus refs, returns settlement calldata for objects that do
// not exist or that name a different recipient/asset/amount. Used to
// prove no ZAP response moves money.
// honestVenue is the cooperative backend. It is wired to an AtomicLedger so the
// refs it returns name objects the ledger actually holds (after the test places
// them). It still NEVER credits — it only reports.
type honestVenue struct {
mu sync.Mutex
bookPrice map[ID]float64 // marketID -> price (quote currency per base)
bookLiq map[ID]bool
committed map[ID]DExportRef // intentID -> the export ref, set when D "commits"
outAsset map[ID]ID // intentID -> output asset (for resolveExport amount lookup)
ledger *AtomicLedger
}
func newHonestVenue(l *AtomicLedger) *honestVenue {
return &honestVenue{
bookPrice: make(map[ID]float64),
bookLiq: make(map[ID]bool),
committed: make(map[ID]DExportRef),
outAsset: make(map[ID]ID),
ledger: l,
}
}
func (v *honestVenue) setBook(market ID, price float64) {
v.mu.Lock()
v.bookPrice[market] = price
v.bookLiq[market] = true
v.mu.Unlock()
}
// commit models a committed D block: it records the export ref the watch will
// report and (the test having placed the real object in the ledger) the output
// asset for the resolveExport amount.
func (v *honestVenue) commit(intentID ID, ref DExportRef, outAsset ID) {
v.mu.Lock()
v.committed[intentID] = ref
v.outAsset[intentID] = outAsset
v.mu.Unlock()
}
func (v *honestVenue) Quote(_ context.Context, req QuoteRequest) (QuoteResult, error) {
v.mu.Lock()
defer v.mu.Unlock()
price, ok := v.bookPrice[req.MarketID]
if !ok || !v.bookLiq[req.MarketID] {
return QuoteResult{MarketID: req.MarketID, Liquid: false}, nil
}
var out uint64
if req.ZeroForOne {
out = uint64(float64(req.AmountIn) * price)
} else {
if price > 0 {
out = uint64(float64(req.AmountIn) / price)
}
}
return QuoteResult{MarketID: req.MarketID, AmountIn: req.AmountIn, AmountOut: out, Liquid: true}, nil
}
func (v *honestVenue) State(_ context.Context, req StateRequest) (StateResult, error) {
return StateResult{Kind: req.Kind, Exists: true}, nil
}
func (v *honestVenue) NotifyIntent(_ context.Context, intent PreparedIntent) (IntentWatchRef, error) {
return IntentWatchRef{IntentID: intent.IntentID}, nil
}
func (v *honestVenue) WatchStatus(_ context.Context, intentID ID) (IntentStatus, error) {
v.mu.Lock()
defer v.mu.Unlock()
if ref, ok := v.committed[intentID]; ok {
return IntentStatus{IntentID: intentID, Phase: PhaseCommitted, Ref: ref}, nil
}
return IntentStatus{IntentID: intentID, Phase: PhasePending}, nil
}
// ResolveExport builds settlement calldata pointing at the object the ref names.
// The amount is the real object's amount (the venue reads it from the ledger's
// store — modeling reading shared memory). It still cannot credit; it returns
// bytes.
func (v *honestVenue) ResolveExport(_ context.Context, ref DExportRef) (SettlementSubmitResult, error) {
key := ref.ObjectKey()
v.ledger.mu.Lock()
o, ok := v.ledger.objects[key]
v.ledger.mu.Unlock()
var amount uint64
if ok {
amount = o.amount
}
pk := testPoolKey()
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, amount, ref.IntentID))
return SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: key, Calldata: calldata}, nil
}
// maliciousVenue lies about everything. It is the adversary in the critical test.
type maliciousVenue struct {
// fakeAmount is the amount the malicious venue claims in fabricated settlement
// calldata (typically inflated, or for an object that does not exist).
fakeAmount uint64
// fakeRecipient is a recipient the attacker WISHES to credit (their own
// account); irrelevant because the chain binds recipient to the caller.
fakeRecipient Account
// fakeAsset is an asset the attacker wishes to re-denominate to; irrelevant
// because the chain derives the asset from the swap direction.
fakeAsset ID
// fakeKey is the object key the attacker points at (often a non-existent or a
// victim's object).
fakeKey ID
}
func (v *maliciousVenue) Quote(_ context.Context, req QuoteRequest) (QuoteResult, error) {
// Lie: claim a wildly favorable, liquid quote for any market.
return QuoteResult{MarketID: req.MarketID, AmountIn: req.AmountIn, AmountOut: 1 << 62, Liquid: true}, nil
}
func (v *maliciousVenue) State(_ context.Context, req StateRequest) (StateResult, error) {
// Lie: claim huge available balances and that everything exists.
return StateResult{Kind: req.Kind, Exists: true, Available: 1 << 62, Known: true}, nil
}
func (v *maliciousVenue) NotifyIntent(_ context.Context, intent PreparedIntent) (IntentWatchRef, error) {
return IntentWatchRef{IntentID: intent.IntentID}, nil
}
func (v *maliciousVenue) WatchStatus(_ context.Context, intentID ID) (IntentStatus, error) {
// Lie: claim the intent is COMMITTED immediately with a fabricated ref pointing
// at the attacker's chosen (non-existent / victim) object.
return IntentStatus{
IntentID: intentID,
Phase: PhaseCommitted,
Ref: DExportRef{SourceChainID: ID{0xDD}, SourceTxID: v.fakeKey, OutputIndex: 0, IntentID: intentID},
}, nil
}
func (v *maliciousVenue) ResolveExport(_ context.Context, ref DExportRef) (SettlementSubmitResult, error) {
// Lie: return settlement calldata for an inflated amount on the attacker's key,
// naming the attacker's recipient/asset in the (irrelevant) ref fields. The
// calldata can only carry outputID + amount; recipient/asset are the chain's.
key := v.fakeKey
if key == (ID{}) {
key = ref.ObjectKey()
}
pk := testPoolKey()
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, v.fakeAmount, ref.IntentID))
return SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: key, Calldata: calldata}, nil
}
// testPoolKey is a fixed PoolKey for the suite's single market.
func testPoolKey() PoolKeyArgs {
return PoolKeyArgs{
Currency0: Account{}, // native (zero) sorts first
Currency1: Account{0x11, 0x22, 0x33},
Fee: 3000,
TickSpacing: 60,
Hooks: Account{},
}
}
// testMarkets is the market mapping a client session is configured with.
func testMarkets(_ ID) (PoolKeyArgs, bool) { return testPoolKey(), true }