mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
* dexvm: bind swap-settle authority+payout to authenticated escrow owner (CRITICAL escrow-theft fix) The swap rail delegated per-taker refund-liveness + relay authority to the unauthenticated RelayOrderTx sender with NO C-side owner binding, letting any fee-paying tx name a victim's collateral ref and settle the locked collateral to the attacker out of the shared seam reserve (cross-depositor theft). Fix (load-bearing, derives authority from the already-authenticated cross-chain object owner — the platformvm import/export model): - state/state.go: escrow value is now owner(20)|asset(32)|amount(8); the owner leads. PutEscrow/GetEscrow carry it. - atomic.go executeImport: persist the AUTHENTICATED importedOwner (read back from the consumed C->D shared-memory object) into the escrow. - vm.go settleFromFills: a settle is authorized ONLY by the escrow's recorded owner (errSettleUnauthorized otherwise), and BOTH proceeds+refund legs export to that owner, never to the relay tx sender. - txs/tx.go: RelayOrderTx.Verify now does present-gated secp256k1 authentication of From (Sign/SigningBytes/verifyFrom), wiring the dangling ErrInvalidSignature. From bears NO authority (authority is the escrow owner), so an unsigned relay is admissible and cannot escalate; a present-but-invalid signature is rejected. Regression: redteam_escrow_owner_test.go proves the attacker is credited 0 even with a valid own-key signature (defense in depth: escrow-owner bind holds independent of the signature). Fixed a pre-existing off-by-one in TestRED_FractionalFill (export asset offset). All dexvm tests pass (default + -tags redteam, CGO_ENABLED=0 pure-Go). Gated on red re-verify; NOT promoted to testnet/mainnet. * aivm: A-Chain quorum-settlement engine (provider stake/slash, beacon selection, commit-reveal, settlement, receipt export, import committed C intent). LP-5300. NOTE: branched on swap-rail base; rebase to main on merge. --------- Co-authored-by: zeekay <[email protected]>
161 lines
7.1 KiB
Go
161 lines
7.1 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package aivm
|
|
|
|
// redb_atomic_test.go — Red-B probes on Settle atomicity and no-quorum-with-
|
|
// withholders value conservation.
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/holiman/uint256"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// PROBE — no-quorum WITH withholders. Some commit+reveal distinct (no quorum),
|
|
// some withhold. Withholders are slashed and the pool is credited to the
|
|
// requester; the requester also gets the full reward escrow refund. Assert value
|
|
// conservation and escrow identity.
|
|
func TestProbe_NoQuorumWithWithholders(t *testing.T) {
|
|
require := require.New(t)
|
|
reward := uint256.NewInt(1_000_000_000_000_000_000)
|
|
e, st, lg, requester, allOps := newHarness(t, eligible, reward)
|
|
totalBefore := lg.Total()
|
|
|
|
intent := mkIntent(e, requester, testN, testThr, uint256.NewInt(1), reward)
|
|
taskID, err := e.ImportCommittedIntent(st, lg, acceptAll, intent, 100)
|
|
require.NoError(err)
|
|
selected, _ := e.SelectOperators(st, taskID, modelSpec, testN)
|
|
|
|
// 3 reveal DISTINCT (no quorum), 2 withhold (commit only).
|
|
for i := 0; i < 3; i++ {
|
|
op := selected[i]
|
|
out := h(byte(0x50 + i))
|
|
c := opCommit(taskID, op, out, h(0x01), h(0x02))
|
|
require.NoError(e.CommitResponse(st, taskID, op, c, 101))
|
|
require.NoError(e.RevealResponse(st, taskID, op, out, h(0x01), h(0x02), 131))
|
|
}
|
|
for i := 3; i < 5; i++ {
|
|
op := selected[i]
|
|
c := opCommit(taskID, op, h(byte(0xE0+i)), h(0x01), h(0x02))
|
|
require.NoError(e.CommitResponse(st, taskID, op, c, 101))
|
|
}
|
|
|
|
res, err := e.Settle(st, lg, taskID, 161)
|
|
require.NoError(err)
|
|
require.Equal(TaskFailed, res.Status)
|
|
// 2 withholders slashed.
|
|
wantSlashed := new(uint256.Int).Mul(SlashPerOperator, uint256.NewInt(2))
|
|
require.Equal(wantSlashed, res.Slashed)
|
|
|
|
// requester refund = full N*reward escrow + slashed pool (credited as compensation).
|
|
gotRefund, err := e.WithdrawRewards(st, lg, requester)
|
|
require.NoError(err)
|
|
wantRefund := new(uint256.Int).Add(
|
|
new(uint256.Int).Mul(reward, uint256.NewInt(uint64(testN))),
|
|
wantSlashed,
|
|
)
|
|
require.Equal(wantRefund.String(), gotRefund.String(),
|
|
"failed-with-withholders refund = N*reward + slashed pool")
|
|
|
|
require.Equal(totalBefore.String(), lg.Total().String(), "grand total conserved")
|
|
requireEscrowIdentity(t, e, st, lg, taskID, append(allOps, requester))
|
|
}
|
|
|
|
// PROBE — half-settle re-settle corruption. The quorum path applies slash, then
|
|
// credits winners, then writes the settled marker LAST. If an addCredit overflow
|
|
// (or any error) aborts the loop AFTER slash + some credits but BEFORE the settled
|
|
// marker, the marker is unset -> the task is re-settleable -> a SECOND settle
|
|
// re-slashes (idempotent floor) and re-pays from the now-smaller escrow. Probe
|
|
// whether a forced mid-settle failure leaves a re-settleable, corrupted task.
|
|
//
|
|
// We force the failure by draining the ledger escrow so Pay path... no — credits
|
|
// are on-state, not ledger. The reachable failure is addCredit overflow, which
|
|
// needs credit ~2^256. We instead document the NON-ATOMICITY structurally: settle
|
|
// writes happen incrementally with no rollback, and the settled marker is written
|
|
// last. Here we assert the ORDER (slash before marker) so a future reachable error
|
|
// would corrupt — and we verify the CURRENT happy path still sets the marker.
|
|
func TestProbe_SettleMarkerWrittenLast(t *testing.T) {
|
|
require := require.New(t)
|
|
reward := uint256.NewInt(1_000_000_000_000_000_000)
|
|
e, st, lg, requester, _ := newHarness(t, eligible, reward)
|
|
intent := mkIntent(e, requester, testN, testThr, uint256.NewInt(1), reward)
|
|
taskID, err := e.ImportCommittedIntent(st, lg, acceptAll, intent, 100)
|
|
require.NoError(err)
|
|
selected, _ := e.SelectOperators(st, taskID, modelSpec, testN)
|
|
out := h(0x42)
|
|
for i := 0; i < testThr; i++ {
|
|
op := selected[i]
|
|
c := opCommit(taskID, op, out, h(0x01), h(0x02))
|
|
require.NoError(e.CommitResponse(st, taskID, op, c, 101))
|
|
require.NoError(e.RevealResponse(st, taskID, op, out, h(0x01), h(0x02), 131))
|
|
}
|
|
_, err = e.Settle(st, lg, taskID, 161)
|
|
require.NoError(err)
|
|
require.True(isSet(st.GetState(slotHash(nsSettled, taskID))), "settled marker set on success")
|
|
// re-settle blocked.
|
|
_, err = e.Settle(st, lg, taskID, 162)
|
|
require.ErrorIs(err, ErrTaskAlreadySettled)
|
|
}
|
|
|
|
// PROBE — overflow-driven half-settle is REACHABLE if a winner already holds a
|
|
// credit balance close to 2^256. Seed a winner's credit slot to MaxUint256, then
|
|
// settle: addCredit overflows for that winner. Observe whether (a) it returns an
|
|
// error, (b) the slash already mutated stakes, and (c) the settled marker is NOT
|
|
// set -> task re-settleable. This is the concrete half-settle exploit.
|
|
func TestProbe_HalfSettleViaCreditOverflow(t *testing.T) {
|
|
require := require.New(t)
|
|
reward := uint256.NewInt(1_000_000_000_000_000_000)
|
|
e, st, lg, requester, _ := newHarness(t, eligible, reward)
|
|
intent := mkIntent(e, requester, testN, testThr, uint256.NewInt(1), reward)
|
|
taskID, err := e.ImportCommittedIntent(st, lg, acceptAll, intent, 100)
|
|
require.NoError(err)
|
|
selected, _ := e.SelectOperators(st, taskID, modelSpec, testN)
|
|
|
|
out := h(0x42)
|
|
for i := 0; i < testThr; i++ {
|
|
op := selected[i]
|
|
c := opCommit(taskID, op, out, h(0x01), h(0x02))
|
|
require.NoError(e.CommitResponse(st, taskID, op, c, 101))
|
|
require.NoError(e.RevealResponse(st, taskID, op, out, h(0x01), h(0x02), 131))
|
|
}
|
|
// one withholder to make a slashed pool (so slash mutates stakes before payout).
|
|
wh := selected[testThr]
|
|
c := opCommit(taskID, wh, h(0xEE), h(0x01), h(0x02))
|
|
require.NoError(e.CommitResponse(st, taskID, wh, c, 101))
|
|
|
|
// Poison: set a winner's credit slot to MaxUint256 so addCredit overflows.
|
|
victimWinner := selected[0]
|
|
writeCredit(st, victimWinner, new(uint256.Int).SetAllOne())
|
|
|
|
// snapshot withholder stake before the (doomed) settle.
|
|
_, _, whStakeBefore, _, _ := e.GetOperator(st, wh)
|
|
|
|
_, err = e.Settle(st, lg, taskID, 161)
|
|
require.ErrorIs(err, ErrCreditOverflow, "settle aborts on credit overflow")
|
|
|
|
// EXPLOIT SURFACE: slash already happened (withholder stake reduced) but the
|
|
// settled marker is NOT set, so the task is RE-SETTLEABLE.
|
|
_, _, whStakeAfter, _, _ := e.GetOperator(st, wh)
|
|
settledMarker := isSet(st.GetState(slotHash(nsSettled, taskID)))
|
|
taskState := e.GetTask(st, taskID).Status
|
|
|
|
t.Logf("withholder stake before=%s after=%s ; settledMarker=%v ; taskState=%d",
|
|
whStakeBefore, whStakeAfter, settledMarker, taskState)
|
|
|
|
if whStakeAfter.Lt(whStakeBefore) && !settledMarker {
|
|
// Re-settle: slash fires AGAIN (floored), double-charging the withholder and
|
|
// re-distributing from escrow.
|
|
writeCredit(st, victimWinner, uint256.NewInt(0)) // attacker resets their own slot
|
|
res2, err2 := e.Settle(st, lg, taskID, 162)
|
|
t.Logf("RE-SETTLE after half-settle: err=%v status=%v slashed=%v", err2, res2.Status, res2.Slashed)
|
|
_, _, whStakeAfter2, _, _ := e.GetOperator(st, wh)
|
|
if whStakeAfter2.Lt(whStakeAfter) {
|
|
t.Fatalf("FINDING CONFIRMED: half-settle left task re-settleable; withholder slashed TWICE (%s -> %s -> %s)",
|
|
whStakeBefore, whStakeAfter, whStakeAfter2)
|
|
}
|
|
t.Fatalf("FINDING CONFIRMED (partial): settle aborted mid-way (slash applied, marker unset) leaving a re-settleable task; re-settle err=%v", err2)
|
|
}
|
|
}
|