Files
dex/pkg/dchain/redteam_unbacked_deposit_test.go
zeekayandHanzo Dev 7ca0c2c521 fix(dex): F9 — gate TxDeposit to a trusted bridge authority; close the infinite-money self-signed deposit
A TxDeposit MINTS ledger balance, but its authorization was the DEPOSITOR'S OWN
signature (verifyTxSignature required signer == the body's beneficiary), and
handleDeposit exposed it on the same public ZAP route as dex_place/dex_submit. So
any authenticated principal could self-sign a deposit and mint unlimited unbacked
balance, then withdraw it (redteam_unbacked_deposit_test proved 500B minted).

OPTION B (gate to the trusted bridge authority) — chosen over Option A (route all
deposits through the atomic import) because: (1) the trustless atomic import
(TxImport, atomic.go) is only wired in production (runtime + shared memory) and
credits a DIFFERENT account axis (crossChainAccount(C-address)) than the DEX
tx-auth account (Account16) — routing all deposits through it would require wiring
shared memory + reconciling account models across 17 test files; (2) tx.go:47
already documents TxDeposit as the bridge/proxy-credit path — this implements that
intent correctly (the bug was the missing authority check); (3) the trustless
atomic import remains UNCHANGED and available for cross-chain swaps. Both backing
mechanisms now coexist: trustless import for swaps, authority-gated deposit for
bridge-custodied funds.

The fix:
- VM gains depositAuthority (a CONSENSUS config param, vmConfig.DepositAuthority
  hex, decoded at Initialize). ZERO => no authority => EVERY TxDeposit rejected
  fail-closed (value then enters only via the backed atomic import).
- authorizeTx (the authoritative consensus gate): a TxDeposit is accepted ONLY when
  the recovered signer == depositAuthority; else rejected (no mint, no nonce burn).
- verifyTxSignature: a deposit no longer requires signer==beneficiary (the signer is
  the authority; the beneficiary is bound via the signed digest). Withdraw is
  UNCHANGED — still signer==beneficiary (a user withdraws their own funds).
- submitTx: boundary pre-screen rejects a non-authority deposit before the mempool.

Backing invariant enforced: Σ(ledger credits via TxDeposit) ≤ Σ(bridge-authorized
deposits); combined with the import's Σ(credits) ≤ Σ(consumed C->D objects), NO path
credits the ledger without backing. The authority gate also HARDENS RED #4: a
stranded/self-signed deposit that lands in a later block is rejected, so it cannot
mint; withdraw is untouched (debits own available, clamped — no burn).

TDD: TestRedteam_UnbackedSelfSignedDepositMints now PASSES (self-signed deposit
rejected at boundary + consensus; minted 0, withdrew 0). New
TestBackedAuthoritySignedDepositCredits proves the legitimate authority-signed
deposit credits + withdraws correctly. Test funding migrated to an authority-signed
helper (fundAcct/fundTx); auth_test nonce expectations updated (a funded account's
first own tx is nonce 0 — funding no longer consumes the user's nonce). ALL suites
green (go test ./... -count=1), go vet clean; the F1-F5 cross-arch execRoots + ledgers
still match byte-for-byte across arm64/amd64.

KNOWN FOLLOW-UPS (tracked): (a) chains/dexvm + precompile/dex are separate modules
that also encode the ZAP frame — they need the same exact-integer wire bump to
interoperate; (b) F7 — retract the inflated 12.76B/9.13B/5.60B "ord/s" GPU benchmark
numbers in ~/work/lux/papers (they measure all-cross synthetic fills/s ~71-85x
inflated; the paper's own text already caps a single GPU below 10^9 fills/s).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-02 10:07:12 -07:00

95 lines
4.2 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// redteam_unbacked_deposit_test.go — RED TEAM (infinite money), now a REGRESSION
// GUARD for the F9 fix. A TxDeposit MINTS ledger balance, so it must be authorized
// by the trusted deposit AUTHORITY (the bridge/proxy that custodies the backing
// C-side value), NEVER by the crediting account self-signing. This test proves:
//
// 1. a SELF-SIGNED deposit (the attacker signs their own credit) is REJECTED at
// the public RPC boundary AND at consensus — it mints 0, withdraws 0;
// 2. a legitimate AUTHORITY-signed deposit credits the beneficiary correctly and
// is fully withdrawable (the real bridge funding flow still works).
//
// Backing invariant: Σ(ledger credits) ≤ Σ(authority-authorized deposits); an
// authenticated principal can no longer mint unbacked balance.
package dchain
import (
"context"
"errors"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
func TestRedteam_UnbackedSelfSignedDepositMints(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const attacker = "attacker"
const minted uint64 = 500_000_000_000
att := acctFor(t, attacker)
if a, l, _ := vm.Balance(att.user, assetLUX); a != 0 || l != 0 {
t.Fatalf("precondition: attacker not empty (avail=%d locked=%d)", a, l)
}
// The attacker SELF-SIGNS a deposit crediting their own account — the F9 mint.
ref := contentRef(byte(TxDeposit), attacker, assetLUX, minted)
selfBody := zapwire.EncodeDeposit(att.user, assetLUX, minted, ref)
selfTx := att.signed(t, TxDeposit, selfBody)
// (1) PUBLIC-BOUNDARY: the RPC pre-screen REJECTS a deposit not signed by the
// configured bridge authority (submitTx). The unbacked mint never enters the mempool.
frame := append(append([]byte{}, selfTx.Body...), selfTx.Auth.encode()...)
if _, verr := vm.submitTx(context.Background(), TxDeposit, frame); !errors.Is(verr, ErrTxBadSignature) {
t.Fatalf("public dex_deposit boundary admitted a self-signed deposit: err=%v, want ErrTxBadSignature", verr)
}
// (2) CONSENSUS: even if a malicious proposer smuggles the tx into a block, the
// AUTHORITATIVE gate (authorizeTx) rejects it — mints 0.
addBlock(t, vm, selfTx)
if avail, _, _ := vm.Balance(att.user, assetLUX); avail != 0 {
t.Fatalf("SELF-SIGNED DEPOSIT MINTED %d — the F9 infinite-money glitch is BACK", avail)
}
// (3) DRAIN attempt: nothing was minted, so a withdraw realizes 0.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, attacker, assetLUX, minted))
if realized := withdrawRealizedOf(wo, TxWithdraw); realized != 0 {
t.Fatalf("attacker withdrew %d from an unbacked deposit — mint not fully closed", realized)
}
t.Logf("GUARD OK: self-signed deposit rejected at boundary + consensus; minted 0, withdrew 0")
}
// TestBackedAuthoritySignedDepositCredits proves the LEGITIMATE flow: a deposit
// signed by the configured bridge authority credits the beneficiary exactly and is
// fully withdrawable. This is the real funding path the backing invariant permits.
func TestBackedAuthoritySignedDepositCredits(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const user = "honest-depositor"
const amount uint64 = 777_000
ben := acctFor(t, user)
// fundAcct signs with the authority; the body names `ben` as the beneficiary.
ref := contentRef(byte(TxDeposit), user, assetLUX, amount)
addBlock(t, vm, fundAcct(t, ben, assetLUX, amount, ref))
if avail, locked, _ := vm.Balance(ben.user, assetLUX); avail != amount || locked != 0 {
t.Fatalf("authority deposit credited avail=%d locked=%d, want %d / 0", avail, locked, amount)
}
// The credited balance is real and fully withdrawable by its owner.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, user, assetLUX, amount))
if realized := withdrawRealizedOf(wo, TxWithdraw); realized != amount {
t.Fatalf("withdraw of a backed deposit realized %d, want %d", realized, amount)
}
if avail, _, _ := vm.Balance(ben.user, assetLUX); avail != 0 {
t.Fatalf("after full withdraw avail=%d, want 0", avail)
}
t.Logf("GUARD OK: authority-signed deposit credited %d and withdrew %d (legitimate backed flow works)", amount, amount)
}