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.
180 lines
6.8 KiB
Go
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 }
|