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:
zeekay
2026-06-21 22:39:02 -07:00
parent 1c4e7947f1
commit 9b0c85bc80
9 changed files with 153 additions and 177 deletions
+23 -14
View File
@@ -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
}
+3 -1
View File
@@ -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
}
+5 -2
View File
@@ -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{})),
}
}
+15 -4
View File
@@ -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)
}
+35 -67
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+2 -2
View File
@@ -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
}