mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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]>
95 lines
4.2 KiB
Go
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)
|
|
}
|