merge: ZAP DexSession V4 bidirectional control plane (v0.8.9)

This commit is contained in:
zeekay
2026-06-21 07:27:08 -07:00
25 changed files with 7357 additions and 0 deletions
+41
View File
@@ -169,3 +169,44 @@ Reference = handler latency on a serialized control-stream path
(pre-optimization measurement, retained for context). Per-stream
overlaps handlers in flight (peak inflight = 32 observed in
`TestQUICCallConcurrent`).
## DexSession orchestration (`dexsession/`)
ZAP/Cap'n-Proto orchestration layer for the Lux DEX: capability transport +
promise pipelining that makes the native C↔D atomic settlement flow FEEL
synchronous, while the value boundary stays 100% native.
THE HARD INVARIANT: a ZAP response is NEVER sufficient to move money. Money
moves ONLY when C consumes a real D→C atomic export object, or D consumes a real
C→D object. The orchestration layer holds no StateDB/AtomicState/shared-memory
and has NO compile edge to `precompile/dex` or the EVM — it cannot import the C
Verify path and Verify cannot import it. It traffics in VALUES (`[20]byte`
accounts, `[32]byte` ids, `uint64` amounts) and bytes only:
- `prepareSwapIntent` → 0x9999 CALLDATA the USER signs (no funds reserved, no
enforceable amountOut — the floor rides MinAmountOut in the signed tx).
- `notifyIntent` → tells D to scan/import the C→D object the signed tx created;
cannot make a D match valid without a committed D block; returns a watch.
- `importSettlement` → finalization CALLDATA / a tx that POINTS at a `DExportRef`;
the chain re-reads the real D→C object and binds recipient/asset/amount to it.
`DExportRef` is a POINTER `{sourceChainID, sourceTxID, outputIndex, intentID}`,
NOT a DFillReceipt. "No capability moves money" is provable by EXHAUSTION: the
surface has no creditBalance/settleFill/overrideMatch/adminWithdraw method.
Capabilities (QuoteCap/IntentCap/WatchCap/SettlementCap/AdminCap) are unforgeable
tokens scoped to a bootstrap grant; a session cannot widen its own authority.
The 60-byte atomic object wire, `DeriveIntentID`, `DeriveUTXOID`, the V4 swap
selector (0xF3CD914C), and the Phase-A/B hookData tags are REPRODUCED as pure
values (not imported) and pinned byte-identical to `precompile/dex` +
`chains/dexvm` by `parity_test.go` — the "three homes" pattern, keeping the
transport module free of the EVM/cgo dep graph.
Promise pipelining (`promise.go`/`pipeline.go`): dependent calls dispatch the
instant their inputs resolve (`thenAsync`), so `quote→prepare→notify→
onCommitted→import` overlaps network latency; `SwapFlow`/`SwapFlowWithSender`
wire the declarative end-to-end path. The watch streams D's result (poll/push)
and resolves to a pointer on commit.
Build/test (pure Go): `CGO_ENABLED=0 GOWORK=off go test ./dexsession/`. The
critical adversarial proof is `TestRED_ZAP_ResponseAloneCannotMoveMoney`.
+225
View File
@@ -0,0 +1,225 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"encoding/binary"
)
// calldata.go reproduces the 0x9999 CALLDATA byte format the precompile owns, as
// pure functions over values. The precompile (luxfi/precompile/dex) is the
// authority on this encoding; dexsession reproduces it so prepareSwapIntent /
// importSettlement can hand a wallet the exact bytes to sign. calldata_parity_test
// pins every constant against a hardcoded vector so a precompile-side change that
// drifts the format trips a red test here.
//
// WHY REPRODUCE INSTEAD OF IMPORT: importing precompile/dex would drag the EVM +
// geth + (broken) cgo dependency graph into the transport module AND give
// dexsession a compile edge to the C Verify path — both forbidden. The format is a
// small, frozen byte layout (a selector + two ABI tuples + a tagged hookData); the
// "three homes" pattern (already used for the CLOB frames) reproduces it and pins
// it. 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.
// SelectorSwap is the V4 swap selector — IDENTICAL to
// precompile/dex/module.go SelectorSwap (0xF3CD914C):
//
// swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes)
//
// Both Phase A (intent) and Phase B (settlement) ride this one selector; the
// hookData CONTENTS select the phase (the V4 ABI is unchanged).
const SelectorSwap uint32 = 0xF3CD914C
// Phase tags inside hookData — IDENTICAL to precompile/dex/settle_hookdata.go.
//
// empty hookData -> PHASE A (intent), the common case.
// "DI01" -> PHASE A (intent), explicit.
// "DS01" + body -> PHASE B (settlement), consume a D->C object.
var (
intentPhaseTag = [4]byte{'D', 'I', '0', '1'}
settlementPhaseTag = [4]byte{'D', 'S', '0', '1'}
)
// settlementBodyLen is the fixed Phase-B body width: outputID(32) | amount(32).
// IDENTICAL to precompile/dex/settle_hookdata.go settlementBodyLen.
const settlementBodyLen = 32 + 32
// EncodeIntentHookData builds an explicit Phase-A hookData (the tag alone).
// Byte-identical to precompile/dex EncodeIntentHookData.
func EncodeIntentHookData() []byte {
out := make([]byte, 4)
copy(out, intentPhaseTag[:])
return out
}
// EncodeSettlementHookData builds a Phase-B hookData: tag + outputID + amount.
// Byte-identical to precompile/dex EncodeSettlementHookData. amount is right-
// aligned in a uint256 word (the low 8 bytes), matching the precompile's
// binary.BigEndian.PutUint64(amt[24:32], amount).
//
// CRITICAL: the body carries ONLY outputID + amount. The output ASSET and the
// RECIPIENT are NOT wire fields — on-chain the asset is derived from the swap
// direction and the recipient is the CALLER. So this calldata cannot name a
// victim's recipient or a re-denominated asset; ImportSettlement binds even
// outputID+amount against the RECORDED object. (This is why a tampered DExportRef
// cannot substitute recipient/asset/amount — see the package invariant.)
func EncodeSettlementHookData(outputID ID, amount uint64) []byte {
out := make([]byte, 0, 4+settlementBodyLen)
out = append(out, settlementPhaseTag[:]...)
out = append(out, outputID[:]...)
var amt [32]byte
binary.BigEndian.PutUint64(amt[24:32], amount)
out = append(out, amt[:]...)
return out
}
// abiWord is one 32-byte ABI word.
type abiWord [32]byte
// wordAddr right-aligns a 20-byte account into an ABI address word.
func wordAddr(a Account) abiWord {
var w abiWord
copy(w[12:32], a[:])
return w
}
// wordU24 right-aligns a uint24 (lpFee) into an ABI word.
func wordU24(v uint32) abiWord {
var w abiWord
w[29] = byte(v >> 16)
w[30] = byte(v >> 8)
w[31] = byte(v)
return w
}
// wordI24 two's-complement encodes an int24 (tickSpacing) into an ABI word
// (sign-extended to 32 bytes).
func wordI24(v int32) abiWord {
var w abiWord
u := uint64(int64(v))
for i := 0; i < 8; i++ {
w[31-i] = byte(u >> (8 * i))
}
if v < 0 {
for i := 8; i < 32; i++ {
w[31-i] = 0xff
}
}
return w
}
// wordBool encodes a bool into an ABI word.
func wordBool(b bool) abiWord {
var w abiWord
if b {
w[31] = 1
}
return w
}
// wordI256FromInt encodes a signed int256 from an int64 (amountSpecified). The V4
// convention: amountSpecified < 0 = exact input. A swap intent locks AmountIn of
// the input, so the prepared calldata encodes -AmountIn (exact input).
func wordI256FromNegU64(v uint64) abiWord {
var w abiWord
// -v in two's complement over 256 bits.
neg := new256Neg(v)
copy(w[:], neg[:])
return w
}
// new256Neg returns the 32-byte big-endian two's-complement of -v for v>0.
func new256Neg(v uint64) [32]byte {
var w [32]byte
// Start with v big-endian in the low 8 bytes, then negate the whole 256-bit.
binary.BigEndian.PutUint64(w[24:32], v)
// two's complement: invert all, add 1.
var carry uint16 = 1
for i := 31; i >= 0; i-- {
s := uint16(^w[i]) + carry
w[i] = byte(s)
carry = s >> 8
}
return w
}
// wordU160 right-aligns a uint160 sqrtPriceLimit into an ABI word. The native
// seam ignores the price limit for matching (D enforces slippage via MinAmountOut
// in the routing event), so prepared calldata passes 0 (no on-chain limit) and the
// enforceable floor rides MinAmountOut. Kept as a helper for completeness.
func wordU160Zero() abiWord { return abiWord{} }
// PoolKeyArgs are the V4 PoolKey tuple fields the swap selector takes:
// (currency0, currency1, fee uint24, tickSpacing int24, hooks address). For the
// native seam the pool is identified by these; the keeper/market binds them to a
// D market. dexsession fills them from the request's market mapping.
type PoolKeyArgs struct {
Currency0 Account
Currency1 Account
Fee uint32 // uint24
TickSpacing int32 // int24
Hooks Account
}
// EncodeSwapCalldata builds the full 0x9999 swap calldata:
//
// selector(4) || PoolKey(5 words) || SwapParams(3 words) || offset(1 word) ||
// hookData(length word + padded bytes)
//
// This is the standard Solidity ABI encoding of
// swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes). The
// dynamic `bytes hookData` is tail-encoded with a head offset word.
//
// zeroForOne + amountSpecified come from the request; amountSpecified is encoded
// as -AmountIn (exact input). The hookData selects the phase: pass
// EncodeIntentHookData()/empty for Phase A, EncodeSettlementHookData(...) for
// Phase B.
func EncodeSwapCalldata(pk PoolKeyArgs, zeroForOne bool, amountIn uint64, hookData []byte) []byte {
// Head: selector + 5 PoolKey words + 3 SwapParams words + 1 hookData offset
// word = 4 + 9*32. Tail: hookData length word + padded data.
const headWords = 5 + 3 + 1
head := make([]byte, 4+headWords*32)
binary.BigEndian.PutUint32(head[0:4], SelectorSwap)
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))
// SwapParams tuple (inline — fixed-size members).
put(wordBool(zeroForOne))
put(wordI256FromNegU64(amountIn)) // exact input = -amountIn
put(wordU160Zero()) // sqrtPriceLimit: 0 (no on-chain limit)
// hookData dynamic offset: bytes start right after the head (offset measured
// from the first argument word, i.e. after the selector). The 9 head words are
// the args; the dynamic tail begins at 9*32.
put(wordU256FromU64(uint64(headWords * 32)))
// Tail: length word + right-padded data to a 32-byte boundary.
tail := encodeDynamicBytes(hookData)
return append(head, tail...)
}
// wordU256FromU64 right-aligns a uint64 into an ABI word.
func wordU256FromU64(v uint64) abiWord {
var w abiWord
binary.BigEndian.PutUint64(w[24:32], v)
return w
}
// encodeDynamicBytes ABI-encodes a `bytes` value: a 32-byte length word followed
// by the bytes right-padded to a 32-byte multiple.
func encodeDynamicBytes(b []byte) []byte {
padded := (len(b) + 31) / 32 * 32
out := make([]byte, 32+padded)
binary.BigEndian.PutUint64(out[24:32], uint64(len(b)))
copy(out[32:], b)
return out
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"crypto/rand"
"errors"
)
// capability.go is the Cap'n-Proto-style authority model. A capability is an
// unforgeable reference to a NARROW slice of authority. The defining property of
// this whole layer:
//
// NO CAPABILITY CAN MINT OR CREDIT A C BALANCE.
//
// This is true by EXHAUSTION, not by a runtime check alone. Enumerate the entire
// method set a capability can reach:
//
// QuoteCap -> Quote, GetState (READ)
// IntentCap -> PrepareSwapIntent, NotifyIntent (BUILD calldata / trigger D scan)
// WatchCap -> Poll, OnCommitted (SUBSCRIBE)
// SettlementCap -> ImportSettlement (POINT at an existing D->C object)
// AdminCap -> Halt, Status (kill-switch / status only)
//
// There is NO creditBalance, settleFill, overrideMatch, adminWithdraw, mint, or
// any method that returns or asserts a balance change. The build methods return
// CALLDATA (bytes the user signs); the point method returns CALLDATA / a tx hash
// that names an object the chain independently verifies. So even a capability with
// EVERY authority bit set cannot move money — there is nothing value-moving to
// invoke. The Authority bitmask below is defence in depth on top of a surface that
// is value-free by construction.
// Authority is a bitmask of the operation classes a capability permits. Each bit
// maps to exactly one capability type; a bit grants ONLY the read/build/point/
// subscribe operations of that class — never a credit.
type Authority uint32
const (
AuthQuote Authority = 1 << 0 // read book/state (Quote, GetState)
AuthIntent Authority = 1 << 1 // build a C->D intent + notify (PrepareSwapIntent, NotifyIntent)
AuthWatch Authority = 1 << 2 // subscribe to a D result (Poll, OnCommitted)
AuthSettlement Authority = 1 << 3 // point C at an existing D->C object (ImportSettlement)
AuthAdmin Authority = 1 << 4 // halt / status only (separately gated)
)
// AuthorityPublic is the grant a public API surface bootstraps with: everything a
// user needs to trade end-to-end, WITHOUT admin. Note this is the maximal
// user-facing grant and it STILL cannot move money — see the package invariant.
const AuthorityPublic = AuthQuote | AuthIntent | AuthWatch | AuthSettlement
// AuthorityReadOnly is a quote/state-only grant (e.g. a price widget).
const AuthorityReadOnly = AuthQuote
var (
// ErrNoAuthority is returned when a session/capability is asked for an
// operation outside its granted authority. Fail-closed: deny, never widen.
ErrNoAuthority = errors.New("dexsession: capability lacks the required authority")
// ErrRevoked is returned when a capability has been revoked at the session.
ErrRevoked = errors.New("dexsession: capability revoked")
)
// token is a 32-byte unforgeable capability reference. It is generated with
// crypto/rand at grant time and never leaves the process boundary in a form a
// caller can mint: you obtain a capability ONLY by asking a session that holds the
// authority, and the session hands back a value whose token it generated. There is
// no exported constructor that takes a token, so a caller cannot fabricate a
// higher-authority capability.
type token [32]byte
func newToken() token {
var t token
// crypto/rand.Read never returns a short read on success; a failure here means
// the OS RNG is unavailable, which is unrecoverable for a security token —
// surface it by leaving the token zero, which the session's grant set will
// reject (a zero token is never registered). Callers see ErrNoAuthority.
_, _ = rand.Read(t[:])
return t
}
// grant is the authority record a session keeps per issued capability. The token
// is the key; the bits are what it permits; revoked flips it off without
// forgetting (so a replayed token after revocation is explicitly ErrRevoked, not
// a silent miss).
type grant struct {
bits Authority
revoked bool
}
// capCore is the unexported shared state every capability points at. It binds a
// capability to its issuing session's grant table, so authority is checked at the
// session of record — a capability cannot outlive or out-scope its session.
type capCore struct {
session *clientSession
tok token
bits Authority
}
// authorize verifies the capability still holds `need` authority at its session.
// The check is at the SESSION of record (not the capability's own cached bits) so
// a revocation takes effect immediately. The cached bits are an early-out only.
func (c *capCore) authorize(need Authority) error {
if c == nil || c.session == nil {
return ErrNoAuthority
}
if c.bits&need != need {
return ErrNoAuthority
}
return c.session.checkGrant(c.tok, need)
}
// --- The five capability types. Each exposes ONLY its class of operations. ----
//
// A capability is a thin typed handle over capCore: the type system is the first
// line ("a QuoteCap has no ImportSettlement method"), the authority gate is the
// second ("even if you somehow held the core, the bits must permit it"), and the
// surface being value-free is the third (no method moves money at all).
// QuoteCap permits read-only book/state queries. It can quote and observe; it
// cannot build an intent, notify, settle, or administer.
type QuoteCap struct{ core *capCore }
// IntentCap permits requesting a C->D intent (build calldata) and notifying D to
// scan/import the resulting object. It cannot reserve funds, claim a fill, credit,
// or settle.
type IntentCap struct{ core *capCore }
// WatchCap permits subscribing to a notified intent's D result. It cannot move
// value; OnCommitted yields a POINTER (DExportRef), not a credit.
type WatchCap struct{ core *capCore }
// SettlementCap permits asking C to import an EXISTING D->C object (build the
// finalization calldata / submit a pointing tx). It cannot credit C through RPC,
// nor substitute recipient/asset/amount — the chain binds those to the object.
type SettlementCap struct{ core *capCore }
// AdminCap permits halt/status only, and is granted SEPARATELY (AuthAdmin is not
// in AuthorityPublic). It cannot withdraw, credit, or override a match.
type AdminCap struct{ core *capCore }
// Authority reports the bits each capability was granted (introspection / tests).
func (c QuoteCap) Authority() Authority { return c.core.bits }
func (c IntentCap) Authority() Authority { return c.core.bits }
func (c WatchCap) Authority() Authority { return c.core.bits }
func (c SettlementCap) Authority() Authority { return c.core.bits }
func (c AdminCap) Authority() Authority { return c.core.bits }
+68
View File
@@ -0,0 +1,68 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package dexsession is the ZAP/Cap'n-Proto orchestration layer for the Lux DEX.
//
// It makes the native C<->D atomic settlement flow FEEL synchronous (capability
// transport + promise pipelining) while keeping the value boundary 100% native.
//
// # The hard invariant (the whole point)
//
// A ZAP response is NEVER sufficient to move money. Money moves ONLY when:
//
// C consumes a real D->C atomic shared-memory export object, OR
// D consumes a real C->D atomic export object.
//
// Even a fully malicious or stale ZAP layer cannot mint, credit, or substitute
// value: it can only POINT the precompile/keeper at an atomic object that the
// chain independently verifies (asset / owner / amount / one-time bound). This
// package therefore traffics in VALUES — quotes (estimates), calldata (bytes to
// sign), and POINTERS (DExportRef) — never in authority over a balance.
//
// # Why the invariant holds structurally, not just by policy
//
// The C-side value boundary (luxfi/precompile/dex/native_dchain_client.go) and
// the D-side value boundary (luxfi/chains/dexvm/atomic.go) both operate on EVM
// host capabilities (a StateDB and an AtomicState / shared memory). This package
// holds NONE of those: it has no StateDB, no AtomicState, no shared memory, and
// — by deliberate module discipline — no compile-time edge to the precompile or
// the EVM at all. It cannot import the C Verify path, and the C Verify path does
// not import it. The only things crossing the boundary are bytes:
//
// - prepareSwapIntent returns CALLDATA (hookData for 0x9999) the USER signs;
// it reserves no funds and returns no amountOut the chain trusts.
// - notifyIntent tells D to SCAN/import a C->D object the user's signed C tx
// already created; it cannot make a D match valid without a committed D
// block.
// - importSettlement returns finalization CALLDATA / submits a C tx that
// merely POINTS at a DExportRef; the chain's ImportSettlement re-reads the
// real D->C object and binds recipient/asset/amount to it.
//
// So "no capability moves money" is provable by EXHAUSTION over the API surface:
// there is no creditBalance / settleFill / overrideMatch / adminWithdraw method
// to call (see capability.go). The capability gate is defence in depth on top of
// a surface that is already value-free by construction.
//
// # Capabilities (Cap'n-Proto style)
//
// FullServiceNode.Bootstrap returns a RESTRICTED DexSession. From the session a
// caller derives narrowly-scoped, unforgeable capabilities — QuoteCap (read),
// IntentCap (request a C->D intent + notify), WatchCap (subscribe to a D
// result), SettlementCap (ask C to import an EXISTING D->C object), AdminCap
// (halt/status only, separately gated). A capability cannot grant authority the
// session was not bootstrapped with, and none of them can mint or credit a C
// balance.
//
// # Promise pipelining (the latency win)
//
// Dependent calls issue before each prior resolves:
//
// quote := dex.Quote(req)
// intent := dex.PrepareSwapIntent(req, quote) // takes the quote promise
// watch := dex.NotifyIntent(intent) // takes the intent promise
// settle := watch.OnCommitted().ImportSettlement()
//
// The client overlaps network latency (each call dispatches the instant its
// inputs resolve), and a Pipeline batch collapses a whole dependency chain into
// a single round trip. The consensus path stays native atomic throughout.
package dexsession
+320
View File
@@ -0,0 +1,320 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"sync"
zaplib "github.com/luxfi/zap"
)
// harness_test.go provides the test scaffolding: an in-memory ZAP loopback (so the
// suite needs no socket), and a faithful AtomicLedger that models the C<->D value
// boundary EXACTLY as luxfi/precompile/dex/native_dchain_client.go and
// luxfi/chains/dexvm/atomic.go enforce it. The AtomicLedger is the JUDGE in the
// invariant tests: ZAP responses drive the client; only consuming a real atomic
// object in the ledger moves a balance.
// loopback is an in-process caller that routes a Call straight to a handler map,
// mirroring zap.Node's correlated dispatch without a network. It lets the suite
// drive the full client session against an arbitrary (including malicious) server
// implementation.
type loopback struct {
mu sync.RWMutex
handlers map[uint16]zaplib.Handler
}
func newLoopback() *loopback {
return &loopback{handlers: make(map[uint16]zaplib.Handler)}
}
func (l *loopback) Handle(msgType uint16, h zaplib.Handler) {
l.mu.Lock()
l.handlers[msgType] = h
l.mu.Unlock()
}
// Call routes msg to the handler for its msgType, returning the handler's response.
// It re-parses the response bytes so the returned *Message is independent (the
// client may Release it). Satisfies the caller interface used by clientSession.
func (l *loopback) Call(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
msgType := msg.Flags() >> 8
l.mu.RLock()
h, ok := l.handlers[msgType]
l.mu.RUnlock()
if !ok {
return nil, context.Canceled // no handler => treat as a dead peer
}
resp, err := h(ctx, "test-peer", msg)
if err != nil {
return nil, err
}
if resp == nil {
return nil, context.Canceled
}
// Re-parse a fresh copy so the caller's Release does not disturb the server's
// buffer (mirrors the network path, which copies across the wire).
buf := make([]byte, len(resp.Bytes()))
copy(buf, resp.Bytes())
return zaplib.Parse(buf)
}
// loopbackHandler is the caller interface backed directly by a Venue, registering
// the same handlers the real FullServiceNode does — but on a loopback. This lets a
// test point a client session at a Venue with no network.
func venueLoopback(v Venue) *loopback {
l := newLoopback()
// Re-register the production handlers on the loopback by constructing a
// FullServiceNode-equivalent handler set. We inline them (mirrors server.go) so
// the test exercises the SAME request/response codec.
l.Handle(MsgQuote, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
req := readQuoteRequest(msg)
res, err := v.Quote(ctx, req)
if err != nil {
return buildQuoteResult(QuoteResult{MarketID: req.MarketID, Liquid: false})
}
return buildQuoteResult(res)
})
l.Handle(MsgGetState, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
req := readStateRequest(msg)
res, err := v.State(ctx, req)
if err != nil {
return buildStateResult(StateResult{Kind: req.Kind})
}
return buildStateResult(res)
})
l.Handle(MsgNotify, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
pi := readPreparedIntent(msg)
ref, err := v.NotifyIntent(ctx, pi)
if err != nil {
return buildIntentWatchRef(IntentWatchRef{IntentID: pi.IntentID})
}
return buildIntentWatchRef(ref)
})
l.Handle(MsgWatchPoll, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
intentID := readWatchPollRequest(msg)
st, err := v.WatchStatus(ctx, intentID)
if err != nil {
return buildIntentStatus(IntentStatus{IntentID: intentID, Phase: PhaseUnknown})
}
st.IntentID = intentID
return buildIntentStatus(st)
})
l.Handle(MsgImport, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
ref := readDExportRef(msg)
res, err := v.ResolveExport(ctx, ref)
if err != nil {
return buildSettlementResult(SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: ref.ObjectKey()})
}
res.ObjectKey = ref.ObjectKey()
res.To = addr9999()
return buildSettlementResult(res)
})
return l
}
// =============================================================================
// AtomicLedger — a faithful model of the C<->D value boundary (the JUDGE).
// =============================================================================
//
// It mirrors, byte-rule for byte-rule, the discipline in native_dchain_client.go
// (C side) and dexvm/atomic.go (D side):
//
// C balance is credited ONLY by ImportSettlement consuming a D->C object: the
// credited owner/asset/amount are BOUND to the recorded object (mismatch => revert),
// the object is consumed EXACTLY ONCE (replay => revert), and there is NO MINT (the
// seam reserve must back it).
//
// Crucially, the ledger's credit path takes NO input from ZAP. It reads the object
// store (shared memory) and a claim. The ONLY way an object enters the store is a
// real D->C export (put). A test that wants money to move MUST place a real object;
// no ZAP response can.
// atomicObject is a recorded D->C export (owner|asset|amount), exactly the 60-byte
// wire DecodeAtomicObject reads.
type atomicObject struct {
owner Account
asset ID
amount uint64
}
// AtomicLedger models C-side shared memory + the seam reserve + the consumed set +
// C balances. It is concurrency-safe.
type AtomicLedger struct {
mu sync.Mutex
objects map[ID]atomicObject // shared-memory D->C export objects (key = ObjectKey)
consumed map[ID]bool // one-time settlement guard
reserve map[ID]uint64 // seam reserve per asset (must back a credit; no mint)
balance map[balKey]uint64 // C balances credited to (owner, asset)
}
type balKey struct {
owner Account
asset ID
}
func newAtomicLedger() *AtomicLedger {
return &AtomicLedger{
objects: make(map[ID]atomicObject),
consumed: make(map[ID]bool),
reserve: make(map[ID]uint64),
balance: make(map[balKey]uint64),
}
}
// PutExport places a real D->C export object into shared memory and funds the seam
// reserve to back it. This is the ONLY way value becomes claimable on C — it models
// a real dexvm executeExport after a committed D match. ZAP cannot call this; only
// a test that establishes a real cross-chain object does.
func (l *AtomicLedger) PutExport(key ID, o atomicObject) {
l.mu.Lock()
l.objects[key] = o
l.reserve[o.asset] += o.amount // the export is backed by locked tokenIn / seeding
l.mu.Unlock()
}
// Balance returns the C balance credited to (owner, asset).
func (l *AtomicLedger) Balance(owner Account, asset ID) uint64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.balance[balKey{owner, asset}]
}
// settleClaim is what a settlement tx declares — mirrors precompile SettlementClaim.
// CRITICAL: on-chain the recipient is the CALLER and the asset is DERIVED from the
// swap direction; only outputID + amount ride the calldata. The ledger models the
// FULL bind so a test can attempt substitution and observe rejection.
type settleClaim struct {
objectKey ID
asset ID
amount uint64
recipient Account
}
// ImportSettlement is the faithful C-credit path. It mirrors
// native_dchain_client.go ImportSettlement steps 1-6 EXACTLY:
//
// 1. read the recorded object (missing => revert);
// 2. BIND asset==claim.asset, owner==claim.recipient, amount==claim.amount;
// 3. replay guard (consumed => revert);
// 4. mark consumed (CEI) before value movement;
// 5. credit C from the seam reserve — NO MINT (reserve must back it);
// 6. (atomic remove of the object — modeled by deleting it).
//
// It takes a claim, NOT a ZAP response. There is no parameter for a "fill value" a
// relayer hands it — the credit is the RECORDED object's amount. This is the whole
// invariant in one function.
func (l *AtomicLedger) ImportSettlement(claim settleClaim) (credited uint64, err error) {
l.mu.Lock()
defer l.mu.Unlock()
// (1) read the recorded object.
o, ok := l.objects[claim.objectKey]
if !ok {
return 0, errLedgerNoSettlement
}
// (2) BIND the credit to the RECORDED value (authoritative, not declared).
if o.asset != claim.asset {
return 0, errLedgerAsset
}
if o.owner != claim.recipient {
return 0, errLedgerOwner
}
if o.amount != claim.amount {
return 0, errLedgerAmount
}
if o.amount == 0 {
return 0, errLedgerAmount
}
// (3) replay guard.
if l.consumed[claim.objectKey] {
return 0, errLedgerReplay
}
// (4) mark consumed (CEI) before value movement.
l.consumed[claim.objectKey] = true
// (5) credit from the seam reserve — NO MINT.
if l.reserve[o.asset] < o.amount {
l.consumed[claim.objectKey] = false // roll back the mark (EVM revert semantics)
return 0, errLedgerUnbacked
}
l.reserve[o.asset] -= o.amount
l.balance[balKey{o.owner, o.asset}] += o.amount
// (6) remove the consumed object.
delete(l.objects, claim.objectKey)
return o.amount, nil
}
// ledger errors mirror the precompile's ErrNativeSettle* set.
var (
errLedgerNoSettlement = errLedger("no D->C settlement object for the claimed key")
errLedgerAsset = errLedger("object asset != claimed asset")
errLedgerOwner = errLedger("object owner != claimed recipient")
errLedgerAmount = errLedger("object amount != claimed amount")
errLedgerReplay = errLedger("object already consumed (replay)")
errLedgerUnbacked = errLedger("seam reserve cannot back the credit (no mint)")
)
type errLedger string
func (e errLedger) Error() string { return "ledger: " + string(e) }
// applySettlementCalldata is the test bridge from a SettlementSubmitResult to the
// ledger: it decodes the Phase-B calldata the way the on-chain handler does
// (deriving recipient=caller, asset from the swap output side) and applies the
// resulting claim. This models "a wallet signs the calldata and the chain executes
// it" — the chain, not ZAP, performs the credit, binding recipient to the caller
// and asset to the swap direction.
//
// caller is who signs/submits (the chain binds recipient := caller). outAsset is
// the swap's output asset (the chain derives it; here the test supplies the true
// output asset). A malicious ZAP cannot influence caller or outAsset — they are the
// chain's, not the ref's.
func (l *AtomicLedger) applySettlementCalldata(res SettlementSubmitResult, caller Account, outAsset ID) (uint64, error) {
outputID, amount, ok := decodeSettlementCalldata(res.Calldata)
if !ok {
return 0, errLedger("malformed settlement calldata")
}
return l.ImportSettlement(settleClaim{
objectKey: outputID,
asset: outAsset, // DERIVED on-chain, not from the ZAP ref
amount: amount,
recipient: caller, // BOUND to the caller on-chain, not from the ZAP ref
})
}
// decodeSettlementCalldata extracts (outputID, amount) from 0x9999 swap calldata
// whose hookData is a Phase-B body. Mirrors the precompile's decodeSwapPhase +
// decodeSettlementBody. Returns ok=false for non-settlement / malformed calldata.
func decodeSettlementCalldata(calldata []byte) (outputID ID, amount uint64, ok bool) {
// selector(4) + 9 head words (PoolKey 5 + SwapParams 3 + hookData offset 1) +
// hookData (length word + padded body). The hookData body begins after the
// length word.
const headWords = 5 + 3 + 1
headEnd := 4 + headWords*32
if len(calldata) < headEnd+32 {
return ID{}, 0, false
}
// hookData length word.
lenOff := headEnd
hookLen := int(calldata[lenOff+31]) | int(calldata[lenOff+30])<<8 // small lengths
bodyOff := lenOff + 32
if bodyOff+hookLen > len(calldata) {
return ID{}, 0, false
}
hook := calldata[bodyOff : bodyOff+hookLen]
// Phase-B: tag(4) + outputID(32) + amount(32).
if len(hook) != 4+settlementBodyLen {
return ID{}, 0, false
}
if string(hook[:4]) != string(settlementPhaseTag[:]) {
return ID{}, 0, false
}
copy(outputID[:], hook[4:36])
// amount is the low 8 bytes of the 32-byte amount word at hook[36:68].
for i := 0; i < 8; i++ {
amount = amount<<8 | uint64(hook[60+i])
}
return outputID, amount, true
}
+447
View File
@@ -0,0 +1,447 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"testing"
"time"
)
// invariant_test.go is the security test suite. It proves the hard invariant: a
// ZAP response is NEVER sufficient to move money. The AtomicLedger (harness_test.go)
// is the judge — it mirrors the precompile/dexvm value boundary, and the ONLY way a
// balance changes is consuming a real atomic object placed by a real cross-chain
// export. No ZAP response can place one.
func newClientFor(v Venue) *clientSession {
lb := venueLoopback(v)
return NewSession(SessionConfig{Conn: lb, PeerID: "test-peer", Grant: AuthorityPublic, Markets: testMarkets})
}
// canonical fixed request used across tests.
func testReq() SwapIntentRequest {
return SwapIntentRequest{
NetworkID: 96369,
CChainID: ID{0xC0},
DChainID: ID{0xD0},
Account: Account{0xAA, 0xBB, 0xCC},
AssetIn: ID{}, // native
AmountIn: 1_000_000,
MarketID: ID{0x4D, 0x4B, 0x54},
MinAmountOut: 990_000,
Recipient: Account{0xAA, 0xBB, 0xCC},
Deadline: 1 << 40,
CallIndex: 0,
}
}
// TestZAP_QuoteIsReadOnly_CannotMoveValue: a quote is an estimate; issuing any
// number of quotes (even from a lying venue) moves no balance.
func TestZAP_QuoteIsReadOnly_CannotMoveValue(t *testing.T) {
ledger := newAtomicLedger()
owner := Account{0xAA, 0xBB, 0xCC}
asset := ID{}
// Malicious venue returns enormous quotes.
c := newClientFor(&maliciousVenue{})
ctx := context.Background()
before := ledger.Balance(owner, asset)
for i := 0; i < 100; i++ {
qr, err := c.Quote(ctx, QuoteRequest{MarketID: ID{0x01}, AmountIn: 1000, ZeroForOne: true}).Await(ctx)
if err != nil {
t.Fatalf("quote: %v", err)
}
_ = qr // a huge estimate is returned, and ignored by the value boundary
}
if after := ledger.Balance(owner, asset); after != before {
t.Fatalf("quote moved balance: before=%d after=%d", before, after)
}
// The quote result is NOT enforceable: there is no code path from a QuoteResult
// to the ledger. (Compile-time: ImportSettlement takes a settleClaim, never a
// QuoteResult.)
}
// TestZAP_PrepareIntent_ReturnsCalldataNotFunds: prepareSwapIntent yields calldata
// + hookData + an intent id; it reserves nothing and returns no enforceable
// amountOut.
func TestZAP_PrepareIntent_ReturnsCalldataNotFunds(t *testing.T) {
ledger := newAtomicLedger()
hv := newHonestVenue(ledger)
hv.setBook(ID{0x4D, 0x4B, 0x54}, 1.0)
c := newClientFor(hv)
ctx := context.Background()
req := testReq()
owner := req.Account
before := ledger.Balance(owner, req.AssetIn)
pi, err := c.PrepareSwapIntent(ctx, req).Await(ctx)
if err != nil {
t.Fatalf("prepare: %v", err)
}
// Output is calldata, not funds.
if len(pi.Calldata) == 0 {
t.Fatalf("prepare returned empty calldata")
}
if len(pi.HookData) == 0 {
t.Fatalf("prepare returned empty hookData")
}
// hookData is Phase A (intent) — NOT a settlement that could credit.
if string(pi.HookData) != string(EncodeIntentHookData()) {
t.Fatalf("prepare hookData is not the intent phase: %x", pi.HookData)
}
// The intent id is the deterministic derivation (the user's tx will mint it).
wantID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, req.MarketID)
if pi.IntentID != wantID {
t.Fatalf("prepare intent id mismatch")
}
// No funds reserved: the ledger balance is unchanged by preparing.
if after := ledger.Balance(owner, req.AssetIn); after != before {
t.Fatalf("prepare reserved funds: before=%d after=%d", before, after)
}
// QuotedOut is informational; the calldata's enforceable floor is MinAmountOut
// (off-chain, the chain/D enforce it). There is no field in PreparedIntent the
// chain trusts as an output credit — only Calldata the user signs.
}
// TestZAP_NotifyIntent_CannotValidateMatchWithoutDBlock: a notify triggers D, but
// without a committed D block the watch never resolves to a committed ref, and no
// balance moves.
func TestZAP_NotifyIntent_CannotValidateMatchWithoutDBlock(t *testing.T) {
ledger := newAtomicLedger()
hv := newHonestVenue(ledger)
hv.setBook(ID{0x4D, 0x4B, 0x54}, 1.0)
c := newClientFor(hv)
req := testReq()
owner := req.Account
before := ledger.Balance(owner, req.AssetIn)
ctx := context.Background()
intent := c.PrepareSwapIntent(ctx, req)
watch, err := c.NotifyIntent(ctx, intent).Await(ctx)
if err != nil {
t.Fatalf("notify: %v", err)
}
// D has NOT committed (honestVenue.commit never called) => poll stays Pending.
st, err := watch.Poll(ctx)
if err != nil {
t.Fatalf("poll: %v", err)
}
if st.Phase != PhasePending {
t.Fatalf("phase = %d, want Pending (no D block yet)", st.Phase)
}
// OnCommitted must NOT resolve within a bounded wait (no D block => no ref).
wctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
_, err = watch.OnCommitted(ctx).Await(wctx)
if err == nil {
t.Fatalf("OnCommitted resolved without a D block")
}
// No balance moved.
if after := ledger.Balance(owner, req.AssetIn); after != before {
t.Fatalf("notify moved balance without a D block: before=%d after=%d", before, after)
}
}
// TestZAP_ImportSettlement_CannotSubstituteRecipientAssetAmount: the actual object
// binds recipient/asset/amount; a tampered ref/claim fails on-chain.
func TestZAP_ImportSettlement_CannotSubstituteRecipientAssetAmount(t *testing.T) {
ledger := newAtomicLedger()
// A real D->C export exists: owner=victim, asset=A, amount=500.
victim := Account{0x11, 0x22, 0x33}
attacker := Account{0x99, 0x88, 0x77}
assetA := ID{0x0A}
exportTx := ID{0xEE}
key := DeriveUTXOID(exportTx, 0)
ledger.PutExport(key, atomicObject{owner: victim, asset: assetA, amount: 500})
// (a) Attacker tries to substitute the RECIPIENT (credit themselves). On-chain
// the recipient is bound to the caller, so even if the attacker submits the
// settlement (caller=attacker), the object's owner (victim) != caller => reject.
_, err := ledger.applySettlementCalldata(
settlementCalldataFor(key, 500), attacker /*caller*/, assetA,
)
if err != errLedgerOwner {
t.Fatalf("recipient substitution not rejected: err=%v", err)
}
// (b) Attacker tries to substitute the AMOUNT (claim 5000 for a 500 object).
_, err = ledger.applySettlementCalldata(
settlementCalldataFor(key, 5000), victim /*caller*/, assetA,
)
if err != errLedgerAmount {
t.Fatalf("amount substitution not rejected: err=%v", err)
}
// (c) Attacker tries to substitute the ASSET (claim asset B). On-chain the asset
// is DERIVED from the swap direction; pass a different derived asset => reject.
assetB := ID{0x0B}
_, err = ledger.applySettlementCalldata(
settlementCalldataFor(key, 500), victim /*caller*/, assetB,
)
if err != errLedgerAsset {
t.Fatalf("asset substitution not rejected: err=%v", err)
}
// (d) The HONEST claim (correct caller=victim, correct derived asset=A, correct
// amount=500) succeeds — proving the rejections above are about substitution,
// not a broken path.
got, err := ledger.applySettlementCalldata(
settlementCalldataFor(key, 500), victim, assetA,
)
if err != nil || got != 500 {
t.Fatalf("honest settlement failed: got=%d err=%v", got, err)
}
if bal := ledger.Balance(victim, assetA); bal != 500 {
t.Fatalf("victim balance = %d, want 500", bal)
}
if bal := ledger.Balance(attacker, assetA); bal != 0 {
t.Fatalf("attacker credited %d, want 0", bal)
}
}
// settlementCalldataFor builds 0x9999 settlement calldata pointing at key for
// amount (the bytes a wallet would sign). Helper for the substitution test.
func settlementCalldataFor(key ID, amount uint64) SettlementSubmitResult {
pk := testPoolKey()
return SettlementSubmitResult{
Mode: SettleCalldata,
To: addr9999(),
ObjectKey: key,
Calldata: EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, amount)),
}
}
// TestZAP_Capability_NoCapCanMintOrCreditC: exhaustive over the capability set —
// no capability exposes a value-moving method, and each is confined to its grant.
func TestZAP_Capability_NoCapCanMintOrCreditC(t *testing.T) {
ledger := newAtomicLedger()
hv := newHonestVenue(ledger)
c := newClientFor(hv)
// A session granted EVERYTHING (including admin) still cannot move money.
full := NewSession(SessionConfig{Conn: venueLoopback(hv), PeerID: "p", Grant: AuthorityPublic | AuthAdmin, Markets: testMarkets})
qc, err := full.QuoteCap()
if err != nil {
t.Fatalf("QuoteCap: %v", err)
}
ic, err := full.IntentCap()
if err != nil {
t.Fatalf("IntentCap: %v", err)
}
wc, err := full.WatchCap()
if err != nil {
t.Fatalf("WatchCap: %v", err)
}
sc, err := full.SettlementCap()
if err != nil {
t.Fatalf("SettlementCap: %v", err)
}
ac, err := full.AdminCap()
if err != nil {
t.Fatalf("AdminCap: %v", err)
}
// Each capability holds ONLY its bit. This is the authority confinement.
if qc.Authority() != AuthQuote {
t.Fatalf("QuoteCap authority = %b", qc.Authority())
}
if ic.Authority() != AuthIntent {
t.Fatalf("IntentCap authority = %b", ic.Authority())
}
if wc.Authority() != AuthWatch {
t.Fatalf("WatchCap authority = %b", wc.Authority())
}
if sc.Authority() != AuthSettlement {
t.Fatalf("SettlementCap authority = %b", sc.Authority())
}
if ac.Authority() != AuthAdmin {
t.Fatalf("AdminCap authority = %b", ac.Authority())
}
// The PROOF by exhaustion: the entire method surface of every capability is
// READ / BUILD / POINT / SUBSCRIBE — none returns a balance change. This is a
// compile-time property (there is no creditBalance/settleFill/overrideMatch/
// adminWithdraw method to call). We assert the ledger is untouched after using
// every capability's full surface.
c.assertNoValueMoved(t, ledger)
// A read-only session CANNOT derive an intent/settlement/admin capability
// (authority confinement: you cannot widen your grant).
ro := NewSession(SessionConfig{Conn: venueLoopback(hv), PeerID: "p", Grant: AuthorityReadOnly, Markets: testMarkets})
if _, err := ro.IntentCap(); err != ErrNoAuthority {
t.Fatalf("read-only session minted an IntentCap: %v", err)
}
if _, err := ro.SettlementCap(); err != ErrNoAuthority {
t.Fatalf("read-only session minted a SettlementCap: %v", err)
}
if _, err := ro.AdminCap(); err != ErrNoAuthority {
t.Fatalf("read-only session minted an AdminCap: %v", err)
}
// And a public (non-admin) session cannot mint an AdminCap.
if _, err := c.AdminCap(); err != ErrNoAuthority {
t.Fatalf("public session minted an AdminCap: %v", err)
}
}
// assertNoValueMoved exercises every operation the session exposes and asserts the
// ledger balance for the test account is unchanged. The operations return
// estimates / calldata / pointers; none credits.
func (s *clientSession) assertNoValueMoved(t *testing.T, ledger *AtomicLedger) {
t.Helper()
ctx := context.Background()
req := testReq()
owner := req.Account
before := ledger.Balance(owner, req.AssetIn)
_, _ = s.Quote(ctx, QuoteRequest{MarketID: req.MarketID, AmountIn: 1000, ZeroForOne: true}).Await(ctx)
_, _ = s.GetState(ctx, StateRequest{Kind: StateBalance, Account: owner, Asset: req.AssetIn}).Await(ctx)
pi := s.PrepareSwapIntent(ctx, req)
w, _ := s.NotifyIntent(ctx, pi).Await(ctx)
// poll (no commit) — read only.
_, _ = w.Poll(ctx)
// import a (non-existent) ref — returns calldata, credits nothing.
_, _ = s.ImportSettlement(ctx, resolved(DExportRef{SourceTxID: ID{0x01}}, nil)).Await(ctx)
if after := ledger.Balance(owner, req.AssetIn); after != before {
t.Fatalf("a capability operation moved value: before=%d after=%d", before, after)
}
}
// TestZAP_PromisePipeline_QuoteIntentNotifyWatchSettle: dependent calls pipeline
// correctly — the declarative chain produces the right artifacts end to end.
func TestZAP_PromisePipeline_QuoteIntentNotifyWatchSettle(t *testing.T) {
ledger := newAtomicLedger()
hv := newHonestVenue(ledger)
market := ID{0x4D, 0x4B, 0x54}
hv.setBook(market, 1.0)
c := newClientFor(hv)
ctx := context.Background()
req := testReq()
// Establish the funded path: the user's signed C tx created the C->D object and
// D matched + exported a D->C object. We model that by placing the real export
// and marking the D commit. (The pipeline does not sign the user's tx — only the
// user funds their own swap; here the test plays the user + the chain.)
exportTx := ID{0xEE, 0x01}
key := DeriveUTXOID(exportTx, 0)
ledger.PutExport(key, atomicObject{owner: req.Recipient, asset: req.AssetIn, amount: 1_000_000})
intentID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, req.MarketID)
hv.commit(intentID, DExportRef{SourceChainID: req.DChainID, SourceTxID: exportTx, OutputIndex: 0, IntentID: intentID}, req.AssetIn)
// THE PIPELINE — declarative, dependent calls overlap:
quote := c.Quote(ctx, QuoteRequest{MarketID: market, AmountIn: req.AmountIn, ZeroForOne: true})
intent := c.PrepareSwapIntent(ctx, req)
watch := c.NotifyIntent(ctx, intent)
committed := thenAsync(ctx, watch, func(ctx context.Context, w IntentWatch) (DExportRef, error) {
return w.OnCommitted(ctx).Await(ctx)
})
settle := c.ImportSettlement(ctx, committed)
// Await the final artifact (the settlement calldata).
res, err := settle.Await(ctx)
if err != nil {
t.Fatalf("pipeline settle: %v", err)
}
if len(res.Calldata) == 0 {
t.Fatalf("pipeline produced empty settlement calldata")
}
// Each intermediate resolved correctly.
if qr, _ := quote.Await(ctx); !qr.Liquid {
t.Fatalf("quote not liquid")
}
if pi, _ := intent.Await(ctx); pi.IntentID != intentID {
t.Fatalf("intent id mismatch in pipeline")
}
// The settlement calldata, when the chain applies it (caller=recipient, asset
// derived=AssetIn), credits exactly the object amount — money moved ONLY here,
// via the real object.
got, err := ledger.applySettlementCalldata(res, req.Recipient, req.AssetIn)
if err != nil || got != 1_000_000 {
t.Fatalf("final on-chain settle: got=%d err=%v", got, err)
}
if bal := ledger.Balance(req.Recipient, req.AssetIn); bal != 1_000_000 {
t.Fatalf("recipient balance = %d, want 1_000_000", bal)
}
}
// TestZAP_StaleResponse_BadQuoteMissesMinOutOrReverts: a stale/bad quote cannot
// force a bad trade — the enforceable floor is MinAmountOut, which the chain/D
// check independently of the quote.
func TestZAP_StaleResponse_BadQuoteMissesMinOutOrReverts(t *testing.T) {
ledger := newAtomicLedger()
// Malicious venue returns a hugely favorable (stale/false) quote.
c := newClientFor(&maliciousVenue{})
ctx := context.Background()
req := testReq()
req.MinAmountOut = 990_000 // the user's real floor
pi, err := c.PrepareSwapIntent(ctx, req).Await(ctx)
if err != nil {
t.Fatalf("prepare: %v", err)
}
// The prepared QuotedOut reflects the lie (huge), but it is informational. The
// ENFORCEABLE floor rides MinAmountOut in the user's request, which the chain/D
// honor. Model the chain check: a real D export that delivers LESS than
// MinAmountOut would be rejected at match (D enforces minOut); here we assert the
// prepared calldata is a Phase-A intent (no premature credit), and that the lie
// did not change the user's MinAmountOut.
if string(pi.HookData) != string(EncodeIntentHookData()) {
t.Fatalf("stale quote produced a settlement, not an intent")
}
// Model: D matched only 980_000 (below the 990_000 floor) -> a correct D would
// NOT export (slippage). So no D->C object exists -> importSettlement of any ref
// reverts on-chain (object missing). Prove a settlement attempt with no object
// credits nothing.
owner := req.Recipient
before := ledger.Balance(owner, req.AssetIn)
missingKey := DeriveUTXOID(ID{0xBA, 0xD0}, 0)
_, serr := ledger.applySettlementCalldata(settlementCalldataFor(missingKey, 980_000), owner, req.AssetIn)
if serr != errLedgerNoSettlement {
t.Fatalf("stale settlement did not revert on missing object: %v", serr)
}
if after := ledger.Balance(owner, req.AssetIn); after != before {
t.Fatalf("stale quote moved balance: before=%d after=%d", before, after)
}
}
// TestZAP_DuplicateSettlement_ReplayRejected: a D->C object is consumed exactly
// once; replaying the same settlement is rejected.
func TestZAP_DuplicateSettlement_ReplayRejected(t *testing.T) {
ledger := newAtomicLedger()
owner := Account{0x11, 0x22, 0x33}
asset := ID{0x0A}
exportTx := ID{0xEE}
key := DeriveUTXOID(exportTx, 0)
ledger.PutExport(key, atomicObject{owner: owner, asset: asset, amount: 750})
// First settlement succeeds.
got, err := ledger.applySettlementCalldata(settlementCalldataFor(key, 750), owner, asset)
if err != nil || got != 750 {
t.Fatalf("first settle: got=%d err=%v", got, err)
}
// Replay the SAME settlement calldata -> rejected (one-time).
_, err = ledger.applySettlementCalldata(settlementCalldataFor(key, 750), owner, asset)
if err != errLedgerReplay && err != errLedgerNoSettlement {
// After consumption the object is removed; a replay sees either the replay
// guard or a missing object. Both are correct rejections.
t.Fatalf("replay not rejected: %v", err)
}
// Balance credited exactly once.
if bal := ledger.Balance(owner, asset); bal != 750 {
t.Fatalf("balance after replay = %d, want 750 (credited once)", bal)
}
}
+219
View File
@@ -0,0 +1,219 @@
// 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}
txID := ID{0x77}
callIndex := uint32(3)
account := Account{0xAB, 0xCD}
assetIn := ID{0x01}
amountIn := uint64(1_000_000)
marketID := ID{0x4d}
got := DeriveIntentID(networkID, cChainID, dChainID, txID, callIndex, account, assetIn, amountIn, marketID)
// Recompute independently (the canonical algorithm).
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(txID[:])
binary.BigEndian.PutUint32(u4[:], callIndex)
h.Write(u4[:])
h.Write(account[:])
h.Write(assetIn[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], amountIn)
h.Write(u8[:])
h.Write(marketID[:])
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). And confirms Phase A is the explicit "DI01" tag.
func TestParity_SettlementHookData(t *testing.T) {
outputID := ID{0xfe, 0xed}
outputID[31] = 0x01
amount := uint64(424242)
hook := EncodeSettlementHookData(outputID, amount)
want := make([]byte, 0, 4+64)
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[:]...)
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)
}
intent := EncodeIntentHookData()
if !bytes.Equal(intent, []byte{'D', 'I', '0', '1'}) {
t.Fatalf("intent hookData = %x, want DI01", intent)
}
}
// 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))
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{}, ID{}, 0,
Account{}, ID{}, 0, ID{},
)
// Frozen expected value: SHA-256 of domain || u32(1) || 32x0 || 32x0 || 32x0 ||
// u32(0) || 20x0 || 32x0 || u64(0) || 32x0. 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(z32) // txID
binary.BigEndian.PutUint32(u4[:], 0)
h.Write(u4[:]) // callIndex
h.Write(make([]byte, 20)) // account
h.Write(z32) // assetIn
h.Write(make([]byte, 8)) // amountIn
h.Write(z32) // marketID
var out ID
copy(out[:], h.Sum(nil))
return out
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"fmt"
)
// pipeline.go is the high-level, declarative end-to-end flow that wires the five
// operations into the best practical trade path with promise pipelining. It is
// the convenience the spec's example expresses:
//
// quote = dex.quote(params)
// intent = dex.prepareSwapIntent(params, quote)
// dWatch = dex.notifyIntent(intent)
// settle = dWatch.onCommitted().importSettlement()
//
// Every stage dispatches the instant its dependency resolves (client-side
// pipelining via thenAsync), so the user-visible latency collapses toward a single
// dependency chain while the consensus path stays native atomic.
//
// SwapFlow returns the FINAL artifact: the on-chain settlement calldata/tx the
// chain consumes against the real D->C object. It is bytes + a pointer — never a
// credit. The caller (wallet) signs the prepared intent calldata, waits for the D
// commit, then signs/submits the settlement calldata. The chain credits.
// FlowStages is the set of promises a SwapFlow produces, exposed so a caller can
// observe intermediate artifacts (e.g. show the user the estimate, the intent id,
// the watch) without re-issuing calls. Each is a value/pointer, never authority.
type FlowStages struct {
Quote *Promise[QuoteResult]
Intent *Promise[PreparedIntent]
Watch *Promise[IntentWatch]
// Committed resolves to the D->C export POINTER when D commits.
Committed *Promise[DExportRef]
// Settle resolves to the on-chain settlement calldata/tx (bytes/pointer).
Settle *Promise[SettlementSubmitResult]
}
// SwapFlow builds and dispatches the full pipelined swap orchestration for a
// request. It does NOT submit the user's C tx (the user signs the prepared intent
// calldata themselves — only the user funds their own swap); it produces the
// artifacts and, after the user's tx + D commit, the settlement calldata.
//
// Pipelining: PrepareSwapIntent overlaps with the Quote (the quote feeds only the
// estimate, not the calldata); NotifyIntent overlaps after the user signals the
// intent is funded; OnCommitted streams the D result; ImportSettlement fires the
// instant the export commits.
//
// NOTE: the user MUST sign+send the PreparedIntent.Calldata to create the funded
// C->D object BEFORE NotifyIntent can find anything to scan. SwapFlow assumes the
// caller drives that step between Intent and Notify (a wallet signs; this layer has
// no signing key and no custody). Callers that want a fully-managed keeper flow
// pass a sign-and-send callback to SwapFlowWithSender below.
func (s *clientSession) SwapFlow(ctx context.Context, req SwapIntentRequest) FlowStages {
quote := s.Quote(ctx, QuoteRequest{MarketID: req.MarketID, AmountIn: req.AmountIn, ZeroForOne: zeroForOneFor(req)})
intent := s.PrepareSwapIntent(ctx, req)
watch := s.NotifyIntent(ctx, intent)
committed := thenAsync(ctx, watch, func(ctx context.Context, w IntentWatch) (DExportRef, error) {
return w.OnCommitted(ctx).Await(ctx)
})
settle := s.ImportSettlement(ctx, committed)
return FlowStages{Quote: quote, Intent: intent, Watch: watch, Committed: committed, Settle: settle}
}
// SwapFlowWithSender is SwapFlow with a caller-supplied sign-and-send step
// inserted between PrepareSwapIntent and NotifyIntent. `send` receives the
// prepared calldata + target, signs and submits the C tx (the caller's wallet /
// non-custodial keeper), and returns when the C tx is included (so the C->D object
// exists for D to scan). `send` is the ONLY component with a signing key; this
// layer never holds one.
//
// If send returns an error the flow short-circuits: NotifyIntent is not issued
// (there is no funded object to scan), and every downstream promise resolves with
// that error.
func (s *clientSession) SwapFlowWithSender(
ctx context.Context,
req SwapIntentRequest,
send func(ctx context.Context, to Account, calldata []byte) error,
) FlowStages {
quote := s.Quote(ctx, QuoteRequest{MarketID: req.MarketID, AmountIn: req.AmountIn, ZeroForOne: zeroForOneFor(req)})
intent := s.PrepareSwapIntent(ctx, req)
// After the intent is prepared, sign+send the user's C tx, THEN notify D. This
// is the one stage that requires authority OUTSIDE this layer (a signing key);
// it is injected, never held here.
funded := thenAsync(ctx, intent, func(ctx context.Context, pi PreparedIntent) (PreparedIntent, error) {
if send == nil {
return PreparedIntent{}, fmt.Errorf("dexsession: SwapFlowWithSender requires a sender")
}
if err := send(ctx, pi.To, pi.Calldata); err != nil {
return PreparedIntent{}, fmt.Errorf("dexsession: fund C->D intent: %w", err)
}
return pi, nil
})
watch := s.NotifyIntent(ctx, funded)
committed := thenAsync(ctx, watch, func(ctx context.Context, w IntentWatch) (DExportRef, error) {
return w.OnCommitted(ctx).Await(ctx)
})
settle := s.ImportSettlement(ctx, committed)
return FlowStages{Quote: quote, Intent: intent, Watch: watch, Committed: committed, Settle: settle}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"sync"
)
// promise.go is Cap'n-Proto-style promise pipelining over the ZAP Node.Call
// primitive. Two complementary mechanisms:
//
// 1. Promise[T] — a CLIENT future. A call returns immediately with a Promise;
// dependent calls take the Promise and dispatch the instant it resolves, so
// network latencies OVERLAP. The user writes the chain declaratively:
//
// quote := dex.Quote(ctx, req)
// intent := dex.PrepareSwapIntent(ctx, req, quote) // waits on quote internally
// watch := dex.NotifyIntent(ctx, intent) // waits on intent internally
// settle := dex.ImportSettlement(ctx, watch.OnCommitted())
// res, err := settle.Await(ctx)
//
// 2. Pipeline batch (see pipeline.go) — a SERVER-side mechanism: a single frame
// carries an ordered list of dependent calls with placeholder refs the server
// substitutes from prior results, collapsing a whole chain into ONE round trip.
//
// Neither mechanism touches value: a Promise resolves to a quote estimate, a
// PreparedIntent (calldata), an IntentWatch, or a SettlementSubmitResult (calldata
// / tx hash). The consensus path stays native atomic; pipelining only hides
// latency.
// Promise is a future for an async DexSession call result of type T. It is safe
// for one producer (the dispatching goroutine) and many consumers (Await is
// idempotent and concurrent-safe).
type Promise[T any] struct {
mu sync.Mutex
done chan struct{}
val T
err error
ready bool
}
// newPromise creates an unresolved Promise.
func newPromise[T any]() *Promise[T] {
return &Promise[T]{done: make(chan struct{})}
}
// resolved returns an already-resolved Promise (used to lift a concrete value
// into the pipeline, e.g. a request the caller already holds).
func resolved[T any](v T, err error) *Promise[T] {
p := newPromise[T]()
p.fulfil(v, err)
return p
}
// fulfil resolves the promise exactly once. Subsequent calls are no-ops (the first
// result wins), so a racing double-resolve cannot corrupt the value.
func (p *Promise[T]) fulfil(v T, err error) {
p.mu.Lock()
if p.ready {
p.mu.Unlock()
return
}
p.val, p.err, p.ready = v, err, true
close(p.done)
p.mu.Unlock()
}
// Await blocks until the promise resolves or ctx is cancelled. On cancellation it
// returns the context error WITHOUT resolving the promise (a later resolution
// still delivers to other awaiters).
func (p *Promise[T]) Await(ctx context.Context) (T, error) {
select {
case <-p.done:
p.mu.Lock()
v, err := p.val, p.err
p.mu.Unlock()
return v, err
case <-ctx.Done():
var zero T
return zero, ctx.Err()
}
}
// thenAsync chains a dependent call: it spawns a goroutine that awaits this
// promise, then runs fn (which issues the next Call) and resolves the returned
// promise. This is the client-side pipelining primitive — the dependent call's
// goroutine is queued immediately and fires the moment the dependency resolves, so
// the caller never blocks between stages and the network round trips overlap.
//
// fn receives the resolved value of THIS promise and the context. An error in this
// promise short-circuits: fn is not called and the error propagates.
func thenAsync[T, U any](ctx context.Context, p *Promise[T], fn func(context.Context, T) (U, error)) *Promise[U] {
out := newPromise[U]()
go func() {
v, err := p.Await(ctx)
if err != nil {
var zero U
out.fulfil(zero, err)
return
}
u, ferr := fn(ctx, v)
out.fulfil(u, ferr)
}()
return out
}
+249
View File
@@ -0,0 +1,249 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"testing"
"time"
)
// red_test.go is the critical adversarial proof: NO sequence of ZAP responses,
// absent a real atomic object, credits or mints any C balance. The adversary
// controls the ENTIRE ZAP layer (maliciousVenue lies about every response). The
// AtomicLedger is the chain — the sole judge of value. The test drives the full
// client pipeline against the adversary and proves money moves IFF a real object
// exists, regardless of ZAP.
// TestRED_ZAP_ResponseAloneCannotMoveMoney is THE test. Three acts:
//
// Act 1 — Empty chain, malicious ZAP. Run the full pipeline; the adversary claims
// COMMITTED with fabricated refs and returns inflated settlement calldata.
// Apply that calldata to the EMPTY ledger as the attacker AND as the
// victim. Assert: zero balance moves (every settlement reverts — no object).
//
// Act 2 — A real object for the VICTIM exists. The adversary tries to redirect it
// to the attacker (fabricated ref/recipient/asset/amount). Assert: the
// attacker is never credited; only the victim, and only the true amount,
// and only when the victim submits the correct claim.
//
// Act 3 — Exhaustive fuzz: thousands of adversarial response permutations
// (arbitrary amounts, keys, recipients, assets) against a ledger holding
// one real object. Assert: the ONLY successful credit is the exact
// (owner, asset, amount) of the real object, consumed exactly once. The
// sum of all credits never exceeds the real object's value.
func TestRED_ZAP_ResponseAloneCannotMoveMoney(t *testing.T) {
ctx := context.Background()
// ---- Act 1: empty chain, fully malicious ZAP -------------------------------
{
ledger := newAtomicLedger()
attacker := Account{0x99, 0x88, 0x77}
victim := Account{0x11, 0x22, 0x33}
asset := ID{0x0A}
adversary := &maliciousVenue{
fakeAmount: 1_000_000_000, // claim a billion units
fakeRecipient: attacker,
fakeAsset: asset,
fakeKey: DeriveUTXOID(ID{0xBA, 0xD0}, 0), // points at nothing
}
c := NewSession(SessionConfig{Conn: venueLoopback(adversary), PeerID: "evil", Grant: AuthorityPublic, Markets: testMarkets})
// Drive the WHOLE pipeline. The adversary claims instant commit.
req := testReq()
req.Account = attacker
req.Recipient = attacker
stages := c.SwapFlow(ctx, req)
// The adversary makes OnCommitted resolve (it lies PhaseCommitted). The
// settlement promise resolves to (lying) calldata.
res, err := stages.Settle.Await(ctx)
if err != nil {
t.Fatalf("act1: adversary settle promise errored unexpectedly: %v", err)
}
// Now the CHAIN judges. Apply the adversary's calldata every way an attacker
// could try: as the attacker (caller=attacker), as the victim, with the
// adversary's claimed asset, etc. EVERY path must move zero (the ledger is
// empty — no object exists).
for _, caller := range []Account{attacker, victim} {
for _, outAsset := range []ID{asset, {0x0B}, {}} {
_, serr := ledger.applySettlementCalldata(res, caller, outAsset)
if serr == nil {
t.Fatalf("act1: a settlement SUCCEEDED on an empty ledger (caller=%x asset=%x)", caller[:3], outAsset[:2])
}
}
}
// Total balances across the universe are zero.
if total := ledger.totalCredited(); total != 0 {
t.Fatalf("act1: %d units credited from ZAP responses alone (want 0)", total)
}
}
// ---- Act 2: a real object exists for the victim; adversary tries to steal ---
{
ledger := newAtomicLedger()
attacker := Account{0x99, 0x88, 0x77}
victim := Account{0x11, 0x22, 0x33}
assetA := ID{0x0A}
assetB := ID{0x0B}
// REAL object: victim is owed 500 of asset A.
realTx := ID{0xEE}
realKey := DeriveUTXOID(realTx, 0)
ledger.PutExport(realKey, atomicObject{owner: victim, asset: assetA, amount: 500})
// The adversary points the attacker at the victim's real object (the WORST
// case: the object EXISTS), claiming the attacker's recipient/asset. fakeKey
// is the correctly-derived object key, so the object is FOUND on import and
// the only thing standing between the attacker and the funds is the on-chain
// owner-bind.
adversary := &maliciousVenue{
fakeAmount: 500, // the true amount
fakeRecipient: attacker,
fakeAsset: assetB,
fakeKey: realKey, // the real, correctly-derived object key
}
c := NewSession(SessionConfig{Conn: venueLoopback(adversary), PeerID: "evil", Grant: AuthorityPublic, Markets: testMarkets})
req := testReq()
req.Account = attacker
req.Recipient = attacker
res, err := c.SwapFlow(ctx, req).Settle.Await(ctx)
if err != nil {
t.Fatalf("act2: settle promise: %v", err)
}
// The adversary's calldata points at the REAL object (it exists) — so the
// import gets PAST step 1 (object found) and is stopped by the owner-bind.
if outID, _, ok := decodeSettlementCalldata(res.Calldata); !ok || outID != realKey {
t.Fatalf("act2: adversary calldata did not point at the real object key")
}
// The attacker submits the calldata (caller=attacker). The object's owner is
// the VICTIM, so the on-chain owner-bind rejects (owner != caller).
_, serr := ledger.applySettlementCalldata(res, attacker, assetA)
if serr != errLedgerOwner {
t.Fatalf("act2: attacker stole the victim's object: %v", serr)
}
// The attacker tries with the adversary's claimed asset B too — now the
// asset-bind (B != recorded A) fires first; still rejected, still no credit.
_, serr = ledger.applySettlementCalldata(res, attacker, assetB)
if serr != errLedgerOwner && serr != errLedgerAsset {
t.Fatalf("act2: attacker stole with asset substitution: %v", serr)
}
if ledger.Balance(attacker, assetA) != 0 || ledger.Balance(attacker, assetB) != 0 {
t.Fatalf("act2: attacker was credited")
}
// Only the VICTIM, submitting the correct claim (caller=victim, derived
// asset=A, amount=500), is credited — and exactly once.
got, err := ledger.applySettlementCalldata(settlementCalldataFor(realKey, 500), victim, assetA)
if err != nil || got != 500 {
t.Fatalf("act2: victim's honest claim failed: got=%d err=%v", got, err)
}
if ledger.Balance(victim, assetA) != 500 {
t.Fatalf("act2: victim balance = %d, want 500", ledger.Balance(victim, assetA))
}
if total := ledger.totalCredited(); total != 500 {
t.Fatalf("act2: total credited = %d, want exactly 500 (the real object)", total)
}
}
// ---- Act 3: exhaustive adversarial permutation fuzz ------------------------
{
ledger := newAtomicLedger()
victim := Account{0x11, 0x22, 0x33}
assetA := ID{0x0A}
realTx := ID{0xEE}
realKey := DeriveUTXOID(realTx, 0)
realAmount := uint64(500)
ledger.PutExport(realKey, atomicObject{owner: victim, asset: assetA, amount: realAmount})
callers := []Account{victim, {0x99}, {0xAB, 0xCD}, {}}
assets := []ID{assetA, {0x0B}, {}, {0xFF}}
amounts := []uint64{0, 1, 499, 500, 501, 1_000_000, 1 << 62}
keys := []ID{realKey, DeriveUTXOID(realTx, 1), DeriveUTXOID(ID{0xBA}, 0), {}, {0xDE, 0xAD}}
var successCount int
for _, caller := range callers {
for _, asset := range assets {
for _, amount := range amounts {
for _, key := range keys {
res := settlementCalldataFor(key, amount)
credited, err := ledger.applySettlementCalldata(res, caller, asset)
if err == nil {
successCount++
// The ONLY legitimate success: the exact real object,
// claimed by its true owner, true asset, true amount.
if caller != victim || asset != assetA || amount != realAmount || key != realKey {
t.Fatalf("act3: ILLEGITIMATE credit succeeded: caller=%x asset=%x amount=%d key=%x credited=%d",
caller[:1], asset[:1], amount, key[:2], credited)
}
if credited != realAmount {
t.Fatalf("act3: legitimate credit was %d, want %d", credited, realAmount)
}
}
}
}
}
}
// Exactly one legitimate success across the whole permutation space (the
// object is one-time; after it is consumed, every later attempt fails).
if successCount != 1 {
t.Fatalf("act3: %d settlements succeeded, want exactly 1 (one-time object)", successCount)
}
// And the total moved is bounded by the real object's value.
if total := ledger.totalCredited(); total != realAmount {
t.Fatalf("act3: total credited = %d, want %d (no mint beyond the real object)", total, realAmount)
}
}
}
// totalCredited sums all C balances across the ledger — the universe's total
// credited value. Used to assert ZAP responses mint nothing.
func (l *AtomicLedger) totalCredited() uint64 {
l.mu.Lock()
defer l.mu.Unlock()
var total uint64
for _, v := range l.balance {
total += v
}
return total
}
// TestRED_ZAP_StreamedFakeCommitsNeverSettle drives the streaming watch against an
// adversary that floods PhaseCommitted with rotating fake refs. Each fake ref's
// importSettlement reverts on-chain; no flood of fake commits ever moves value.
func TestRED_ZAP_StreamedFakeCommitsNeverSettle(t *testing.T) {
ledger := newAtomicLedger()
victim := Account{0x11, 0x22, 0x33}
c := NewSession(SessionConfig{
Conn: venueLoopback(&maliciousVenue{fakeAmount: 1 << 40, fakeKey: DeriveUTXOID(ID{0xBA}, 0)}),
PeerID: "evil",
Grant: AuthorityPublic,
Markets: testMarkets,
})
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req := testReq()
req.Account = victim
req.Recipient = victim
// The adversary resolves OnCommitted instantly (it lies). Settle yields fake
// calldata. Apply it 1000 times every which way — zero moves.
res, err := c.SwapFlow(ctx, req).Settle.Await(ctx)
if err != nil {
t.Fatalf("settle promise: %v", err)
}
for i := 0; i < 1000; i++ {
if _, serr := ledger.applySettlementCalldata(res, victim, req.AssetIn); serr == nil {
t.Fatalf("a flooded fake commit settled on an empty ledger at i=%d", i)
}
}
if total := ledger.totalCredited(); total != 0 {
t.Fatalf("flooded fake commits credited %d (want 0)", total)
}
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
zaplib "github.com/luxfi/zap"
)
// server.go is the FullServiceNode side: it registers the DexSession ZAP handlers
// on a zap.Node and backs them against a Venue (the D-Chain CLOB + a C/D state
// reader). Bootstrap returns a restricted client DexSession for a peer.
//
// THE SERVER NEVER CREDITS. It reads the D book (quote), reads C/D state
// (getState), tells D to scan an intent and reports the resulting export POINTER
// (notify + watch), and reports a D->C export object's coordinates (the ref). It
// has no path to write a C balance — that is the chain's, via consuming a real
// atomic object. A compromised server can lie about every response; the value
// boundary (precompile / dexvm) rejects any lie because it reads the real object.
// Venue is the read + trigger backend a DexSession server delegates to. It is the
// D-Chain CLOB venue and a C/D state reader. CRITICALLY, it has no credit/settle
// method: the orchestration server cannot move value because its backend cannot
// either. (A real deployment backs Venue with the lux/dex ZAP CLOB client over the
// existing clob_* transport for quotes, plus a chain-state reader for exports.)
type Venue interface {
// Quote returns an ESTIMATE from the resting book. Read-only.
Quote(ctx context.Context, req QuoteRequest) (QuoteResult, error)
// State returns a read-only C/D DEX observation.
State(ctx context.Context, req StateRequest) (StateResult, error)
// NotifyIntent asks the venue to begin scanning shared memory for the C->D
// object named by the intent and submit a D tx that imports+matches it under
// dexvm consensus. It returns immediately with the intent id to watch; it does
// NOT block on a D block and does NOT credit anything. A no-op-but-record
// implementation is valid (the keeper picks up the on-chain IntentSubmitted
// event); the watch then observes the export when D commits.
NotifyIntent(ctx context.Context, intent PreparedIntent) (IntentWatchRef, error)
// WatchStatus reports the current phase of a notified intent and, when D has
// committed an export, the POINTER to it. It reads D/shared-memory state; it
// never fabricates a credit. Returning PhaseCommitted with a ref is a CLAIM the
// chain re-verifies on import.
WatchStatus(ctx context.Context, intentID ID) (IntentStatus, error)
// ResolveExport maps a DExportRef to the on-chain settlement calldata that
// points at the real D->C object. It does NOT submit and does NOT credit; it
// returns the bytes a wallet/keeper signs. The asset+recipient are NOT taken
// from the ref (the chain derives the asset from swap direction and binds the
// recipient to the caller); the calldata carries only outputID + amount, and
// even those are bound on-chain. amount is the venue's best knowledge of the
// export amount, used to populate the claim; a wrong amount makes the on-chain
// bind revert (it must equal the recorded object's amount).
ResolveExport(ctx context.Context, ref DExportRef) (SettlementSubmitResult, error)
}
// FullServiceNode wraps a zap.Node and a Venue, exposing Bootstrap. It is the
// server-side object the spec's `FullServiceNode.bootstrap() -> DexSession`
// describes.
type FullServiceNode struct {
node *zaplib.Node
venue Venue
markets func(marketID ID) (PoolKeyArgs, bool)
}
// FullServiceConfig configures the server node.
type FullServiceConfig struct {
Node *zaplib.Node
Venue Venue
// Markets maps a marketID to its V4 PoolKey args, shared with bootstrapped
// client sessions so prepareSwapIntent can build calldata. Required for swap
// preparation; nil => prepare fail-closes for every market.
Markets func(marketID ID) (PoolKeyArgs, bool)
}
// NewFullServiceNode constructs the server and registers its ZAP handlers on the
// node. Call node.Start() separately (the caller owns the node lifecycle).
func NewFullServiceNode(cfg FullServiceConfig) *FullServiceNode {
fsn := &FullServiceNode{node: cfg.Node, venue: cfg.Venue, markets: cfg.Markets}
fsn.registerHandlers()
return fsn
}
// Bootstrap returns a RESTRICTED client DexSession for a peer, granted `grant`
// authority. This is the capability bootstrap: the caller receives a session that
// can derive only capabilities within `grant`, and NONE of those can move money
// (see capability.go). A public surface passes AuthorityPublic; a price widget
// passes AuthorityReadOnly; an operator console adds AuthAdmin.
//
// peerID is the ZAP node id of THIS server (the session Calls it). In-process and
// remote callers both use the node's correlated Call.
func (fsn *FullServiceNode) Bootstrap(peerID string, grant Authority) DexSession {
return NewSession(SessionConfig{
Conn: fsn.node,
PeerID: peerID,
Grant: grant,
Markets: fsn.markets,
})
}
// registerHandlers wires the five read/trigger operations onto the node. Each
// handler decodes a typed request, delegates to the Venue (read/trigger only), and
// encodes a typed response. There is no handler that writes a balance.
func (fsn *FullServiceNode) registerHandlers() {
fsn.node.Handle(MsgQuote, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
res, err := fsn.venue.Quote(ctx, readQuoteRequest(msg))
if err != nil {
// Fail-closed: an unliquid/unknown market returns a non-liquid estimate,
// never an error that could be mistaken for a tradeable quote.
return buildQuoteResult(QuoteResult{MarketID: readQuoteRequest(msg).MarketID, Liquid: false})
}
return buildQuoteResult(res)
})
fsn.node.Handle(MsgGetState, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
res, err := fsn.venue.State(ctx, readStateRequest(msg))
if err != nil {
return buildStateResult(StateResult{Kind: readStateRequest(msg).Kind, Exists: false})
}
return buildStateResult(res)
})
fsn.node.Handle(MsgNotify, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
ref, err := fsn.venue.NotifyIntent(ctx, readPreparedIntent(msg))
if err != nil {
// A notify that the venue cannot accept still returns a watch ref for the
// intent id; the watch then reports PhaseRejected. We never error the
// notify into a value claim.
pi := readPreparedIntent(msg)
return buildIntentWatchRef(IntentWatchRef{IntentID: pi.IntentID})
}
return buildIntentWatchRef(ref)
})
fsn.node.Handle(MsgWatchPoll, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
intentID := readWatchPollRequest(msg)
st, err := fsn.venue.WatchStatus(ctx, intentID)
if err != nil {
return buildIntentStatus(IntentStatus{IntentID: intentID, Phase: PhaseUnknown, Reason: "venue unavailable"})
}
st.IntentID = intentID
return buildIntentStatus(st)
})
fsn.node.Handle(MsgImport, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
ref := readDExportRef(msg)
res, err := fsn.venue.ResolveExport(ctx, ref)
if err != nil {
// Fail-closed: if the venue cannot resolve the export, return a result
// whose calldata is empty (the caller cannot submit a no-op as a credit;
// the chain would reject empty/invalid calldata). Never fabricate a credit.
return buildSettlementResult(SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: ref.ObjectKey()})
}
// Defence in depth: force the result's ObjectKey to the ref-derived key so the
// venue cannot point the caller at a different object than the ref names.
res.ObjectKey = ref.ObjectKey()
res.To = addr9999()
return buildSettlementResult(res)
})
// Route handlers — registered ONLY when the venue ALSO implements RouteVenue
// (composition, not interface-expansion). A base Venue without route support
// simply has no route frames; a route notify/poll to such a node is a dead peer
// (fail-closed). The route handlers report progress + the ONE final POINTER; they
// never credit (RouteVenue, like Venue, has no settle method).
if rv, ok := fsn.venue.(RouteVenue); ok {
fsn.node.Handle(MsgRoutePrepare, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
req := readRouteRequest(msg)
ref, err := rv.NotifyRoute(req)
if err != nil {
// The client builds calldata locally; NotifyRoute only triggers the walk.
// On failure, return the deterministic intent id so the watch can report
// the rejection — never an error mistaken for a value claim.
first, _ := req.firstMarket()
id := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, first)
return buildIntentWatchRef(IntentWatchRef{IntentID: id})
}
return buildIntentWatchRef(ref)
})
fsn.node.Handle(MsgRouteStatus, func(ctx context.Context, _ string, msg *zaplib.Message) (*zaplib.Message, error) {
intentID := readRouteWatchPoll(msg)
st, err := rv.RouteStatus(intentID)
if err != nil {
return buildRouteStatus(RouteStatus{IntentID: intentID, Phase: RouteUnknown, Reason: "venue unavailable"})
}
st.IntentID = intentID
return buildRouteStatus(st)
})
}
}
+424
View File
@@ -0,0 +1,424 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"fmt"
"sync"
zaplib "github.com/luxfi/zap"
)
// session.go is the CLIENT side of the DexSession capability transport. A
// FullServiceNode.Bootstrap (server.go) returns a restricted DexSession; from it a
// caller derives capabilities and issues the five operations. Every operation is a
// typed ZAP Node.Call returning a Promise — the consensus path stays native
// atomic; this layer only orchestrates and pipelines.
//
// PER-METHOD may / MUST-NOT (enforced here + by the server + by the value
// boundary):
//
// Quote / GetState — READ. May estimate / observe. MUST NOT move value (no
// value-moving code path exists; returns estimates).
// PrepareSwapIntent — May validate params, estimate, derive the intent id, build
// 0x9999 calldata + hookData. MUST NOT reserve funds off-chain,
// claim a fill final, or return an enforceable amountOut.
// NotifyIntent — May tell D to scan/import the C->D object the user's signed
// tx created, returning a watch. MUST NOT make a D match valid
// without a D block, or return a live fill C credits.
// ImportSettlement — May build a C tx / calldata pointing at a DExportRef. MUST
// NOT credit C via RPC or substitute recipient/asset/amount.
// caller is the minimal slice of the ZAP node the client session needs: a
// correlated request/response Call to the bootstrapped peer. *zaplib.Node
// satisfies it; tests supply an in-memory loopback so the suite needs no socket.
type caller interface {
Call(ctx context.Context, peerID string, msg *zaplib.Message) (*zaplib.Message, error)
}
// DexSession is the RESTRICTED capability a FullServiceNode.Bootstrap returns. It
// exposes ONLY the orchestration surface — quote/getState/prepareSwapIntent/
// notifyIntent/importSettlement — and derives narrowly-scoped capabilities. There
// is no creditBalance / settleFill / overrideMatch / adminWithdraw: the surface is
// value-free by construction (see capability.go).
type DexSession interface {
// Capability derivation (Cap'n-Proto: you get authority by ASKING the session;
// it only hands out caps within its bootstrap grant).
QuoteCap() (QuoteCap, error)
IntentCap() (IntentCap, error)
WatchCap() (WatchCap, error)
SettlementCap() (SettlementCap, error)
AdminCap() (AdminCap, error)
// Operations. Each returns a Promise for pipelining; Await for the value.
Quote(ctx context.Context, req QuoteRequest) *Promise[QuoteResult]
GetState(ctx context.Context, req StateRequest) *Promise[StateResult]
PrepareSwapIntent(ctx context.Context, req SwapIntentRequest) *Promise[PreparedIntent]
NotifyIntent(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[IntentWatch]
ImportSettlement(ctx context.Context, ref *Promise[DExportRef]) *Promise[SettlementSubmitResult]
// Grant reports the authority this session was bootstrapped with.
Grant() Authority
}
// clientSession is the concrete DexSession. It holds the bootstrap grant, the
// peer to Call, and the unforgeable-token grant table for derived capabilities.
type clientSession struct {
conn caller
peerID string
grant Authority
// market resolves a 32-byte marketID to the V4 PoolKey args the swap calldata
// needs. Without a mapping prepareSwapIntent cannot build calldata (fail-closed)
// — the session is configured with the markets it serves at bootstrap.
market func(marketID ID) (PoolKeyArgs, bool)
mu sync.Mutex
grants map[token]*grant // issued capability tokens -> authority
}
var _ DexSession = (*clientSession)(nil)
// SessionConfig configures a client session (normally produced by Bootstrap, but
// exported so a node can construct one directly against a known peer).
type SessionConfig struct {
Conn caller
PeerID string
Grant Authority
Markets func(marketID ID) (PoolKeyArgs, bool)
}
// NewSession builds a client session. Bootstrap (server.go) is the canonical
// entry; this is the explicit constructor for a node that already has a peer.
func NewSession(cfg SessionConfig) *clientSession {
m := cfg.Markets
if m == nil {
// No market mapping => prepareSwapIntent fail-closes (cannot build calldata
// for an unknown market), which is the correct secure default.
m = func(ID) (PoolKeyArgs, bool) { return PoolKeyArgs{}, false }
}
return &clientSession{
conn: cfg.Conn,
peerID: cfg.PeerID,
grant: cfg.Grant,
market: m,
grants: make(map[token]*grant),
}
}
func (s *clientSession) Grant() Authority { return s.grant }
// issue mints a capability core for `bits`, but ONLY for bits the session was
// bootstrapped with. Asking for authority outside the grant fails — a session
// cannot widen its own authority, and therefore neither can any capability derived
// from it (the Cap'n-Proto confinement property).
func (s *clientSession) issue(bits Authority) (*capCore, error) {
if s.grant&bits != bits {
return nil, ErrNoAuthority
}
t := newToken()
if t == (token{}) {
// Zero token => RNG failure; refuse rather than issue an unbound capability.
return nil, ErrNoAuthority
}
s.mu.Lock()
s.grants[t] = &grant{bits: bits}
s.mu.Unlock()
return &capCore{session: s, tok: t, bits: bits}, nil
}
// checkGrant verifies a token still permits `need` at this session. Called by
// capCore.authorize on every operation so a revocation is immediate.
func (s *clientSession) checkGrant(t token, need Authority) error {
s.mu.Lock()
g, ok := s.grants[t]
s.mu.Unlock()
if !ok {
return ErrNoAuthority
}
if g.revoked {
return ErrRevoked
}
if g.bits&need != need {
return ErrNoAuthority
}
return nil
}
// Revoke invalidates a previously issued capability token. After revoke, any
// operation through that capability returns ErrRevoked (fail-closed).
func (s *clientSession) Revoke(t token) {
s.mu.Lock()
if g, ok := s.grants[t]; ok {
g.revoked = true
}
s.mu.Unlock()
}
// --- Capability derivation -----------------------------------------------------
func (s *clientSession) QuoteCap() (QuoteCap, error) {
c, err := s.issue(AuthQuote)
return QuoteCap{core: c}, err
}
func (s *clientSession) IntentCap() (IntentCap, error) {
c, err := s.issue(AuthIntent)
return IntentCap{core: c}, err
}
func (s *clientSession) WatchCap() (WatchCap, error) {
c, err := s.issue(AuthWatch)
return WatchCap{core: c}, err
}
func (s *clientSession) SettlementCap() (SettlementCap, error) {
c, err := s.issue(AuthSettlement)
return SettlementCap{core: c}, err
}
func (s *clientSession) AdminCap() (AdminCap, error) {
c, err := s.issue(AuthAdmin)
return AdminCap{core: c}, err
}
// --- Operations ----------------------------------------------------------------
// Quote issues a read-only book estimate. Gated by AuthQuote. The result is an
// ESTIMATE; nothing the chain trusts.
func (s *clientSession) Quote(ctx context.Context, req QuoteRequest) *Promise[QuoteResult] {
p := newPromise[QuoteResult]()
if err := s.requireGrant(AuthQuote); err != nil {
p.fulfil(QuoteResult{}, err)
return p
}
go func() {
reqMsg, err := buildQuoteRequest(req)
if err != nil {
p.fulfil(QuoteResult{}, err)
return
}
resp, err := s.conn.Call(ctx, s.peerID, reqMsg)
if err != nil {
p.fulfil(QuoteResult{}, err)
return
}
defer resp.Release()
p.fulfil(readQuoteResult(resp), nil)
}()
return p
}
// GetState issues a read-only C/D DEX state observation. Gated by AuthQuote.
func (s *clientSession) GetState(ctx context.Context, req StateRequest) *Promise[StateResult] {
p := newPromise[StateResult]()
if err := s.requireGrant(AuthQuote); err != nil {
p.fulfil(StateResult{}, err)
return p
}
go func() {
reqMsg, err := buildStateRequest(req)
if err != nil {
p.fulfil(StateResult{}, err)
return
}
resp, err := s.conn.Call(ctx, s.peerID, reqMsg)
if err != nil {
p.fulfil(StateResult{}, err)
return
}
defer resp.Release()
p.fulfil(readStateResult(resp), nil)
}()
return p
}
// PrepareSwapIntent builds the 0x9999 CALLDATA the user signs to create the funded
// C->D intent. Gated by AuthIntent.
//
// MUST NOT: reserve funds (it cannot — no StateDB), claim a fill (it returns no
// fill), return an enforceable amountOut (QuotedOut is flagged estimate-only and
// the enforceable floor rides MinAmountOut baked into the calldata).
//
// The intent id is derived HERE identically to the on-chain SubmitSwapIntent, so
// the user's signed tx mints exactly this id and the watch can locate the D
// result. The derivation is a pure function of the request — no authority.
func (s *clientSession) PrepareSwapIntent(ctx context.Context, req SwapIntentRequest) *Promise[PreparedIntent] {
p := newPromise[PreparedIntent]()
if err := s.requireGrant(AuthIntent); err != nil {
p.fulfil(PreparedIntent{}, err)
return p
}
go func() {
p.fulfil(s.prepareLocal(ctx, req))
}()
return p
}
// prepareLocal is the pure build of a PreparedIntent. It optionally consults the
// server for a quote (estimate only); the calldata + intent id are derived
// locally so a malicious server cannot inject a forged intent id or calldata that
// the user would sign. (If the server were trusted to return the calldata, a
// malicious server could swap the recipient — so we BUILD it client-side from the
// user's own request.)
func (s *clientSession) prepareLocal(ctx context.Context, req SwapIntentRequest) (PreparedIntent, error) {
if req.AmountIn == 0 {
return PreparedIntent{}, fmt.Errorf("dexsession: prepareSwapIntent: zero amountIn")
}
pk, ok := s.market(req.MarketID)
if !ok {
// Fail-closed: cannot build swap calldata for a market we have no PoolKey
// for. A user must not be handed calldata for an unknown market.
return PreparedIntent{}, fmt.Errorf("dexsession: prepareSwapIntent: unknown market %x", req.MarketID[:8])
}
// Estimate the output (informational). A failed/absent quote does NOT fail the
// prepare — the calldata is enforceable via MinAmountOut regardless.
var quoted uint64
if qr, err := s.Quote(ctx, QuoteRequest{MarketID: req.MarketID, AmountIn: req.AmountIn, ZeroForOne: zeroForOneFor(req)}).Await(ctx); err == nil {
quoted = qr.AmountOut
}
// Derive the intent id identically to the on-chain SubmitSwapIntent.
intentID := DeriveIntentID(
req.NetworkID, req.CChainID, req.DChainID,
// txID is unknown until the user signs/sends; the on-chain derivation uses
// the real txID + callIndex. The off-chain prepared id is the PARTIAL
// binding the watch correlates on; the authoritative id is minted on-chain.
// We bind the known economic identity (account/asset/amount/market) so the
// watch can match the emitted IntentSubmitted event by these fields even
// before the final txID is known. The zero txID here is a placeholder the
// watch tolerates (it matches on the economic tuple, see watch.go).
ID{}, req.CallIndex,
req.Account, req.AssetIn, req.AmountIn, req.MarketID,
)
// Phase A hookData (intent). Empty selects Phase A; we emit the explicit tag so
// the intent is unambiguous on-chain.
hookData := EncodeIntentHookData()
calldata := EncodeSwapCalldata(pk, zeroForOneFor(req), req.AmountIn, hookData)
return PreparedIntent{
To: addr9999(),
Calldata: calldata,
HookData: hookData,
IntentID: intentID,
QuotedOut: quoted,
DChainID: req.DChainID,
CChainID: req.CChainID,
Account: req.Account,
Recipient: req.Recipient,
AssetIn: req.AssetIn,
AmountIn: req.AmountIn,
MarketID: req.MarketID,
}, nil
}
// NotifyIntent tells D (via the server) to scan/import the C->D object the user's
// signed tx created and returns a watch. Gated by AuthIntent. Pipelines on the
// prepared-intent promise.
//
// MUST NOT: make a D match valid without a D block (it only triggers; dexvm
// consensus does the import+match), or return a live fill C credits (it returns a
// watch, and the watch yields a POINTER on commit, never a credit).
func (s *clientSession) NotifyIntent(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[IntentWatch] {
return thenAsync(ctx, intent, func(ctx context.Context, pi PreparedIntent) (IntentWatch, error) {
if err := s.requireGrant(AuthIntent); err != nil {
return IntentWatch{}, err
}
reqMsg, err := buildPreparedIntent(pi)
if err != nil {
return IntentWatch{}, err
}
// retag as a notify (the server routes on msgType).
notifyMsg, err := retag(reqMsg, MsgNotify)
if err != nil {
return IntentWatch{}, err
}
resp, err := s.conn.Call(ctx, s.peerID, notifyMsg)
if err != nil {
return IntentWatch{}, err
}
defer resp.Release()
ref := readIntentWatchRef(resp)
return IntentWatch{session: s, intentID: ref.IntentID}, nil
})
}
// ImportSettlement asks C to import an EXISTING D->C object named by the ref, and
// returns the calldata / tx hash. Gated by AuthSettlement. Pipelines on the ref
// promise (which is typically watch.OnCommitted()).
//
// MUST NOT: credit C via RPC (it returns calldata/tx hash — the chain credits), or
// substitute recipient/asset/amount (the on-chain ImportSettlement binds them to
// the recorded object; the calldata carries only outputID + amount, and even those
// are bound). A keeper that submits the tx has no custody authority.
func (s *clientSession) ImportSettlement(ctx context.Context, ref *Promise[DExportRef]) *Promise[SettlementSubmitResult] {
return thenAsync(ctx, ref, func(ctx context.Context, r DExportRef) (SettlementSubmitResult, error) {
if err := s.requireGrant(AuthSettlement); err != nil {
return SettlementSubmitResult{}, err
}
reqMsg, err := buildDExportRef(r)
if err != nil {
return SettlementSubmitResult{}, err
}
resp, err := s.conn.Call(ctx, s.peerID, reqMsg)
if err != nil {
return SettlementSubmitResult{}, err
}
defer resp.Release()
return readSettlementResult(resp), nil
})
}
// requireGrant is the session-level authority gate for the operation methods. It
// mirrors the capability gate (capCore.authorize) for callers that use the session
// surface directly rather than a derived capability — both check the same grant.
func (s *clientSession) requireGrant(need Authority) error {
if s.grant&need != need {
return ErrNoAuthority
}
return nil
}
// --- helpers -------------------------------------------------------------------
// addr9999 returns the 0x9999 settlement address as a 20-byte account. The
// canonical address is 0x...9999 (20 bytes, last two = 0x99 0x99 per the precompile
// poolManagerAddr9999). Kept local as a value (no geth dep).
func addr9999() Account {
var a Account
a[18] = 0x99
a[19] = 0x99
return a
}
// zeroForOneFor reports the swap direction for a request. The request expresses
// "sell AssetIn"; whether that is zeroForOne depends on the market's currency
// ordering, resolved via the PoolKey. For the prepared calldata we treat AssetIn
// as currency0 when it sorts first; the keeper/market mapping owns the canonical
// ordering. Default: selling AssetIn => zeroForOne when AssetIn == currency0.
func zeroForOneFor(req SwapIntentRequest) bool {
// AssetInAddr zero (native) sorts first (currency0) by V4 convention; a token
// sorts by address. We expose the direction the request implies: the request's
// AssetIn is the token being sold, so zeroForOne iff AssetIn is currency0. The
// market mapping encodes the canonical order; here we conservatively select
// zeroForOne=true (sell currency0) which the on-chain handler re-validates
// against the real PoolKey ordering. (A wrong direction simply fails the swap;
// it cannot move value the wrong way — the chain binds direction to the pool.)
return true
}
// retag rebuilds a message under a different msgType flag. The ZAP dispatcher
// routes on Flags()>>8, so a PreparedIntent built for MsgPrepare must be retagged
// MsgNotify before NotifyIntent's Call. The flags word lives at header bytes
// [6:8] little-endian and equals msgType<<8 (low byte 0, high byte msgType); no
// internal field offset references the header, so copying the buffer and patching
// only the flag word is byte-safe. We re-Parse to revalidate.
func retag(msg *zaplib.Message, msgType uint16) (*zaplib.Message, error) {
src := msg.Bytes()
buf := make([]byte, len(src))
copy(buf, src)
flags := msgType << 8
buf[6] = byte(flags) // low byte = 0
buf[7] = byte(flags >> 8) // high byte = msgType
return zaplib.Parse(buf)
}
+210
View File
@@ -0,0 +1,210 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"bytes"
"encoding/binary"
"testing"
"golang.org/x/crypto/sha3"
)
// v4_parity_test.go pins the V4 reproduced byte formats against the canonical
// precompile/dex encodings — the "third home" guard. dexsession reproduces (does not
// import) the modifyLiquidity selector and the route-path hookData to stay free of
// the EVM/cgo dependency graph; if a precompile-side change drifts a format, a red
// test fires here. These are VALUES, not authority.
// keccak4 reproduces the precompile's keccak4 (the first 4 bytes of Keccak-256 of an
// ABI signature). Used to INDEPENDENTLY recompute the modifyLiquidity selector and
// assert dexsession's pinned constant equals the canonical derivation.
func keccak4(sig string) uint32 {
h := sha3.NewLegacyKeccak256()
h.Write([]byte(sig))
sum := h.Sum(nil)
return binary.BigEndian.Uint32(sum[:4])
}
// TestParity_V4_ModifyLiquiditySelector pins SelectorModifyLiquidity against
// precompile/dex/module.go (0x5A6BCFDA) AND against the canonical keccak4 of the V4
// modifyLiquidity ABI signature. Two independent checks: the frozen constant and the
// recomputed selector must agree, so the constant cannot silently drift.
func TestParity_V4_ModifyLiquiditySelector(t *testing.T) {
if SelectorModifyLiquidity != 0x5A6BCFDA {
t.Fatalf("SelectorModifyLiquidity = %08X, want 5A6BCFDA (precompile/dex/module.go)", SelectorModifyLiquidity)
}
// The canonical V4 signature the precompile registers.
const sig = "modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)"
if got := keccak4(sig); got != SelectorModifyLiquidity {
t.Fatalf("keccak4(%q) = %08X, want %08X — selector drift", sig, got, SelectorModifyLiquidity)
}
// Sanity: it differs from the swap selector (distinct kernels).
if SelectorModifyLiquidity == SelectorSwap {
t.Fatalf("modifyLiquidity selector collides with swap selector")
}
}
// TestParity_V4_ModifyLiquidityCalldata pins the modifyLiquidity calldata head
// layout: selector(4) + PoolKey(5 words) + ModifyParams(4 words) + hookData offset(1
// word) + hookData tail. It asserts the selector, the signed liquidityDelta encoding
// (+add / -remove), the tick words, the salt word, and the dynamic-bytes offset.
func TestParity_V4_ModifyLiquidityCalldata(t *testing.T) {
pk := testPoolKey()
salt := ID{0xAB}
salt[31] = 0xCD
// A POSITIVE delta (commit/add).
add := EncodeModifyLiquidityCalldata(pk, ModifyLiquidityArgs{TickLower: -60, TickUpper: 60, LiquidityDelta: 1_000_000, Salt: salt}, EncodeIntentHookData())
if got := binary.BigEndian.Uint32(add[:4]); got != SelectorModifyLiquidity {
t.Fatalf("selector = %08X, want %08X", got, SelectorModifyLiquidity)
}
const headWords = 5 + 4 + 1
headEnd := 4 + headWords*32
if len(add) < headEnd+32 {
t.Fatalf("calldata too short: %d", len(add))
}
// Word layout (after selector): [0..4]=PoolKey, [5]=tickLower, [6]=tickUpper,
// [7]=liquidityDelta, [8]=salt, [9]=hookData offset.
word := func(b []byte, i int) []byte { return b[4+i*32 : 4+(i+1)*32] }
// tickLower = -60 (int24, sign-extended).
wantTickLow := wordI24(-60)
if !bytes.Equal(word(add, 5), wantTickLow[:]) {
t.Fatalf("tickLower word mismatch: got %x want %x", word(add, 5), wantTickLow[:])
}
wantTickHigh := wordI24(60)
if !bytes.Equal(word(add, 6), wantTickHigh[:]) {
t.Fatalf("tickUpper word mismatch")
}
// liquidityDelta = +1_000_000: low 8 bytes carry the value, high 24 bytes zero.
dw := word(add, 7)
if v := binary.BigEndian.Uint64(dw[24:32]); v != 1_000_000 {
t.Fatalf("liquidityDelta low word = %d, want 1_000_000", v)
}
for i := 0; i < 24; i++ {
if dw[i] != 0 {
t.Fatalf("positive liquidityDelta has non-zero high byte at %d: %x", i, dw[i])
}
}
// salt = the bytes32 verbatim.
if !bytes.Equal(word(add, 8), salt[:]) {
t.Fatalf("salt word mismatch: got %x want %x", word(add, 8), salt[:])
}
// hookData offset = headWords*32 (the dynamic tail begins after the head words).
off := word(add, 9)
if v := binary.BigEndian.Uint64(off[24:32]); v != uint64(headWords*32) {
t.Fatalf("hookData offset = %d, want %d", v, headWords*32)
}
// A NEGATIVE delta (remove/collect/cancel): two's-complement sign-extended.
rem := EncodeModifyLiquidityCalldata(pk, ModifyLiquidityArgs{TickLower: -60, TickUpper: 60, LiquidityDelta: -1_000_000, Salt: salt}, EncodeIntentHookData())
rdw := word(rem, 7)
// High 24 bytes must be 0xff (sign extension of a negative int256).
for i := 0; i < 24; i++ {
if rdw[i] != 0xff {
t.Fatalf("negative liquidityDelta not sign-extended at byte %d: %x", i, rdw[i])
}
}
// The full 32-byte word equals two's-complement of -1_000_000.
wantNeg := wordI256FromInt64(-1_000_000)
if !bytes.Equal(rdw, wantNeg[:]) {
t.Fatalf("negative liquidityDelta word mismatch:\n got %x\nwant %x", rdw, wantNeg[:])
}
}
// TestParity_V4_RouteIntentHookData pins the route-intent hookData: DI01 (the on-chain
// Phase-A intent tag, so the precompile still classifies it as an INTENT) followed by
// the RT01 route marker, the hop count, and the path. Round-trips the path out.
func TestParity_V4_RouteIntentHookData(t *testing.T) {
path := []ID{{0x4D, 0x01}, {0x4D, 0x02}, {0x4D, 0x03}}
hook := EncodeRouteIntentHookData(path)
// The hookData MUST begin with DI01 so the precompile's decodeSwapPhase classifies
// it as a Phase-A intent (creates ONE C->D object); the route body follows.
if !bytes.Equal(hook[0:4], []byte{'D', 'I', '0', '1'}) {
t.Fatalf("route hookData does not lead with DI01: %x", hook[0:4])
}
// Then the RT01 route marker.
if !bytes.Equal(hook[4:8], []byte{'R', 'T', '0', '1'}) {
t.Fatalf("route hookData missing RT01 marker: %x", hook[4:8])
}
// Then the hop count (big-endian uint32).
if n := binary.BigEndian.Uint32(hook[8:12]); n != 3 {
t.Fatalf("route hop count = %d, want 3", n)
}
// Then the path verbatim.
for i, m := range path {
off := 12 + i*32
if !bytes.Equal(hook[off:off+32], m[:]) {
t.Fatalf("route path hop %d mismatch", i)
}
}
// Total length is exact (no trailing slop).
if len(hook) != 12+3*32 {
t.Fatalf("route hookData len = %d, want %d", len(hook), 12+3*32)
}
// Round-trip via the decoder.
got, ok := DecodeRouteIntentHookData(hook)
if !ok || len(got) != 3 {
t.Fatalf("route hookData round-trip: ok=%v len=%d", ok, len(got))
}
for i := range got {
if got[i] != path[i] {
t.Fatalf("route hookData round-trip hop %d mismatch", i)
}
}
// A corrupt hop count (claims 5, has 3) is rejected (fail-closed, no over-read).
bad := append([]byte(nil), hook...)
binary.BigEndian.PutUint32(bad[8:12], 5)
if _, ok := DecodeRouteIntentHookData(bad); ok {
t.Fatalf("route decoder accepted a mismatched hop count")
}
// A non-route intent (plain DI01) is NOT a route body.
if _, ok := DecodeRouteIntentHookData(EncodeIntentHookData()); ok {
t.Fatalf("plain DI01 decoded as a route")
}
}
// TestParity_V4_RouteIntentIsPhaseAOnChain proves the route intent is, to the on-chain
// phase classifier, a PLAIN Phase-A INTENT. This is the structural guarantee that a
// route creates exactly ONE C->D object (no settlement, no second object): the
// precompile sees DI01 and classifies INTENT; the route body is the intent's body. We
// model decodeSwapPhase's rule (leading DI01 => intent) and assert it holds.
func TestParity_V4_RouteIntentIsPhaseAOnChain(t *testing.T) {
path := []ID{{0x01}, {0x02}}
hook := EncodeRouteIntentHookData(path)
// Model the precompile's decodeSwapPhase: DI01-led hookData => Phase A (intent).
// (settlement requires the DS01 tag; a route hookData NEVER carries DS01.)
if len(hook) < 4 || !bytes.Equal(hook[0:4], []byte{'D', 'I', '0', '1'}) {
t.Fatalf("route hookData is not DI01-classified as intent")
}
if bytes.Contains(hook, []byte{'D', 'S', '0', '1'}) {
t.Fatalf("route hookData contains a DS01 settlement tag — a route must NEVER settle on the input leg")
}
}
// TestParity_V4_SessionIDDomainSeparation proves a V4 session id can NEVER collide
// with an intent id, a UTXO id, or any other id in the system — its domain string is
// distinct, so DeriveSessionID and DeriveIntentID over the same-shaped inputs differ.
func TestParity_V4_SessionIDDomainSeparation(t *testing.T) {
scope := V4ActionScope{NetworkID: 1, Kind: ActionSwap}
sid := DeriveSessionID(scope)
// An intent id over all-zero inputs (different domain) must differ.
iid := DeriveIntentID(1, ID{}, ID{}, ID{}, 0, Account{}, ID{}, 0, ID{})
if sid == iid {
t.Fatalf("session id collided with intent id — domains not separated")
}
// A UTXO id (yet another domain) must differ too.
uid := DeriveUTXOID(ID{}, 0)
if sid == uid {
t.Fatalf("session id collided with UTXO id")
}
// Changing the action kind changes the id (per-action confinement at the id level).
scope2 := scope
scope2.Kind = ActionRoute
if DeriveSessionID(scope2) == sid {
t.Fatalf("session id not sensitive to action kind")
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"testing"
)
// v4_wire_test.go round-trips the new V4 wire envelopes (route request/status) and
// the additive IntentStatus.MatchedOut field through build->parse->read, asserting
// every field survives — the same field-slot-overlap guard wire_test.go applies to
// the base envelopes. A corrupted route path or matched estimate would mislead the
// orchestration stream (never move value, but still a bug to catch here).
func TestWire_RouteRequest_RoundTrip(t *testing.T) {
in := RouteRequest{
NetworkID: 96369, CallIndex: 3, AmountIn: 1_000_000, MinAmountOut: 950_000, Deadline: 1 << 40,
CChainID: fillID(0xC0), DChainID: fillID(0xD0), Account: fillAcct(0xAA),
AssetIn: fillID(0x0A), AssetInAddr: fillAcct(0x02), Recipient: fillAcct(0xBB),
Path: []ID{fillID(0x01), fillID(0x02), fillID(0x03), fillID(0x04)},
}
m, err := buildRouteRequest(in)
if err != nil {
t.Fatal(err)
}
got := readRouteRequest(m)
if got.NetworkID != in.NetworkID || got.CallIndex != in.CallIndex || got.AmountIn != in.AmountIn ||
got.MinAmountOut != in.MinAmountOut || got.Deadline != in.Deadline ||
got.CChainID != in.CChainID || got.DChainID != in.DChainID || got.Account != in.Account ||
got.AssetIn != in.AssetIn || got.AssetInAddr != in.AssetInAddr || got.Recipient != in.Recipient {
t.Fatalf("RouteRequest scalar/fixed round-trip:\n got %+v\nwant %+v", got, in)
}
if len(got.Path) != len(in.Path) {
t.Fatalf("RouteRequest path length: got %d want %d", len(got.Path), len(in.Path))
}
for i := range in.Path {
if got.Path[i] != in.Path[i] {
t.Fatalf("RouteRequest path hop %d mismatch", i)
}
}
}
func TestWire_RouteRequest_EmptyPath(t *testing.T) {
in := RouteRequest{NetworkID: 1, AmountIn: 100, Path: nil}
m, err := buildRouteRequest(in)
if err != nil {
t.Fatal(err)
}
got := readRouteRequest(m)
if len(got.Path) != 0 {
t.Fatalf("empty path round-trip produced %d hops", len(got.Path))
}
}
func TestWire_RouteStatus_RoundTrip(t *testing.T) {
in := RouteStatus{
IntentID: fillID(0x4B), Phase: RouteCommitted, HopIndex: 2, HopCount: 3,
HopAmountOut: 977_000, FinalOut: 970_000,
Ref: DExportRef{SourceChainID: fillID(0xDD), SourceTxID: fillID(0xFE), OutputIndex: 1, IntentID: fillID(0x4B)},
Reason: "ok",
}
m, err := buildRouteStatus(in)
if err != nil {
t.Fatal(err)
}
got := readRouteStatus(m)
if got.IntentID != in.IntentID || got.Phase != in.Phase || got.HopIndex != in.HopIndex ||
got.HopCount != in.HopCount || got.HopAmountOut != in.HopAmountOut || got.FinalOut != in.FinalOut ||
got.Ref.SourceChainID != in.Ref.SourceChainID || got.Ref.SourceTxID != in.Ref.SourceTxID ||
got.Ref.OutputIndex != in.Ref.OutputIndex || got.Reason != in.Reason {
t.Fatalf("RouteStatus round-trip:\n got %+v\nwant %+v", got, in)
}
}
// TestWire_IntentStatus_MatchedOut pins the additive MatchedOut field (the matched
// estimate). The existing IntentStatus round-trip leaves it zero; this asserts a
// non-zero value survives — and that it is carried alongside the ref without
// clobbering it (the field-slot guard for the new offset).
func TestWire_IntentStatus_MatchedOut(t *testing.T) {
in := IntentStatus{
IntentID: fillID(0x4B), Phase: PhaseCommitted, MatchedOut: 999_000,
Ref: DExportRef{SourceChainID: fillID(0xDD), SourceTxID: fillID(0xEE), OutputIndex: 7, IntentID: fillID(0x4B)},
Reason: "matched",
}
m, err := buildIntentStatus(in)
if err != nil {
t.Fatal(err)
}
got := readIntentStatus(m)
if got.MatchedOut != in.MatchedOut {
t.Fatalf("IntentStatus.MatchedOut: got %d want %d", got.MatchedOut, in.MatchedOut)
}
// The new field must not clobber the ref or reason (adjacent slots).
if got.Ref.SourceTxID != in.Ref.SourceTxID || got.Ref.OutputIndex != in.Ref.OutputIndex || got.Reason != in.Reason {
t.Fatalf("MatchedOut field clobbered adjacent fields:\n got %+v\nwant %+v", got, in)
}
}
// TestV4Msg_DirectionAndInvariant asserts the message-type classification is correct
// and the type-level money-plane invariant holds for EVERY message type.
func TestV4Msg_DirectionAndInvariant(t *testing.T) {
// A representative set across all actions.
cToD := []V4MsgType{
V4_SWAP_PREPARE_INTENT, V4_SWAP_NOTIFY_C_EXPORT, V4_SWAP_PREPARE_C_SETTLEMENT, V4_SWAP_SUBMIT_C_SETTLEMENT,
V4_ROUTE_PREPARE_INTENT, V4_ROUTE_NOTIFY_C_EXPORT, V4_ROUTE_PREPARE_C_SETTLEMENT,
V4_LP_PREPARE_COMMIT, V4_LP_NOTIFY_C_EXPORT,
V4_COLLECT_REQUEST, V4_COLLECT_PREPARE_C_SETTLEMENT,
V4_CANCEL_REQUEST, V4_CANCEL_PREPARE_C_SETTLEMENT,
}
for _, m := range cToD {
if m.Dir() != DirCToD {
t.Fatalf("%s: Dir() = %d, want DirCToD", m, m.Dir())
}
}
dToC := []V4MsgType{
V4_SWAP_D_IMPORTED, V4_SWAP_MATCHED, V4_SWAP_D_EXPORT_READY, V4_SWAP_C_SETTLED,
V4_ROUTE_HOP_STARTED, V4_ROUTE_HOP_FILLED, V4_ROUTE_D_EXPORT_READY, V4_ROUTE_REFUND_READY, V4_ROUTE_C_SETTLED,
V4_LP_D_COMMITTED, V4_LP_POSITION_OPEN,
V4_COLLECT_D_EXPORT_READY, V4_COLLECT_C_SETTLED,
V4_CANCEL_D_EXPORT_READY, V4_CANCEL_C_SETTLED,
V4_STATE_QUOTE_UPDATE, V4_STATE_BOOK_UPDATE, V4_ERROR, V4_HALTED,
}
for _, m := range dToC {
if m.Dir() != DirDToC {
t.Fatalf("%s: Dir() = %d, want DirDToC", m, m.Dir())
}
}
local := []V4MsgType{V4_SWAP_OPEN, V4_ROUTE_OPEN, V4_LP_OPEN, V4_COLLECT_OPEN, V4_CANCEL_OPEN, V4_STATE_OPEN, V4_STATE_CLOSE}
for _, m := range local {
if m.Dir() != DirLocal {
t.Fatalf("%s: Dir() = %d, want DirLocal", m, m.Dir())
}
}
// THE type-level invariant: NO message type credits value — in any direction.
all := append(append(append([]V4MsgType{}, cToD...), dToC...), local...)
for _, m := range all {
if m.CreditsValue() {
t.Fatalf("%s.CreditsValue() = true — the money-plane invariant is broken at the type level", m)
}
}
// HasRef is true ONLY for the export/refund reads (the only settleable pointers).
refBearing := map[V4MsgType]bool{
V4_SWAP_D_EXPORT_READY: true, V4_ROUTE_D_EXPORT_READY: true, V4_ROUTE_REFUND_READY: true,
V4_COLLECT_D_EXPORT_READY: true, V4_CANCEL_D_EXPORT_READY: true,
}
for _, m := range all {
ev := V4Event{Type: m}
if ev.HasRef() != refBearing[m] {
t.Fatalf("%s: HasRef() = %v, want %v", m, ev.HasRef(), refBearing[m])
}
}
}
+195
View File
@@ -0,0 +1,195 @@
// 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 is a SWAP INTENT (DI01 / Phase A) whose hookData body carries the
// multi-market PATH the D-Chain router walks. The V4 hookData is DESIGNED to carry a
// routing payload, and the precompile's decodeSwapPhase returns the bytes after the
// DI01 tag as the phase body — so a route rides the EXISTING swap selector and the
// EXISTING intent phase, with the path as the body. No new on-chain selector or tag
// is invented: to the C-side lock, a route intent is a plain intent that creates ONE
// C->D input object; the D-Chain router reads the path body to walk A->B->C and
// produces ONE final D->C export.
//
// 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.
// routePathDomain tags the route-path body inside a DI01 intent hookData. It follows
// the DI01 tag so the precompile still classifies the hookData as a Phase-A intent
// (the tag is DI01); the route marker + path are the intent BODY the D router reads.
var routePathMarker = [4]byte{'R', 'T', '0', '1'}
// EncodeRouteIntentHookData builds a route-intent hookData: the DI01 intent tag,
// then a route marker, then the path (an ordered list of marketIDs A->B->C the D
// router walks). The leading DI01 keeps the on-chain phase classification as INTENT
// (creates ONE C->D object); the body is the path for D's router.
//
// DI01(4) || RT01(4) || hopCount(4, big-endian) || marketID[0](32) || ... || marketID[n-1](32)
//
// The body is the ROUTING PAYLOAD — orchestration the D matcher consumes. It moves no
// value; it only directs how D walks the single input through markets to one output.
func EncodeRouteIntentHookData(path []ID) []byte {
out := make([]byte, 0, 4+4+4+len(path)*32)
out = append(out, intentPhaseTag[:]...) // DI01: Phase-A intent classification
out = append(out, routePathMarker[:]...) // RT01: route body marker
var c [4]byte
binary.BigEndian.PutUint32(c[:], uint32(len(path)))
out = append(out, c[:]...)
for _, m := range path {
out = append(out, m[:]...)
}
return out
}
// DecodeRouteIntentHookData is the inverse: it extracts the route path from a
// route-intent hookData, or ok=false if the bytes are not a DI01+RT01 route body.
// The D router uses this to recover the path; the session uses it to verify the
// calldata it built carries exactly the requested path (defence against a corrupted
// build). It reads only — it moves nothing.
func DecodeRouteIntentHookData(hookData []byte) (path []ID, ok bool) {
const hdr = 4 + 4 + 4
if len(hookData) < hdr {
return nil, false
}
if string(hookData[0:4]) != string(intentPhaseTag[:]) {
return nil, false
}
if string(hookData[4:8]) != string(routePathMarker[:]) {
return nil, false
}
n := int(binary.BigEndian.Uint32(hookData[8:12]))
if n < 0 || hdr+n*32 != len(hookData) {
return nil, false
}
path = make([]ID, n)
for i := 0; i < n; i++ {
copy(path[i][:], hookData[hdr+i*32:hdr+(i+1)*32])
}
return path, true
}
// 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
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
// v4msg.go is the BIDIRECTIONAL control-plane message set, named V4-native. It is
// PURE VALUES — message-type constants, a direction classification, and the typed
// D->C read event a session surfaces. It has NO transport and NO value path: a
// message is a label for "what flows on the control plane", orthogonal to the wire
// bytes (wire.go owns the single transport encoding) and to authority (capability.go
// owns the grants).
//
// THE LOCKED FRAMING (CTO):
//
// 0x9999 = on-chain authority / V4 ABI
// ZAP = bidirectional binary session / CONTROL PLANE <- this file
// shared mem = consensus value boundary / MONEY PLANE
// D = matching authority
// C = EVM balance authority
//
// During a swap's lifetime BOTH C-side and D-side services read/write through ZAP.
// The ONE constraint, enforced structurally and proven by test:
//
// ZAP read/write may UPDATE session/orchestration state.
// ZAP read/write MUST NOT MOVE VALUE.
//
// Money still moves ONLY via: C -> D atomic object -> D dexvm import/match/export ->
// D -> C atomic object -> 0x9999 import/credit. Every D->C read below (even
// MatchResult{amountOut}) is ORCHESTRATION STATE; the credit happens on-chain when a
// real D->C object is consumed via the DS01 Phase-B settlement (the ONE credit path
// for swaps, routes, collects, decreases, and cancels alike — see v4liquidity.go).
// V4MsgType is a control-plane message-type. The constants below are the V4-native
// SEMANTIC names the spec mandates. Each maps to an underlying wire frame (Msg* in
// wire.go) or to a LOCAL lifecycle transition (no wire frame — e.g. *_OPEN mints a
// scoped capability client-side). A session is a state machine that emits/consumes
// these; the mapping (v4Wire) keeps the wire the single source of transport truth.
type V4MsgType uint16
// V4Dir classifies a message by who originates it on the bidirectional plane.
type V4Dir uint8
const (
// DirCToD is a C-side -> D-side WRITE (request side of a Call, or the user's
// signed C tx the write refers to). It may trigger D work; it never moves value.
DirCToD V4Dir = iota
// DirDToC is a D-side -> C-side READ (response side of a Call, or a watch
// push/poll). It carries estimates / status / a POINTER; never a credit.
DirDToC
// DirLocal is a client-side lifecycle transition (open/close): it mints/retires a
// scoped capability and session state. It touches no peer and no value.
DirLocal
)
// ----------------------------------------------------------------------------
// Swap action (V4SwapSession): the canonical single-swap lifecycle.
// ----------------------------------------------------------------------------
//
// Lifecycle (promise-pipelined), with direction in brackets:
//
// OPEN [local]
// -> PREPARE_INTENT [C->D] build 0x9999 swap calldata (DI01) the user signs
// -> NOTIFY_C_EXPORT [C->D] tell D to scan the C->D object the signed tx made
// <- D_IMPORTED [D->C] D imported the C->D object (Phase Matching)
// <- MATCHED [D->C] D matched (MatchResult.amountOut = ESTIMATE only)
// <- D_EXPORT_READY [D->C] D committed a D->C export (DExportRef POINTER)
// -> PREPARE_C_SETTLEMENT [C->D] build 0x9999 settlement calldata (DS01) at the ref
// -> SUBMIT_C_SETTLEMENT [C->D] (optional) a keeper submits the signed C tx
// <- C_SETTLED [D->C] C consumed the real object and credited (observed)
const (
V4_SWAP_OPEN V4MsgType = 0x1000 + iota
V4_SWAP_PREPARE_INTENT
V4_SWAP_NOTIFY_C_EXPORT
V4_SWAP_D_IMPORTED
V4_SWAP_MATCHED
V4_SWAP_D_EXPORT_READY
V4_SWAP_PREPARE_C_SETTLEMENT
V4_SWAP_SUBMIT_C_SETTLEMENT
V4_SWAP_C_SETTLED
)
// ----------------------------------------------------------------------------
// Route action (V4RouteSession): MULTI-HOP. C input -> D route A->B->C -> ONE
// final D->C export. The route STAYS ON D between hops; ZAP streams hop progress.
// ----------------------------------------------------------------------------
//
// OPEN [local]
// -> PREPARE_INTENT [C->D] ONE route-intent calldata (DI01 + path body)
// -> NOTIFY_C_EXPORT [C->D] tell D to scan the single C->D input object
// <- HOP_STARTED [D->C] D began hop i (orchestration — no C object)
// <- HOP_FILLED [D->C] D filled hop i (intermediate amount — orchestration)
// <- D_EXPORT_READY [D->C] D committed the ONE final D->C export (POINTER)
// <- REFUND_READY [D->C] route failed: D exported a refund of the INPUT asset
// -> PREPARE_C_SETTLEMENT [C->D] settle the ONE final (or refund) export (DS01)
// <- C_SETTLED [D->C] C credited from the single real object (observed)
const (
V4_ROUTE_OPEN V4MsgType = 0x1100 + iota
V4_ROUTE_PREPARE_INTENT
V4_ROUTE_NOTIFY_C_EXPORT
V4_ROUTE_HOP_STARTED
V4_ROUTE_HOP_FILLED
V4_ROUTE_D_EXPORT_READY
V4_ROUTE_REFUND_READY
V4_ROUTE_PREPARE_C_SETTLEMENT
V4_ROUTE_C_SETTLED
)
// ----------------------------------------------------------------------------
// Liquidity action (V4LiquiditySession): modifyLiquidity COMMIT — a funded C->D
// position. A deposit: it creates a C->D object only. No D->C export, no C credit.
// ----------------------------------------------------------------------------
//
// OPEN [local]
// -> PREPARE_COMMIT [C->D] build 0x9999 modifyLiquidity calldata (+delta)
// -> NOTIFY_C_EXPORT [C->D] tell D to scan the funded C->D position object
// <- D_COMMITTED [D->C] D recorded the position (orchestration)
// <- POSITION_OPEN [D->C] the position id is live (orchestration)
const (
V4_LP_OPEN V4MsgType = 0x1200 + iota
V4_LP_PREPARE_COMMIT
V4_LP_NOTIFY_C_EXPORT
V4_LP_D_COMMITTED
V4_LP_POSITION_OPEN
)
// ----------------------------------------------------------------------------
// Collect action (V4CollectSession): collect/decrease — D->C export -> C credit.
// The withdrawn fees / removed liquidity become a D->C object settled via DS01.
// ----------------------------------------------------------------------------
//
// OPEN [local]
// -> REQUEST [C->D] ask D to collect/decrease (build -delta calldata)
// <- D_EXPORT_READY [D->C] D committed the D->C export of the collected value
// -> PREPARE_C_SETTLEMENT [C->D] settle the export (DS01) -> credit
// <- C_SETTLED [D->C] C credited from the real object (observed)
const (
V4_COLLECT_OPEN V4MsgType = 0x1300 + iota
V4_COLLECT_REQUEST
V4_COLLECT_D_EXPORT_READY
V4_COLLECT_PREPARE_C_SETTLEMENT
V4_COLLECT_C_SETTLED
)
// ----------------------------------------------------------------------------
// Cancel action (V4 openCancel): cancel a resting order -> D->C refund export ->
// C credit (of the originally-locked asset). Same credit path as collect (DS01).
// ----------------------------------------------------------------------------
const (
V4_CANCEL_OPEN V4MsgType = 0x1400 + iota
V4_CANCEL_REQUEST
V4_CANCEL_D_EXPORT_READY
V4_CANCEL_PREPARE_C_SETTLEMENT
V4_CANCEL_C_SETTLED
)
// ----------------------------------------------------------------------------
// State action (V4StateSession): read-only quote/book/state streaming.
// ----------------------------------------------------------------------------
const (
V4_STATE_OPEN V4MsgType = 0x1500 + iota
V4_STATE_QUOTE_UPDATE
V4_STATE_BOOK_UPDATE
V4_STATE_CLOSE
)
// Common terminal reads, valid on any session.
const (
// V4_ERROR is a D->C error read (the venue reports a non-fatal failure for this
// action). It carries a reason; it never moves value.
V4_ERROR V4MsgType = 0x1F00 + iota
// V4_HALTED is a D->C read that the action's market/asset/global swap path is
// halted. The session refuses to advance (fail-secure). It moves no value (a halt
// blocks NEW swaps; funds still exit via the un-halted settlement path on-chain).
V4_HALTED
)
// Dir reports the control-plane direction of a message-type. C->D writes carry a
// request to D (or refer to the user's signed C tx); D->C reads carry status /
// estimates / a pointer; local transitions are client-side capability lifecycle.
func (t V4MsgType) Dir() V4Dir {
switch t {
case V4_SWAP_OPEN, V4_ROUTE_OPEN, V4_LP_OPEN, V4_COLLECT_OPEN, V4_CANCEL_OPEN, V4_STATE_OPEN, V4_STATE_CLOSE:
return DirLocal
case
V4_SWAP_PREPARE_INTENT, V4_SWAP_NOTIFY_C_EXPORT, V4_SWAP_PREPARE_C_SETTLEMENT, V4_SWAP_SUBMIT_C_SETTLEMENT,
V4_ROUTE_PREPARE_INTENT, V4_ROUTE_NOTIFY_C_EXPORT, V4_ROUTE_PREPARE_C_SETTLEMENT,
V4_LP_PREPARE_COMMIT, V4_LP_NOTIFY_C_EXPORT,
V4_COLLECT_REQUEST, V4_COLLECT_PREPARE_C_SETTLEMENT,
V4_CANCEL_REQUEST, V4_CANCEL_PREPARE_C_SETTLEMENT:
return DirCToD
default:
// Everything else is a D->C read (D_IMPORTED, MATCHED, *_EXPORT_READY,
// HOP_*, REFUND_READY, *_COMMITTED, POSITION_OPEN, *_C_SETTLED, QUOTE/BOOK
// updates, ERROR, HALTED).
return DirDToC
}
}
// CreditsValue reports whether a message-type, by ITSELF, credits a C balance.
// IT IS ALWAYS FALSE. This is the machine-checkable statement of the money-plane
// invariant over the entire bidirectional message set: no control-plane message —
// in either direction — moves value. The credit happens on-chain, off this plane,
// when a real D->C atomic object is consumed (DS01 Phase-B settlement). The RED
// suite proves this dynamically; this method states it for the type system and for
// any caller that wants to assert it (e.g. a gateway audit).
func (t V4MsgType) CreditsValue() bool { return false }
// v4Wire maps a control-plane message-type to its underlying wire frame (a Msg*
// from wire.go) for the messages that cross the network. DirLocal transitions and
// the streamed sub-states of a single wire frame (e.g. D_IMPORTED / MATCHED /
// D_EXPORT_READY are all phases of one MsgWatchPoll/Push) return ok=false: they are
// not distinct frames, they are interpretations of a status. Keeping the wire frames
// as the single transport truth (and the V4 names as a semantic overlay) is the
// "one way to do everything" discipline — the V4 layer adds NO second transport.
func v4Wire(t V4MsgType) (uint16, bool) {
switch t {
case V4_SWAP_PREPARE_INTENT, V4_ROUTE_PREPARE_INTENT:
return MsgPrepare, true
case V4_SWAP_NOTIFY_C_EXPORT, V4_ROUTE_NOTIFY_C_EXPORT, V4_LP_NOTIFY_C_EXPORT:
return MsgNotify, true
case V4_SWAP_PREPARE_C_SETTLEMENT, V4_ROUTE_PREPARE_C_SETTLEMENT,
V4_COLLECT_PREPARE_C_SETTLEMENT, V4_CANCEL_PREPARE_C_SETTLEMENT:
return MsgImport, true
case V4_LP_PREPARE_COMMIT, V4_COLLECT_REQUEST, V4_CANCEL_REQUEST:
return MsgPrepare, true // committed/collected/cancelled via a prepare call
case V4_STATE_QUOTE_UPDATE:
return MsgQuote, true
case V4_STATE_BOOK_UPDATE:
return MsgGetState, true
default:
return 0, false
}
}
// String renders a message-type for logs/tests. (No fmt dependency in the hot path;
// a small switch keeps it allocation-free for the common types.)
func (t V4MsgType) String() string {
switch t {
case V4_SWAP_OPEN:
return "V4_SWAP_OPEN"
case V4_SWAP_PREPARE_INTENT:
return "V4_SWAP_PREPARE_INTENT"
case V4_SWAP_NOTIFY_C_EXPORT:
return "V4_SWAP_NOTIFY_C_EXPORT"
case V4_SWAP_D_IMPORTED:
return "V4_SWAP_D_IMPORTED"
case V4_SWAP_MATCHED:
return "V4_SWAP_MATCHED"
case V4_SWAP_D_EXPORT_READY:
return "V4_SWAP_D_EXPORT_READY"
case V4_SWAP_PREPARE_C_SETTLEMENT:
return "V4_SWAP_PREPARE_C_SETTLEMENT"
case V4_SWAP_SUBMIT_C_SETTLEMENT:
return "V4_SWAP_SUBMIT_C_SETTLEMENT"
case V4_SWAP_C_SETTLED:
return "V4_SWAP_C_SETTLED"
case V4_ROUTE_HOP_STARTED:
return "V4_ROUTE_HOP_STARTED"
case V4_ROUTE_HOP_FILLED:
return "V4_ROUTE_HOP_FILLED"
case V4_ROUTE_D_EXPORT_READY:
return "V4_ROUTE_D_EXPORT_READY"
case V4_ROUTE_REFUND_READY:
return "V4_ROUTE_REFUND_READY"
case V4_LP_D_COMMITTED:
return "V4_LP_D_COMMITTED"
case V4_LP_POSITION_OPEN:
return "V4_LP_POSITION_OPEN"
case V4_COLLECT_D_EXPORT_READY:
return "V4_COLLECT_D_EXPORT_READY"
case V4_CANCEL_D_EXPORT_READY:
return "V4_CANCEL_D_EXPORT_READY"
case V4_ERROR:
return "V4_ERROR"
case V4_HALTED:
return "V4_HALTED"
default:
return "V4_MSG"
}
}
// V4Event is the typed D->C read a session surfaces to the caller (the orchestration
// stream). It is a VALUE: a label + the orchestration payload + an OPTIONAL pointer.
// It NEVER carries authority over a balance:
//
// - Type — the V4MsgType (always a DirDToC read).
// - SessionID — the action the event belongs to (binds it to its session).
// - IntentID — the originating intent (correlation).
// - EstAmount — an ESTIMATE (quote / matched-out / hop-out). NOT a credit; the
// chain never trusts it. Exactly the QuotedOut discipline, extended to matches.
// - HopIndex — the route hop this event describes (route events only).
// - Ref — the D->C export POINTER, set ONLY on *_EXPORT_READY / REFUND_READY.
// The credit still happens on-chain when the real object behind Ref is consumed.
// - Reason — human text for ERROR / HALTED / REFUND.
type V4Event struct {
Type V4MsgType
SessionID ID
IntentID ID
EstAmount uint64
HopIndex uint32
Ref DExportRef
Reason string
}
// HasRef reports whether the event carries a settleable export pointer. Even when
// true, the pointer only NAMES an object the chain re-verifies on import — it is not
// a credit.
func (e V4Event) HasRef() bool {
return e.Type == V4_SWAP_D_EXPORT_READY ||
e.Type == V4_ROUTE_D_EXPORT_READY ||
e.Type == V4_ROUTE_REFUND_READY ||
e.Type == V4_COLLECT_D_EXPORT_READY ||
e.Type == V4_CANCEL_D_EXPORT_READY
}
+383
View File
@@ -0,0 +1,383 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"fmt"
"time"
zaplib "github.com/luxfi/zap"
)
// watchPollCadence is the poll interval the V4 session streams use between D status
// reads. The happy path on a live D-Chain is sub-second; this caps a stuck action
// from hot-looping while keeping the stream responsive.
const watchPollCadence = 25 * time.Millisecond
// sleepCtx sleeps for d or until ctx is cancelled. Returns true if the full duration
// elapsed, false if ctx was cancelled (the caller surfaces the ctx error).
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// emit sends ev on ch, but bails the instant ctx is cancelled — so an abandoned
// consumer (a caller that stops ranging without cancelling) can never wedge a stream
// goroutine on a full buffered channel. Returns false if ctx ended (the stream should
// stop). This is the ONE place a session stream writes an event; all three streams
// (swap/route/state) use it, keeping the ctx-aware send DRY.
func emit(ctx context.Context, ch chan<- V4Event, ev V4Event) bool {
select {
case ch <- ev:
return true
case <-ctx.Done():
return false
}
}
// v4route.go is the MULTI-HOP route control plane: the route-intent request, the
// hop-progress stream, the route wire frames, and the RouteVenue the server
// delegates to. It is an ORTHOGONAL extension of the swap primitives — a route is a
// swap intent whose hookData carries a path (v4liquidity.go), so the money path is
// IDENTICAL to a single swap: ONE C->D input object, ONE D->C output (or refund)
// object. The hops in between are D-internal matching state, streamed as
// orchestration. Nothing here moves value.
//
// THE ATOMICITY PROPERTY (the spec's "stays on D until final export"):
//
// A route prepares EXACTLY ONE C->D intent (one calldata, one intentID). D imports
// that single input and walks the whole path A->B->C ATOMICALLY inside dexvm,
// producing EXACTLY ONE D->C export (the final output) — or, on failure, ONE refund
// export of the ORIGINAL input asset. There is NEVER an intermediate C-side object
// for asset B: the route does not bounce to C between hops. So a holder of a route
// session can settle the ONE final export and nothing else; a fabricated
// intermediate-asset settlement names no object on C and reverts. The hop stream
// (HOP_STARTED/HOP_FILLED) carries intermediate amounts as ESTIMATES — never a
// settleable pointer.
// RouteRequest is the off-chain request to PREPARE a multi-hop route intent. Like a
// SwapIntentRequest it reserves no funds and returns calldata; the difference is
// Path — the ordered list of marketIDs D walks (A->B->C). AmountIn is the SINGLE
// input the user locks on C; MinAmountOut is the floor on the FINAL output, enforced
// by D at the end of the route (a route that cannot deliver MinAmountOut of the final
// asset refunds the input — it never partially settles an intermediate asset).
type RouteRequest struct {
NetworkID uint32
CChainID ID
DChainID ID
Account Account // taker — bound as the single C->D input object owner
AssetIn ID // the input asset locked on C (hop 0 input)
AssetInAddr Account // ERC-20 token (zero for native) for the C lock
AmountIn uint64 // the SINGLE input amount
Path []ID // ordered marketIDs the route walks: A->B->C
MinAmountOut uint64 // floor on the FINAL output asset (D-enforced)
Recipient Account // where the FINAL D->C export must credit
Deadline uint64
CallIndex uint32
}
// firstMarket is the route's entry market (hop 0). The prepared C->D intent binds the
// route by its FIRST pool (the input lock happens against hop 0's pool); the path
// body carries the rest. ok=false for an empty path (a route needs >=1 hop).
func (r RouteRequest) firstMarket() (ID, bool) {
if len(r.Path) == 0 {
return ID{}, false
}
return r.Path[0], true
}
// RoutePhase is the lifecycle of a route as the off-chain watcher observes it. Like
// IntentPhase, NONE of these moves value; Committed means the watcher saw the ONE
// final D->C export, Refunded means it saw the ONE refund export.
type RoutePhase uint8
const (
RoutePending RoutePhase = 0 // notified; D has not started the route
RouteHopping RoutePhase = 1 // D is walking the path (a hop is in progress)
RouteCommitted RoutePhase = 2 // D produced the ONE final D->C export
RouteRefunded RoutePhase = 3 // route failed; D produced the ONE refund export
RouteRejected RoutePhase = 4 // D could not import/route (e.g. unbacked, expired)
RouteUnknown RoutePhase = 5
)
// RouteStatus is one poll/push of a route watch. HopIndex/HopAmountOut describe the
// CURRENT hop (orchestration ESTIMATES). Ref is the FINAL export POINTER, valid only
// when Phase==RouteCommitted (the final output) or RouteRefunded (the refund). There
// is no per-hop Ref field — by construction there is no per-hop settleable object.
type RouteStatus struct {
IntentID ID
Phase RoutePhase
HopIndex uint32 // the current/last hop the router reached (0-based)
HopCount uint32 // total hops in the path
HopAmountOut uint64 // ESTIMATE: output of the current hop (orchestration only)
FinalOut uint64 // ESTIMATE: final output (orchestration only)
Ref DExportRef // the ONE final (or refund) export; valid at Committed/Refunded
Reason string
}
// =============================================================================
// Route wire frames.
// =============================================================================
// RouteRequest envelope (client -> server for MsgRoutePrepare). The path is a list of
// bytes32 marketIDs; the fixed header carries the scalars and the single input/asset.
const (
rrNet = 0 // uint32
rrCallIdx = 4 // uint32
rrHopCount = 8 // uint32 (path length)
rrAmountIn = 16 // uint64
rrMinOut = 24 // uint64
rrDeadline = 32 // uint64
rrCChain = 40 // bytes32 [40:72]
rrDChain = 72 // bytes32 [72:104]
rrAccount = 104 // bytes20 [104:124]
rrAssetIn = 124 // bytes32 [124:156]
rrAssetAddr = 156 // bytes20 [156:176]
rrRecipient = 176 // bytes20 [176:196]
rrPath = 196 // bytes (slot 8) — concatenated marketIDs (hopCount*32)
rrSize = 204
)
func buildRouteRequest(r RouteRequest) (*zaplib.Message, error) {
pathBytes := make([]byte, 0, len(r.Path)*32)
for _, m := range r.Path {
pathBytes = append(pathBytes, m[:]...)
}
b := zaplib.NewBuilder(rrSize + len(pathBytes) + 128)
ob := b.StartObject(rrSize)
ob.SetUint32(rrNet, r.NetworkID)
ob.SetUint32(rrCallIdx, r.CallIndex)
ob.SetUint32(rrHopCount, uint32(len(r.Path)))
ob.SetUint64(rrAmountIn, r.AmountIn)
ob.SetUint64(rrMinOut, r.MinAmountOut)
ob.SetUint64(rrDeadline, r.Deadline)
ob.SetBytesFixed(rrCChain, r.CChainID[:])
ob.SetBytesFixed(rrDChain, r.DChainID[:])
ob.SetBytesFixed(rrAccount, r.Account[:])
ob.SetBytesFixed(rrAssetIn, r.AssetIn[:])
ob.SetBytesFixed(rrAssetAddr, r.AssetInAddr[:])
ob.SetBytesFixed(rrRecipient, r.Recipient[:])
ob.SetBytes(rrPath, pathBytes)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgRoutePrepare << 8))
}
func readRouteRequest(m *zaplib.Message) RouteRequest {
r := m.Root()
var out RouteRequest
out.NetworkID = r.Uint32(rrNet)
out.CallIndex = r.Uint32(rrCallIdx)
hopCount := int(r.Uint32(rrHopCount))
out.AmountIn = r.Uint64(rrAmountIn)
out.MinAmountOut = r.Uint64(rrMinOut)
out.Deadline = r.Uint64(rrDeadline)
copy(out.CChainID[:], r.BytesFixedSlice(rrCChain, 32))
copy(out.DChainID[:], r.BytesFixedSlice(rrDChain, 32))
copy(out.Account[:], r.BytesFixedSlice(rrAccount, 20))
copy(out.AssetIn[:], r.BytesFixedSlice(rrAssetIn, 32))
copy(out.AssetInAddr[:], r.BytesFixedSlice(rrAssetAddr, 20))
copy(out.Recipient[:], r.BytesFixedSlice(rrRecipient, 20))
pathBytes := r.Bytes(rrPath)
// Bound the declared hop count to what the bytes actually carry, so a corrupt
// hopCount cannot drive an over-read (fail-closed to the real byte length).
// Compare via division, never `hopCount*32`, so the bound holds even on a
// 32-bit `int` build where a huge hopCount would overflow the multiply.
if hopCount > len(pathBytes)/32 {
hopCount = len(pathBytes) / 32
}
out.Path = make([]ID, hopCount)
for i := 0; i < hopCount; i++ {
copy(out.Path[i][:], pathBytes[i*32:(i+1)*32])
}
return out
}
// RouteStatus envelope (server -> client for MsgRouteStatus).
const (
rsPhase = 0 // uint8
rsHopIndex = 4 // uint32
rsHopCount = 8 // uint32
rsRefIndex = 12 // uint32 (final ref output index)
rsHopOut = 16 // uint64 (hop output ESTIMATE)
rsFinalOut = 24 // uint64 (final output ESTIMATE)
rsIntent = 32 // bytes32 [32:64]
rsRefChain = 64 // bytes32 [64:96] final ref source chain
rsRefTx = 96 // bytes32 [96:128] final ref source tx
rsReason = 128 // text (slot 8)
rsSize = 136
)
func buildRouteStatus(s RouteStatus) (*zaplib.Message, error) {
b := zaplib.NewBuilder(rsSize + len(s.Reason) + 160)
ob := b.StartObject(rsSize)
ob.SetUint8(rsPhase, uint8(s.Phase))
ob.SetUint32(rsHopIndex, s.HopIndex)
ob.SetUint32(rsHopCount, s.HopCount)
ob.SetUint32(rsRefIndex, s.Ref.OutputIndex)
ob.SetUint64(rsHopOut, s.HopAmountOut)
ob.SetUint64(rsFinalOut, s.FinalOut)
ob.SetBytesFixed(rsIntent, s.IntentID[:])
ob.SetBytesFixed(rsRefChain, s.Ref.SourceChainID[:])
ob.SetBytesFixed(rsRefTx, s.Ref.SourceTxID[:])
ob.SetText(rsReason, s.Reason)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgRouteStatus << 8))
}
func readRouteStatus(m *zaplib.Message) RouteStatus {
r := m.Root()
var s RouteStatus
s.Phase = RoutePhase(r.Uint8(rsPhase))
s.HopIndex = r.Uint32(rsHopIndex)
s.HopCount = r.Uint32(rsHopCount)
s.Ref.OutputIndex = r.Uint32(rsRefIndex)
s.HopAmountOut = r.Uint64(rsHopOut)
s.FinalOut = r.Uint64(rsFinalOut)
copy(s.IntentID[:], r.BytesFixedSlice(rsIntent, 32))
copy(s.Ref.SourceChainID[:], r.BytesFixedSlice(rsRefChain, 32))
copy(s.Ref.SourceTxID[:], r.BytesFixedSlice(rsRefTx, 32))
s.Ref.IntentID = s.IntentID
s.Reason = r.Text(rsReason)
// Hygiene (mirror the streamRoute terminal-phase gate at line ~350): the D->C
// export pointer is meaningful ONLY in a terminal phase. Zero it otherwise so a
// non-terminal frame cannot surface a venue-supplied Ref to a direct Poll()
// caller. Defense-in-depth — the chain re-binds the object on import regardless,
// so this is not the money-plane gate, just orchestration-surface hygiene.
if s.Phase != RouteCommitted && s.Phase != RouteRefunded {
s.Ref = DExportRef{}
}
return s
}
// the route-watch-poll request carries just the intent id (tagged MsgRouteStatus).
func buildRouteWatchPoll(intentID ID) (*zaplib.Message, error) {
b := zaplib.NewBuilder(iwSize + 40)
ob := b.StartObject(iwSize)
ob.SetBytesFixed(iwIntent, intentID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgRouteStatus << 8))
}
func readRouteWatchPoll(m *zaplib.Message) ID {
r := m.Root()
var id ID
copy(id[:], r.BytesFixedSlice(iwIntent, 32))
return id
}
// =============================================================================
// RouteVenue — the OPTIONAL backend a server implements to serve routes.
// =============================================================================
// RouteVenue is the route backend a Venue MAY ALSO implement. It is a SEPARATE
// interface (composition, not interface-expansion) so the base Venue — and every
// existing implementation — stays unchanged: a server type-asserts for RouteVenue
// and serves routes only when the backend provides it. Like Venue, it has NO
// credit/settle method: it reports route progress and the final export POINTER; it
// never moves value. Exactly TWO methods — one trigger, one status — keeps it DRY.
type RouteVenue interface {
// NotifyRoute tells the venue to scan the SINGLE C->D input object the user's
// signed tx created and begin walking the path carried in the request. It returns
// the deterministic route intent id to watch; it does NOT build calldata (the
// SESSION builds calldata client-side from the user's own path, so a malicious
// server cannot inject a forged path the user would sign), does not block on a D
// block, and credits nothing.
NotifyRoute(req RouteRequest) (IntentWatchRef, error)
// RouteStatus reports the current route phase + hop progress, and (when D has
// committed) the POINTER to the ONE final (or refund) export. It reads D/shared-
// memory state; it never fabricates a credit.
RouteStatus(intentID ID) (RouteStatus, error)
}
// =============================================================================
// RouteWatch — the route subscription handle.
// =============================================================================
// RouteWatch is the subscription a route NotifyCToDExport returns. Poll observes the
// current route phase + hop progress; streamUntilFinal resolves the ONE final (or
// refund) export pointer. It NEVER moves value — the terminal pointer feeds the one
// DS01 settlement, which the chain judges.
type RouteWatch struct {
session *V4RouteSession
intentID ID
hopCount uint32
}
// IntentID is the route intent this watch tracks.
func (w RouteWatch) IntentID() ID { return w.intentID }
// Poll observes the current route status once (read-only). Gated by AuthWatch.
func (w RouteWatch) Poll(ctx context.Context) (RouteStatus, error) {
if w.session == nil {
return RouteStatus{Phase: RouteUnknown}, fmt.Errorf("dexsession: nil route watch")
}
if err := w.session.cap.authorizeSelf(AuthWatch); err != nil {
return RouteStatus{Phase: RouteUnknown}, err
}
reqMsg, err := buildRouteWatchPoll(w.intentID)
if err != nil {
return RouteStatus{Phase: RouteUnknown}, err
}
resp, err := w.session.dex.conn.Call(ctx, w.session.dex.peerID, reqMsg)
if err != nil {
return RouteStatus{Phase: RouteUnknown}, err
}
defer resp.Release()
return readRouteStatus(resp), nil
}
// streamUntilFinal polls the route until it commits (the ONE final export), refunds
// (the ONE refund export), is rejected, or ctx ends. A never-resolving route leaves
// the promise pending until ctx — the correct behaviour (no committed export => no
// settlement). It resolves the SINGLE settleable pointer of the whole route.
func (w RouteWatch) streamUntilFinal(ctx context.Context, p *Promise[DExportRef]) {
backoff := 25 * time.Millisecond
const maxBackoff = 2 * time.Second
for {
select {
case <-ctx.Done():
p.fulfil(DExportRef{}, ctx.Err())
return
default:
}
st, err := w.Poll(ctx)
if err != nil {
p.fulfil(DExportRef{}, err)
return
}
switch st.Phase {
case RouteCommitted, RouteRefunded:
ref := st.Ref
ref.IntentID = w.intentID
p.fulfil(ref, nil)
return
case RouteRejected:
reason := st.Reason
if reason == "" {
reason = "route rejected by D"
}
p.fulfil(DExportRef{}, fmt.Errorf("dexsession: route %x rejected: %s", w.intentID[:8], reason))
return
default:
if !sleepCtx(ctx, backoff) {
p.fulfil(DExportRef{}, ctx.Err())
return
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"crypto/sha256"
"encoding/binary"
"errors"
)
// v4scope.go is the PER-ACTION capability confinement. The existing capability.go
// gates by operation CLASS (a QuoteCap can quote, a SettlementCap can point). A V4
// session needs a finer grant: a capability bound to ONE V4 action — one swap, one
// route, one position commit. Holding a swap session's capability must not let you
// drive a DIFFERENT intent, pool, or params, nor a different action kind.
//
// This is defence in depth on top of the value-free surface: even though NO message
// moves money, scoping each session to one action means a leaked/confused capability
// cannot redirect orchestration onto a victim's intent (e.g. notify D about, or build
// settlement calldata for, an intent the holder was not granted). The scope is bound
// at open time and checked on every session operation; a mismatch fails closed.
// V4Action is the kind of V4 action a session capability is scoped to. A scoped
// capability is confined to exactly one kind — a swap cap cannot drive a liquidity
// action and vice versa.
type V4Action uint8
const (
ActionSwap V4Action = iota
ActionRoute
ActionModifyLiquidity
ActionCollect
ActionCancel
ActionState
)
func (a V4Action) String() string {
switch a {
case ActionSwap:
return "swap"
case ActionRoute:
return "route"
case ActionModifyLiquidity:
return "modifyLiquidity"
case ActionCollect:
return "collect"
case ActionCancel:
return "cancel"
case ActionState:
return "state"
default:
return "unknown"
}
}
var (
// ErrScopeMismatch is returned when a session capability is used for an action
// (intent / pool / params / kind) it was not scoped to. Fail-closed.
ErrScopeMismatch = errors.New("dexsession: capability is scoped to a different V4 action")
// ErrScopeUnset is returned when a scoped operation is attempted on a session
// whose scope was never bound (a zero scope never authorizes).
ErrScopeUnset = errors.New("dexsession: V4 session scope is unset")
)
// V4ActionScope binds a session capability to ONE V4 action. Every field that
// distinguishes one action from another is here; DeriveSessionID hashes them into a
// stable id. Two sessions for different intents/pools/params/kinds derive different
// ids and their capabilities cannot be cross-used.
//
// NetworkID — the Lux network the action runs on.
// CChainID — the C-Chain (EVM balance authority).
// DChainID — the D-Chain (matching authority).
// Addr9999 — the on-chain settlement authority (the precompile address).
// Account — the caller / account the action is for (bound as the object owner).
// PoolKeyHash — hash of the V4 PoolKey (which market). For a route, hash of the
// whole path (see DeriveRoutePathHash).
// ParamsHash — hash of the action params (direction+amount for a swap; tick range
// +delta for liquidity; the collected/cancelled selector for those).
// Kind — the V4Action this scope permits.
// IntentID — the deterministic intent id (for swap/route) the action targets.
// Zero for actions whose object is not an intent (a position commit
// binds by PoolKeyHash+ParamsHash+Account instead).
type V4ActionScope struct {
NetworkID uint32
CChainID ID
DChainID ID
Addr9999 Account
Account Account
PoolKeyHash ID
ParamsHash ID
Kind V4Action
IntentID ID
}
// v4ScopeDomain separates the session-id hash from every other id in the system, so
// a session id can never collide with an intent id, a UTXO id, or an asset id.
const v4ScopeDomain = "lux.dex.v4.session.v1"
// DeriveSessionID is the stable, deterministic id of a V4 action scope. It binds
// EVERY distinguishing field, so the id is a faithful fingerprint of the action: any
// change to network/chain/account/pool/params/kind/intent yields a different id, and
// a capability presented for one session id cannot satisfy another.
func DeriveSessionID(s V4ActionScope) ID {
h := sha256.New()
h.Write([]byte(v4ScopeDomain))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], s.NetworkID)
h.Write(u4[:])
h.Write(s.CChainID[:])
h.Write(s.DChainID[:])
h.Write(s.Addr9999[:])
h.Write(s.Account[:])
h.Write(s.PoolKeyHash[:])
h.Write(s.ParamsHash[:])
h.Write([]byte{byte(s.Kind)})
h.Write(s.IntentID[:])
var out ID
copy(out[:], h.Sum(nil))
return out
}
// DerivePoolKeyHash hashes a V4 PoolKey tuple into the scope's PoolKeyHash. It binds
// the exact market a session may act on; a capability for pool P1 cannot drive pool
// P2 because the derived session ids differ.
func DerivePoolKeyHash(pk PoolKeyArgs) ID {
h := sha256.New()
h.Write([]byte("lux.dex.v4.poolkey.v1"))
h.Write(pk.Currency0[:])
h.Write(pk.Currency1[:])
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], pk.Fee)
h.Write(u4[:])
binary.BigEndian.PutUint32(u4[:], uint32(pk.TickSpacing))
h.Write(u4[:])
h.Write(pk.Hooks[:])
var out ID
copy(out[:], h.Sum(nil))
return out
}
// DeriveSwapParamsHash hashes the swap-defining params (direction + exact-input
// amount) into the scope's ParamsHash. Two swaps that differ in direction or amount
// derive different session ids — a capability for one cannot drive the other.
func DeriveSwapParamsHash(zeroForOne bool, amountIn uint64) ID {
h := sha256.New()
h.Write([]byte("lux.dex.v4.swapparams.v1"))
var b [9]byte
if zeroForOne {
b[0] = 1
}
binary.BigEndian.PutUint64(b[1:9], amountIn)
h.Write(b[:])
var out ID
copy(out[:], h.Sum(nil))
return out
}
// DeriveLiquidityParamsHash hashes the modifyLiquidity-defining params (tick range,
// signed liquidity delta, salt) into the scope's ParamsHash.
func DeriveLiquidityParamsHash(tickLower, tickUpper int32, liquidityDelta int64, salt ID) ID {
h := sha256.New()
h.Write([]byte("lux.dex.v4.lpparams.v1"))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], uint32(tickLower))
h.Write(u4[:])
binary.BigEndian.PutUint32(u4[:], uint32(tickUpper))
h.Write(u4[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], uint64(liquidityDelta))
h.Write(u8[:])
h.Write(salt[:])
var out ID
copy(out[:], h.Sum(nil))
return out
}
// scopedCap is a capability CONFINED to one V4 action scope. It composes the
// existing capCore (operation-class authority) with a session scope (per-action
// confinement). Authority is checked in TWO independent gates:
//
// 1. core.authorize(need) — the operation class is permitted at the session of
// record (the existing, revocation-aware grant table).
// 2. checkScope(scope) — the action the operation is for matches the scope this
// capability was bound to.
//
// Composition over inheritance (Hickey): scopedCap WRAPS capCore; it does not
// subclass or widen it. A scopedCap can never grant authority capCore lacks, and the
// scope can only NARROW, never broaden.
type scopedCap struct {
core *capCore
scope V4ActionScope
sessionID ID
}
// newScopedCap binds a freshly-issued capability core to an action scope. The
// session id is derived once and frozen; subsequent checks compare against it.
func newScopedCap(core *capCore, scope V4ActionScope) *scopedCap {
return &scopedCap{core: core, scope: scope, sessionID: DeriveSessionID(scope)}
}
// authorizeScoped is the combined gate: operation-class authority AND action-scope
// confinement. `need` is the operation class (AuthQuote/AuthIntent/...); `want` is
// the action scope the caller is acting on. Both must pass, or fail closed.
func (s *scopedCap) authorizeScoped(need Authority, want V4ActionScope) error {
if s == nil || s.core == nil {
return ErrNoAuthority
}
if s.sessionID == (ID{}) {
return ErrScopeUnset
}
if err := s.core.authorize(need); err != nil {
return err
}
if DeriveSessionID(want) != s.sessionID {
return ErrScopeMismatch
}
return nil
}
// authorizeSelf is the gate for operations on the capability's OWN bound scope (the
// common case — a session driving its own action). It checks operation-class
// authority and that the scope is set; the scope match is trivially true.
func (s *scopedCap) authorizeSelf(need Authority) error {
return s.authorizeScoped(need, s.scope)
}
// SessionID exposes the bound session id (for V4Event tagging and tests).
func (s *scopedCap) SessionID() ID { return s.sessionID }
// Authority reports the operation-class bits the underlying core holds.
func (s *scopedCap) Authority() Authority {
if s == nil || s.core == nil {
return 0
}
return s.core.bits
}
+888
View File
@@ -0,0 +1,888 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"fmt"
)
// v4session.go is the V4-native, bidirectional, capability-scoped session model. It
// is the TOP layer of the orchestration stack:
//
// V4PrecompileSession (this file) — opens a session per V4 action
// V4SwapSession / V4RouteSession / V4LiquiditySession / V4CollectSession /
// V4StateSession — one action's lifecycle state machine
// clientSession (session.go) — the typed Call/watch primitives
// zap.Node (luxfi/zap) — the correlated binary transport
//
// COMPOSITION, NOT A SECOND TRANSPORT (Hickey): a V4 session does NOT open its own
// socket or invent a new wire. It COMPOSES the existing clientSession operations
// (Quote/PrepareSwapIntent/NotifyIntent/ImportSettlement/watch) under named V4
// transitions, and holds a scopedCap that confines it to ONE action. The
// bidirectional message set (v4msg.go) is the SEMANTIC overlay; the wire (wire.go)
// stays the single source of transport truth. This keeps "one way to do everything".
//
// THE MONEY-PLANE INVARIANT, RESTATED FOR THE BIDIRECTIONAL SESSION:
//
// A V4 session is a live bidirectional control plane: both C-side and D-side write
// through it during an action's lifetime. EVERY such read/write may update session
// state; NONE moves value. The session exposes:
// - WRITES that BUILD calldata (PrepareIntent / PrepareSettlement / commit /
// collect / cancel) — bytes the user signs; reserve nothing, credit nothing.
// - WRITES that TRIGGER D (NotifyCToDExport) — make D scan a C->D object the
// user's signed tx already created; cannot make a match valid without a D block.
// - READS that STREAM status/estimates (QuoteUpdate / D_IMPORTED / MATCHED /
// HOP_* / D_COMMITTED) — orchestration; the chain trusts none of them.
// - READS that yield a POINTER (D_EXPORT_READY / REFUND_READY) — a DExportRef the
// chain re-verifies on import.
// There is NO session method that takes a D->C read (MatchResult, hop fill, …) and
// produces a credit. The ONLY way to credit C is to settle a real D->C object via
// the DS01 Phase-B path — which binds owner/asset/amount to the recorded object.
// The RED suite proves no bidirectional message sequence, absent a real object,
// moves money.
// =============================================================================
// V4PrecompileSession — the top-level capability.
// =============================================================================
// V4PrecompileSession is the per-action session factory the spec mandates. It wraps a
// restricted DexSession (the bootstrap grant) and opens a NARROWLY-SCOPED session per
// V4 action. Each open* mints a scopedCap confined to {networkID, cChainID, dChainID,
// 0x9999, account, poolKeyHash, paramsHash, intentID} for exactly that action, so a
// session cannot drive any other intent/pool/params/kind.
type V4PrecompileSession struct {
dex *clientSession
netID uint32
cChain ID
dChain ID
addr Account // 0x9999
}
// V4Config configures the V4 precompile session. The chain identifiers and the 0x9999
// address are fixed for the deployment; they bind every action's scope.
type V4Config struct {
Session DexSession
NetworkID uint32
CChainID ID
DChainID ID
// Addr9999 is the on-chain settlement authority. Defaults to the canonical
// 0x...9999 if zero.
Addr9999 Account
}
// NewV4PrecompileSession builds the top-level session over a bootstrapped DexSession.
// The DexSession MUST be a *clientSession (the only implementation); a foreign
// implementation is rejected, because the V4 layer composes the concrete client ops.
func NewV4PrecompileSession(cfg V4Config) (*V4PrecompileSession, error) {
cs, ok := cfg.Session.(*clientSession)
if !ok || cs == nil {
return nil, fmt.Errorf("dexsession: V4PrecompileSession requires a bootstrapped DexSession")
}
addr := cfg.Addr9999
if addr == (Account{}) {
addr = addr9999()
}
return &V4PrecompileSession{
dex: cs,
netID: cfg.NetworkID,
cChain: cfg.CChainID,
dChain: cfg.DChainID,
addr: addr,
}, nil
}
// scopeFor builds an action scope from the session's fixed identity plus the
// per-action fields. The session id derived from it confines the minted capability.
func (v *V4PrecompileSession) scopeFor(kind V4Action, account Account, poolKeyHash, paramsHash, intentID ID) V4ActionScope {
return V4ActionScope{
NetworkID: v.netID,
CChainID: v.cChain,
DChainID: v.dChain,
Addr9999: v.addr,
Account: account,
PoolKeyHash: poolKeyHash,
ParamsHash: paramsHash,
Kind: kind,
IntentID: intentID,
}
}
// mint issues a scoped capability for `bits` confined to `scope`. It fails closed if
// the underlying session lacks the operation-class authority (you cannot open a
// session whose actions you could not perform).
func (v *V4PrecompileSession) mint(bits Authority, scope V4ActionScope) (*scopedCap, error) {
core, err := v.dex.issue(bits)
if err != nil {
return nil, err
}
return newScopedCap(core, scope), nil
}
// =============================================================================
// openSwap -> V4SwapSession
// =============================================================================
// openSwap opens a single-swap session scoped to {req}. The intent id is derived
// deterministically (identically to the on-chain SubmitSwapIntent) and frozen into
// the scope, so this session's capability can drive ONLY this swap. Requires the
// intent + watch + settlement authorities (a swap walks the full lifecycle).
func (v *V4PrecompileSession) OpenSwap(req SwapIntentRequest) (*V4SwapSession, error) {
pk, ok := v.dex.market(req.MarketID)
if !ok {
return nil, fmt.Errorf("dexsession: openSwap: unknown market %x", req.MarketID[:8])
}
zfo := zeroForOneFor(req)
poolKeyHash := DerivePoolKeyHash(pk)
paramsHash := DeriveSwapParamsHash(zfo, req.AmountIn)
intentID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, req.MarketID)
scope := v.scopeFor(ActionSwap, req.Account, poolKeyHash, paramsHash, intentID)
cap, err := v.mint(AuthIntent|AuthWatch|AuthSettlement, scope)
if err != nil {
return nil, err
}
return &V4SwapSession{dex: v.dex, cap: cap, req: req, intentID: intentID}, nil
}
// V4SwapSession is the single-swap lifecycle state machine. It exposes the
// bidirectional message set as typed methods that compose the clientSession ops and
// pipeline via Promise. It holds a scopedCap confined to ONE swap.
type V4SwapSession struct {
dex *clientSession
cap *scopedCap
req SwapIntentRequest
intentID ID
}
// SessionID is the action's stable id (binds events to this session).
func (s *V4SwapSession) SessionID() ID { return s.cap.SessionID() }
// IntentID is the deterministic intent id this swap targets.
func (s *V4SwapSession) IntentID() ID { return s.intentID }
// WritePrepareIntent [C->D, V4_SWAP_PREPARE_INTENT] builds the 0x9999 swap calldata
// (DI01 intent) the user signs. It reserves nothing; QuotedOut is an estimate. Scoped:
// only this swap's params. Pipelined: returns a Promise.
func (s *V4SwapSession) WritePrepareIntent(ctx context.Context) *Promise[PreparedIntent] {
p := newPromise[PreparedIntent]()
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
p.fulfil(PreparedIntent{}, err)
return p
}
return s.dex.PrepareSwapIntent(ctx, s.req)
}
// WriteNotifyCToDExport [C->D, V4_SWAP_NOTIFY_C_EXPORT] tells D to scan the C->D
// object the user's signed tx created and returns a watch. It cannot validate a match
// without a D block. Pipelines on the prepared-intent promise.
func (s *V4SwapSession) WriteNotifyCToDExport(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[IntentWatch] {
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
out := newPromise[IntentWatch]()
out.fulfil(IntentWatch{}, err)
return out
}
// Bind the intent's scope to THIS session before notifying: a prepared intent for
// a different swap (different intentID) must not be notified through this session.
guarded := thenAsync(ctx, intent, func(_ context.Context, pi PreparedIntent) (PreparedIntent, error) {
if pi.IntentID != s.intentID {
return PreparedIntent{}, ErrScopeMismatch
}
return pi, nil
})
return s.dex.NotifyIntent(ctx, guarded)
}
// ReadStream [D->C] streams the D->C reads of the swap lifecycle (D_IMPORTED ->
// MATCHED -> D_EXPORT_READY) as V4Events on the returned channel, until the export is
// ready, the intent is rejected, or ctx ends. Each event is orchestration; only the
// final D_EXPORT_READY carries the settleable POINTER. This is the bidirectional read
// side made explicit: the caller observes D writing to the control plane.
func (s *V4SwapSession) ReadStream(ctx context.Context, watch IntentWatch) <-chan V4Event {
ch := make(chan V4Event, 4)
if err := s.cap.authorizeSelf(AuthWatch); err != nil {
ch <- V4Event{Type: V4_ERROR, SessionID: s.SessionID(), IntentID: s.intentID, Reason: err.Error()}
close(ch)
return ch
}
go s.streamEvents(ctx, watch, ch)
return ch
}
// streamEvents polls the watch and translates each IntentStatus phase into the
// corresponding V4 D->C read event. It emits D_IMPORTED on the first Matching, MATCHED
// (with the estimate) while matching, and D_EXPORT_READY with the POINTER on commit.
// It NEVER credits — it labels what D reported.
func (s *V4SwapSession) streamEvents(ctx context.Context, watch IntentWatch, ch chan<- V4Event) {
defer close(ch)
sid := s.SessionID()
emittedImported := false
for {
select {
case <-ctx.Done():
return
default:
}
st, err := watch.Poll(ctx)
if err != nil {
emit(ctx, ch, V4Event{Type: V4_ERROR, SessionID: sid, IntentID: s.intentID, Reason: err.Error()})
return
}
switch st.Phase {
case PhaseMatching:
if !emittedImported {
if !emit(ctx, ch, V4Event{Type: V4_SWAP_D_IMPORTED, SessionID: sid, IntentID: s.intentID}) {
return
}
emittedImported = true
}
// MatchResult: the matched-out ESTIMATE. Orchestration only.
if !emit(ctx, ch, V4Event{Type: V4_SWAP_MATCHED, SessionID: sid, IntentID: s.intentID, EstAmount: st.MatchedOut}) {
return
}
case PhaseCommitted:
ref := st.Ref
ref.IntentID = s.intentID // bind the pointer to THIS intent
emit(ctx, ch, V4Event{Type: V4_SWAP_D_EXPORT_READY, SessionID: sid, IntentID: s.intentID, EstAmount: st.MatchedOut, Ref: ref})
return
case PhaseRejected:
reason := st.Reason
if reason == "" {
reason = "intent rejected by D"
}
emit(ctx, ch, V4Event{Type: V4_ERROR, SessionID: sid, IntentID: s.intentID, Reason: reason})
return
default:
// Pending / Unknown: keep polling on the cadence below.
}
// Yield to D's commit cadence between polls. A committed/rejected returns above.
if !sleepCtx(ctx, watchPollCadence) {
return
}
}
}
// OnExportReady [D->C, V4_SWAP_D_EXPORT_READY] resolves to the ONE D->C export
// POINTER when D commits — the pipelining hook the spec's lifecycle wants
// (watch.OnCommitted -> settlement). It is the streaming watch; the credit still
// happens on-chain against the real object.
func (s *V4SwapSession) OnExportReady(ctx context.Context, watch IntentWatch) *Promise[DExportRef] {
if err := s.cap.authorizeSelf(AuthWatch); err != nil {
p := newPromise[DExportRef]()
p.fulfil(DExportRef{}, err)
return p
}
return watch.OnCommitted(ctx)
}
// WritePrepareCSettlement [C->D, V4_SWAP_PREPARE_C_SETTLEMENT] builds the 0x9999 DS01
// settlement calldata pointing at the export ref. It binds the ref's intent id to
// THIS session (a ref for another intent is refused). The chain credits on consuming
// the real object; this returns bytes. Pipelines on the ref promise.
func (s *V4SwapSession) WritePrepareCSettlement(ctx context.Context, ref *Promise[DExportRef]) *Promise[SettlementSubmitResult] {
if err := s.cap.authorizeSelf(AuthSettlement); err != nil {
out := newPromise[SettlementSubmitResult]()
out.fulfil(SettlementSubmitResult{}, err)
return out
}
guarded := thenAsync(ctx, ref, func(_ context.Context, r DExportRef) (DExportRef, error) {
if r.IntentID != s.intentID {
return DExportRef{}, ErrScopeMismatch
}
return r, nil
})
return s.dex.ImportSettlement(ctx, guarded)
}
// Run drives the FULL bidirectional swap lifecycle, promise-pipelined, returning the
// stages. It is the V4-native analogue of SwapFlow: prepare -> notify -> watch ->
// export-ready -> settlement calldata. The user signs+sends the prepared intent (this
// layer has no key); pass a sender to RunWithSender for a managed flow. Every stage is
// scoped to this swap.
func (s *V4SwapSession) Run(ctx context.Context) FlowStages {
intent := s.WritePrepareIntent(ctx)
watch := s.WriteNotifyCToDExport(ctx, intent)
committed := thenAsync(ctx, watch, func(ctx context.Context, w IntentWatch) (DExportRef, error) {
return s.OnExportReady(ctx, w).Await(ctx)
})
settle := s.WritePrepareCSettlement(ctx, committed)
quote := s.dex.Quote(ctx, QuoteRequest{MarketID: s.req.MarketID, AmountIn: s.req.AmountIn, ZeroForOne: zeroForOneFor(s.req)})
return FlowStages{Quote: quote, Intent: intent, Watch: watch, Committed: committed, Settle: settle}
}
// Close retires the session's capability (no further operation may use it). Idempotent.
func (s *V4SwapSession) Close() { s.dex.Revoke(s.cap.core.tok) }
// =============================================================================
// openRoute -> V4RouteSession
// =============================================================================
// OpenRoute opens a MULTI-HOP route session scoped to {req.Path}. The route intent id
// is derived from the FIRST hop's market (the input lock binds there) and the path is
// hashed into the scope, so this capability can drive ONLY this exact path.
func (v *V4PrecompileSession) OpenRoute(req RouteRequest) (*V4RouteSession, error) {
first, ok := req.firstMarket()
if !ok {
return nil, fmt.Errorf("dexsession: openRoute: empty path")
}
pk, ok := v.dex.market(first)
if !ok {
return nil, fmt.Errorf("dexsession: openRoute: unknown entry market %x", first[:8])
}
_ = pk
pathHash := DeriveRoutePathHash(req.Path)
paramsHash := DeriveSwapParamsHash(true, req.AmountIn)
// The route's intent id binds the single C->D input by the FIRST market (the lock
// market), identically to a swap intent on that market.
intentID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, first)
scope := v.scopeFor(ActionRoute, req.Account, pathHash, paramsHash, intentID)
cap, err := v.mint(AuthIntent|AuthWatch|AuthSettlement, scope)
if err != nil {
return nil, err
}
return &V4RouteSession{dex: v.dex, cap: cap, req: req, intentID: intentID, entry: first}, nil
}
// V4RouteSession is the multi-hop route lifecycle state machine. It prepares EXACTLY
// ONE C->D input intent (carrying the path), streams hop progress as orchestration,
// and settles the ONE final D->C export. It NEVER produces an intermediate-asset
// settlement.
type V4RouteSession struct {
dex *clientSession
cap *scopedCap
req RouteRequest
intentID ID
entry ID // entry market (hop 0)
}
func (s *V4RouteSession) SessionID() ID { return s.cap.SessionID() }
func (s *V4RouteSession) IntentID() ID { return s.intentID }
func (s *V4RouteSession) Path() []ID { return s.req.Path }
// WritePrepareIntent [C->D, V4_ROUTE_PREPARE_INTENT] builds the ONE route-intent
// calldata: a 0x9999 swap (DI01 intent) on the ENTRY market whose hookData carries the
// whole path. This is the single C->D input the user signs; there is no per-hop
// calldata. The route's atomicity starts here: one calldata, one intent id, one input.
func (s *V4RouteSession) WritePrepareIntent(ctx context.Context) *Promise[PreparedIntent] {
p := newPromise[PreparedIntent]()
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
p.fulfil(PreparedIntent{}, err)
return p
}
go func() {
p.fulfil(s.prepareRouteLocal())
}()
return p
}
// prepareRouteLocal builds the single route-intent PreparedIntent CLIENT-SIDE (so a
// malicious server cannot inject a forged path or recipient the user would sign). The
// calldata is a swap on the entry market with a route-path hookData; the path is the
// user's own. It reserves nothing and returns no enforceable amount.
func (s *V4RouteSession) prepareRouteLocal() (PreparedIntent, error) {
if s.req.AmountIn == 0 {
return PreparedIntent{}, fmt.Errorf("dexsession: openRoute: zero amountIn")
}
pk, ok := s.dex.market(s.entry)
if !ok {
return PreparedIntent{}, fmt.Errorf("dexsession: openRoute: unknown entry market %x", s.entry[:8])
}
hookData := EncodeRouteIntentHookData(s.req.Path)
// Verify the path we encoded round-trips (defence against a corrupted build before
// handing the user bytes to sign).
if got, ok := DecodeRouteIntentHookData(hookData); !ok || len(got) != len(s.req.Path) {
return PreparedIntent{}, fmt.Errorf("dexsession: openRoute: route hookData build failed")
}
calldata := EncodeSwapCalldata(pk, true /*entry direction; D re-validates*/, s.req.AmountIn, hookData)
return PreparedIntent{
To: addr9999(),
Calldata: calldata,
HookData: hookData,
IntentID: s.intentID,
DChainID: s.req.DChainID,
CChainID: s.req.CChainID,
Account: s.req.Account,
Recipient: s.req.Recipient,
AssetIn: s.req.AssetIn,
AmountIn: s.req.AmountIn,
MarketID: s.entry,
}, nil
}
// WriteNotifyCToDExport [C->D, V4_ROUTE_NOTIFY_C_EXPORT] tells D to scan the SINGLE
// C->D input object and walk the path. Returns a route watch.
func (s *V4RouteSession) WriteNotifyCToDExport(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[RouteWatch] {
out := newPromise[RouteWatch]()
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
out.fulfil(RouteWatch{}, err)
return out
}
return thenAsync(ctx, intent, func(ctx context.Context, pi PreparedIntent) (RouteWatch, error) {
if pi.IntentID != s.intentID {
return RouteWatch{}, ErrScopeMismatch
}
reqMsg, err := buildRouteRequest(s.req)
if err != nil {
return RouteWatch{}, err
}
// retag as a notify-route (server routes MsgRouteStatus for the notify+poll;
// the request body carries the path so the venue can begin walking).
resp, err := s.dex.conn.Call(ctx, s.dex.peerID, reqMsg)
if err != nil {
return RouteWatch{}, err
}
defer resp.Release()
return RouteWatch{session: s, intentID: s.intentID, hopCount: uint32(len(s.req.Path))}, nil
})
}
// ReadStream [D->C] streams the route's D->C reads (HOP_STARTED / HOP_FILLED ->
// D_EXPORT_READY or REFUND_READY) as V4Events. Hop events carry intermediate amounts
// as ESTIMATES; only the terminal export/refund carries the ONE settleable POINTER.
func (s *V4RouteSession) ReadStream(ctx context.Context, watch RouteWatch) <-chan V4Event {
ch := make(chan V4Event, 8)
if err := s.cap.authorizeSelf(AuthWatch); err != nil {
ch <- V4Event{Type: V4_ERROR, SessionID: s.SessionID(), IntentID: s.intentID, Reason: err.Error()}
close(ch)
return ch
}
go s.streamRoute(ctx, watch, ch)
return ch
}
func (s *V4RouteSession) streamRoute(ctx context.Context, watch RouteWatch, ch chan<- V4Event) {
defer close(ch)
sid := s.SessionID()
lastHop := int64(-1)
for {
select {
case <-ctx.Done():
return
default:
}
st, err := watch.Poll(ctx)
if err != nil {
emit(ctx, ch, V4Event{Type: V4_ERROR, SessionID: sid, IntentID: s.intentID, Reason: err.Error()})
return
}
switch st.Phase {
case RouteHopping:
if int64(st.HopIndex) > lastHop {
lastHop = int64(st.HopIndex)
if !emit(ctx, ch, V4Event{Type: V4_ROUTE_HOP_STARTED, SessionID: sid, IntentID: s.intentID, HopIndex: st.HopIndex}) {
return
}
}
// HOP_FILLED carries the hop output ESTIMATE — orchestration. NO ref: there
// is no per-hop settleable object (the route stays on D between hops). The
// event is constructed with a ZERO Ref regardless of what the venue reported
// for this phase, so a malicious venue cannot smuggle a settleable pointer
// into a hop event.
if !emit(ctx, ch, V4Event{Type: V4_ROUTE_HOP_FILLED, SessionID: sid, IntentID: s.intentID, HopIndex: st.HopIndex, EstAmount: st.HopAmountOut}) {
return
}
case RouteCommitted:
ref := st.Ref
ref.IntentID = s.intentID
emit(ctx, ch, V4Event{Type: V4_ROUTE_D_EXPORT_READY, SessionID: sid, IntentID: s.intentID, EstAmount: st.FinalOut, Ref: ref})
return
case RouteRefunded:
ref := st.Ref
ref.IntentID = s.intentID
emit(ctx, ch, V4Event{Type: V4_ROUTE_REFUND_READY, SessionID: sid, IntentID: s.intentID, EstAmount: st.FinalOut, Ref: ref, Reason: st.Reason})
return
case RouteRejected:
reason := st.Reason
if reason == "" {
reason = "route rejected by D"
}
emit(ctx, ch, V4Event{Type: V4_ERROR, SessionID: sid, IntentID: s.intentID, Reason: reason})
return
default:
}
if !sleepCtx(ctx, watchPollCadence) {
return
}
}
}
// OnFinalExport [D->C] resolves to the ONE final (or refund) export POINTER when the
// route terminates. This is the SINGLE settleable pointer of the whole route.
func (s *V4RouteSession) OnFinalExport(ctx context.Context, watch RouteWatch) *Promise[DExportRef] {
p := newPromise[DExportRef]()
if err := s.cap.authorizeSelf(AuthWatch); err != nil {
p.fulfil(DExportRef{}, err)
return p
}
go watch.streamUntilFinal(ctx, p)
return p
}
// WritePrepareCSettlement [C->D, V4_ROUTE_PREPARE_C_SETTLEMENT] settles the ONE final
// (or refund) export. Identical to the swap settlement — the route's single output is
// just a D->C object consumed by the one DS01 credit path.
func (s *V4RouteSession) WritePrepareCSettlement(ctx context.Context, ref *Promise[DExportRef]) *Promise[SettlementSubmitResult] {
if err := s.cap.authorizeSelf(AuthSettlement); err != nil {
out := newPromise[SettlementSubmitResult]()
out.fulfil(SettlementSubmitResult{}, err)
return out
}
guarded := thenAsync(ctx, ref, func(_ context.Context, r DExportRef) (DExportRef, error) {
if r.IntentID != s.intentID {
return DExportRef{}, ErrScopeMismatch
}
return r, nil
})
return s.dex.ImportSettlement(ctx, guarded)
}
// Close retires the route session's capability.
func (s *V4RouteSession) Close() { s.dex.Revoke(s.cap.core.tok) }
// =============================================================================
// openModifyLiquidity -> V4LiquiditySession (commit C->D funded position)
// =============================================================================
// LiquidityRequest is the off-chain request to COMMIT (open/increase) a funded
// position. LiquidityDelta MUST be positive for a commit (it funds the position from
// C); the negative-delta (collect/decrease/cancel) path is V4CollectSession /
// openCancel, whose credit rides the DS01 settlement.
type LiquidityRequest struct {
NetworkID uint32
CChainID ID
DChainID ID
Account Account
MarketID ID
TickLower int32
TickUpper int32
LiquidityDelta int64 // >0 to commit funds
Salt ID
AssetIn ID
AmountIn uint64 // the C-side funding amount locked for the position
CallIndex uint32
}
// OpenModifyLiquidity opens a position-commit session scoped to {req}. A commit is a
// DEPOSIT: it creates a C->D funded-position object and never credits C, so the
// session needs only the intent authority (build + notify), not settlement.
func (v *V4PrecompileSession) OpenModifyLiquidity(req LiquidityRequest) (*V4LiquiditySession, error) {
if req.LiquidityDelta <= 0 {
return nil, fmt.Errorf("dexsession: openModifyLiquidity: commit requires positive liquidityDelta (use openCollect/openCancel to remove)")
}
pk, ok := v.dex.market(req.MarketID)
if !ok {
return nil, fmt.Errorf("dexsession: openModifyLiquidity: unknown market %x", req.MarketID[:8])
}
poolKeyHash := DerivePoolKeyHash(pk)
paramsHash := DeriveLiquidityParamsHash(req.TickLower, req.TickUpper, req.LiquidityDelta, req.Salt)
// The position-commit object binds by account+pool+params (no swap intent id); the
// intent id slot is the derivation over the funding identity for correlation.
intentID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetIn, req.AmountIn, req.MarketID)
scope := v.scopeFor(ActionModifyLiquidity, req.Account, poolKeyHash, paramsHash, intentID)
cap, err := v.mint(AuthIntent|AuthWatch, scope)
if err != nil {
return nil, err
}
return &V4LiquiditySession{dex: v.dex, cap: cap, req: req, pk: pk, intentID: intentID}, nil
}
// V4LiquiditySession is the position-commit lifecycle. It builds modifyLiquidity
// (+delta) calldata the user signs to fund the position, notifies D, and observes the
// position opening. It has NO settlement method (a commit credits nothing).
type V4LiquiditySession struct {
dex *clientSession
cap *scopedCap
req LiquidityRequest
pk PoolKeyArgs
intentID ID
}
func (s *V4LiquiditySession) SessionID() ID { return s.cap.SessionID() }
func (s *V4LiquiditySession) IntentID() ID { return s.intentID }
// WritePrepareCommit [C->D, V4_LP_PREPARE_COMMIT] builds the 0x9999 modifyLiquidity
// (+delta) calldata the user signs to fund the position from C. It reserves nothing
// off-chain; the C lock happens when the user's signed tx executes. Scoped to this
// position's exact params.
func (s *V4LiquiditySession) WritePrepareCommit(ctx context.Context) *Promise[PreparedIntent] {
p := newPromise[PreparedIntent]()
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
p.fulfil(PreparedIntent{}, err)
return p
}
go func() {
args := ModifyLiquidityArgs{
TickLower: s.req.TickLower,
TickUpper: s.req.TickUpper,
LiquidityDelta: s.req.LiquidityDelta, // positive: add/commit
Salt: s.req.Salt,
}
hookData := EncodeIntentHookData() // commit is a Phase-A funding intent
calldata := EncodeModifyLiquidityCalldata(s.pk, args, hookData)
p.fulfil(PreparedIntent{
To: addr9999(),
Calldata: calldata,
HookData: hookData,
IntentID: s.intentID,
DChainID: s.req.DChainID,
CChainID: s.req.CChainID,
Account: s.req.Account,
AssetIn: s.req.AssetIn,
AmountIn: s.req.AmountIn,
MarketID: s.req.MarketID,
}, nil)
}()
return p
}
// WriteNotifyCToDExport [C->D, V4_LP_NOTIFY_C_EXPORT] tells D to scan the funded
// C->D position object and record the position. Returns a watch (position-open is the
// committed phase).
func (s *V4LiquiditySession) WriteNotifyCToDExport(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[IntentWatch] {
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
out := newPromise[IntentWatch]()
out.fulfil(IntentWatch{}, err)
return out
}
guarded := thenAsync(ctx, intent, func(_ context.Context, pi PreparedIntent) (PreparedIntent, error) {
if pi.IntentID != s.intentID {
return PreparedIntent{}, ErrScopeMismatch
}
return pi, nil
})
return s.dex.NotifyIntent(ctx, guarded)
}
// Close retires the liquidity session's capability.
func (s *V4LiquiditySession) Close() { s.dex.Revoke(s.cap.core.tok) }
// =============================================================================
// openCollect / openCancel -> V4CollectSession (D->C export -> C credit)
// =============================================================================
// CollectRequest is the off-chain request to COLLECT/DECREASE (negative-delta) or
// CANCEL a position/order. Both produce a D->C export (collected fees / withdrawn
// liquidity / cancelled-order refund) consumed by the ONE DS01 credit path. Cancel is
// the same shape with the cancel marker; the credit is identical.
type CollectRequest struct {
NetworkID uint32
CChainID ID
DChainID ID
Account Account
MarketID ID
TickLower int32
TickUpper int32
LiquidityDelta int64 // <0 to remove/collect (a cancel removes the resting amount)
Salt ID
AssetOut ID // the asset the collect/cancel returns (for correlation)
CallIndex uint32
IsCancel bool // true => cancel a resting order; false => collect/decrease
}
// OpenCollect opens a collect/decrease session (negative-delta removal). It produces a
// D->C export -> C credit via DS01, so it needs the watch + settlement authorities.
func (v *V4PrecompileSession) OpenCollect(req CollectRequest) (*V4CollectSession, error) {
return v.openRemoval(req, ActionCollect)
}
// OpenCancel opens a cancel session (cancel a resting order; refund the locked asset).
// Same removal shape as collect; the credit is the identical DS01 settlement.
func (v *V4PrecompileSession) OpenCancel(req CollectRequest) (*V4CollectSession, error) {
req.IsCancel = true
return v.openRemoval(req, ActionCancel)
}
func (v *V4PrecompileSession) openRemoval(req CollectRequest, kind V4Action) (*V4CollectSession, error) {
if req.LiquidityDelta >= 0 {
return nil, fmt.Errorf("dexsession: openCollect/openCancel: removal requires negative liquidityDelta")
}
pk, ok := v.dex.market(req.MarketID)
if !ok {
return nil, fmt.Errorf("dexsession: openCollect: unknown market %x", req.MarketID[:8])
}
poolKeyHash := DerivePoolKeyHash(pk)
paramsHash := DeriveLiquidityParamsHash(req.TickLower, req.TickUpper, req.LiquidityDelta, req.Salt)
intentID := DeriveIntentID(req.NetworkID, req.CChainID, req.DChainID, ID{}, req.CallIndex, req.Account, req.AssetOut, 0, req.MarketID)
scope := v.scopeFor(kind, req.Account, poolKeyHash, paramsHash, intentID)
cap, err := v.mint(AuthIntent|AuthWatch|AuthSettlement, scope)
if err != nil {
return nil, err
}
return &V4CollectSession{dex: v.dex, cap: cap, req: req, pk: pk, intentID: intentID, kind: kind}, nil
}
// V4CollectSession is the removal lifecycle (collect/decrease/cancel). It requests the
// removal, watches for the ONE D->C export, and settles it via the DS01 credit path.
type V4CollectSession struct {
dex *clientSession
cap *scopedCap
req CollectRequest
pk PoolKeyArgs
intentID ID
kind V4Action
}
func (s *V4CollectSession) SessionID() ID { return s.cap.SessionID() }
func (s *V4CollectSession) IntentID() ID { return s.intentID }
// WriteRequest [C->D, V4_COLLECT_REQUEST / V4_CANCEL_REQUEST] builds the 0x9999
// modifyLiquidity (-delta) calldata the user signs to ask D to remove. The removal's
// output becomes a D->C export the session then settles. It reserves/credits nothing.
func (s *V4CollectSession) WriteRequest(ctx context.Context) *Promise[PreparedIntent] {
p := newPromise[PreparedIntent]()
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
p.fulfil(PreparedIntent{}, err)
return p
}
go func() {
args := ModifyLiquidityArgs{
TickLower: s.req.TickLower,
TickUpper: s.req.TickUpper,
LiquidityDelta: s.req.LiquidityDelta, // negative: remove
Salt: s.req.Salt,
}
hookData := EncodeIntentHookData()
calldata := EncodeModifyLiquidityCalldata(s.pk, args, hookData)
p.fulfil(PreparedIntent{
To: addr9999(),
Calldata: calldata,
HookData: hookData,
IntentID: s.intentID,
DChainID: s.req.DChainID,
CChainID: s.req.CChainID,
Account: s.req.Account,
AssetIn: s.req.AssetOut,
MarketID: s.req.MarketID,
}, nil)
}()
return p
}
// WriteNotify [C->D] tells D to scan the removal request and produce the D->C export.
func (s *V4CollectSession) WriteNotify(ctx context.Context, intent *Promise[PreparedIntent]) *Promise[IntentWatch] {
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
out := newPromise[IntentWatch]()
out.fulfil(IntentWatch{}, err)
return out
}
guarded := thenAsync(ctx, intent, func(_ context.Context, pi PreparedIntent) (PreparedIntent, error) {
if pi.IntentID != s.intentID {
return PreparedIntent{}, ErrScopeMismatch
}
return pi, nil
})
return s.dex.NotifyIntent(ctx, guarded)
}
// OnExportReady [D->C, V4_COLLECT_D_EXPORT_READY / V4_CANCEL_D_EXPORT_READY] resolves
// to the D->C export POINTER of the collected/cancelled value.
func (s *V4CollectSession) OnExportReady(ctx context.Context, watch IntentWatch) *Promise[DExportRef] {
p := newPromise[DExportRef]()
if err := s.cap.authorizeSelf(AuthWatch); err != nil {
p.fulfil(DExportRef{}, err)
return p
}
return watch.OnCommitted(ctx)
}
// WritePrepareCSettlement [C->D] settles the export via the ONE DS01 credit path —
// byte-identical to a swap settlement. The collected/cancelled value credits on-chain
// when the real object is consumed.
func (s *V4CollectSession) WritePrepareCSettlement(ctx context.Context, ref *Promise[DExportRef]) *Promise[SettlementSubmitResult] {
if err := s.cap.authorizeSelf(AuthSettlement); err != nil {
out := newPromise[SettlementSubmitResult]()
out.fulfil(SettlementSubmitResult{}, err)
return out
}
guarded := thenAsync(ctx, ref, func(_ context.Context, r DExportRef) (DExportRef, error) {
if r.IntentID != s.intentID {
return DExportRef{}, ErrScopeMismatch
}
return r, nil
})
return s.dex.ImportSettlement(ctx, guarded)
}
// Close retires the collect session's capability.
func (s *V4CollectSession) Close() { s.dex.Revoke(s.cap.core.tok) }
// =============================================================================
// V4StateSession (read-only quote/book/state streaming)
// =============================================================================
// OpenState opens a read-only state session scoped to a market. It needs only the
// quote authority; it has no write or settlement surface.
func (v *V4PrecompileSession) OpenState(account Account, marketID ID) (*V4StateSession, error) {
pk, ok := v.dex.market(marketID)
if !ok {
return nil, fmt.Errorf("dexsession: openState: unknown market %x", marketID[:8])
}
scope := v.scopeFor(ActionState, account, DerivePoolKeyHash(pk), ID{}, ID{})
cap, err := v.mint(AuthQuote, scope)
if err != nil {
return nil, err
}
return &V4StateSession{dex: v.dex, cap: cap, marketID: marketID}, nil
}
// V4StateSession is the read-only streaming session: quotes and book/state. It cannot
// build calldata, notify, or settle — it observes.
type V4StateSession struct {
dex *clientSession
cap *scopedCap
marketID ID
}
func (s *V4StateSession) SessionID() ID { return s.cap.SessionID() }
// ReadQuote [D->C, V4_STATE_QUOTE_UPDATE] reads one quote estimate. Read-only.
func (s *V4StateSession) ReadQuote(ctx context.Context, amountIn uint64, zeroForOne bool) *Promise[QuoteResult] {
p := newPromise[QuoteResult]()
if err := s.cap.authorizeSelf(AuthQuote); err != nil {
p.fulfil(QuoteResult{}, err)
return p
}
return s.dex.Quote(ctx, QuoteRequest{MarketID: s.marketID, AmountIn: amountIn, ZeroForOne: zeroForOne})
}
// ReadState [D->C, V4_STATE_BOOK_UPDATE] reads one state observation. Read-only.
func (s *V4StateSession) ReadState(ctx context.Context, kind StateKind, account Account, asset ID) *Promise[StateResult] {
p := newPromise[StateResult]()
if err := s.cap.authorizeSelf(AuthQuote); err != nil {
p.fulfil(StateResult{}, err)
return p
}
return s.dex.GetState(ctx, StateRequest{Kind: kind, MarketID: s.marketID, Account: account, Asset: asset})
}
// StreamQuotes [D->C] streams quote updates on a cadence until ctx ends, surfacing
// each as a V4_STATE_QUOTE_UPDATE event. Pure reads; moves nothing.
func (s *V4StateSession) StreamQuotes(ctx context.Context, amountIn uint64, zeroForOne bool) <-chan V4Event {
ch := make(chan V4Event, 4)
if err := s.cap.authorizeSelf(AuthQuote); err != nil {
ch <- V4Event{Type: V4_ERROR, SessionID: s.SessionID(), Reason: err.Error()}
close(ch)
return ch
}
go func() {
defer close(ch)
sid := s.SessionID()
for {
qr, err := s.dex.Quote(ctx, QuoteRequest{MarketID: s.marketID, AmountIn: amountIn, ZeroForOne: zeroForOne}).Await(ctx)
if err != nil {
emit(ctx, ch, V4Event{Type: V4_ERROR, SessionID: sid, Reason: err.Error()})
return
}
if !emit(ctx, ch, V4Event{Type: V4_STATE_QUOTE_UPDATE, SessionID: sid, EstAmount: qr.AmountOut}) {
return
}
if !sleepCtx(ctx, watchPollCadence) {
return
}
}
}()
return ch
}
// Close retires the state session's capability.
func (s *V4StateSession) Close() { s.dex.Revoke(s.cap.core.tok) }
+179
View File
@@ -0,0 +1,179 @@
// 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))
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))
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 }
+139
View File
@@ -0,0 +1,139 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"context"
"fmt"
"time"
)
// watch.go is the IntentWatch capability: subscribe to a notified intent's D
// result. It NEVER moves value — OnCommitted resolves to a POINTER (DExportRef),
// which the caller then feeds to ImportSettlement, which itself only builds
// calldata the chain consumes against the real object.
//
// A malicious server can drive a watch to PhaseCommitted with a fabricated
// DExportRef. That is HARMLESS: the only thing a caller does with the ref is
// importSettlement -> on-chain ImportSettlement, which reads the real object and
// reverts if it is missing or binds to a different owner/asset/amount. The watch
// surfaces a pointer; the chain is the judge.
// IntentWatch is the subscription handle a NotifyIntent returns. Poll observes the
// current phase; OnCommitted yields a Promise that resolves to the DExportRef when
// (and only when) the watch observes a committed D export.
type IntentWatch struct {
session *clientSession
intentID ID
}
// IntentID is the intent this watch tracks.
func (w IntentWatch) IntentID() ID { return w.intentID }
// Poll observes the current status once. Gated by AuthWatch. Read-only.
func (w IntentWatch) Poll(ctx context.Context) (IntentStatus, error) {
if w.session == nil {
return IntentStatus{Phase: PhaseUnknown}, fmt.Errorf("dexsession: nil watch")
}
if err := w.session.requireGrant(AuthWatch); err != nil {
return IntentStatus{Phase: PhaseUnknown}, err
}
reqMsg, err := buildWatchPollRequest(w.intentID)
if err != nil {
return IntentStatus{Phase: PhaseUnknown}, err
}
resp, err := w.session.conn.Call(ctx, w.session.peerID, reqMsg)
if err != nil {
return IntentStatus{Phase: PhaseUnknown}, err
}
defer resp.Release()
return readIntentStatus(resp), nil
}
// OnCommitted returns a Promise that resolves to the committed DExportRef. It
// pipelines: the returned promise feeds straight into ImportSettlement. Internally
// it streams the watch — polling the server until PhaseCommitted (a committed D
// block produced the export) or a terminal PhaseRejected, with a bounded backoff.
//
// This is the streaming watch the spec asks for: the caller writes
// watch.OnCommitted().then(ImportSettlement) and the resolution arrives when D has
// actually committed — not a synchronous fiction. (A server that supports push
// resolution sends MsgWatchPush; the client treats a push and a poll identically —
// both deliver an IntentStatus, and only a Committed status with a ref resolves
// the promise.)
//
// CANNOT move value: the promise resolves to a POINTER. The credit happens later,
// on-chain, against the real object.
func (w IntentWatch) OnCommitted(ctx context.Context) *Promise[DExportRef] {
p := newPromise[DExportRef]()
if w.session == nil {
p.fulfil(DExportRef{}, fmt.Errorf("dexsession: nil watch"))
return p
}
if err := w.session.requireGrant(AuthWatch); err != nil {
p.fulfil(DExportRef{}, err)
return p
}
go w.streamUntilCommitted(ctx, p)
return p
}
// streamUntilCommitted polls the watch until it commits, is rejected, or ctx ends.
// Backoff starts tight (the happy path is sub-second on a live D-Chain) and caps,
// so a stuck intent does not hot-loop. A PhaseRejected resolves the promise with an
// error (the intent will never settle — e.g. unbacked / expired); a never-
// resolving D block leaves the promise pending until ctx is cancelled, which is
// the correct behaviour (no D block => no committed ref => no settlement — the
// TestZAP_NotifyIntent_CannotValidateMatchWithoutDBlock property).
func (w IntentWatch) streamUntilCommitted(ctx context.Context, p *Promise[DExportRef]) {
backoff := 25 * time.Millisecond
const maxBackoff = 2 * time.Second
for {
select {
case <-ctx.Done():
p.fulfil(DExportRef{}, ctx.Err())
return
default:
}
st, err := w.Poll(ctx)
if err != nil {
p.fulfil(DExportRef{}, err)
return
}
switch st.Phase {
case PhaseCommitted:
// Bind the resolved ref's intent id to the watch's intent id (defence
// against a server returning a ref for a different intent — the caller
// only ever asked about THIS intent). The ref is still just a pointer;
// the chain re-verifies on import.
ref := st.Ref
ref.IntentID = w.intentID
p.fulfil(ref, nil)
return
case PhaseRejected:
reason := st.Reason
if reason == "" {
reason = "intent rejected by D"
}
p.fulfil(DExportRef{}, fmt.Errorf("dexsession: intent %x rejected: %s", w.intentID[:8], reason))
return
default:
// Pending / Matching / Unknown: wait and re-poll.
t := time.NewTimer(backoff)
select {
case <-ctx.Done():
t.Stop()
p.fulfil(DExportRef{}, ctx.Err())
return
case <-t.C:
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"crypto/sha256"
"encoding/binary"
zaplib "github.com/luxfi/zap"
)
// wire.go is the typed request/response contract for the DexSession capability
// transport, plus the VALUE-boundary primitives reproduced as pure values.
//
// VALUES, NOT PLACES: this package speaks in [20]byte accounts, [32]byte ids,
// and uint64 amounts — never luxfi/geth common.Address or luxfi/ids ids.ID. The
// EVM type mapping is the precompile caller's concern. Keeping dexsession on
// primitives means the transport module pulls in no EVM / blockchain dep graph,
// and the orchestration layer literally cannot reference a StateDB or an atomic
// shared-memory handle (the structural half of the invariant — see doc.go).
//
// On-wire bytes are github.com/luxfi/zap Objects (the same zero-copy frame the
// forward/ HTTP contract uses). Every field sits at an explicit byte offset; a
// text/bytes slot is 8 bytes (relOffset+length), scalars are natural width.
// Account is a 20-byte EVM account (taker / recipient / owner). Byte-identical
// to the low 20 bytes of an EVM address; the precompile binds it as the atomic
// object owner.
type Account [20]byte
// ID is a 32-byte identifier (chain id, tx id, market id, intent id, asset id).
// Byte-identical to luxfi/ids ids.ID and to an EVM bytes32. Native asset == the
// all-zero ID (mirrors ids.Empty in the dexvm ledger).
type ID [32]byte
// ZeroID is the all-zero ID — native asset / empty sentinel.
var ZeroID = ID{}
// --- Message types -----------------------------------------------------------
//
// The ZAP dispatcher routes on msg.Flags()>>8 (FinishWithFlags(t<<8)), so a type
// must fit the upper byte (uint8). The 0xA0+ range avoids collision with
// forward's 0x80/0x81 and base/plugins' lower-byte IDs.
const (
MsgQuote uint16 = 0xA0 // QuoteRequest -> QuoteResult
MsgGetState uint16 = 0xA1 // StateRequest -> StateResult
MsgPrepare uint16 = 0xA2 // SwapIntentRequest -> PreparedIntent
MsgNotify uint16 = 0xA3 // PreparedIntent -> IntentWatchRef
MsgWatchPoll uint16 = 0xA4 // IntentWatchRef -> IntentStatus
MsgImport uint16 = 0xA5 // DExportRef -> SettlementSubmitResult
MsgPipeline uint16 = 0xA6 // PipelineBatch -> PipelineResult
MsgAdminHalt uint16 = 0xA7 // AdminRequest -> AdminStatus
MsgWatchPush uint16 = 0xA8 // server-initiated watch resolution (Push)
MsgRoutePrepare uint16 = 0xA9 // RouteRequest -> PreparedIntent (route-intent calldata)
MsgRouteStatus uint16 = 0xAA // RouteWatchRef -> RouteStatus (hop progress + final ref)
)
// --- Value-boundary primitives (reproduced as values; pinned by parity test) -
// exportedObjectSize is the fixed cross-chain atomic object width:
// owner(20) | asset(32) | amount(8) = 60 bytes. IDENTICAL to
// precompile/dex/native_wire.go exportedOutputSize9999 and
// chains/dexvm/atomic.go exportedOutputSize. wire_parity_test.go pins it.
const exportedObjectSize = 20 + 32 + 8
// EncodeAtomicObject serializes a cross-chain value object as the shared-memory
// value: owner(20) | asset(32) | amount(8). Byte-identical with the precompile
// and dexvm encoders. This is the ONLY value-bearing identity the atomic
// conservation binds; dexsession reproduces the encoding so a watcher can derive
// the object key it points at — it NEVER writes one into shared memory (it has
// no shared memory).
func EncodeAtomicObject(owner Account, asset ID, amount uint64) []byte {
v := make([]byte, exportedObjectSize)
copy(v[0:20], owner[:])
copy(v[20:52], asset[:])
binary.BigEndian.PutUint64(v[52:60], amount)
return v
}
// DecodeAtomicObject is the inverse. ok=false for any value that is not exactly
// the canonical width, so a corrupt record is never reinterpreted — the same
// defence the precompile and dexvm decoders apply. A consumer binds the credited
// owner/asset/amount to THIS recorded value, never to a declared claim.
func DecodeAtomicObject(v []byte) (owner Account, asset ID, amount uint64, ok bool) {
if len(v) != exportedObjectSize {
return Account{}, ID{}, 0, false
}
copy(owner[:], v[0:20])
copy(asset[:], v[20:52])
amount = binary.BigEndian.Uint64(v[52:60])
return owner, asset, amount, true
}
// nativeIntentDomain scopes the intent-id derivation. IDENTICAL string to
// precompile/dex/native_wire.go nativeIntentDomain — the id dexsession derives
// for a PreparedIntent MUST equal the id the precompile derives for the same
// inputs, or the off-chain pointer would name a different object than the chain.
const nativeIntentDomain = "lux.dex.native.intent.v1"
// DeriveIntentID computes the deterministic id of a C->D atomic intent object,
// byte-identical to precompile/dex/native_wire.go DeriveIntentID:
//
// SHA-256( domain | networkID | cChainID | dChainID | txID | callIndex |
// account | assetIn | amountIn | marketID )
//
// Every component is fixed width and length-stable. dexsession derives it so a
// PreparedIntent can carry the id the user's signed C tx will mint and the watch
// can locate the matching D result — it is a NAME, never an authority.
func DeriveIntentID(
networkID uint32,
cChainID, dChainID ID,
txID ID,
callIndex uint32,
account Account,
assetIn ID,
amountIn uint64,
marketID ID,
) ID {
h := sha256.New()
h.Write([]byte(nativeIntentDomain))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], networkID)
h.Write(u4[:])
h.Write(cChainID[:])
h.Write(dChainID[:])
h.Write(txID[:])
binary.BigEndian.PutUint32(u4[:], callIndex)
h.Write(u4[:])
h.Write(account[:])
h.Write(assetIn[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], amountIn)
h.Write(u8[:])
h.Write(marketID[:])
var out ID
copy(out[:], h.Sum(nil))
return out
}
// DeriveUTXOID computes the deterministic D->C export object key from
// (sourceTxID, outputIndex), byte-identical to chains/dexvm/atomic.go
// deriveUTXOID (SHA-256 over txID||index). importSettlement uses it to name the
// object the on-chain ImportSettlement will read; the chain re-derives and binds
// it, so a wrong index just names a non-existent (or someone else's) object and
// the on-chain bind rejects.
func DeriveUTXOID(sourceTxID ID, outputIndex uint32) ID {
var buf [36]byte
copy(buf[0:32], sourceTxID[:])
binary.BigEndian.PutUint32(buf[32:36], outputIndex)
sum := sha256.Sum256(buf[:])
return ID(sum)
}
// =============================================================================
// QuoteRequest / QuoteResult (read-only — estimate, never an enforceable price)
// =============================================================================
// QuoteRequest asks the D book for an estimated output. Read-only.
type QuoteRequest struct {
MarketID ID
AmountIn uint64
ZeroForOne bool // sell currency0 for currency1
}
// QuoteResult is an ESTIMATE. AmountOut is informational only: the chain never
// trusts it. The user still encodes their own MinAmountOut in the signed C tx,
// and D enforces slippage at match. A stale or malicious quote can only mislead
// a UI — it cannot move value, and a bad quote that misses MinAmountOut makes
// the on-chain swap revert (TestZAP_StaleResponse_BadQuoteMissesMinOutOrReverts).
type QuoteResult struct {
MarketID ID
AmountIn uint64
AmountOut uint64 // ESTIMATE ONLY — not enforceable, not a credit
BestPricePx uint64 // top-of-book price, IEEE-754 bits (informational)
Liquid bool // false = no resting book / market unknown
}
// Fixed inline byte arrays (SetBytesFixed) consume their FULL width; scalars use
// natural width; only variable bytes/text (SetBytes/SetText) consume an 8-byte
// {relOffset,length} slot. Offsets below are packed accordingly and must not
// overlap. (Mismatching a bytes32 to an 8-byte slot silently clobbers adjacent
// fields — the layout bug the test suite pins against.)
const (
qReqMarket = 0 // bytes32 [0:32]
qReqAmount = 32 // uint64
qReqZForOne = 40 // bool
qReqSize = 48
)
func buildQuoteRequest(r QuoteRequest) (*zaplib.Message, error) {
b := zaplib.NewBuilder(qReqSize + 40)
ob := b.StartObject(qReqSize)
ob.SetBytesFixed(qReqMarket, r.MarketID[:])
ob.SetUint64(qReqAmount, r.AmountIn)
ob.SetBool(qReqZForOne, r.ZeroForOne)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgQuote << 8))
}
func readQuoteRequest(m *zaplib.Message) QuoteRequest {
r := m.Root()
var out QuoteRequest
copy(out.MarketID[:], r.BytesFixedSlice(qReqMarket, 32))
out.AmountIn = r.Uint64(qReqAmount)
out.ZeroForOne = r.Bool(qReqZForOne)
return out
}
const (
qResMarket = 0 // bytes32 [0:32]
qResIn = 32 // uint64
qResOut = 40 // uint64
qResPrice = 48 // uint64 (float bits)
qResLiquid = 56 // bool
qResSize = 64
)
func buildQuoteResult(r QuoteResult) (*zaplib.Message, error) {
b := zaplib.NewBuilder(qResSize + 40)
ob := b.StartObject(qResSize)
ob.SetBytesFixed(qResMarket, r.MarketID[:])
ob.SetUint64(qResIn, r.AmountIn)
ob.SetUint64(qResOut, r.AmountOut)
ob.SetUint64(qResPrice, r.BestPricePx)
ob.SetBool(qResLiquid, r.Liquid)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgQuote << 8))
}
func readQuoteResult(m *zaplib.Message) QuoteResult {
r := m.Root()
var out QuoteResult
copy(out.MarketID[:], r.BytesFixedSlice(qResMarket, 32))
out.AmountIn = r.Uint64(qResIn)
out.AmountOut = r.Uint64(qResOut)
out.BestPricePx = r.Uint64(qResPrice)
out.Liquid = r.Bool(qResLiquid)
return out
}
// =============================================================================
// StateRequest / StateResult (read-only — observe C/D DEX state)
// =============================================================================
// StateKind selects which read-only view getState returns.
type StateKind uint8
const (
StateMarket StateKind = 0 // market existence / base+quote asset ids
StateBalance StateKind = 1 // an account's D available balance for an asset
StateIntent StateKind = 2 // a C->D intent's known routing status (off-chain view)
)
// StateRequest is a read-only DEX state query.
type StateRequest struct {
Kind StateKind
MarketID ID
Account Account
Asset ID
IntentID ID
}
// StateResult is the read-only answer. Like a quote it is informational: a
// balance reported here is NOT a spendable claim the chain honours — only a
// consumed D->C object credits C.
type StateResult struct {
Kind StateKind
Exists bool
BaseID ID
QuoteID ID
Available uint64 // D available balance (observation only)
Known bool // intent known to the venue's router index
}
const (
sReqKind = 0 // uint8
sReqMarket = 8 // bytes32 [8:40]
sReqAccount = 40 // bytes20 [40:60]
sReqAsset = 60 // bytes32 [60:92]
sReqIntent = 92 // bytes32 [92:124]
sReqSize = 124
)
func buildStateRequest(r StateRequest) (*zaplib.Message, error) {
b := zaplib.NewBuilder(sReqSize + 128)
ob := b.StartObject(sReqSize)
ob.SetUint8(sReqKind, uint8(r.Kind))
ob.SetBytesFixed(sReqMarket, r.MarketID[:])
ob.SetBytesFixed(sReqAccount, r.Account[:])
ob.SetBytesFixed(sReqAsset, r.Asset[:])
ob.SetBytesFixed(sReqIntent, r.IntentID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgGetState << 8))
}
func readStateRequest(m *zaplib.Message) StateRequest {
r := m.Root()
var out StateRequest
out.Kind = StateKind(r.Uint8(sReqKind))
copy(out.MarketID[:], r.BytesFixedSlice(sReqMarket, 32))
copy(out.Account[:], r.BytesFixedSlice(sReqAccount, 20))
copy(out.Asset[:], r.BytesFixedSlice(sReqAsset, 32))
copy(out.IntentID[:], r.BytesFixedSlice(sReqIntent, 32))
return out
}
const (
sResKind = 0 // uint8
sResExists = 1 // bool
sResKnown = 2 // bool
sResAvail = 8 // uint64
sResBase = 16 // bytes32 [16:48]
sResQuote = 48 // bytes32 [48:80]
sResSize = 80
)
func buildStateResult(r StateResult) (*zaplib.Message, error) {
b := zaplib.NewBuilder(sResSize + 80)
ob := b.StartObject(sResSize)
ob.SetUint8(sResKind, uint8(r.Kind))
ob.SetBool(sResExists, r.Exists)
ob.SetBool(sResKnown, r.Known)
ob.SetUint64(sResAvail, r.Available)
ob.SetBytesFixed(sResBase, r.BaseID[:])
ob.SetBytesFixed(sResQuote, r.QuoteID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgGetState << 8))
}
func readStateResult(m *zaplib.Message) StateResult {
r := m.Root()
var out StateResult
out.Kind = StateKind(r.Uint8(sResKind))
out.Exists = r.Bool(sResExists)
out.Known = r.Bool(sResKnown)
out.Available = r.Uint64(sResAvail)
copy(out.BaseID[:], r.BytesFixedSlice(sResBase, 32))
copy(out.QuoteID[:], r.BytesFixedSlice(sResQuote, 32))
return out
}
+419
View File
@@ -0,0 +1,419 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
zaplib "github.com/luxfi/zap"
)
// wire_intent.go carries the intent / settlement envelopes — the request side
// (SwapIntentRequest), the build result (PreparedIntent: CALLDATA, never funds),
// the D-result pointer (DExportRef), and the watch/import results.
//
// The whole point lives here: a PreparedIntent is CALLDATA the user signs, and a
// DExportRef is a POINTER. Neither is value. The chain mints/credits only when a
// real atomic object is consumed (precompile ImportSettlement / dexvm
// executeImport); these envelopes can only name what to sign or which object to
// point at.
// =============================================================================
// SwapIntentRequest / PreparedIntent
// =============================================================================
// SwapIntentRequest is the off-chain request to PREPARE a C->D swap intent. The
// session validates params, estimates a quote, derives the intent id, and
// returns a PreparedIntent (calldata). It reserves NO funds and returns NO
// enforceable amountOut.
type SwapIntentRequest struct {
NetworkID uint32
CChainID ID
DChainID ID
Account Account // taker — bound as the C->D object owner on-chain
AssetIn ID // full injective asset id locked on C
AssetInAddr Account // ERC-20 token (zero for native) for the C lock
AmountIn uint64
MarketID ID
MinAmountOut uint64 // taker slippage floor — encoded into the signed tx
Recipient Account // where the D->C settlement must credit
Deadline uint64
// CallIndex disambiguates two swaps in one tx — matches the precompile's
// per-tx CallIndex. The user's wallet sets it; off-chain prepare uses 0 for a
// single-call tx and the caller overrides for batched calls.
CallIndex uint32
}
// PreparedIntent is the OUTPUT of prepareSwapIntent: everything the user needs to
// sign a normal C tx to 0x9999 that creates the funded C->D intent. It is bytes,
// not authority:
//
// - To — the 0x9999 settlement address (the precompile).
// - Calldata — selector + ABI-encoded args for the on-chain swap entry.
// - HookData — the V4 hookData the 0x9999 handler consumes (routing payload).
// - IntentID — the deterministic id the on-chain SubmitSwapIntent will mint
// (derived identically here; lets the watch locate the D result).
// - QuotedOut — the ESTIMATE used to build it (informational, not enforceable).
//
// MUST NOT: reserve funds off-chain, claim a fill final, return an amountOut the
// chain trusts. The QuotedOut is explicitly informational; the enforceable floor
// is MinAmountOut baked into Calldata, which the chain/D check.
type PreparedIntent struct {
To Account // 0x9999
Calldata []byte // selector + args (the user signs a tx with this data)
HookData []byte // V4 hookData consumed by the 0x9999 handler
IntentID ID // deterministic id the signed tx will create on C
QuotedOut uint64 // estimate only
// Echoed request identity so the watch / import can reconstruct the binding
// without holding the whole request.
DChainID ID
CChainID ID
Account Account
Recipient Account
AssetIn ID
AmountIn uint64
MarketID ID
}
// Fixed inline byte arrays first (full width), then the two variable bytes slots
// (8 bytes each). Calldata/HookData tails are appended after the fixed section.
const (
piTo = 0 // bytes20 [0:20]
piIntent = 20 // bytes32 [20:52]
piQuoted = 52 // uint64
piAmountIn = 60 // uint64
piDChain = 68 // bytes32 [68:100]
piCChain = 100 // bytes32 [100:132]
piAccount = 132 // bytes20 [132:152]
piRecipient = 152 // bytes20 [152:172]
piAssetIn = 172 // bytes32 [172:204]
piMarket = 204 // bytes32 [204:236]
piCalldata = 236 // bytes (slot 8)
piHookData = 244 // bytes (slot 8)
piSize = 252
)
func buildPreparedIntent(p PreparedIntent) (*zaplib.Message, error) {
b := zaplib.NewBuilder(piSize + len(p.Calldata) + len(p.HookData) + 256)
ob := b.StartObject(piSize)
ob.SetBytesFixed(piTo, p.To[:])
ob.SetBytesFixed(piIntent, p.IntentID[:])
ob.SetUint64(piQuoted, p.QuotedOut)
ob.SetUint64(piAmountIn, p.AmountIn)
ob.SetBytesFixed(piDChain, p.DChainID[:])
ob.SetBytesFixed(piCChain, p.CChainID[:])
ob.SetBytesFixed(piAccount, p.Account[:])
ob.SetBytesFixed(piRecipient, p.Recipient[:])
ob.SetBytesFixed(piAssetIn, p.AssetIn[:])
ob.SetBytesFixed(piMarket, p.MarketID[:])
ob.SetBytes(piCalldata, p.Calldata)
ob.SetBytes(piHookData, p.HookData)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgPrepare << 8))
}
func readPreparedIntent(m *zaplib.Message) PreparedIntent {
r := m.Root()
var p PreparedIntent
copy(p.To[:], r.BytesFixedSlice(piTo, 20))
copy(p.IntentID[:], r.BytesFixedSlice(piIntent, 32))
p.QuotedOut = r.Uint64(piQuoted)
p.AmountIn = r.Uint64(piAmountIn)
copy(p.DChainID[:], r.BytesFixedSlice(piDChain, 32))
copy(p.CChainID[:], r.BytesFixedSlice(piCChain, 32))
copy(p.Account[:], r.BytesFixedSlice(piAccount, 20))
copy(p.Recipient[:], r.BytesFixedSlice(piRecipient, 20))
copy(p.AssetIn[:], r.BytesFixedSlice(piAssetIn, 32))
copy(p.MarketID[:], r.BytesFixedSlice(piMarket, 32))
p.Calldata = append([]byte(nil), r.Bytes(piCalldata)...)
p.HookData = append([]byte(nil), r.Bytes(piHookData)...)
return p
}
// the SwapIntentRequest envelope (client -> server for MsgPrepare).
const (
sirNet = 0 // uint32
sirCallIdx = 4 // uint32
sirAmountIn = 8 // uint64
sirMinOut = 16 // uint64
sirDeadline = 24 // uint64
sirCChain = 32 // bytes32 [32:64]
sirDChain = 64 // bytes32 [64:96]
sirAccount = 96 // bytes20 [96:116]
sirAssetIn = 116 // bytes32 [116:148]
sirAssetAddr = 148 // bytes20 [148:168]
sirMarket = 168 // bytes32 [168:200]
sirRecipient = 200 // bytes20 [200:220]
sirSize = 220
)
func buildSwapIntentRequest(r SwapIntentRequest) (*zaplib.Message, error) {
b := zaplib.NewBuilder(sirSize + 256)
ob := b.StartObject(sirSize)
ob.SetUint32(sirNet, r.NetworkID)
ob.SetUint32(sirCallIdx, r.CallIndex)
ob.SetUint64(sirAmountIn, r.AmountIn)
ob.SetUint64(sirMinOut, r.MinAmountOut)
ob.SetUint64(sirDeadline, r.Deadline)
ob.SetBytesFixed(sirCChain, r.CChainID[:])
ob.SetBytesFixed(sirDChain, r.DChainID[:])
ob.SetBytesFixed(sirAccount, r.Account[:])
ob.SetBytesFixed(sirAssetIn, r.AssetIn[:])
ob.SetBytesFixed(sirAssetAddr, r.AssetInAddr[:])
ob.SetBytesFixed(sirMarket, r.MarketID[:])
ob.SetBytesFixed(sirRecipient, r.Recipient[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgPrepare << 8))
}
func readSwapIntentRequest(m *zaplib.Message) SwapIntentRequest {
r := m.Root()
var out SwapIntentRequest
out.NetworkID = r.Uint32(sirNet)
out.CallIndex = r.Uint32(sirCallIdx)
out.AmountIn = r.Uint64(sirAmountIn)
out.MinAmountOut = r.Uint64(sirMinOut)
out.Deadline = r.Uint64(sirDeadline)
copy(out.CChainID[:], r.BytesFixedSlice(sirCChain, 32))
copy(out.DChainID[:], r.BytesFixedSlice(sirDChain, 32))
copy(out.Account[:], r.BytesFixedSlice(sirAccount, 20))
copy(out.AssetIn[:], r.BytesFixedSlice(sirAssetIn, 32))
copy(out.AssetInAddr[:], r.BytesFixedSlice(sirAssetAddr, 20))
copy(out.MarketID[:], r.BytesFixedSlice(sirMarket, 32))
copy(out.Recipient[:], r.BytesFixedSlice(sirRecipient, 20))
return out
}
// =============================================================================
// DExportRef — the POINTER (NOT a DFillReceipt)
// =============================================================================
// DExportRef points at a D->C atomic export object. It is deliberately NOT a
// DFillReceipt: it carries no amount the chain trusts and no signature C honours.
// The chain re-reads the actual object (asset/owner/amount/one-time) on import.
//
// SourceChainID — the D chain whose export the object lives under.
// SourceTxID — the D tx that produced the export.
// OutputIndex — which exported output (deriveUTXOID(SourceTxID, OutputIndex)).
// IntentID — the originating C->D intent (correlation only).
type DExportRef struct {
SourceChainID ID
SourceTxID ID
OutputIndex uint32
IntentID ID
}
// ObjectKey is the shared-memory UTXO key the on-chain ImportSettlement reads.
// Derived deterministically from (SourceTxID, OutputIndex) — the chain derives
// the same key and binds the object to it, so a forged key just names a missing
// object and the import reverts.
func (r DExportRef) ObjectKey() ID { return DeriveUTXOID(r.SourceTxID, r.OutputIndex) }
const (
derIndex = 0 // uint32
derChain = 8 // bytes32 [8:40]
derTx = 40 // bytes32 [40:72]
derIntent = 72 // bytes32 [72:104]
derSize = 104
)
func buildDExportRef(r DExportRef) (*zaplib.Message, error) {
b := zaplib.NewBuilder(derSize + 120)
ob := b.StartObject(derSize)
ob.SetUint32(derIndex, r.OutputIndex)
ob.SetBytesFixed(derChain, r.SourceChainID[:])
ob.SetBytesFixed(derTx, r.SourceTxID[:])
ob.SetBytesFixed(derIntent, r.IntentID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgImport << 8))
}
func readDExportRef(m *zaplib.Message) DExportRef {
r := m.Root()
var out DExportRef
out.OutputIndex = r.Uint32(derIndex)
copy(out.SourceChainID[:], r.BytesFixedSlice(derChain, 32))
copy(out.SourceTxID[:], r.BytesFixedSlice(derTx, 32))
copy(out.IntentID[:], r.BytesFixedSlice(derIntent, 32))
return out
}
// =============================================================================
// IntentStatus / IntentWatchRef
// =============================================================================
// IntentPhase is the lifecycle of a notified intent as the off-chain watcher
// observes it. NONE of these phases moves value — Committed merely means the
// watcher SAW a D export object; the user still imports it on C.
type IntentPhase uint8
const (
PhasePending IntentPhase = 0 // notified; D has not produced a block yet
PhaseMatching IntentPhase = 1 // D imported the C->D object, matching in progress
PhaseCommitted IntentPhase = 2 // D exported a D->C object (a D block committed)
PhaseRejected IntentPhase = 3 // D could not import/match (e.g. unbacked, expired)
PhaseUnknown IntentPhase = 4 // watcher has no information
)
// IntentStatus is one poll/push of a watch. When Phase==PhaseCommitted the Ref
// points at the produced D->C object. A malicious server can set Phase=Committed
// with a bogus Ref, but importSettlement of that Ref reverts on-chain (the object
// is missing or binds to a different owner/asset/amount).
//
// MatchedOut is an ESTIMATE the venue reports when D has matched (PhaseMatching/
// Committed): the orchestration "you'll receive ~N" figure the bidirectional
// MatchResult read surfaces. It is INFORMATIONAL ONLY — exactly the QuotedOut
// discipline extended to the match phase. The chain NEVER trusts it: the credit is
// the recorded D->C object's amount, bound on-chain at settlement. A lying venue can
// set MatchedOut to anything; it changes no balance (proven by the RED suite).
type IntentStatus struct {
IntentID ID
Phase IntentPhase
Ref DExportRef // valid only when Phase==PhaseCommitted
Reason string // human-readable, for Rejected/Unknown
MatchedOut uint64 // ESTIMATE (matched output) — orchestration only, never a credit
}
const (
isPhase = 0 // uint8
isIndex = 4 // uint32 (ref output index)
isIntent = 8 // bytes32 [8:40]
isChain = 40 // bytes32 [40:72] ref source chain
isTx = 72 // bytes32 [72:104] ref source tx
isMatched = 104 // uint64 (matched-out ESTIMATE — orchestration only)
isReason = 112 // text (slot 8)
isSize = 120
)
func buildIntentStatus(s IntentStatus) (*zaplib.Message, error) {
b := zaplib.NewBuilder(isSize + len(s.Reason) + 160)
ob := b.StartObject(isSize)
ob.SetUint8(isPhase, uint8(s.Phase))
ob.SetUint32(isIndex, s.Ref.OutputIndex)
ob.SetBytesFixed(isIntent, s.IntentID[:])
ob.SetBytesFixed(isChain, s.Ref.SourceChainID[:])
ob.SetBytesFixed(isTx, s.Ref.SourceTxID[:])
ob.SetUint64(isMatched, s.MatchedOut)
ob.SetText(isReason, s.Reason)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgWatchPoll << 8))
}
func readIntentStatus(m *zaplib.Message) IntentStatus {
r := m.Root()
var s IntentStatus
s.Phase = IntentPhase(r.Uint8(isPhase))
copy(s.IntentID[:], r.BytesFixedSlice(isIntent, 32))
s.Ref.OutputIndex = r.Uint32(isIndex)
copy(s.Ref.SourceChainID[:], r.BytesFixedSlice(isChain, 32))
copy(s.Ref.SourceTxID[:], r.BytesFixedSlice(isTx, 32))
s.Ref.IntentID = s.IntentID
s.MatchedOut = r.Uint64(isMatched)
s.Reason = r.Text(isReason)
return s
}
// IntentWatchRef is the server-side handle a notifyIntent returns: the intent id
// the watch tracks. The client polls it (MsgWatchPoll) or receives a Push
// (MsgWatchPush). It is a subscription token, not authority.
type IntentWatchRef struct {
IntentID ID
}
const (
iwIntent = 0 // bytes32 [0:32]
iwSize = 32
)
func buildIntentWatchRef(w IntentWatchRef) (*zaplib.Message, error) {
b := zaplib.NewBuilder(iwSize + 40)
ob := b.StartObject(iwSize)
ob.SetBytesFixed(iwIntent, w.IntentID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgNotify << 8))
}
func readIntentWatchRef(m *zaplib.Message) IntentWatchRef {
r := m.Root()
var w IntentWatchRef
copy(w.IntentID[:], r.BytesFixedSlice(iwIntent, 32))
return w
}
// the watch-poll request carries just the intent id (same shape as IntentWatchRef
// but tagged MsgWatchPoll).
func buildWatchPollRequest(intentID ID) (*zaplib.Message, error) {
b := zaplib.NewBuilder(iwSize + 40)
ob := b.StartObject(iwSize)
ob.SetBytesFixed(iwIntent, intentID[:])
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgWatchPoll << 8))
}
func readWatchPollRequest(m *zaplib.Message) ID {
r := m.Root()
var id ID
copy(id[:], r.BytesFixedSlice(iwIntent, 32))
return id
}
// =============================================================================
// SettlementSubmitResult — what importSettlement returns
// =============================================================================
// SettlementMode tells the caller HOW the settlement is realised. In both modes
// the credit happens ON-CHAIN by consuming the real object — never via this RPC.
type SettlementMode uint8
const (
// SettleCalldata: the caller (a wallet / keeper) signs a C tx to 0x9999 with
// the returned Calldata. The chain's ImportSettlement reads the real D->C
// object and credits the recipient.
SettleCalldata SettlementMode = 0
// SettleSubmitted: a keeper with a (non-custodial) signing key already
// submitted the C tx; CTxHash is the submitted tx. The keeper cannot alter
// recipient/asset/amount — the chain binds them to the object.
SettleSubmitted SettlementMode = 1
)
// SettlementSubmitResult is the OUTPUT of importSettlement. It NEVER credits C; it
// returns the calldata to consume the object (or the hash of a tx that points at
// it). The on-chain ImportSettlement is the sole credit path.
type SettlementSubmitResult struct {
Mode SettlementMode
To Account // 0x9999
Calldata []byte // selector + SettlementClaim args, points at the object
ObjectKey ID // the shared-memory key the chain will read
CTxHash ID // set only in SettleSubmitted mode
}
const (
ssMode = 0 // uint8
ssTo = 8 // bytes20 [8:28]
ssObjKey = 28 // bytes32 [28:60]
ssCTx = 60 // bytes32 [60:92]
ssCall = 92 // bytes (slot 8)
ssSize = 100
)
func buildSettlementResult(s SettlementSubmitResult) (*zaplib.Message, error) {
b := zaplib.NewBuilder(ssSize + len(s.Calldata) + 120)
ob := b.StartObject(ssSize)
ob.SetUint8(ssMode, uint8(s.Mode))
ob.SetBytesFixed(ssTo, s.To[:])
ob.SetBytesFixed(ssObjKey, s.ObjectKey[:])
ob.SetBytesFixed(ssCTx, s.CTxHash[:])
ob.SetBytes(ssCall, s.Calldata)
ob.FinishAsRoot()
return zaplib.Parse(b.FinishWithFlags(MsgImport << 8))
}
func readSettlementResult(m *zaplib.Message) SettlementSubmitResult {
r := m.Root()
var s SettlementSubmitResult
s.Mode = SettlementMode(r.Uint8(ssMode))
copy(s.To[:], r.BytesFixedSlice(ssTo, 20))
copy(s.ObjectKey[:], r.BytesFixedSlice(ssObjKey, 32))
copy(s.CTxHash[:], r.BytesFixedSlice(ssCTx, 32))
s.Calldata = append([]byte(nil), r.Bytes(ssCall)...)
return s
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexsession
import (
"bytes"
"testing"
)
// wire_test.go round-trips every envelope through build->parse->read and asserts
// EVERY field survives. This catches field-slot overlap (a bytes32 mistakenly
// given an 8-byte slot clobbers the next field) — the exact class of bug that
// must never reach the value-boundary id derivation, since a corrupted intent id
// would name the wrong object.
func TestWire_QuoteRequest_RoundTrip(t *testing.T) {
in := QuoteRequest{MarketID: fillID(0xA1), AmountIn: 123456789, ZeroForOne: true}
m, err := buildQuoteRequest(in)
if err != nil {
t.Fatal(err)
}
got := readQuoteRequest(m)
if got != in {
t.Fatalf("QuoteRequest round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_QuoteResult_RoundTrip(t *testing.T) {
in := QuoteResult{MarketID: fillID(0xB2), AmountIn: 1000, AmountOut: 999, BestPricePx: 0xDEADBEEF, Liquid: true}
m, err := buildQuoteResult(in)
if err != nil {
t.Fatal(err)
}
got := readQuoteResult(m)
if got != in {
t.Fatalf("QuoteResult round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_StateRequest_RoundTrip(t *testing.T) {
in := StateRequest{Kind: StateBalance, MarketID: fillID(0x11), Account: fillAcct(0x22), Asset: fillID(0x33), IntentID: fillID(0x44)}
m, err := buildStateRequest(in)
if err != nil {
t.Fatal(err)
}
got := readStateRequest(m)
if got != in {
t.Fatalf("StateRequest round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_StateResult_RoundTrip(t *testing.T) {
in := StateResult{Kind: StateMarket, Exists: true, Known: true, Available: 42, BaseID: fillID(0x55), QuoteID: fillID(0x66)}
m, err := buildStateResult(in)
if err != nil {
t.Fatal(err)
}
got := readStateResult(m)
if got != in {
t.Fatalf("StateResult round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_SwapIntentRequest_RoundTrip(t *testing.T) {
in := SwapIntentRequest{
NetworkID: 96369, CallIndex: 7, AmountIn: 1_000_000, MinAmountOut: 990_000, Deadline: 1 << 40,
CChainID: fillID(0xC0), DChainID: fillID(0xD0), Account: fillAcct(0xAA),
AssetIn: fillID(0x01), AssetInAddr: fillAcct(0x02), MarketID: fillID(0x4D), Recipient: fillAcct(0xBB),
}
m, err := buildSwapIntentRequest(in)
if err != nil {
t.Fatal(err)
}
got := readSwapIntentRequest(m)
if got != in {
t.Fatalf("SwapIntentRequest round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_PreparedIntent_RoundTrip(t *testing.T) {
in := PreparedIntent{
To: fillAcct(0x99), IntentID: fillID(0x4B), QuotedOut: 12345, AmountIn: 1_000_000,
DChainID: fillID(0xD0), CChainID: fillID(0xC0), Account: fillAcct(0xAA), Recipient: fillAcct(0xBB),
AssetIn: fillID(0x01), MarketID: fillID(0x4D),
Calldata: bytes.Repeat([]byte{0xCA}, 200), HookData: []byte{'D', 'I', '0', '1'},
}
m, err := buildPreparedIntent(in)
if err != nil {
t.Fatal(err)
}
got := readPreparedIntent(m)
// Compare field by field (slices need bytes.Equal).
if got.To != in.To || got.IntentID != in.IntentID || got.QuotedOut != in.QuotedOut || got.AmountIn != in.AmountIn ||
got.DChainID != in.DChainID || got.CChainID != in.CChainID || got.Account != in.Account || got.Recipient != in.Recipient ||
got.AssetIn != in.AssetIn || got.MarketID != in.MarketID {
t.Fatalf("PreparedIntent fixed-field round-trip:\n got %+v\nwant %+v", got, in)
}
if !bytes.Equal(got.Calldata, in.Calldata) {
t.Fatalf("PreparedIntent.Calldata: got %x want %x", got.Calldata, in.Calldata)
}
if !bytes.Equal(got.HookData, in.HookData) {
t.Fatalf("PreparedIntent.HookData: got %x want %x", got.HookData, in.HookData)
}
}
func TestWire_DExportRef_RoundTrip(t *testing.T) {
in := DExportRef{SourceChainID: fillID(0xDD), SourceTxID: fillID(0xEE), OutputIndex: 9, IntentID: fillID(0x4B)}
m, err := buildDExportRef(in)
if err != nil {
t.Fatal(err)
}
got := readDExportRef(m)
if got != in {
t.Fatalf("DExportRef round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_IntentStatus_RoundTrip(t *testing.T) {
in := IntentStatus{
IntentID: fillID(0x4B), Phase: PhaseCommitted,
Ref: DExportRef{SourceChainID: fillID(0xDD), SourceTxID: fillID(0xEE), OutputIndex: 3, IntentID: fillID(0x4B)},
Reason: "ok",
}
m, err := buildIntentStatus(in)
if err != nil {
t.Fatal(err)
}
got := readIntentStatus(m)
if got.IntentID != in.IntentID || got.Phase != in.Phase || got.Reason != in.Reason ||
got.Ref.SourceChainID != in.Ref.SourceChainID || got.Ref.SourceTxID != in.Ref.SourceTxID || got.Ref.OutputIndex != in.Ref.OutputIndex {
t.Fatalf("IntentStatus round-trip:\n got %+v\nwant %+v", got, in)
}
}
func TestWire_SettlementResult_RoundTrip(t *testing.T) {
in := SettlementSubmitResult{
Mode: SettleCalldata, To: fillAcct(0x99), ObjectKey: fillID(0x0B), CTxHash: fillID(0x0C),
Calldata: bytes.Repeat([]byte{0x5E}, 137),
}
m, err := buildSettlementResult(in)
if err != nil {
t.Fatal(err)
}
got := readSettlementResult(m)
if got.Mode != in.Mode || got.To != in.To || got.ObjectKey != in.ObjectKey || got.CTxHash != in.CTxHash {
t.Fatalf("SettlementResult fixed fields:\n got %+v\nwant %+v", got, in)
}
if !bytes.Equal(got.Calldata, in.Calldata) {
t.Fatalf("SettlementResult.Calldata: got %x want %x", got.Calldata, in.Calldata)
}
}
func TestWire_IntentWatchRef_RoundTrip(t *testing.T) {
in := IntentWatchRef{IntentID: fillID(0x4B)}
m, err := buildIntentWatchRef(in)
if err != nil {
t.Fatal(err)
}
got := readIntentWatchRef(m)
if got != in {
t.Fatalf("IntentWatchRef round-trip: got %x want %x", got.IntentID[:], in.IntentID[:])
}
}
// fillID returns an ID whose every byte is b (so any truncation/overlap shows).
func fillID(b byte) ID {
var id ID
for i := range id {
id[i] = b
}
return id
}
// fillAcct returns an Account whose every byte is b.
func fillAcct(b byte) Account {
var a Account
for i := range a {
a[i] = b
}
return a
}