mirror of
https://github.com/luxfi/zap.git
synced 2026-07-27 05:54:26 +00:00
merge: ZAP DexSession V4 bidirectional control plane (v0.8.9)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user