Files
precompile/dex/settle_custody.go
zeekay 1362566d34 Merge github/main into the hardening branch
Both sides carried work. Ours is the security pass: the stableswap amp=0
and empty-pool halts, the wei-per-swap leak, uint256 truncation in math,
three verifiers that returned a verdict without verifying, the cgo/non-cgo
consensus split in cggmp21, and the starkfri soundness floor. Theirs is
the fhe parameter set, the mldsa crafted-length refusal, and the naming
sweep.

Resolved per hunk rather than by side. Where both fixed one defect the
better fix survives alone, never two guards for one bug.
2026-08-28 20:13:25 -07:00

241 lines
10 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
// settle_custody.go is the 0x9999 vault's funds-in / funds-out / balance surface.
// It is SEPARATE from the deprecated 0x9010 ZAP-custody deposit/withdraw (which
// fund a D-Chain ledger via a live client). The 0x9999 vault is the receipt-
// settlement RESERVE: it holds the per-asset balances that back amountOut credits.
//
// THE CONSERVATION MODEL (no mint): every credit a settle performs comes out of
// the vault; the vault is funded by deposits and by the tokenIn legs of prior
// settlements. A settle that cannot be backed reverts (settle9999.go
// ErrSettleUnbacked). Liquidity providers / market-makers fund the vault's
// counterparty side; day-1 the operator seeds it. Withdraw lets a depositor pull
// their own balance back. balanceOf reads a depositor's claim.
//
// These funds-flow handlers move REAL EVM value (native via AddBalance/SubBalance
// on the 0x9999 self-address; ERC-20 via the erc20Vault observed-delta), keyed by
// the SAME injective AssetID the settle path uses, so deposits and settlements
// agree on what an asset is.
// depositorClaimPrefix keys a depositor's per-asset claim on the 0x9999 vault.
// This is the depositor's withdrawable balance (NOT the vault total — the vault
// total is settleVault[asset], which also reflects settlement tokenIn legs).
var depositorClaimPrefix = []byte(settleStateNamespace + "claim")
func depositorClaimKey(account common.Address, assetID [32]byte) common.Hash {
id := make([]byte, 0, 20+32)
id = append(id, account.Bytes()...)
id = append(id, assetID[:]...)
return makeStorageKey(depositorClaimPrefix, id)
}
func loadDepositorClaim(stateDB StateDB, account common.Address, assetID [32]byte) *big.Int {
return new(big.Int).SetBytes(stateDB.GetState(poolManagerAddr9999, depositorClaimKey(account, assetID)).Bytes())
}
func storeDepositorClaim(stateDB StateDB, account common.Address, assetID [32]byte, amount *big.Int) {
var w common.Hash
if amount != nil && amount.Sign() > 0 {
amount.FillBytes(w[:])
}
stateDB.SetState(poolManagerAddr9999, depositorClaimKey(account, assetID), w)
}
var (
ErrSettleDepositShort = errors.New("dex: native deposit requires msg.value == amount")
ErrSettleClaimShort = errors.New("dex: withdraw exceeds depositor claim")
)
// runSettleDeposit funds the 0x9999 vault: deposit(address asset, uint256 amount).
// Native (asset == address(0)) requires the EVM to have moved msg.value == amount
// into 0x9999 (the precompile self), which the host call frame does; we VERIFY the
// self-balance covers it and record the claim + vault total. ERC-20 pulls via
// transferFrom (observed-delta). Halt-gated by haltAsset only (a global halt must
// not strand the ability to fund).
func (s *SettleContract) runSettleDeposit(
state contract.AccessibleState, caller common.Address, input []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, errors.New("dex: cannot deposit in read-only mode")
}
if gas < GasSettlement {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasSettlement
if len(input) < 64 {
return nil, gasLeft, errors.New("dex: deposit input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
amount := new(big.Int).SetBytes(input[32:64])
if amount.Sign() <= 0 || !isWord(amount) {
return nil, gasLeft, ErrInvalidAmount
}
aid := assetID(asset)
stateDB := newPoolStateAdapter(state)
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, aid[:])) {
return nil, gasLeft, ErrAssetHalted
}
// GLOBAL non-reentrant guard for the 0x9999 custody entrypoint set (deposit /
// withdraw / modifyLiquidity share ONE slot). Set BEFORE any ERC-20 sub-call so a
// malicious token cannot re-enter ANY custody op from inside its transferFrom
// callback (the observed-delta + CEI ordering already mitigates double-spend; this
// is uniform defense-in-depth across the whole custody surface).
if !enterCustodyKV(stateDB) {
return nil, gasLeft, ErrCustodyReentrant
}
defer exitCustodyKV(stateDB)
if isNativeAsset(aid) {
if _, of := uint256.FromBig(amount); of {
return nil, gasLeft, ErrSettleAmountRange
}
// NATIVE DEPOSIT — OBSERVED DELTA, not an absolute balance test. deliveredNative
// (native_zap.go) is the one answer to how much native this CALL carried: the vault's
// real balance less every pot already accounted for. An ABSOLUTE check would let a
// value==0 call mint a claim against OTHERS' funds in the SHARED vault; the delta makes
// value==0 deliver 0 (reverts), and counting custody as accounted keeps a depositor's
// claim to what a depositor sent — subtracting settleVault alone reads the whole
// custody pot as this call's delivery. This is all reads + balance — no nested call —
// so the claim/vault writes below are Phase A (never dropped by the nested-call host
// bug).
delivered := deliveredNative(stateDB, aid)
if delivered.Sign() < 0 || delivered.Cmp(amount) < 0 {
return nil, gasLeft, ErrSettleDepositShort // value not delivered this call.
}
storeDepositorClaim(stateDB, caller, aid, new(big.Int).Add(loadDepositorClaim(stateDB, caller, aid), amount))
storeSettleVault(stateDB, aid, new(big.Int).Add(loadSettleVault(stateDB, aid), amount))
out := make([]byte, 32)
amount.FillBytes(out)
return out, gasLeft, nil
}
// ERC-20 DEPOSIT — NATIVE-ZAP two phase (the geth nested-call fund-loss fix). Record the
// depositor claim + vault total at the REQUESTED amount (PHASE A) BEFORE the single
// transferFrom, which is the frame's TERMINAL external effect (PHASE B). The prior order
// (transferFrom THEN the two stores) had the host DROP the post-transfer claim/vault writes
// => a deposited token with no withdrawable claim (the stranded-funds fund-loss). An under-
// delivering token (fee-on-transfer / rebasing-down) reverts the whole frame (pullERC20-
// Terminal's delivered>=amount guard) rather than recording a claim the vault cannot back.
vault, ok := stateDBERC20(stateDB)
if !ok {
return nil, gasLeft, ErrSettleERC20Vault
}
storeDepositorClaim(stateDB, caller, aid, new(big.Int).Add(loadDepositorClaim(stateDB, caller, aid), amount))
storeSettleVault(stateDB, aid, new(big.Int).Add(loadSettleVault(stateDB, aid), amount))
if perr := pullERC20Terminal(vault, asset.Address, caller, amount); perr != nil {
return nil, gasLeft, perr
}
out := make([]byte, 32)
amount.FillBytes(out)
return out, gasLeft, nil
}
// runSettleWithdraw pulls a depositor's claim back out of the 0x9999 vault:
// withdraw(address asset, uint256 want). Clamps to the depositor's claim AND the
// vault total (defense in depth). NOT gated by the global swap halt — a halt must
// never strand funds; withdraw always works. Returns the realized amount.
func (s *SettleContract) runSettleWithdraw(
state contract.AccessibleState, caller common.Address, input []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, errors.New("dex: cannot withdraw in read-only mode")
}
if gas < GasSettlement {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasSettlement
if len(input) < 64 {
return nil, gasLeft, errors.New("dex: withdraw input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
want := new(big.Int).SetBytes(input[32:64])
aid := assetID(asset)
stateDB := newPoolStateAdapter(state)
// GLOBAL non-reentrant guard (shared 0x9999 custody slot). Set BEFORE the vault
// release sub-call so a malicious token cannot re-enter withdraw (or any custody
// op) and double-release inside the transfer callback. CEI below is the primary
// guarantee; this is uniform defense across the custody surface.
if !enterCustodyKV(stateDB) {
return nil, gasLeft, ErrCustodyReentrant
}
defer exitCustodyKV(stateDB)
claim := loadDepositorClaim(stateDB, caller, aid)
realized := new(big.Int).Set(want)
if realized.Cmp(claim) > 0 {
realized.Set(claim)
}
held := loadSettleVault(stateDB, aid)
if realized.Cmp(held) > 0 {
realized.Set(held) // invariant: claim <= held; defense in depth.
}
if realized.Sign() <= 0 {
out := make([]byte, 32)
return out, gasLeft, nil // nothing to withdraw
}
// Debit claim + vault total BEFORE the external value movement (CEI pattern).
storeDepositorClaim(stateDB, caller, aid, new(big.Int).Sub(claim, realized))
storeSettleVault(stateDB, aid, new(big.Int).Sub(held, realized))
if isNativeAsset(aid) {
amt, of := uint256.FromBig(realized)
if of {
return nil, gasLeft, ErrSettleAmountRange
}
// UNDERFLOW GUARD (defense in depth): SubBalance is uint256-modular and does
// not revert on underflow from a precompile. realized is already clamped to
// the tracked vault total (<= real balance by invariant), so this only fires
// on a regression — fail loud rather than wrap 0x9999's native balance.
if stateDB.GetBalance(poolManagerAddr9999).Cmp(amt) < 0 {
return nil, gasLeft, ErrSettleClaimShort
}
stateDB.SubBalance(poolManagerAddr9999, amt)
stateDB.AddBalance(caller, amt)
} else {
vault, ok := stateDBERC20(stateDB)
if !ok {
return nil, gasLeft, ErrSettleERC20Vault
}
if err := safeTransferTokenTo(vault, asset.Address, caller, realized); err != nil {
return nil, gasLeft, err
}
}
out := make([]byte, 32)
realized.FillBytes(out)
return out, gasLeft, nil
}
// runSettleBalanceOf reads a depositor's withdrawable claim: balanceOf(address
// account, address asset). Read-only, no mutation.
func (s *SettleContract) runSettleBalanceOf(
state contract.AccessibleState, input []byte, gas uint64,
) ([]byte, uint64, error) {
if gas < GasPoolLookup {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasPoolLookup
if len(input) < 64 {
return nil, gasLeft, errors.New("dex: balanceOf input too short")
}
account := common.BytesToAddress(input[12:32])
asset := Currency{Address: common.BytesToAddress(input[44:64])}
claim := loadDepositorClaim(newPoolStateAdapter(state), account, assetID(asset))
out := make([]byte, 32)
claim.FillBytes(out)
return out, gasLeft, nil
}