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} + + {/if} + - {#if deroTransfers.length > 0} + {#if deroTransfers.length > 0 && !isBurnBlocked} {/if} - - - {#if totalAmount > 0 || totalBurn > 0 || totalFees > 0} + + + {#if !isBurnBlocked && (totalAmount > 0 || totalBurn > 0 || totalFees > 0)} {/if} - - - {#if request.payload.transfers[0]?.destination} + + + {#if request.payload.transfers[0]?.destination && !isBurnBlocked} @@ -728,24 +787,35 @@ {/if} diff --git a/frontend/src/lib/stores/appState.js b/frontend/src/lib/stores/appState.js index c468ab2..b095d0d 100644 --- a/frontend/src/lib/stores/appState.js +++ b/frontend/src/lib/stores/appState.js @@ -158,14 +158,14 @@ export async function approveWalletRequest(id, password, txid = null, permission // Find the request const requests = get(walletRequests); const request = requests.find(r => r.id === id); - + if (request) { // Log to history logWalletRequest(request, 'approved', txid); - + // We resolve with the password and permissions so the caller can use them request.resolve({ approved: true, password, permissions }); - + // Remove from queue walletRequests.update(reqs => reqs.filter(r => r.id !== id)); } diff --git a/frontend/src/styles/hologram.css b/frontend/src/styles/hologram.css index 6cbe577..a99fa43 100644 --- a/frontend/src/styles/hologram.css +++ b/frontend/src/styles/hologram.css @@ -7432,7 +7432,62 @@ opacity: 0.5; cursor: not-allowed; } - + + /* ===== Destructive native-DERO burn (permanent destruction) ===== */ + .modal-burn-danger { + background: linear-gradient(180deg, rgba(248, 113, 113, 0.16), rgba(248, 113, 113, 0.06)); + border: 1px solid rgba(248, 113, 113, 0.45); + border-radius: var(--r-md); + padding: var(--s-4); + margin-bottom: var(--s-4); + } + + .modal-burn-danger-title { + display: flex; + align-items: center; + gap: var(--s-2); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--status-err); + margin-bottom: 6px; + } + + .modal-burn-danger-ic { + font-size: 18px; + line-height: 1; + } + + .modal-burn-danger-amount { + font-family: var(--font-mono); + font-size: 28px; + font-weight: 700; + color: var(--status-err); + text-shadow: 0 0 24px rgba(248, 113, 113, 0.35); + margin: 4px 0 8px; + } + + .modal-burn-danger-copy { + font-size: 12.5px; + line-height: 1.55; + color: #ffd5d5; + } + .modal-burn-danger-copy strong { color: #fff; } + + .modal-burn-danger-norecipient { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 11px; + color: var(--status-err); + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed rgba(248, 113, 113, 0.3); + } + /* Modal Panel App Info Card */ .modal-app-info-card { background: rgba(24, 24, 36, 0.5); diff --git a/wallet.go b/wallet.go index 8ea72c5..9a7376f 100644 --- a/wallet.go +++ b/wallet.go @@ -1542,6 +1542,31 @@ func parseXSWDScArgs(params map[string]interface{}, scid string) rpc.Arguments { return append(prefix, scArgs...) } +// detectDestructiveBurn scans transfers for a burn that would permanently destroy native +// DERO. A burn on a non-zero SCID moves token value within that asset's pool (a normal +// token transfer); a burn on the zero SCID (native DERO) with no smart contract attached +// to the transaction destroys the coins forever. The latter is never a legitimate native +// send, so it is rejected. hasSCCall reports whether the overall tx carries an SC call. +func detectDestructiveBurn(transfers []rpc.Transfer, hasSCCall bool) (uint64, bool) { + if hasSCCall { + return 0, false + } + for _, t := range transfers { + if t.Burn > 0 && t.SCID == crypto.ZEROHASH { + return t.Burn, true + } + } + return 0, false +} + +// shouldBlockBurn applies the destruction policy: a destructive native-DERO burn (zero SCID, +// no contract attached) is ALWAYS blocked. HOLOGRAM is a consumer wallet and never burns DERO +// -- a burn with no contract only destroys coins, so there is no legitimate path for it here. +// Anyone who genuinely intends to burn DERO must use the DERO CLI wallet. There is no override. +func shouldBlockBurn(transfers []rpc.Transfer, hasSCCall bool) (uint64, bool) { + return detectDestructiveBurn(transfers, hasSCCall) +} + // InternalWalletCall executes a wallet method directly using the embedded wallet func (a *App) InternalWalletCall(method string, params map[string]interface{}, password string) map[string]interface{} { walletManager.Lock() @@ -1735,6 +1760,19 @@ func (a *App) InternalWalletCall(method string, params map[string]interface{}, p return map[string]interface{}{"success": false, "error": "Please specify a transfer amount and destination, or a smart contract call."} } + // Reject any burn that would permanently destroy native DERO. A native-DERO (zero-SCID) + // burn with no smart contract attached does not send funds anywhere -- it destroys them + // irrecoverably. HOLOGRAM never burns DERO; there is no override. Anyone who genuinely + // intends to burn DERO must use the DERO CLI wallet. This is a hard, unconditional block. + if burnAmt, block := shouldBlockBurn(transfers, len(scArgs) > 0); block { + a.logToConsole(fmt.Sprintf("[XSWD] BLOCKED native-DERO burn: %s DERO with no contract attached", formatDEROAmount(burnAmt))) + return map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("HOLOGRAM does not allow burning DERO. This request would permanently destroy %s DERO -- a burn with no smart contract attached sends the coins to no one and cannot be undone. If you intend to deliberately burn DERO, use the DERO CLI wallet.", formatDEROAmount(burnAmt)), + "technicalError": fmt.Sprintf("rejected native-DERO burn of %d atomic units (zero SCID, no SC call); HOLOGRAM prohibits burns", burnAmt), + } + } + runTransfer := func() map[string]interface{} { if !a.IsInSimulatorMode() { if errResp := checkDaemonConnectivity(wallet); errResp != nil { @@ -1888,6 +1926,19 @@ func (a *App) InternalWalletCall(method string, params map[string]interface{}, p // Merge deposit entries in front of any explicit transfers transfers = append(scDeposit, transfers...) + // Defense in depth: a native-DERO (zero-SCID) burn is only safe here when it routes to + // a real contract call. Block it explicitly at this broadcast site too, so the burn + // prohibition does not silently depend on chain-side refund behavior if this path is + // ever refactored. HOLOGRAM never burns DERO; deliberate burns belong in the CLI. + if burnAmt, block := shouldBlockBurn(transfers, len(scArgs) > 0); block { + a.logToConsole(fmt.Sprintf("[XSWD] BLOCKED native-DERO burn in scinvoke: %s DERO with no contract attached", formatDEROAmount(burnAmt))) + return map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("HOLOGRAM does not allow burning DERO. This request would permanently destroy %s DERO with no contract attached. If you intend to deliberately burn DERO, use the DERO CLI wallet.", formatDEROAmount(burnAmt)), + "technicalError": fmt.Sprintf("rejected native-DERO burn of %d atomic units in scinvoke (zero SCID, no SC call); HOLOGRAM prohibits burns", burnAmt), + } + } + runSCInvoke := func() map[string]interface{} { if !a.IsInSimulatorMode() { if errResp := checkDaemonConnectivity(wallet); errResp != nil { diff --git a/xswd_server.go b/xswd_server.go index d5df3a1..a232ef2 100644 --- a/xswd_server.go +++ b/xswd_server.go @@ -1069,11 +1069,16 @@ func (s *XSWDServer) sendRawJSON(conn *websocket.Conn, payload interface{}) { // ProcessApproval is called from App (legacy, no permissions) func (s *XSWDServer) ProcessApproval(reqID string, approved bool, password string) { - s.ProcessApprovalWithPermissions(reqID, approved, password, nil) + s.processApproval(reqID, approved, password, nil) } // ProcessApprovalWithPermissions is called from App with explicit permissions func (s *XSWDServer) ProcessApprovalWithPermissions(reqID string, approved bool, password string, permissions []string) { + s.processApproval(reqID, approved, password, permissions) +} + +// processApproval is the shared approval path. +func (s *XSWDServer) processApproval(reqID string, approved bool, password string, permissions []string) { s.pendingLock.Lock() req, ok := s.pendingRequests[reqID] s.pendingLock.Unlock()