fix(dexcore): widen AccountID 16->32 bytes — injective for 20-byte EVM addresses

The cEVM home's taker is a 20-byte EVM address; the prior 16-byte AccountID would
fold a 20-byte address lossily, so two distinct addresses sharing a leading 16-byte
prefix could collide as one dexcore account and drain each other's resting-order
proceeds (cross-user drain on the account axis). Widen AccountID to the full 32-byte
injective settlement identity both homes map into without a lossy fold: the cEVM
left-pads the 20-byte address (accountFromAddress), dchain left-pads its 16-byte wire
user. 32 bytes is birthday-safe at 2^128 and injective for every real identity.

matcherHint renders the full 32 account bytes as the matcher's User/UserID string —
the hint is ONLY a matching collision handle, never authoritative for value (settlement
resolves the taker from the explicit AccountID and the maker from the orderuser: row).
Replaces the trimTrailingZeros round-trip (no longer needed: settlement never reads the
hint back).
This commit is contained in:
2026-06-22 17:46:49 -07:00
parent fa586680b5
commit 98d4f676db
3 changed files with 43 additions and 29 deletions
+31 -14
View File
@@ -33,7 +33,6 @@ import (
"fmt"
"github.com/luxfi/database"
"github.com/luxfi/dex/pkg/zapwire"
)
// Store is the key/value surface dexcore reads and writes its state over. It is
@@ -54,24 +53,42 @@ type Store interface {
// drain fix on the asset axis).
type AssetID = [32]byte
// AccountID is the FULL 16-byte injective wire identity the ledger keys an
// account by — NEVER a folded 8-byte handle. Keeping the account at full width
// makes two addresses sharing a leading 8-byte prefix DISTINCT accounts so
// neither can withdraw the other's balance (the cross-user native-drain fix on
// the account axis). It is byte-identical to the wire user[16] field.
type AccountID = [zapwire.UserSize]byte
// AccountID is the FULL 32-byte injective settlement identity the ledger keys an
// account by — the AccountID32 width both homes map their native identity into
// WITHOUT a lossy fold:
//
// - the cEVM home left-pads the 20-byte EVM address into 32 bytes (12 zero bytes
// + the 20 address bytes), so two distinct addresses are ALWAYS distinct
// accounts — no address pair can collide and drain each other's resting-order
// proceeds (the cross-user-drain guard, made injective for 20-byte addresses
// that a 16-byte identity could alias);
// - the dchain home maps its 16-byte wire user into the same 32-byte space.
//
// 32 bytes is birthday-safe at 2^128 and injective for every real identity. It is
// NEVER the matcher's 8-byte STP handle (which may collide and is purely a matching
// hint); settlement resolves the taker from the explicit AccountID and the maker
// from the orderuser: row, both full-width.
type AccountID = [32]byte
// AccountFromString folds a user-identity string to the canonical 16-byte ledger
// identity: the leading 16 bytes, zero-padded — byte-identical to the wire's
// user[16] null-padding so the account the ledger credits is the SAME account the
// orders/deposits name. This is the ONE fold from a user string to the settlement
// identity; there is no 8-byte fold anywhere on the value path.
func AccountFromString(user string) AccountID {
// AccountFromBytes maps a raw identity (an EVM address, a wire user) to the
// canonical 32-byte account by left-padding into the low bytes — injective for any
// identity up to 32 bytes. This is the ONE map from a native identity to the
// settlement identity; there is no lossy fold anywhere on the value path.
func AccountFromBytes(id []byte) AccountID {
var k AccountID
copy(k[:], user)
if len(id) > 32 {
id = id[len(id)-32:]
}
copy(k[32-len(id):], id)
return k
}
// AccountFromString maps a user-identity string to the canonical 32-byte account
// (left-padded). Used by the dchain home where the identity arrives as a string.
func AccountFromString(user string) AccountID {
return AccountFromBytes([]byte(user))
}
// KEY SCHEMA — the durable ledger rows. Identical in both homes; the precompile
// maps each []byte key to a keccak-derived 0x9999 storage slot, dchain writes it
// to versiondb directly. BOTH halves of a balance key are full injective width.
+2 -2
View File
@@ -77,8 +77,8 @@ func PlaceOrder(db Store, poolID [32]byte, maker AccountID, side lx.Side, price,
Price: price,
Size: size,
SizeUnits: new(big.Int).SetUint64(SizeToUnits(size)),
User: string(trimTrailingZeros(maker[:])),
UserID: string(trimTrailingZeros(maker[:])),
User: matcherHint(maker),
UserID: matcherHint(maker),
Symbol: symbol,
Timestamp: ts,
}
+10 -13
View File
@@ -164,8 +164,8 @@ func buildMarketableOrder(req SwapRequest, remainingIn uint64) *lx.Order {
order := &lx.Order{
ID: req.OrderID,
Side: req.Side,
User: string(trimTrailingZeros(req.TakerUser[:])),
UserID: string(trimTrailingZeros(req.TakerUser[:])),
User: matcherHint(req.TakerUser),
UserID: matcherHint(req.TakerUser),
Symbol: PoolSymbol(req.PoolID),
Timestamp: time.Unix(0, req.TimestampN),
}
@@ -256,14 +256,11 @@ func persistTouchedMakers(db Store, poolID [32]byte, book *lx.OrderBook, fills [
return nil
}
// trimTrailingZeros returns b with trailing NUL bytes removed (the inverse of the
// wire's user[16] null-padding), so the matcher's user string round-trips to the
// same 16-byte AccountID via AccountFromString.
func trimTrailingZeros(b []byte) []byte {
for i := len(b) - 1; i >= 0; i-- {
if b[i] != 0 {
return b[:i+1]
}
}
return b[:0]
}
// matcherHint renders an AccountID to the matcher's User/UserID string. This string
// is ONLY a matching hint: the matcher derives its 8-byte STP collision handle from
// it, and that handle is NEVER authoritative for value (settlement resolves the taker
// from the explicit AccountID and the maker from the orderuser: row). So the hint
// only needs to be a deterministic, injective-enough function of the account — the
// raw 32 account bytes as a string serve exactly that, with no lossy round-trip
// concern because settlement never reads it back.
func matcherHint(a AccountID) string { return string(a[:]) }