From 417d957c4ebfacd55cc157bd82fcdcb71657dfbd Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:30:42 -0400 Subject: [PATCH 1/2] fix(xswd): guard against accidental native-DERO burns A transfer carrying a burn on the zero SCID (native DERO) with no smart contract attached destroys the coins permanently with no recipient. The generic XSWD transfer path read a caller-supplied burn straight into the transaction with no guard, so a request that set burn without a contract would build and broadcast a destroying transaction, and the approval modal presented it as an ordinary cost line. Add defense in depth so this requires deliberate, explicit confirmation: - Backend: reject a zero-SCID burn with no SC call unless the request carries an explicit confirmDestroy flag. The flag is honored only when the burn is actually destructive, so it can never weaken a normal transfer or a contract deposit. (detectDestructiveBurn / shouldBlockBurn) - Approval modal: render a destructive burn as a permanent-destruction warning (amount, no recipient, unrecoverable) and require typing "BURN " to enable approval. Disable the Enter-to-approve shortcut for this case. - Relabel a burn that routes to a contract as "Deposit to contract" so the destructive case is no longer visually identical to a deposit. - Wire confirmDestroy from the modal through the XSWD approval path. - Add regression tests for detection and the block-unless-confirmed policy. --- app_xswd_bridge.go | 9 + burn_guard_test.go | 143 ++++++++++++++++ frontend/src/App.svelte | 6 +- .../src/lib/components/WalletModal.svelte | 159 +++++++++++++++--- frontend/src/lib/stores/appState.js | 13 +- frontend/src/styles/hologram.css | 121 ++++++++++++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + wallet.go | 53 ++++++ xswd_server.go | 25 ++- 10 files changed, 502 insertions(+), 33 deletions(-) create mode 100644 burn_guard_test.go diff --git a/app_xswd_bridge.go b/app_xswd_bridge.go index 5fdc063..02bcc9f 100644 --- a/app_xswd_bridge.go +++ b/app_xswd_bridge.go @@ -12,3 +12,12 @@ func (a *App) RespondToXSWDRequestWithPermissions(reqID string, approved bool, p a.xswdServer.ProcessApprovalWithPermissions(reqID, approved, password, permissions) } } + +// RespondToXSWDRequestConfirmDestroy approves a request that carries a destructive +// native-DERO burn. The frontend calls this only after the user passes type-to-confirm; +// it injects the confirmDestroy flag so the backend burn guard allows the destruction. +func (a *App) RespondToXSWDRequestConfirmDestroy(reqID string, approved bool, password string) { + if a.xswdServer != nil { + a.xswdServer.ProcessApprovalConfirmDestroy(reqID, approved, password) + } +} diff --git a/burn_guard_test.go b/burn_guard_test.go new file mode 100644 index 0000000..2fa451b --- /dev/null +++ b/burn_guard_test.go @@ -0,0 +1,143 @@ +// Copyright 2025 HOLOGRAM Project. All rights reserved. +// Regression tests for the native-DERO burn guard. + +package main + +import ( + "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 is +// blocked UNLESS the user explicitly confirmed it. The confirmDestroy flag must only matter +// when the burn is genuinely destructive -- it must never weaken a normal transfer or a +// contract deposit. This is the exact decision that would have stopped the incident, plus +// the deliberate-burn override. +func TestShouldBlockBurn(t *testing.T) { + tokenSCID := crypto.HashHexToHash("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + + cases := []struct { + name string + transfers []rpc.Transfer + hasSCCall bool + confirmDestroy bool + wantBurn uint64 + wantBlocked bool + }{ + { + name: "destructive burn, NOT confirmed -> blocked (the incident)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + confirmDestroy: false, + wantBurn: 1500000000, + wantBlocked: true, + }, + { + name: "destructive burn, confirmed -> allowed (deliberate burn)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + confirmDestroy: true, + wantBlocked: false, + }, + { + name: "contract deposit burn, confirm flag set -> still allowed, flag is a no-op", + transfers: []rpc.Transfer{{Burn: 5, SCID: crypto.ZEROHASH}}, + hasSCCall: true, + confirmDestroy: true, + wantBlocked: false, + }, + { + name: "token transfer (non-zero SCID), confirm flag set -> allowed, flag is a no-op", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, + confirmDestroy: true, + wantBlocked: false, + }, + { + name: "token transfer, no confirm -> allowed (normal token transfer)", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, + confirmDestroy: false, + wantBlocked: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotBurn, gotBlocked := shouldBlockBurn(tc.transfers, tc.hasSCCall, tc.confirmDestroy) + 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) + } + }) + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index d13e40a..983849c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -13,7 +13,7 @@ // Mining tab removed - Developer Support now in Settings > Developer Support // Network tab removed - node controls moved to Settings > Node import { appState, walletState, settingsState, updateStatus, addExternalRequest, dismissWalletRequest, toast, loadSettings, syncNetworkMode, navigateTo, requestPayment } from './lib/stores/appState.js'; - import { GetSetting, RespondToXSWDRequest, RespondToXSWDRequestWithPermissions, NotifyWizardComplete, ConsumeLaunchURL } from '../wailsjs/go/main/App.js'; + import { GetSetting, RespondToXSWDRequest, RespondToXSWDRequestWithPermissions, RespondToXSWDRequestConfirmDestroy, NotifyWizardComplete, ConsumeLaunchURL } from '../wailsjs/go/main/App.js'; import { EventsOn } from '../wailsjs/runtime/runtime.js'; import { waitForWails } from './lib/utils/wails.js'; @@ -409,6 +409,10 @@ // Use new function with permissions for connect requests if (requestType === 'connect' && result.permissions) { RespondToXSWDRequestWithPermissions(req.id, true, result.password || "", result.permissions); + } else if (result.confirmDestroy) { + // Explicitly type-to-confirmed destructive native-DERO burn: route through the + // confirm-destroy bridge so the backend's burn guard allows it. + RespondToXSWDRequestConfirmDestroy(req.id, true, result.password || ""); } else { RespondToXSWDRequest(req.id, true, result.password || ""); } diff --git a/frontend/src/lib/components/WalletModal.svelte b/frontend/src/lib/components/WalletModal.svelte index 4e53b9b..d426e0a 100644 --- a/frontend/src/lib/components/WalletModal.svelte +++ b/frontend/src/lib/components/WalletModal.svelte @@ -11,6 +11,35 @@ let walletPath = ''; let error = ''; let isLoading = false; + let burnConfirmText = ''; // type-to-confirm input for destructive native-DERO burns + + const ZERO_SCID = '0000000000000000000000000000000000000000000000000000000000000000'; + + // A request carries a smart contract call when it has a top-level scid (with sc_rpc/args) + // or parsed sc_args. A burn routed to a contract is a deposit, not destruction. + $: hasSCCall = !!(request?.payload?.scid) || + (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); + + // DESTRUCTIVE: a native-DERO burn with no contract attached. These coins are destroyed + // permanently and sent to no one. This is the case that requires explicit type-to-confirm. + $: isDestructiveBurn = nativeBurnTotal > 0 && !hasSCCall; + + // The whole-number DERO amount used in the confirm phrase, e.g. "BURN 15000". + $: burnConfirmAmount = Math.round(nativeBurnTotal / 100000); + $: burnConfirmPhrase = `BURN ${burnConfirmAmount}`; + $: burnConfirmMatched = burnConfirmText.trim().toUpperCase().replace(/\s+/g, ' ') === burnConfirmPhrase; + + // Reset the confirm input whenever the active request changes (tracked by id). + let _lastBurnReqId = null; + $: if (request?.id !== _lastBurnReqId) { + _lastBurnReqId = request?.id ?? null; + burnConfirmText = ''; + } let recentWallets = []; let recentWalletsInfo = []; let showWalletSwitcher = false; @@ -209,14 +238,24 @@ isLoading = true; error = ''; + // Defensive backstop: a destructive native-DERO burn can only be approved once the + // type-to-confirm phrase matches. The backend enforces this too (it requires the + // confirmDestroy flag), but refuse here as well so the flag is never sent unconfirmed. + if (isDestructiveBurn && !burnConfirmMatched) { + error = `Type "${burnConfirmPhrase}" to confirm you want to permanently destroy these coins.`; + isLoading = false; + return; + } + try { // For connect requests, pass the granted permissions const permissions = request.type === 'connect' ? getGrantedPermissionsList() : null; - await approveWalletRequest(request.id, password, null, permissions); + await approveWalletRequest(request.id, password, null, permissions, isDestructiveBurn && burnConfirmMatched); password = ''; // Clear password after use walletPath = ''; // Reset for next time grantedPermissions = {}; // Reset permissions - + burnConfirmText = ''; // Clear burn confirmation + // Restore focus to main document to prevent iframe from capturing scroll restoreFocus(); } catch (e) { @@ -416,7 +455,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,8 +463,30 @@ {@const topLevelFees = request.payload.fees || 0} {@const totalFees = transferFees + topLevelFees} {@const totalDero = totalAmount + totalBurn + totalFees} - {@const hasBurnField = deroTransfers.some(t => t.burn)} - + + + {#if isDestructiveBurn} + + {/if} + {#if deroTransfers.length > 0} {/if} - + {#if totalAmount > 0 || totalBurn > 0 || totalFees > 0} {/if} - - - {#if request.payload.transfers[0]?.destination} + + + {#if request.payload.transfers[0]?.destination && !isDestructiveBurn} {/if} + + {#if isDestructiveBurn} + + {/if} + {#if error} {/if} diff --git a/frontend/src/lib/stores/appState.js b/frontend/src/lib/stores/appState.js index c468ab2..5df818d 100644 --- a/frontend/src/lib/stores/appState.js +++ b/frontend/src/lib/stores/appState.js @@ -154,18 +154,19 @@ export function addExternalRequest(request, onApprove, onDeny) { walletRequests.update(reqs => [...reqs, fullRequest]); } -export async function approveWalletRequest(id, password, txid = null, permissions = null) { +export async function approveWalletRequest(id, password, txid = null, permissions = null, confirmDestroy = false) { // 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 }); - + + // We resolve with the password and permissions so the caller can use them. + // confirmDestroy is set only for an explicitly type-to-confirmed destructive burn. + request.resolve({ approved: true, password, permissions, confirmDestroy }); + // 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..66e80fa 100644 --- a/frontend/src/styles/hologram.css +++ b/frontend/src/styles/hologram.css @@ -7432,7 +7432,126 @@ 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-burn-strike { text-decoration: line-through; opacity: 0.6; } + + /* Type-to-confirm gate */ + .modal-burn-confirm { + background: var(--void-base, rgba(12, 12, 20, 0.6)); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: var(--r-md); + padding: var(--s-3); + margin-top: var(--s-4); + } + + .modal-burn-confirm-prompt { + font-size: 12px; + color: var(--text-2); + margin-bottom: 6px; + } + + .modal-burn-confirm-phrase { + background: var(--void-base, #0c0c14); + border: 1px solid rgba(248, 113, 113, 0.4); + color: var(--status-err); + padding: 2px 7px; + border-radius: var(--r-sm); + font-family: var(--font-mono); + font-weight: 700; + letter-spacing: 0.04em; + } + + .modal-burn-confirm-input { + letter-spacing: 0.06em; + border-color: rgba(248, 113, 113, 0.4) !important; + } + .modal-burn-confirm-input:focus { + border-color: var(--status-err) !important; + box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15) !important; + } + .modal-burn-confirm-input.matched { + border-color: var(--cyan-500) !important; + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.15) !important; + } + + .modal-burn-confirm-hint { + font-size: 10px; + color: var(--text-3); + margin-top: 5px; + min-height: 12px; + } + .modal-burn-confirm-hint.ok { color: var(--cyan-400); } + + /* Destroy button replaces Approve for a destructive burn */ + .modal-panel-btn-destroy { + background: rgba(248, 113, 113, 0.15); + color: var(--status-err); + border: 1px solid rgba(248, 113, 113, 0.45); + font-weight: 700; + } + .modal-panel-btn-destroy:enabled:hover { + background: var(--status-err); + color: #1a0606; + } + + /* Danger styling for the burn line in the breakdown */ + .modal-tx-breakdown-label-danger { color: var(--status-err); } + .modal-tx-breakdown-value-danger { color: var(--status-err); font-weight: 700; } + /* Modal Panel App Info Card */ .modal-app-info-card { background: rgba(24, 24, 36, 0.5); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 0e06f87..2db6328 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -529,6 +529,8 @@ export function ResolveDropPaths(arg1:Array):Promise export function RespondToXSWDRequest(arg1:string,arg2:boolean,arg3:string):Promise; +export function RespondToXSWDRequestConfirmDestroy(arg1:string,arg2:boolean,arg3:string):Promise; + export function RespondToXSWDRequestWithPermissions(arg1:string,arg2:boolean,arg3:string,arg4:Array):Promise; export function RestoreWallet(arg1:string,arg2:string,arg3:string):Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 147fe08..92aac60 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1054,6 +1054,10 @@ export function RespondToXSWDRequest(arg1, arg2, arg3) { return window['go']['main']['App']['RespondToXSWDRequest'](arg1, arg2, arg3); } +export function RespondToXSWDRequestConfirmDestroy(arg1, arg2, arg3) { + return window['go']['main']['App']['RespondToXSWDRequestConfirmDestroy'](arg1, arg2, arg3); +} + export function RespondToXSWDRequestWithPermissions(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['RespondToXSWDRequestWithPermissions'](arg1, arg2, arg3, arg4); } diff --git a/wallet.go b/wallet.go index 8ea72c5..6bcecea 100644 --- a/wallet.go +++ b/wallet.go @@ -1542,6 +1542,35 @@ 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 is blocked +// unless the user explicitly confirmed it (confirmDestroy). It returns the burn amount and +// whether to block. confirmDestroy is honored ONLY when the burn is actually destructive, so +// the flag can never affect a normal transfer or a contract deposit. +func shouldBlockBurn(transfers []rpc.Transfer, hasSCCall bool, confirmDestroy bool) (uint64, bool) { + burnAmt, destructive := detectDestructiveBurn(transfers, hasSCCall) + if !destructive { + return 0, false + } + return burnAmt, !confirmDestroy +} + // 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 +1764,30 @@ 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 burns 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. This guards against a dApp/caller sending a deposit-style burn + // without the SCID + sc_rpc that would route it to a contract. + // + // A deliberate burn is still possible, but only when the request carries an explicit + // confirmDestroy flag, which the approval modal sets ONLY after the user types the + // type-to-confirm phrase. The backend never relies on the UI alone: both the flag and + // the destructive condition must be present for the burn to proceed. + confirmDestroy := false + if c, ok := params["confirmDestroy"].(bool); ok { + confirmDestroy = c + } + if burnAmt, block := shouldBlockBurn(transfers, len(scArgs) > 0, confirmDestroy); block { + a.logToConsole(fmt.Sprintf("[XSWD] BLOCKED destructive native-DERO burn: %s DERO with no contract attached", formatDEROAmount(burnAmt))) + return map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("This request would permanently destroy %s DERO. A burn with no smart contract attached does not send funds -- it destroys them. The transaction was blocked.", formatDEROAmount(burnAmt)), + "technicalError": fmt.Sprintf("rejected native-DERO burn of %d atomic units (zero SCID, no SC call)", burnAmt), + } + } else if burnAmt > 0 { + a.logToConsole(fmt.Sprintf("[XSWD] CONFIRMED destructive native-DERO burn: %s DERO destroyed by explicit user confirmation", formatDEROAmount(burnAmt))) + } + runTransfer := 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..72006eb 100644 --- a/xswd_server.go +++ b/xswd_server.go @@ -1069,11 +1069,25 @@ 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, false) } // 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, false) +} + +// ProcessApprovalConfirmDestroy approves a request whose destructive native-DERO burn was +// explicitly type-to-confirmed by the user. It injects the confirmDestroy flag so the +// backend burn guard permits the destruction. +func (s *XSWDServer) ProcessApprovalConfirmDestroy(reqID string, approved bool, password string) { + s.processApproval(reqID, approved, password, nil, true) +} + +// processApproval is the shared approval path. confirmDestroy, when true, is injected into +// the request params so the burn guard in InternalWalletCall allows a deliberate native-DERO +// burn that the user explicitly confirmed. +func (s *XSWDServer) processApproval(reqID string, approved bool, password string, permissions []string, confirmDestroy bool) { s.pendingLock.Lock() req, ok := s.pendingRequests[reqID] s.pendingLock.Unlock() @@ -1109,6 +1123,15 @@ func (s *XSWDServer) ProcessApprovalWithPermissions(reqID string, approved bool, return } + // Inject explicit destruction confirmation so the burn guard permits a deliberate, + // user-confirmed native-DERO burn. Without this flag the guard rejects it. + if confirmDestroy { + if req.Params == nil { + req.Params = map[string]interface{}{} + } + req.Params["confirmDestroy"] = true + } + // Execute wallet call // Use the App's InternalWalletCall res := s.app.InternalWalletCall(req.Method, req.Params, password) From 16d4513731bb827ca2bc3dd5f795081870efc49a Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:11:44 -0400 Subject: [PATCH 2/2] fix(xswd): prohibit native-DERO burns entirely HOLOGRAM is a consumer wallet and never burns DERO. Replace the confirm-to-burn flow with an outright prohibition: a native-DERO (zero-SCID) burn with no smart contract attached is rejected with no override and no approve path. Anyone who genuinely intends to burn DERO must use the DERO CLI wallet. - Backend: shouldBlockBurn now blocks every destructive burn unconditionally; remove the confirmDestroy flag and its plumbing (ProcessApprovalConfirmDestroy, the RespondToXSWDRequestConfirmDestroy bridge, and the param injection). Rejection points users to the CLI. - Apply the guard at the scinvoke broadcast site too, so the prohibition is explicit at every path that builds a transfer rather than relying on chain-side refund behavior if the code is later refactored. - Approval modal: a destructive burn renders as a "REQUEST BLOCKED" notice with only a Dismiss action -- no Approve/Destroy button, no type-to-confirm. Mirror the backend's SC-call check (real entrypoint / sc_data, not a bare scid) so a dApp cannot make the UI show Approve for a request the backend blocks. - Keep "Deposit to contract" labeling for contract-attached burns. - Tests: assert destructive burns are always blocked while token transfers and deposits stay allowed; add a source-level sentinel that fails the build if a burn-override path is ever reintroduced. --- app_xswd_bridge.go | 9 - burn_guard_test.go | 125 ++++++++++---- frontend/src/App.svelte | 6 +- .../src/lib/components/WalletModal.svelte | 159 +++++++----------- frontend/src/lib/stores/appState.js | 7 +- frontend/src/styles/hologram.css | 64 ------- frontend/wailsjs/go/main/App.d.ts | 2 - frontend/wailsjs/go/main/App.js | 4 - wallet.go | 56 +++--- xswd_server.go | 26 +-- 10 files changed, 185 insertions(+), 273 deletions(-) diff --git a/app_xswd_bridge.go b/app_xswd_bridge.go index 02bcc9f..5fdc063 100644 --- a/app_xswd_bridge.go +++ b/app_xswd_bridge.go @@ -12,12 +12,3 @@ func (a *App) RespondToXSWDRequestWithPermissions(reqID string, approved bool, p a.xswdServer.ProcessApprovalWithPermissions(reqID, approved, password, permissions) } } - -// RespondToXSWDRequestConfirmDestroy approves a request that carries a destructive -// native-DERO burn. The frontend calls this only after the user passes type-to-confirm; -// it injects the confirmDestroy flag so the backend burn guard allows the destruction. -func (a *App) RespondToXSWDRequestConfirmDestroy(reqID string, approved bool, password string) { - if a.xswdServer != nil { - a.xswdServer.ProcessApprovalConfirmDestroy(reqID, approved, password) - } -} diff --git a/burn_guard_test.go b/burn_guard_test.go index 2fa451b..73d5811 100644 --- a/burn_guard_test.go +++ b/burn_guard_test.go @@ -4,6 +4,10 @@ package main import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" "github.com/deroproject/derohe/cryptography/crypto" @@ -79,59 +83,54 @@ func TestDetectDestructiveBurn(t *testing.T) { } } -// TestShouldBlockBurn locks in the destruction POLICY: a destructive native-DERO burn is -// blocked UNLESS the user explicitly confirmed it. The confirmDestroy flag must only matter -// when the burn is genuinely destructive -- it must never weaken a normal transfer or a -// contract deposit. This is the exact decision that would have stopped the incident, plus -// the deliberate-burn override. +// 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 - confirmDestroy bool - wantBurn uint64 - wantBlocked bool + name string + transfers []rpc.Transfer + hasSCCall bool + wantBurn uint64 + wantBlocked bool }{ { - name: "destructive burn, NOT confirmed -> blocked (the incident)", - transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, - confirmDestroy: false, - wantBurn: 1500000000, - wantBlocked: true, + name: "destructive native burn -> ALWAYS blocked (the incident)", + transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, + wantBurn: 1500000000, + wantBlocked: true, }, { - name: "destructive burn, confirmed -> allowed (deliberate burn)", - transfers: []rpc.Transfer{{Burn: 1500000000, SCID: crypto.ZEROHASH}}, - confirmDestroy: true, - wantBlocked: false, + 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, confirm flag set -> still allowed, flag is a no-op", - transfers: []rpc.Transfer{{Burn: 5, SCID: crypto.ZEROHASH}}, - hasSCCall: true, - confirmDestroy: true, - wantBlocked: false, + 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), confirm flag set -> allowed, flag is a no-op", - transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, - confirmDestroy: true, - wantBlocked: false, + name: "token transfer (non-zero SCID) -> allowed (normal token transfer)", + transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, + wantBlocked: false, }, { - name: "token transfer, no confirm -> allowed (normal token transfer)", - transfers: []rpc.Transfer{{Burn: 1000, SCID: tokenSCID}}, - confirmDestroy: false, - 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, tc.confirmDestroy) + gotBurn, gotBlocked := shouldBlockBurn(tc.transfers, tc.hasSCCall) if gotBlocked != tc.wantBlocked { t.Fatalf("blocked = %v, want %v", gotBlocked, tc.wantBlocked) } @@ -141,3 +140,61 @@ func TestShouldBlockBurn(t *testing.T) { }) } } + +// 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/App.svelte b/frontend/src/App.svelte index 983849c..d13e40a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -13,7 +13,7 @@ // Mining tab removed - Developer Support now in Settings > Developer Support // Network tab removed - node controls moved to Settings > Node import { appState, walletState, settingsState, updateStatus, addExternalRequest, dismissWalletRequest, toast, loadSettings, syncNetworkMode, navigateTo, requestPayment } from './lib/stores/appState.js'; - import { GetSetting, RespondToXSWDRequest, RespondToXSWDRequestWithPermissions, RespondToXSWDRequestConfirmDestroy, NotifyWizardComplete, ConsumeLaunchURL } from '../wailsjs/go/main/App.js'; + import { GetSetting, RespondToXSWDRequest, RespondToXSWDRequestWithPermissions, NotifyWizardComplete, ConsumeLaunchURL } from '../wailsjs/go/main/App.js'; import { EventsOn } from '../wailsjs/runtime/runtime.js'; import { waitForWails } from './lib/utils/wails.js'; @@ -409,10 +409,6 @@ // Use new function with permissions for connect requests if (requestType === 'connect' && result.permissions) { RespondToXSWDRequestWithPermissions(req.id, true, result.password || "", result.permissions); - } else if (result.confirmDestroy) { - // Explicitly type-to-confirmed destructive native-DERO burn: route through the - // confirm-destroy bridge so the backend's burn guard allows it. - RespondToXSWDRequestConfirmDestroy(req.id, true, result.password || ""); } else { RespondToXSWDRequest(req.id, true, result.password || ""); } diff --git a/frontend/src/lib/components/WalletModal.svelte b/frontend/src/lib/components/WalletModal.svelte index d426e0a..ea747f8 100644 --- a/frontend/src/lib/components/WalletModal.svelte +++ b/frontend/src/lib/components/WalletModal.svelte @@ -11,13 +11,18 @@ let walletPath = ''; let error = ''; let isLoading = false; - let burnConfirmText = ''; // type-to-confirm input for destructive native-DERO burns const ZERO_SCID = '0000000000000000000000000000000000000000000000000000000000000000'; - // A request carries a smart contract call when it has a top-level scid (with sc_rpc/args) - // or parsed sc_args. A burn routed to a contract is a deposit, not destruction. - $: hasSCCall = !!(request?.payload?.scid) || + // 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. @@ -25,21 +30,12 @@ .filter(t => !t.scid || t.scid === ZERO_SCID) .reduce((sum, t) => sum + (typeof t.burn === 'number' ? t.burn : 0), 0); - // DESTRUCTIVE: a native-DERO burn with no contract attached. These coins are destroyed - // permanently and sent to no one. This is the case that requires explicit type-to-confirm. - $: isDestructiveBurn = nativeBurnTotal > 0 && !hasSCCall; + // 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); - // The whole-number DERO amount used in the confirm phrase, e.g. "BURN 15000". - $: burnConfirmAmount = Math.round(nativeBurnTotal / 100000); - $: burnConfirmPhrase = `BURN ${burnConfirmAmount}`; - $: burnConfirmMatched = burnConfirmText.trim().toUpperCase().replace(/\s+/g, ' ') === burnConfirmPhrase; - - // Reset the confirm input whenever the active request changes (tracked by id). - let _lastBurnReqId = null; - $: if (request?.id !== _lastBurnReqId) { - _lastBurnReqId = request?.id ?? null; - burnConfirmText = ''; - } let recentWallets = []; let recentWalletsInfo = []; let showWalletSwitcher = false; @@ -238,11 +234,11 @@ isLoading = true; error = ''; - // Defensive backstop: a destructive native-DERO burn can only be approved once the - // type-to-confirm phrase matches. The backend enforces this too (it requires the - // confirmDestroy flag), but refuse here as well so the flag is never sent unconfirmed. - if (isDestructiveBurn && !burnConfirmMatched) { - error = `Type "${burnConfirmPhrase}" to confirm you want to permanently destroy these coins.`; + // 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; } @@ -250,11 +246,10 @@ try { // For connect requests, pass the granted permissions const permissions = request.type === 'connect' ? getGrantedPermissionsList() : null; - await approveWalletRequest(request.id, password, null, permissions, isDestructiveBurn && burnConfirmMatched); + await approveWalletRequest(request.id, password, null, permissions); password = ''; // Clear password after use walletPath = ''; // Reset for next time grantedPermissions = {}; // Reset permissions - burnConfirmText = ''; // Clear burn confirmation // Restore focus to main document to prevent iframe from capturing scroll restoreFocus(); @@ -464,31 +459,31 @@ {@const totalFees = transferFees + topLevelFees} {@const totalDero = totalAmount + totalBurn + totalFees} - - {#if isDestructiveBurn} + + {#if isBurnBlocked} {/if} - {#if deroTransfers.length > 0} + {#if deroTransfers.length > 0 && !isBurnBlocked}