mirror of
https://github.com/luxfi/zap.git
synced 2026-07-27 05:54:26 +00:00
dexsession: settlement byte-identity (intentID word) + scope route off the on-chain hookData (swap-seam r2)
THE SETTLE CALLDATA REVERTED (byte-identity). EncodeSettlementHookData emitted a 64-byte
Phase-B body (outputID|amount), but the precompile's decodeSettlementBody requires 96
(outputID|amount|intentID) — the intentID word binds the credit to the taker's intent record
(the per-taker cap + deadline gate). So every settle tx built off this calldata REVERTED
on-chain (ErrSettleBodyMalformed). Add the intentID word so the body is byte-identical with
precompile/dex/settle_hookdata.go; settlementBodyLen is now 96. The DExportRef already carries
IntentID, so the production importSettlement path and every ResolveExport supplies it.
calldata_parity_test pins the full 96-byte body against a hardcoded vector.
THE MULTI-HOP ROUTE REVERTED (RT01 scoping). prepareRouteLocal signed an on-chain hookData of
DI01|RT01|hopCount|path. The precompile has NO route-marker awareness: it classifies the DI01
tag as a Phase-A intent, then decodeIntentBody rejects the RT01 body width (not in {0,32,64})
-> every multi-hop swap REVERTED on-chain. Fix by scoping the route OFF the on-chain calldata:
the on-chain leg is now a PLAIN DI01 intent on the entry market (nonce=CallIndex, matching the
id this session derives — DeriveIntentID binds the entry market + nonce, NOT the path, so the
id is unchanged). The PATH travels to the D router over the ZAP control plane (the RouteRequest
envelope, buildRouteRequest/NotifyRoute), where it belongs — it is D-matcher orchestration that
moves no C-side value (one input object, one final output object regardless of hop count).
Removed EncodeRouteIntentHookData/DecodeRouteIntentHookData (the on-chain RT01 wire the
precompile never supported — a bug generator); the path's one canonical wire is the
RouteRequest envelope. Reworked the route parity test to pin the new property (on-chain route
leg is a precompile-accepted DI01 body carrying no RT01/DS01 tag) and the multi-hop e2e test to
read the path from the session (control plane), not the signed calldata.
CGO_ENABLED=0 pure-Go (GOWORK=off); full dexsession suite green.
This commit is contained in:
+23
-14
@@ -42,9 +42,14 @@ var (
|
||||
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
|
||||
// settlementBodyLen is the fixed Phase-B body width: outputID(32) | amount(32) |
|
||||
// intentID(32). IDENTICAL to precompile/dex/settle_hookdata.go settlementBodyLen. The
|
||||
// intentID word binds the credit to the taker's OWN intent record on-chain (the per-taker
|
||||
// cap + the deadline gate); omitting it makes the body 64 bytes, which the precompile's
|
||||
// decodeSettlementBody REJECTS (len != 96 -> ErrSettleBodyMalformed) and the settle tx
|
||||
// REVERTS. The three-homes parity test (calldata_parity_test) pins this width against a
|
||||
// hardcoded vector so a one-sided drift trips a red test before any wallet signs bad bytes.
|
||||
const settlementBodyLen = 32 + 32 + 32
|
||||
|
||||
// EncodeIntentHookData builds an explicit Phase-A hookData carrying the deadline and the
|
||||
// intent NONCE. BYTE-IDENTICAL to precompile/dex EncodeIntentHookData — both sides MUST
|
||||
@@ -77,24 +82,28 @@ func EncodeIntentHookData(deadline, nonce uint64) []byte {
|
||||
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).
|
||||
// EncodeSettlementHookData builds a Phase-B hookData: tag + outputID + amount + intentID.
|
||||
// Byte-identical to precompile/dex EncodeSettlementHookData (the INVERSE of its
|
||||
// decodeSettlementBody). amount is right-aligned in a uint256 word (the low 8 bytes),
|
||||
// matching the precompile's binary.BigEndian.PutUint64(amt[24:32], amount). intentID names
|
||||
// the originating C->D intent the settlement draws against — the precompile binds the credit
|
||||
// to that taker's intent record (the per-taker cap + the deadline gate), so it is REQUIRED;
|
||||
// a body without it is the wrong width and the on-chain decode reverts.
|
||||
//
|
||||
// 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 {
|
||||
// CRITICAL: the body carries outputID + amount + intentID, but NOT the output ASSET or the
|
||||
// RECIPIENT — 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 outputID/amount/intentID against the RECORDED object + the recorded
|
||||
// owner's intent. (This is why a tampered DExportRef cannot substitute recipient/asset/amount
|
||||
// — see the package invariant.)
|
||||
func EncodeSettlementHookData(outputID ID, amount uint64, intentID ID) []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[:]...)
|
||||
out = append(out, intentID[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,9 @@ func decodeSettlementCalldata(calldata []byte) (outputID ID, amount uint64, ok b
|
||||
return ID{}, 0, false
|
||||
}
|
||||
hook := calldata[bodyOff : bodyOff+hookLen]
|
||||
// Phase-B: tag(4) + outputID(32) + amount(32).
|
||||
// Phase-B: tag(4) + outputID(32) + amount(32) + intentID(32). The mock binds on
|
||||
// outputID + amount; the intentID word (hook[68:100]) is consumed on-chain by the
|
||||
// per-taker cap and is not needed for the substitution properties under test here.
|
||||
if len(hook) != 4+settlementBodyLen {
|
||||
return ID{}, 0, false
|
||||
}
|
||||
|
||||
@@ -211,14 +211,17 @@ func TestZAP_ImportSettlement_CannotSubstituteRecipientAssetAmount(t *testing.T)
|
||||
}
|
||||
|
||||
// settlementCalldataFor builds 0x9999 settlement calldata pointing at key for
|
||||
// amount (the bytes a wallet would sign). Helper for the substitution test.
|
||||
// amount (the bytes a wallet would sign). Helper for the substitution test. The
|
||||
// intentID word is left zero: these tests exercise the owner/asset/amount object-bind
|
||||
// (the substitution rejections), which are independent of the intent binding, and the
|
||||
// mock ledger's decode does not consult the intentID word.
|
||||
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)),
|
||||
Calldata: EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, amount, ID{})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,25 +125,36 @@ func TestParity_DeriveUTXOID(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// amount in low 8 bytes) + intentID(32). The intentID word is what binds the credit to
|
||||
// the taker's intent on-chain (the per-taker cap + deadline gate); a body without it is
|
||||
// 64 bytes and the precompile reverts (ErrSettleBodyMalformed). This pins the FULL 96-byte
|
||||
// body so a one-sided drift (the bug: zap emitting 64 while the precompile decodes 96) is
|
||||
// caught here. Also confirms Phase A is the explicit "DI01" tag.
|
||||
func TestParity_SettlementHookData(t *testing.T) {
|
||||
outputID := ID{0xfe, 0xed}
|
||||
outputID[31] = 0x01
|
||||
amount := uint64(424242)
|
||||
intentID := ID{0x1A, 0x2B, 0x3C}
|
||||
intentID[31] = 0x09
|
||||
|
||||
hook := EncodeSettlementHookData(outputID, amount)
|
||||
want := make([]byte, 0, 4+64)
|
||||
hook := EncodeSettlementHookData(outputID, amount, intentID)
|
||||
want := make([]byte, 0, 4+settlementBodyLen)
|
||||
want = append(want, 'D', 'S', '0', '1')
|
||||
want = append(want, outputID[:]...)
|
||||
var amt [32]byte
|
||||
binary.BigEndian.PutUint64(amt[24:32], amount)
|
||||
want = append(want, amt[:]...)
|
||||
want = append(want, intentID[:]...)
|
||||
if !bytes.Equal(hook, want) {
|
||||
t.Fatalf("settlement hookData mismatch:\n got %x\nwant %x", hook, want)
|
||||
}
|
||||
if len(hook) != 4+settlementBodyLen {
|
||||
t.Fatalf("settlement hookData len = %d, want %d", len(hook), 4+settlementBodyLen)
|
||||
}
|
||||
if settlementBodyLen != 96 {
|
||||
t.Fatalf("settlementBodyLen = %d, want 96 (outputID|amount|intentID) — must match "+
|
||||
"precompile/dex/settle_hookdata.go or the settle calldata reverts on-chain", settlementBodyLen)
|
||||
}
|
||||
|
||||
// Phase A with deadline 0, nonce 0 is the bare DI01 tag (minimal-width).
|
||||
intent := EncodeIntentHookData(0, 0)
|
||||
@@ -168,7 +179,7 @@ func TestParity_SwapSelector(t *testing.T) {
|
||||
t.Fatalf("SelectorSwap = %08X, want F3CD914C", SelectorSwap)
|
||||
}
|
||||
pk := testPoolKey()
|
||||
calldata := EncodeSwapCalldata(pk, true, 1000, EncodeSettlementHookData(ID{0x01}, 7))
|
||||
calldata := EncodeSwapCalldata(pk, true, 1000, EncodeSettlementHookData(ID{0x01}, 7, ID{0x02}))
|
||||
if got := binary.BigEndian.Uint32(calldata[:4]); got != SelectorSwap {
|
||||
t.Fatalf("calldata selector = %08X, want %08X", got, SelectorSwap)
|
||||
}
|
||||
|
||||
@@ -113,75 +113,43 @@ func TestParity_V4_ModifyLiquidityCalldata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// TestParity_V4_RouteOnChainIsPlainIntent pins the RT01 scoping fix: a route's ON-CHAIN
|
||||
// leg is a PLAIN DI01 intent on the entry market — a body width the precompile's
|
||||
// decodeIntentBody ACCEPTS ({0,32,64}) — NOT a route-path body. The precompile has no
|
||||
// route-marker awareness, so an RT01 path body (a marker + hop count + path) would be a
|
||||
// width decodeIntentBody REJECTS and the swap would REVERT on-chain. The path instead
|
||||
// travels to the D router over the ZAP control plane (the RouteRequest envelope), which is
|
||||
// the one and only place it is serialized for the venue. This test is the regression guard
|
||||
// against re-introducing an on-chain route-path body.
|
||||
func TestParity_V4_RouteOnChainIsPlainIntent(t *testing.T) {
|
||||
// The on-chain intent body a route now signs is the plain DI01 body (deadline+nonce).
|
||||
// Build it the same way prepareRouteLocal does and assert it is a precompile-accepted
|
||||
// width and carries NO route marker.
|
||||
for _, tc := range []struct {
|
||||
deadline, nonce uint64
|
||||
wantLen int
|
||||
}{
|
||||
{0, 0, 4}, // DI01 tag only
|
||||
{1 << 40, 0, 36}, // DI01 + deadline
|
||||
{1 << 40, 7, 68}, // DI01 + deadline + nonce
|
||||
} {
|
||||
hook := EncodeIntentHookData(tc.deadline, tc.nonce)
|
||||
if len(hook) != tc.wantLen {
|
||||
t.Fatalf("route on-chain intent body (deadline=%d nonce=%d) len=%d, want %d",
|
||||
tc.deadline, tc.nonce, len(hook), tc.wantLen)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
// It is a Phase-A intent (DI01-led) the precompile classifies as INTENT.
|
||||
if !bytes.Equal(hook[0:4], []byte{'D', 'I', '0', '1'}) {
|
||||
t.Fatalf("route on-chain intent must lead with DI01, got %x", hook[0:4])
|
||||
}
|
||||
// It carries NO RT01 route marker and NO DS01 settlement tag — a route's input leg
|
||||
// is a clean intent, and the path is not in the signed calldata.
|
||||
if bytes.Contains(hook, []byte{'R', 'T', '0', '1'}) {
|
||||
t.Fatalf("route on-chain intent must NOT carry an RT01 path body (the precompile reverts on it)")
|
||||
}
|
||||
if bytes.Contains(hook, []byte{'D', 'S', '0', '1'}) {
|
||||
t.Fatalf("route on-chain intent must NOT carry a DS01 settlement tag")
|
||||
}
|
||||
}
|
||||
// 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(0, 0)); 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-11
@@ -397,20 +397,31 @@ func TestV4RouteSession_MultiHopStaysOnDUntilFinalExport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("route prepare: %v", err)
|
||||
}
|
||||
// The ONE calldata carries the WHOLE path (A->B->C) — no per-hop calldata exists.
|
||||
gotPath, ok := DecodeRouteIntentHookData(pi.HookData)
|
||||
if !ok || len(gotPath) != 3 {
|
||||
t.Fatalf("route hookData does not carry the 3-hop path: ok=%v len=%d", ok, len(gotPath))
|
||||
}
|
||||
for i := range gotPath {
|
||||
if gotPath[i] != req.Path[i] {
|
||||
t.Fatalf("route path hop %d mismatch: got %x want %x", i, gotPath[i][:2], req.Path[i][:2])
|
||||
}
|
||||
}
|
||||
// It is a single C->D INPUT intent (Phase A — DI01 leads the hookData).
|
||||
// The ONE on-chain calldata is a PLAIN DI01 intent on the entry market — the body the
|
||||
// precompile ACCEPTS. The path is NOT in the signed hookData (an RT01 body would revert
|
||||
// on-chain: decodeIntentBody rejects any width outside {0,32,64}); it travels to the D
|
||||
// router over the ZAP control plane. It is a single C->D INPUT intent (Phase A — DI01
|
||||
// leads the hookData), and the body is a width the precompile's decodeIntentBody accepts.
|
||||
if string(pi.HookData[0:4]) != string(intentPhaseTag[:]) {
|
||||
t.Fatalf("route intent is not a Phase-A intent")
|
||||
}
|
||||
switch len(pi.HookData) {
|
||||
case 4, 4 + 32, 4 + 64: // DI01 | DI01+deadline | DI01+deadline+nonce — all precompile-accepted
|
||||
default:
|
||||
t.Fatalf("route on-chain intent body width %d is not a precompile-accepted DI01 body "+
|
||||
"(would revert on-chain)", len(pi.HookData))
|
||||
}
|
||||
// The PATH is still fully available — carried by the route session (control plane), the
|
||||
// same path the venue learns via buildRouteRequest/NotifyRoute.
|
||||
if got := sess.Path(); len(got) != 3 {
|
||||
t.Fatalf("route session must carry the 3-hop path on the control plane, got len=%d", len(got))
|
||||
} else {
|
||||
for i := range got {
|
||||
if got[i] != req.Path[i] {
|
||||
t.Fatalf("route path hop %d mismatch: got %x want %x", i, got[i][:2], req.Path[i][:2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch, err := sess.WriteNotifyCToDExport(ctx, intent).Await(ctx) // V4_ROUTE_NOTIFY_C_EXPORT [C->D]
|
||||
if err != nil {
|
||||
|
||||
+20
-65
@@ -109,73 +109,28 @@ func wordI256FromInt64(v int64) abiWord {
|
||||
// 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.
|
||||
// A V4 route's ON-CHAIN leg is a PLAIN SWAP INTENT (DI01 / Phase A) on the ENTRY market:
|
||||
// it locks the single input and creates ONE C->D input object, identically to a normal
|
||||
// single-market intent. The multi-market PATH is NOT carried in the signed on-chain
|
||||
// hookData — it travels to the D-Chain router over the ZAP CONTROL PLANE (the RouteRequest
|
||||
// envelope: buildRouteRequest / NotifyRoute, MsgRoutePrepare). Two reasons this is the
|
||||
// correct seam:
|
||||
//
|
||||
// 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.
|
||||
// 1. The on-chain precompile has NO route-marker awareness. Its decodeIntentBody accepts
|
||||
// only a DI01 body of width {0, 32, 64} (empty / deadline / deadline+nonce); a route
|
||||
// body (a marker + hop count + path) is a different width and REVERTS on-chain. So an
|
||||
// on-chain RT01 body would brick every multi-hop swap. (This was the bug.)
|
||||
// 2. The path is D-matcher orchestration that moves NO C-side value, and the on-chain
|
||||
// intent id (DeriveIntentID) binds the ENTRY market + nonce, NOT the path — so a plain
|
||||
// DI01 intent on the entry market yields the SAME id the route session computed. The
|
||||
// path therefore does not belong in the consensus-visible calldata.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
// 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. The path's canonical wire is the RouteRequest
|
||||
// envelope (v4route.go) — there is exactly ONE place the path is serialized for the venue,
|
||||
// and it is not the on-chain hookData.
|
||||
|
||||
// DeriveRoutePathHash hashes a route path (the ordered marketID list) into a scope
|
||||
// PoolKeyHash. Two routes with different paths derive different session ids, so a
|
||||
|
||||
+28
-11
@@ -360,9 +360,16 @@ 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.
|
||||
// calldata: a 0x9999 swap (DI01 intent) on the ENTRY market. 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. The PATH is NOT carried in the signed on-chain
|
||||
// hookData — it travels to the D router over the ZAP control plane (buildRouteRequest /
|
||||
// NotifyRoute, MsgRoutePrepare) — because the on-chain precompile classifies the intent
|
||||
// purely by its entry market + nonce (DeriveIntentID does NOT hash the path) and only
|
||||
// supports the DI01 deadline/nonce body; an RT01 path body in the signed calldata would
|
||||
// REVERT on-chain (the precompile has no route-marker awareness). Keeping the path on the
|
||||
// control plane is also the right seam: the path is D-matcher orchestration that moves no
|
||||
// C-side value (one input object, one final output object regardless of hop count).
|
||||
func (s *V4RouteSession) WritePrepareIntent(ctx context.Context) *Promise[PreparedIntent] {
|
||||
p := newPromise[PreparedIntent]()
|
||||
if err := s.cap.authorizeSelf(AuthIntent); err != nil {
|
||||
@@ -377,22 +384,32 @@ func (s *V4RouteSession) WritePrepareIntent(ctx context.Context) *Promise[Prepar
|
||||
|
||||
// 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.
|
||||
// calldata is a PLAIN DI01 intent (deadline + nonce) on the entry market — the exact body
|
||||
// the on-chain precompile accepts. It reserves nothing and returns no enforceable amount.
|
||||
//
|
||||
// WHY NOT embed the path on-chain (the RT01 fix): the on-chain DeriveIntentID binds the
|
||||
// entry market + nonce, NOT the path, so a plain DI01 intent on the entry market produces
|
||||
// the IDENTICAL id this session computed (OpenRoute derived it with `entry` + CallIndex).
|
||||
// The precompile has no RT01 route-marker awareness; an RT01 body in the signed calldata is
|
||||
// width 8+n*32, which decodeIntentBody rejects -> the swap REVERTS on-chain. The path is
|
||||
// instead carried to the D router over the ZAP control plane (buildRouteRequest /
|
||||
// NotifyRoute), where it belongs — it is D-matcher orchestration, not C-side value. The
|
||||
// nonce is CallIndex (matching OpenRoute's id derivation) so the off-chain and on-chain ids
|
||||
// agree and the watch correlates.
|
||||
func (s *V4RouteSession) prepareRouteLocal() (PreparedIntent, error) {
|
||||
if s.req.AmountIn == 0 {
|
||||
return PreparedIntent{}, fmt.Errorf("dexsession: openRoute: zero amountIn")
|
||||
}
|
||||
if len(s.req.Path) == 0 {
|
||||
return PreparedIntent{}, fmt.Errorf("dexsession: openRoute: empty path")
|
||||
}
|
||||
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")
|
||||
}
|
||||
// Plain DI01 intent on the entry market; nonce == CallIndex to match the id this session
|
||||
// derived (and the id the on-chain SubmitSwapIntent will re-derive from this calldata).
|
||||
hookData := EncodeIntentHookData(s.req.Deadline, uint64(s.req.CallIndex))
|
||||
calldata := EncodeSwapCalldata(pk, true /*entry direction; D re-validates*/, s.req.AmountIn, hookData)
|
||||
return PreparedIntent{
|
||||
To: addr9999(),
|
||||
|
||||
@@ -107,7 +107,7 @@ func (v *honestVenue) ResolveExport(_ context.Context, ref DExportRef) (Settleme
|
||||
amount = o.amount
|
||||
}
|
||||
pk := testPoolKey()
|
||||
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, amount))
|
||||
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, amount, ref.IntentID))
|
||||
return SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: key, Calldata: calldata}, nil
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (v *maliciousVenue) ResolveExport(_ context.Context, ref DExportRef) (Settl
|
||||
key = ref.ObjectKey()
|
||||
}
|
||||
pk := testPoolKey()
|
||||
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, v.fakeAmount))
|
||||
calldata := EncodeSwapCalldata(pk, true, 0, EncodeSettlementHookData(key, v.fakeAmount, ref.IntentID))
|
||||
return SettlementSubmitResult{Mode: SettleCalldata, To: addr9999(), ObjectKey: key, Calldata: calldata}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user