diff --git a/burn_guard_test.go b/burn_guard_test.go new file mode 100644 index 0000000..73d5811 --- /dev/null +++ b/burn_guard_test.go @@ -0,0 +1,200 @@ +// Copyright 2025 HOLOGRAM Project. All rights reserved. +// Regression tests for the native-DERO burn guard. + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/rpc" +) + +// TestDetectDestructiveBurn locks in the rule that a burn on the zero SCID (native DERO) +// with no smart contract attached is treated as destructive and blocked. This guards the +// generic XSWD "transfer" path: a dApp/caller sending a deposit-style burn without the +// SCID + sc_rpc that would route it to a contract must NOT be able to silently destroy +// native DERO. If a future edit removes the guard or weakens the zero-SCID check, the +// "native burn, no SC" case below will start returning destructive=false and fail. +func TestDetectDestructiveBurn(t *testing.T) { + tokenSCID := crypto.HashHexToHash("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + + cases := []struct { + name string + transfers []rpc.Transfer + hasSCCall bool + wantBurn uint64 + wantBlocked bool + }{ + { + name: "native burn, no SC -> blocked (the incident)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + hasSCCall: false, + wantBurn: 1500000000, + wantBlocked: true, + }, + { + name: "native burn WITH SC call -> allowed (deposit/donation)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + hasSCCall: true, + wantBlocked: false, + }, + { + name: "token burn (non-zero SCID) -> allowed (normal token transfer)", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, + hasSCCall: false, + wantBlocked: false, + }, + { + name: "plain native send, no burn -> allowed", + transfers: []rpc.Transfer{{Amount: 100000, Destination: "dero1...", SCID: crypto.ZEROHASH}}, + hasSCCall: false, + wantBlocked: false, + }, + { + name: "mixed: safe token transfer + destructive native burn -> blocked", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}, {Burn: 50000, SCID: crypto.ZEROHASH}}, + hasSCCall: false, + wantBurn: 50000, + wantBlocked: true, + }, + { + name: "no transfers -> allowed", + transfers: nil, + hasSCCall: false, + wantBlocked: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotBurn, gotBlocked := detectDestructiveBurn(tc.transfers, tc.hasSCCall) + if gotBlocked != tc.wantBlocked { + t.Fatalf("blocked = %v, want %v", gotBlocked, tc.wantBlocked) + } + if tc.wantBlocked && gotBurn != tc.wantBurn { + t.Errorf("burn amount = %d, want %d", gotBurn, tc.wantBurn) + } + }) + } +} + +// TestShouldBlockBurn locks in the destruction POLICY: a destructive native-DERO burn (zero +// SCID, no contract) is ALWAYS blocked, with no override. HOLOGRAM never burns DERO. The block +// must apply only to the destructive case -- it must never affect a normal transfer or a +// contract deposit. This is the exact decision that would have stopped the incident. If anyone +// ever reintroduces an override path, the "always blocked" cases below will fail. +func TestShouldBlockBurn(t *testing.T) { + tokenSCID := crypto.HashHexToHash("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + + cases := []struct { + name string + transfers []rpc.Transfer + hasSCCall bool + wantBurn uint64 + wantBlocked bool + }{ + { + name: "destructive native burn -> ALWAYS blocked (the incident)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + wantBurn: 1500000000, + wantBlocked: true, + }, + { + name: "small destructive native burn -> ALWAYS blocked (no override)", + transfers: []rpc.Transfer{{Burn: 1, SCID: crypto.ZEROHASH}}, + wantBurn: 1, + wantBlocked: true, + }, + { + name: "contract deposit burn -> allowed (deposit, not destruction)", + transfers: []rpc.Transfer{{Burn: 5, SCID: crypto.ZEROHASH}}, + hasSCCall: true, + wantBlocked: false, + }, + { + name: "token transfer (non-zero SCID) -> allowed (normal token transfer)", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, + wantBlocked: false, + }, + { + name: "plain native send, no burn -> allowed", + transfers: []rpc.Transfer{{Amount: 100000, SCID: crypto.ZEROHASH}}, + wantBlocked: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotBurn, gotBlocked := shouldBlockBurn(tc.transfers, tc.hasSCCall) + if gotBlocked != tc.wantBlocked { + t.Fatalf("blocked = %v, want %v", gotBlocked, tc.wantBlocked) + } + if tc.wantBlocked && gotBurn != tc.wantBurn { + t.Errorf("burn amount = %d, want %d", gotBurn, tc.wantBurn) + } + }) + } +} + +// TestNoBurnOverrideReintroduced is a source-level sentinel that fails the build if anyone +// reintroduces a way to bypass the burn prohibition. HOLOGRAM must never burn DERO: there is +// no confirmDestroy flag, no override parameter, no approve path for a destructive burn. If a +// future change brings any of that back, this test fails loudly instead of silently shipping a +// path that can destroy a user's coins. To deliberately burn DERO, users must use the CLI. +func TestNoBurnOverrideReintroduced(t *testing.T) { + // Scan the Go and frontend sources for tokens that only exist when an override is present. + roots := []string{".", "frontend/src"} + banned := []string{"confirmDestroy", "ConfirmDestroy"} + + skipDir := func(name string) bool { + switch name { + case "node_modules", "dist", ".git", ".task", "build", "bin": + return true + } + return false + } + + var offenders []string + for _, root := range roots { + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if skipDir(info.Name()) { + return filepath.SkipDir + } + return nil + } + switch filepath.Ext(path) { + case ".go", ".svelte", ".js", ".ts": + default: + return nil + } + // This sentinel test file legitimately names the banned tokens; skip it. + if filepath.Base(path) == "burn_guard_test.go" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + for _, tok := range banned { + if strings.Contains(string(data), tok) { + offenders = append(offenders, fmt.Sprintf("%s contains %q", path, tok)) + } + } + return nil + }) + } + + if len(offenders) > 0 { + t.Fatalf("burn-override token reintroduced (HOLOGRAM must never burn DERO):\n %s", + strings.Join(offenders, "\n ")) + } +} diff --git a/frontend/src/lib/components/WalletModal.svelte b/frontend/src/lib/components/WalletModal.svelte index 4e53b9b..ea747f8 100644 --- a/frontend/src/lib/components/WalletModal.svelte +++ b/frontend/src/lib/components/WalletModal.svelte @@ -11,6 +11,31 @@ let walletPath = ''; let error = ''; let isLoading = false; + + const ZERO_SCID = '0000000000000000000000000000000000000000000000000000000000000000'; + + // A request carries a real smart contract call only when there is actual SC invocation + // data -- an entrypoint, or a non-empty sc_data/sc_args array. This mirrors the backend: + // parseXSWDScArgs (wallet.go) yields args only when sc_rpc carries an entrypoint, so a bare + // scid string with no entrypoint is NOT an SC call. Without this mirror, a dApp could send a + // scid with no sc_rpc to make the UI show "Approve" while the backend blocks it as a burn. + // A burn routed to a real contract is a deposit, not destruction. + $: hasSCCall = + (typeof request?.payload?.entrypoint === 'string' && request.payload.entrypoint.length > 0) || + (Array.isArray(request?.payload?.sc_data) && request.payload.sc_data.length > 0) || + (Array.isArray(request?.payload?.sc_args) && request.payload.sc_args.length > 0); + + // Total native-DERO (zero-SCID) burn across the request's transfers. + $: nativeBurnTotal = (request?.payload?.transfers || []) + .filter(t => !t.scid || t.scid === ZERO_SCID) + .reduce((sum, t) => sum + (typeof t.burn === 'number' ? t.burn : 0), 0); + + // BLOCKED: a native-DERO burn with no contract attached destroys the coins permanently and + // sends them to no one. HOLOGRAM never burns DERO -- this request is rejected outright, with + // no approve path. Anyone who genuinely intends to burn DERO must use the DERO CLI wallet. + $: isBurnBlocked = nativeBurnTotal > 0 && !hasSCCall; + $: blockedBurnAmount = Math.round(nativeBurnTotal / 100000); + let recentWallets = []; let recentWalletsInfo = []; let showWalletSwitcher = false; @@ -209,6 +234,15 @@ isLoading = true; error = ''; + // Hard backstop: HOLOGRAM never burns DERO. A native-DERO burn with no contract attached + // can never be approved here -- it is rejected at the backend too. This refuses any attempt + // to approve one, so there is no path through the UI that broadcasts a destructive burn. + if (isBurnBlocked) { + error = 'HOLOGRAM does not allow burning DERO. To deliberately burn DERO, use the DERO CLI wallet.'; + isLoading = false; + return; + } + try { // For connect requests, pass the granted permissions const permissions = request.type === 'connect' ? getGrantedPermissionsList() : null; @@ -216,7 +250,7 @@ password = ''; // Clear password after use walletPath = ''; // Reset for next time grantedPermissions = {}; // Reset permissions - + // Restore focus to main document to prevent iframe from capturing scroll restoreFocus(); } catch (e) { @@ -416,7 +450,7 @@ {#if request.payload.transfers && request.payload.transfers.length > 0} - {@const deroTransfers = request.payload.transfers.filter(t => !t.scid || t.scid === '0000000000000000000000000000000000000000000000000000000000000000')} + {@const deroTransfers = request.payload.transfers.filter(t => !t.scid || t.scid === ZERO_SCID)} {@const totalAmount = deroTransfers.reduce((sum, t) => sum + (t.amount || 0), 0)} {@const totalBurn = deroTransfers.reduce((sum, t) => sum + (typeof t.burn === 'number' ? t.burn : 0), 0)} @@ -424,10 +458,32 @@ {@const topLevelFees = request.payload.fees || 0} {@const totalFees = transferFees + topLevelFees} {@const totalDero = totalAmount + totalBurn + totalFees} - {@const hasBurnField = deroTransfers.some(t => t.burn)} - + + + {#if isBurnBlocked} +