From 01e878f5ce7aeea373d2165e223251acd52c1e9b Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:33:30 -0400 Subject: [PATCH 01/16] fix(sc): unify SC invocation path and decode hex strings in API responses - Add sc_response_normalizer.go to decode printable ASCII strings from DERO.GetSC while preserving numeric values and binary hashes - Apply normalization to all GetSC exit paths (XSWD, Explorer, direct) - Refactor InvokeSCFunction to delegate to InternalWalletCall scinvoke path, ensuring Studio and dApp calls use identical TX building - Fix parseXSWDScArgs to handle both []interface{} and []map[string]interface{} input types and support uint64/int numeric values - Add 0-transfer fallback in scinvoke to prevent index-out-of-range panic when no explicit transfers are provided --- app.go | 1 + explorer_service.go | 38 +++---- sc_function_parser.go | 201 +++++++++----------------------------- sc_response_normalizer.go | 123 +++++++++++++++++++++++ wallet.go | 110 +++++++++++++++------ xswd_router.go | 3 + 6 files changed, 271 insertions(+), 205 deletions(-) create mode 100644 sc_response_normalizer.go diff --git a/app.go b/app.go index ca95801..cee0b98 100644 --- a/app.go +++ b/app.go @@ -834,6 +834,7 @@ func (a *App) DaemonGetSC(scid string) map[string]interface{} { if err != nil { return ErrorResponse(err) } + res = normalizeDEROGetSCResult(res) return map[string]interface{}{"success": true, "result": res} } diff --git a/explorer_service.go b/explorer_service.go index fca7685..6f31e49 100644 --- a/explorer_service.go +++ b/explorer_service.go @@ -312,9 +312,9 @@ func (a *App) GetCoinbaseMiner(txid string) map[string]interface{} { if txHex == "" { return map[string]interface{}{ - "success": true, + "success": true, "isCoinbase": false, - "message": "No raw transaction hex available", + "message": "No raw transaction hex available", } } @@ -547,7 +547,7 @@ func (a *App) SearchAddress(address string) map[string]interface{} { if a.gnomonClient.IsRunning() { // Search through Gnomon for SCIDs owned by this address ownedSCIDs := a.getOwnedSCIDs(address) - + return map[string]interface{}{ "success": true, "address": address, @@ -910,7 +910,7 @@ func (a *App) GetBlockchainStats() map[string]interface{} { // Extract relevant stats - info is already map[string]interface{} stats := map[string]interface{}{} - + stats["height"] = info["height"] stats["topoheight"] = info["topoheight"] stats["difficulty"] = info["difficulty"] @@ -1192,7 +1192,7 @@ func (a *App) GetMempoolExtended(maxCount int) map[string]interface{} { func (a *App) GetSCInfo(scid string) map[string]interface{} { // Normalize SCID to lowercase (DERO requires lowercase hex) normalizedSCID := strings.ToLower(strings.TrimSpace(scid)) - + a.logToConsole(fmt.Sprintf("[...] Getting SC info: %s", normalizedSCID[:16]+"...")) params := map[string]interface{}{ @@ -1205,6 +1205,7 @@ func (a *App) GetSCInfo(scid string) map[string]interface{} { if err != nil { return ErrorResponse(err) } + result = normalizeDEROGetSCResult(result) scData := map[string]interface{}{} if resultMap, ok := result.(map[string]interface{}); ok { @@ -1248,7 +1249,7 @@ func (a *App) GetRingMembers(txid string) map[string]interface{} { "index": idx, "members": []string{}, } - + if addresses, ok := payload.([]interface{}); ok { members := []string{} for _, addr := range addresses { @@ -1258,12 +1259,12 @@ func (a *App) GetRingMembers(txid string) map[string]interface{} { } payloadRing["members"] = members payloadRing["count"] = len(members) - + if len(members) > ringSize { ringSize = len(members) } } - + ringData = append(ringData, payloadRing) } } @@ -1274,10 +1275,10 @@ func (a *App) GetRingMembers(txid string) map[string]interface{} { a.logToConsole(fmt.Sprintf("[OK] Found %d ring groups, max size %d", len(ringData), ringSize)) return map[string]interface{}{ - "success": true, - "txid": txid, - "rings": ringData, - "ringCount": len(ringData), + "success": true, + "txid": txid, + "rings": ringData, + "ringCount": len(ringData), "maxRingSize": ringSize, } } @@ -1294,15 +1295,14 @@ func (a *App) GetTransactionWithRings(txid string) map[string]interface{} { // Get ring members ringResult := a.GetRingMembers(txid) - + // Combine results return map[string]interface{}{ - "success": true, - "txid": txid, - "tx": txResult["tx"], - "rings": ringResult["rings"], - "ringCount": ringResult["ringCount"], + "success": true, + "txid": txid, + "tx": txResult["tx"], + "rings": ringResult["rings"], + "ringCount": ringResult["ringCount"], "maxRingSize": ringResult["maxRingSize"], } } - diff --git a/sc_function_parser.go b/sc_function_parser.go index 0a27bc2..724491e 100644 --- a/sc_function_parser.go +++ b/sc_function_parser.go @@ -223,7 +223,11 @@ func (a *App) InvokeSCFunction(paramsJSON string) map[string]interface{} { } } - a.logToConsole(fmt.Sprintf("[SC] Invoking %s on %s...", params.Function, params.SCID[:16])) + scidPrefix := params.SCID + if len(scidPrefix) > 16 { + scidPrefix = scidPrefix[:16] + } + a.logToConsole(fmt.Sprintf("[SC] Invoking %s on %s...", params.Function, scidPrefix)) // Check wallet wallet := GetWallet() @@ -238,186 +242,73 @@ func (a *App) InvokeSCFunction(paramsJSON string) map[string]interface{} { } } - // Build SC arguments - scArgs := rpc.Arguments{ - {Name: rpc.SCACTION, DataType: rpc.DataUint64, Value: uint64(rpc.SC_CALL)}, - {Name: rpc.SCID, DataType: rpc.DataHash, Value: crypto.HashHexToHash(params.SCID)}, - {Name: "entrypoint", DataType: rpc.DataString, Value: params.Function}, + // Route through the wallet's built-in scinvoke path (same path used by dApps), + // which keeps SC arg handling consistent across Studio and XSWD calls. + scRPC := []interface{}{ + map[string]interface{}{"name": "entrypoint", "datatype": "S", "value": params.Function}, } - - // Add function parameters for name, value := range params.Params { switch v := value.(type) { case string: - scArgs = append(scArgs, rpc.Argument{ - Name: name, - DataType: rpc.DataString, - Value: v, + scRPC = append(scRPC, map[string]interface{}{ + "name": name, "datatype": "S", "value": v, }) - case float64: // JSON numbers come as float64 - scArgs = append(scArgs, rpc.Argument{ - Name: name, - DataType: rpc.DataUint64, - Value: uint64(v), + case float64: + scRPC = append(scRPC, map[string]interface{}{ + "name": name, "datatype": "U", "value": v, }) case int: - scArgs = append(scArgs, rpc.Argument{ - Name: name, - DataType: rpc.DataUint64, - Value: uint64(v), + scRPC = append(scRPC, map[string]interface{}{ + "name": name, "datatype": "U", "value": float64(v), + }) + case uint64: + scRPC = append(scRPC, map[string]interface{}{ + "name": name, "datatype": "U", "value": float64(v), }) } } - // Validate SC arguments before building the transaction - if err := scArgs.Validate_Arguments(); err != nil { - a.logToConsole(fmt.Sprintf("[ERR] SC argument validation failed: %v", err)) - return map[string]interface{}{ - "success": false, - "error": "Invalid SC arguments: " + err.Error(), - } - } - if a.simulatorManager != nil && a.simulatorManager.isInitialized { - return a.withSimulatorTransactionConnectivity(wallet, "scinvoke", func() map[string]interface{} { - return a.invokeSCFunctionSimulator(params, wallet, scArgs) - }) - } - - // Sync wallet with daemon before building the transaction - // (mirrors InternalWalletCall scinvoke path which does this and works correctly) - if syncErr := wallet.Sync_Wallet_Memory_With_Daemon(); syncErr != nil { - a.logToConsole(fmt.Sprintf("[WARN] Pre-invoke wallet sync failed: %v", syncErr)) - } - - // Check daemon connectivity - if wallet.Get_Daemon_Height() == 0 { - return map[string]interface{}{ - "success": false, - "error": "Wallet is not connected to the daemon. Please check your connection and try again.", - } - } - - // Check wallet has balance for gas fees - mature, _ := wallet.Get_Balance() - if mature == 0 { - return map[string]interface{}{ - "success": false, - "error": "Insufficient balance. Smart contract calls require gas fees — please fund your wallet first.", - } - } - - // Build transfers - transfers := []rpc.Transfer{} - - // Ensure daemon connection before Random_ring_members (needs daemon to fetch decoy outputs) - a.ensureWalletDaemonConnectivity(a.getDaemonEndpointForWallet()) - - // Get random destination for the transfer - randos := wallet.Random_ring_members(crypto.ZEROHASH) - if len(randos) == 0 { - return map[string]interface{}{ - "success": false, - "error": "Could not get ring members - check daemon connection", - } + walletParams := map[string]interface{}{ + "scid": params.SCID, + "sc_rpc": scRPC, + "ringsize": float64(2), } - destination := randos[0] - if destination == wallet.GetAddress().String() && len(randos) > 1 { - destination = randos[1] + if params.Anonymous { + walletParams["ringsize"] = float64(16) } - - // Add DERO transfer if needed if params.DeroAmount > 0 { - transfers = append(transfers, rpc.Transfer{ - Destination: destination, - Amount: 0, - Burn: params.DeroAmount, - }) + walletParams["sc_dero_deposit"] = float64(params.DeroAmount) } - - // Add asset transfer if needed if params.AssetSCID != "" && params.AssetAmount > 0 { - transfers = append(transfers, rpc.Transfer{ - Destination: destination, - SCID: crypto.HashHexToHash(params.AssetSCID), - Amount: 0, - Burn: params.AssetAmount, - }) - } - - // If no transfers, add a minimal one - if len(transfers) == 0 { - transfers = append(transfers, rpc.Transfer{ - Destination: destination, - Amount: 0, - }) + walletParams["sc_token_deposit"] = float64(params.AssetAmount) + walletParams["sc_token_deposit_scid"] = params.AssetSCID } - // Set ringsize - ringsize := uint64(2) - if params.Anonymous { - ringsize = 16 - } - - // Estimate gas via daemon RPC (same as Engram and InternalWalletCall paths) - gasStorage := uint64(0) - gasRPC := []map[string]interface{}{ - {"name": "SC_ACTION", "datatype": "U", "value": uint64(0)}, - {"name": "SC_ID", "datatype": "H", "value": params.SCID}, - {"name": "entrypoint", "datatype": "S", "value": params.Function}, - } - for name, value := range params.Params { - switch v := value.(type) { - case string: - gasRPC = append(gasRPC, map[string]interface{}{"name": name, "datatype": "S", "value": v}) - case float64: - gasRPC = append(gasRPC, map[string]interface{}{"name": name, "datatype": "U", "value": uint64(v)}) + a.logToConsole(fmt.Sprintf("[SC] Forwarding invoke through wallet scinvoke path (args=%d)", len(scRPC)-1)) + invokeResult := a.InternalWalletCall("scinvoke", walletParams, "") + if success, _ := invokeResult["success"].(bool); !success { + errorMessage := "Transaction failed" + if errText, ok := invokeResult["error"].(string); ok && errText != "" { + errorMessage = errText } - } - gasParams := map[string]interface{}{ - "sc_rpc": gasRPC, - "ringsize": ringsize, - "signer": wallet.GetAddress().String(), - } - if a.daemonClient != nil { - gasResult, gasErr := a.daemonClient.Call("DERO.GetGasEstimate", gasParams) - if gasErr != nil { - a.logToConsole(fmt.Sprintf("[WARN] Gas estimate failed (proceeding with 0): %v", gasErr)) - } else if resultMap, ok := gasResult.(map[string]interface{}); ok { - if gs, ok := resultMap["gasstorage"].(float64); ok { - gasStorage = uint64(gs) - } - } - } - - a.logToConsole(fmt.Sprintf("[SC] Building TX: ringsize=%d gasStorage=%d transfers=%d", ringsize, gasStorage, len(transfers))) - - // Build and send transaction - tx, err := wallet.TransferPayload0(transfers, ringsize, false, scArgs, gasStorage, false) - if err != nil { - // Retry after resync (mirrors InternalWalletCall pattern) - a.logToConsole(fmt.Sprintf("[WARN] SC invoke build failed, retrying after resync: %v", err)) - if syncErr := wallet.Sync_Wallet_Memory_With_Daemon(); syncErr != nil { - a.logToConsole(fmt.Sprintf("[WARN] Retry sync failed: %v", syncErr)) + resp := map[string]interface{}{ + "success": false, + "error": errorMessage, } - tx, err = wallet.TransferPayload0(transfers, ringsize, false, scArgs, gasStorage, false) - if err != nil { - return map[string]interface{}{ - "success": false, - "error": "Transaction failed: " + err.Error(), - } + if technicalError, ok := invokeResult["technicalError"].(string); ok && technicalError != "" { + resp["technicalError"] = technicalError } + return resp } - if err := wallet.SendTransaction(tx); err != nil { - return map[string]interface{}{ - "success": false, - "error": "Failed to send transaction: " + err.Error(), + txid := "" + if resultMap, ok := invokeResult["result"].(map[string]interface{}); ok { + if tx, ok := resultMap["txid"].(string); ok { + txid = tx } } - txid := tx.GetHash().String() - a.logToConsole(fmt.Sprintf("[OK] SC invoked successfully: %s...", txid[:16])) - + a.logToConsole(fmt.Sprintf("[OK] SC invoked successfully: %s...", txid)) return map[string]interface{}{ "success": true, "txid": txid, diff --git a/sc_response_normalizer.go b/sc_response_normalizer.go new file mode 100644 index 0000000..d6622ae --- /dev/null +++ b/sc_response_normalizer.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/hex" + "strings" +) + +// normalizeDEROGetSCResult decodes printable ASCII string values from +// DERO.GetSC variable maps to preserve legacy client behavior. +func normalizeDEROGetSCResult(result interface{}) interface{} { + resultMap, ok := result.(map[string]interface{}) + if !ok { + return result + } + + normalizeGetSCMapField(resultMap, "stringkeys") + normalizeGetSCMapField(resultMap, "uint64keys") + return resultMap +} + +func normalizeGetSCMapField(resultMap map[string]interface{}, field string) { + raw, ok := resultMap[field] + if !ok { + return + } + + values, ok := raw.(map[string]interface{}) + if !ok { + return + } + + normalized := make(map[string]interface{}, len(values)) + for key, value := range values { + if strValue, ok := value.(string); ok { + normalized[key] = decodePrintableHexString(strValue) + } else { + normalized[key] = value + } + } + + resultMap[field] = normalized +} + +func decodePrintableHexString(value string) string { + trimmed := strings.TrimSpace(value) + if !shouldDecodeHexString(trimmed) { + return value + } + decimalOnly := isDecimalString(trimmed) + + decoded, err := hex.DecodeString(trimmed) + if err != nil { + return value + } + + if !isPrintableASCII(decoded) { + return value + } + + decodedText := strings.TrimRight(string(decoded), "\x00 \t\n\r") + if decodedText == "" { + return value + } + // Keep short decimal strings as numbers ("50" should stay numeric). + if decimalOnly && len(decodedText) == 1 { + return value + } + // Decimal-only hex should decode only when it clearly becomes text. + if decimalOnly && !containsASCIIAlpha(decodedText) { + return value + } + + return decodedText +} + +func shouldDecodeHexString(value string) bool { + if value == "" || len(value)%2 != 0 || len(value) < 4 { + return false + } + + for _, c := range value { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return false + } + } + + return true +} + +func isPrintableASCII(data []byte) bool { + if len(data) == 0 { + return false + } + + for _, b := range data { + if b < 32 || b > 126 { + return false + } + } + + return true +} + +func isDecimalString(value string) bool { + if value == "" { + return false + } + for _, c := range value { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func containsASCIIAlpha(value string) bool { + for _, c := range value { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + return true + } + } + return false +} diff --git a/wallet.go b/wallet.go index 58294fd..e6184fd 100644 --- a/wallet.go +++ b/wallet.go @@ -1338,42 +1338,60 @@ func parseXSWDScArgs(params map[string]interface{}, scid string) rpc.Arguments { hasEntrypointInScRpc := false entrypointFromScRpc := "" + appendScArg := func(argMap map[string]interface{}) { + name, _ := argMap["name"].(string) + dtype, _ := argMap["datatype"].(string) + val := argMap["value"] + if name == "" { + return + } + if name == "entrypoint" { + hasEntrypointInScRpc = true + if ep, ok := val.(string); ok { + entrypointFromScRpc = ep + } + } + switch dtype { + case "U": + switch v := val.(type) { + case float64: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "U", Value: uint64(v)}) + case uint64: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "U", Value: v}) + case int: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "U", Value: uint64(v)}) + } + case "I": + switch v := val.(type) { + case float64: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "I", Value: int64(v)}) + case int64: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "I", Value: v}) + case int: + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "I", Value: int64(v)}) + } + case "S": + if v, ok := val.(string); ok { + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "S", Value: v}) + } + case "H": + if v, ok := val.(string); ok { + scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "H", Value: crypto.HashHexToHash(v)}) + } + } + } + if args, ok := params["sc_rpc"].([]interface{}); ok { for _, arg := range args { - a, ok := arg.(map[string]interface{}) + argMap, ok := arg.(map[string]interface{}) if !ok { continue } - name, _ := a["name"].(string) - dtype, _ := a["datatype"].(string) - val := a["value"] - if name == "" { - continue - } - if name == "entrypoint" { - hasEntrypointInScRpc = true - if ep, ok := val.(string); ok { - entrypointFromScRpc = ep - } - } - switch dtype { - case "U": - if v, ok := val.(float64); ok { - scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "U", Value: uint64(v)}) - } - case "I": - if v, ok := val.(float64); ok { - scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "I", Value: int64(v)}) - } - case "S": - if v, ok := val.(string); ok { - scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "S", Value: v}) - } - case "H": - if v, ok := val.(string); ok { - scArgs = append(scArgs, rpc.Argument{Name: name, DataType: "H", Value: crypto.HashHexToHash(v)}) - } - } + appendScArg(argMap) + } + } else if args, ok := params["sc_rpc"].([]map[string]interface{}); ok { + for _, argMap := range args { + appendScArg(argMap) } } @@ -1650,6 +1668,12 @@ func (a *App) InternalWalletCall(method string, params map[string]interface{}, p // Parse SC arguments (shared helper handles all datatypes including I, U, S, H) scArgs := parseXSWDScArgs(params, scid) + if len(scArgs) == 0 { + return map[string]interface{}{ + "success": false, + "error": "Invalid smart contract call. Missing or malformed 'entrypoint'/'sc_rpc' parameters.", + } + } // sc_dero_deposit / sc_token_deposit -- amount attached to the SC call. // These are top-level params distinct from the transfers array. @@ -1729,6 +1753,30 @@ func (a *App) InternalWalletCall(method string, params map[string]interface{}, p if f, ok := params["fees"].(float64); ok && f > 0 { fees = uint64(f) } + + // Guard against wallet library panic path: scinvoke with no transfers at all. + if len(transfers) == 0 { + destination := "" + if a.IsInSimulatorMode() { + destination = a.getSimulatorTransferDestination(wallet.GetAddress().String()) + } else { + randos := wallet.Random_ring_members(crypto.ZEROHASH) + if len(randos) == 0 { + return map[string]interface{}{ + "success": false, + "error": "Could not get ring members for SC call. Please check daemon connection and retry.", + } + } + destination = randos[0] + if destination == wallet.GetAddress().String() && len(randos) > 1 { + destination = randos[1] + } + } + transfers = append(transfers, rpc.Transfer{ + Destination: destination, + Amount: 0, + }) + } a.logToConsole(fmt.Sprintf("[XSWD] Building scinvoke TX with ringsize=%d fees=%d", ringsize, fees)) tx, err := wallet.TransferPayload0(transfers, ringsize, false, scArgs, fees, false) diff --git a/xswd_router.go b/xswd_router.go index 16caeb9..2e061a6 100644 --- a/xswd_router.go +++ b/xswd_router.go @@ -46,6 +46,9 @@ func (a *App) routeDaemonCall(method string, params map[string]interface{}) XSWD log.Printf("[ERR] Daemon call failed: %v", err) return xswdError(FriendlyError(err), err.Error()) } + if method == "DERO.GetSC" { + result = normalizeDEROGetSCResult(result) + } return xswdSuccess(result) } From a1d2af2d0df49107ae46fb18dccfd780aab9e1d3 Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Fri, 1 May 2026 21:15:10 -0400 Subject: [PATCH 02/16] fix(explorer): decode SC string values consistently Normalize smart contract search results before Explorer renders them and move SC variable display decoding into a focused helper. This keeps scores numeric while allowing printable string values to display as text with raw hex still available. --- frontend/src/lib/utils/scValueDisplay.js | 74 +++++++++++++++++++ frontend/src/lib/utils/scValueDisplay.test.js | 72 ++++++++++++++++++ frontend/src/routes/Explorer.svelte | 50 ++++--------- search_service.go | 3 + 4 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/utils/scValueDisplay.js create mode 100644 frontend/src/lib/utils/scValueDisplay.test.js diff --git a/frontend/src/lib/utils/scValueDisplay.js b/frontend/src/lib/utils/scValueDisplay.js new file mode 100644 index 0000000..6f601fb --- /dev/null +++ b/frontend/src/lib/utils/scValueDisplay.js @@ -0,0 +1,74 @@ +const HEX_RE = /^[0-9a-fA-F]+$/; +const PRINTABLE_TEXT_RE = /^[\t\n\r -~]*$/; + +function toDisplayString(value) { + return value == null ? '' : String(value); +} + +function decodePrintableHexString(value) { + if (typeof value !== 'string' || value.length === 0 || value.length % 2 !== 0) { + return null; + } + + if (!HEX_RE.test(value)) { + return null; + } + + const bytes = value.match(/.{2}/g).map((pair) => parseInt(pair, 16)); + let decoded; + + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes)); + } catch { + return null; + } + + decoded = decoded.replace(/\0+$/, ''); + + if (decoded.length === 0 || !PRINTABLE_TEXT_RE.test(decoded)) { + return null; + } + + // Scores/counters such as "40", "50", and "60" are valid one-byte hex too. + if (/^\d+$/.test(value) && decoded.length === 1) { + return null; + } + + return decoded; +} + +function formatSCDisplayString(value) { + const raw = toDisplayString(value); + const decoded = decodePrintableHexString(value); + + if (decoded == null || decoded === raw) { + return { + display: raw, + raw, + wasDecoded: false, + }; + } + + return { + display: decoded, + raw, + wasDecoded: true, + }; +} + +export function formatSCDisplayValue(value) { + if (typeof value !== 'string') { + const raw = toDisplayString(value); + return { + display: raw, + raw, + wasDecoded: false, + }; + } + + return formatSCDisplayString(value); +} + +export function formatSCDisplayKey(key) { + return formatSCDisplayString(toDisplayString(key)); +} diff --git a/frontend/src/lib/utils/scValueDisplay.test.js b/frontend/src/lib/utils/scValueDisplay.test.js new file mode 100644 index 0000000..a84acfb --- /dev/null +++ b/frontend/src/lib/utils/scValueDisplay.test.js @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatSCDisplayKey, formatSCDisplayValue } from './scValueDisplay.js'; + +test('decodes printable hex string values shown in SC variables', () => { + const cases = [ + ['53696567', 'Sieg'], + ['536965676672696564', 'Siegfried'], + ['5465737453696567', 'TestSieg'], + ['736563726574', 'secret'], + ['5369656746', 'SiegF'], + ]; + + for (const [raw, display] of cases) { + assert.deepEqual(formatSCDisplayValue(raw), { + display, + raw, + wasDecoded: true, + }); + } +}); + +test('keeps numeric score and time values numeric-looking', () => { + for (const value of [40, 50, 60, 55, 132]) { + assert.deepEqual(formatSCDisplayValue(value), { + display: String(value), + raw: String(value), + wasDecoded: false, + }); + } + + for (const value of ['40', '50', '60', '55', '132', '6962919']) { + assert.deepEqual(formatSCDisplayValue(value), { + display: value, + raw: value, + wasDecoded: false, + }); + } +}); + +test('trims null padding after decoding printable hex strings', () => { + assert.deepEqual(formatSCDisplayValue('48656c6c6f00'), { + display: 'Hello', + raw: '48656c6c6f00', + wasDecoded: true, + }); +}); + +test('leaves invalid or non-printable hex values raw', () => { + for (const value of ['not-hex', '123', '00ff', '48656c6c6g']) { + assert.deepEqual(formatSCDisplayValue(value), { + display: value, + raw: value, + wasDecoded: false, + }); + } +}); + +test('formats keys independently from values', () => { + assert.deepEqual(formatSCDisplayKey('6e616d65'), { + display: 'name', + raw: '6e616d65', + wasDecoded: true, + }); + + assert.deepEqual(formatSCDisplayKey('name_0'), { + display: 'name_0', + raw: 'name_0', + wasDecoded: false, + }); +}); diff --git a/frontend/src/routes/Explorer.svelte b/frontend/src/routes/Explorer.svelte index 290f7cb..ec8dd4e 100644 --- a/frontend/src/routes/Explorer.svelte +++ b/frontend/src/routes/Explorer.svelte @@ -18,6 +18,7 @@ import SearchHistory from '../lib/components/SearchHistory.svelte'; import VersionHistory from '../lib/components/VersionHistory.svelte'; import SCFunctionInteractor from '../lib/components/SCFunctionInteractor.svelte'; + import { formatSCDisplayKey, formatSCDisplayValue } from '../lib/utils/scValueDisplay.js'; import { Package, FileText, Coins, Clock, Copy, ArrowLeft, Home, X, ChevronLeft, ChevronRight, FileCode, User, Globe, Lock, Info, AlertTriangle, Check, Loader2, Shield, Pickaxe, @@ -88,26 +89,12 @@ // Hex toggle: tracks which string var keys are currently showing raw hex instead of decoded text let hexViewKeys = {}; function toggleHexView(key) { hexViewKeys[key] = !hexViewKeys[key]; hexViewKeys = hexViewKeys; } - function tryHexDecode(hex) { - if (typeof hex !== 'string') return String(hex); - try { - const bytes = hex.match(/.{1,2}/g); - if (!bytes) return hex; - const decoded = new TextDecoder('utf-8', { fatal: true }).decode( - new Uint8Array(bytes.map(b => parseInt(b, 16))) - ); - return decoded.replace(/\0+$/, ''); - } catch { return hex; } - } - function isHexEncoded(str) { - if (typeof str !== 'string' || str.length === 0 || str.length % 2 !== 0) return false; - if (!/^[0-9a-fA-F]+$/.test(str)) return false; - // DERO.GetSC can return uint64 values under stringkeys when the variable key - // itself is a string (e.g. score_0 = "50"). Do not decode decimal numerics - // as ASCII hex ("50" -> "P"). - if (/^\d+$/.test(str)) return false; - const decoded = tryHexDecode(str); - return decoded !== str; + function getSCDisplayName(data, fallback = 'Smart Contract') { + const stringkeys = data?.stringkeys || {}; + const rawName = stringkeys.nameHdr ?? stringkeys.var_header_name ?? stringkeys.dURL; + if (rawName == null || rawName === '') return fallback; + + return formatSCDisplayValue(rawName).display || fallback; } // SC Discovery state @@ -1276,9 +1263,7 @@ watchingInProgress = true; try { - const scName = searchResult?.data?.stringkeys?.nameHdr || - searchResult?.data?.stringkeys?.dURL || - searchQuery.substring(0, 16); + const scName = getSCDisplayName(searchResult?.data, searchQuery.substring(0, 16)); const result = await WatchSmartContract(searchQuery, scName); if (result.success) { toast.success('Now watching this smart contract'); @@ -2341,19 +2326,16 @@
{#each Object.entries(searchResult.data.stringkeys) as [key, value]} - {@const strVal = String(value)} - {@const decodedKey = isHexEncoded(key) ? tryHexDecode(key) : key} - {@const decodedVal = isHexEncoded(strVal) ? tryHexDecode(strVal) : strVal} - {@const keyWasDecoded = decodedKey !== key} - {@const valWasDecoded = decodedVal !== strVal} + {@const displayKey = formatSCDisplayKey(key)} + {@const displayVal = formatSCDisplayValue(value)} {@const showingRaw = hexViewKeys[key]}
- {showingRaw ? key : decodedKey} + {showingRaw ? displayKey.raw : displayKey.display}
- - {showingRaw ? strVal : decodedVal} + + {showingRaw ? displayVal.raw : displayVal.display} - {#if keyWasDecoded || valWasDecoded} + {#if displayKey.wasDecoded || displayVal.wasDecoded} +
+ + + + +
+
+{/if} + + diff --git a/frontend/src/routes/Wallet.svelte b/frontend/src/routes/Wallet.svelte index 2eeaf22..758704b 100644 --- a/frontend/src/routes/Wallet.svelte +++ b/frontend/src/routes/Wallet.svelte @@ -15,6 +15,7 @@ import QRCodeComponent from '../lib/components/QRCode.svelte'; import AddContactModal from '../lib/components/AddContactModal.svelte'; import PasswordInput from '../lib/components/PasswordInput.svelte'; + import RevealSecretModal from '../lib/components/RevealSecretModal.svelte'; // ============================================ // NAVIGATION STATE @@ -181,20 +182,14 @@ // ============================================ // BACKUP & SECURITY STATE // ============================================ - let backupPassword = ''; - let seedRevealed = false; - let revealedSeed = ''; - let backupLoading = false; - let backupError = null; - - // Keys state - let keysPassword = ''; - let keysRevealed = false; - let revealedSecretKey = ''; - let revealedPublicKey = ''; - let keysLoading = false; - let keysError = null; - + // NOTE: The decrypted seed and keys intentionally do NOT live here. They are + // owned by children below so that unmounting the modal + // (close, ESC, wallet switch, wallet close, route change) drops the only + // reference and lets GC reclaim the secret. This matches Engram's invariant + // that the seed is only read live from the open wallet at render time. + let showSeedModal = false; + let showKeysModal = false; + // Change Password state let changePasswordCurrent = ''; let changePasswordNew = ''; @@ -1302,78 +1297,15 @@ } // ============================================ - // BACKUP & SECURITY FUNCTIONS + // BACKUP & SECURITY: REVEAL MODAL CONTROL // ============================================ - async function revealSeed() { - if (!backupPassword.trim()) { - backupError = 'Please enter your wallet password'; - return; - } - - backupLoading = true; - backupError = null; - - try { - const result = await GetSeedPhrase(backupPassword); - if (result.success) { - revealedSeed = result.seed; - seedRevealed = true; - backupPassword = ''; // Clear password from memory - toast.success('Seed phrase revealed'); - } else { - backupError = handleBackendError(result, { showToast: false }) || 'Failed to retrieve seed phrase'; - } - } catch (err) { - console.error('Error retrieving seed phrase:', err); - backupError = err.message || 'Failed to retrieve seed phrase'; - } finally { - backupLoading = false; - } - } - - function resetBackup() { - seedRevealed = false; - revealedSeed = ''; - backupPassword = ''; - backupError = null; - showBackupPassword = false; - } - - async function revealKeys() { - if (!keysPassword.trim()) { - keysError = 'Please enter your wallet password'; - return; - } - - keysLoading = true; - keysError = null; - - try { - const result = await GetWalletKeys(keysPassword); - if (result.success) { - revealedSecretKey = result.secretKey; - revealedPublicKey = result.publicKey; - keysRevealed = true; - keysPassword = ''; // Clear password from memory - toast.success('Wallet keys revealed'); - } else { - keysError = handleBackendError(result, { showToast: false }) || 'Failed to retrieve wallet keys'; - } - } catch (err) { - console.error('Error retrieving wallet keys:', err); - keysError = err.message || 'Failed to retrieve wallet keys'; - } finally { - keysLoading = false; - } - } - - function resetKeys() { - keysRevealed = false; - revealedSecretKey = ''; - revealedPublicKey = ''; - keysPassword = ''; - keysError = null; - showKeysPassword = false; + // The decrypted seed/keys live inside . This route only + // toggles which modal is mounted. Whenever the active wallet path changes + // or the wallet is closed, both modals are dismissed so the child is + // unmounted and its local secret state is GC'd. + $: if (!$walletState.isOpen && (showSeedModal || showKeysModal)) { + showSeedModal = false; + showKeysModal = false; } // ============================================ @@ -1575,8 +1507,14 @@ } } - // Keep wallet path display synced when wallet is switched outside this route + // Keep wallet path display synced when wallet is switched outside this route. + // Also dismiss any open reveal modal so the previous wallet's decrypted + // material cannot render under the new wallet (security: cross-wallet leak). $: if ($walletState.walletPath && $walletState.walletPath !== currentWalletPath) { + if (currentWalletPath && (showSeedModal || showKeysModal)) { + showSeedModal = false; + showKeysModal = false; + } currentWalletPath = $walletState.walletPath; } @@ -2847,83 +2785,22 @@ RECOVERY SEED - +
- {#if !seedRevealed} - -
- - Enter your wallet password to view your recovery seed phrase -
- -
- - -
- - {#if backupError} -
- - {backupError} -
- {/if} - -
- -
- {:else} - -
- -

Your Recovery Seed

-

Write down these 25 words in order. This is the ONLY way to recover your wallet.

-
- -
- {#each revealedSeed.split(' ') as word, i} -
- {i + 1} - {word} -
- {/each} -
- -
-
- - NEVER share your seed with anyone -
-
- - Hologram will NEVER ask for your seed -
-
- - Store this offline in a safe place -
-
- -
- - -
- {/if} +
+ + Your password is required every time the seed is revealed. The seed is held only while open and is auto-hidden after 60 seconds. +
+ +
+ +
@@ -2935,97 +2812,29 @@ WALLET KEYS - +
- {#if !keysRevealed} - -
- - Enter your wallet password to view your secret and public keys -
- -
- -
- CRITICAL: Your secret key provides full control over your wallet. Never share it with anyone. -
-
- -
- - -
- - {#if keysError} -
- - {keysError} -
- {/if} - -
- -
- {:else} - -
- -
-
- SECRET KEY - CRITICAL -
-
- {revealedSecretKey} -
- -
- - This key provides full wallet control. Keep it secure and never share it. -
-
- - -
- - -
-
- PUBLIC KEY -
-
- {revealedPublicKey} -
- -
- Public key can be shared safely. It's used to verify signatures. -
-
-
- -
- +
+ + Your password is required every time keys are revealed. Keys are held only while open and are auto-hidden after 60 seconds. +
+ +
+ +
+ CRITICAL: Your secret key provides full control over your wallet. Never share it with anyone.
- {/if} +
+ +
+ +
@@ -3546,6 +3355,18 @@ on:close={() => { editingContact = null; }} /> + + + + From 9994f9179da5eb5774e99d644c7cc6df4df5a0ed Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Sat, 2 May 2026 01:19:30 -0400 Subject: [PATCH 05/16] chore(wallet): remove stale reveal styles Remove old inline seed/key display selectors after moving reveal UI into RevealSecretModal so Wallet.svelte stays warning-free. --- frontend/src/routes/Wallet.svelte | 90 +------------------------------ 1 file changed, 1 insertion(+), 89 deletions(-) diff --git a/frontend/src/routes/Wallet.svelte b/frontend/src/routes/Wallet.svelte index 758704b..04a6212 100644 --- a/frontend/src/routes/Wallet.svelte +++ b/frontend/src/routes/Wallet.svelte @@ -4839,14 +4839,7 @@ font-size: 12px; color: var(--status-warn); } - - .backup-actions { - display: flex; - gap: var(--s-3); - justify-content: flex-end; - margin-top: var(--s-4); - } - + /* Keys Display Styles */ .keys-warning-critical { display: flex; @@ -4864,87 +4857,6 @@ .keys-warning-critical strong { font-weight: 600; } - - .keys-display { - display: flex; - flex-direction: column; - gap: var(--s-4); - } - - .key-section { - display: flex; - flex-direction: column; - gap: var(--s-3); - } - - .key-header { - display: flex; - align-items: center; - justify-content: space-between; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-3); - } - - .key-label { - color: var(--text-2); - } - - .key-warning-badge { - padding: 2px 8px; - background: rgba(248, 113, 113, 0.15); - color: var(--status-err); - border-radius: var(--r-xs); - font-size: 9px; - font-weight: 600; - } - - .key-value-box { - padding: var(--s-3) var(--s-4); - background: var(--void-deep); - border: 1px solid var(--border-dim); - border-radius: var(--r-md); - word-break: break-all; - } - - .key-value { - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-1); - line-height: 1.6; - display: block; - } - - .key-value.mono { - font-variant-numeric: tabular-nums; - } - - .key-warning-text { - display: flex; - align-items: center; - gap: var(--s-2); - padding: var(--s-2) var(--s-3); - background: rgba(251, 191, 36, 0.1); - border-radius: var(--r-sm); - font-size: 11px; - color: var(--status-warn); - } - - .key-info-text { - padding: var(--s-2) var(--s-3); - font-size: 11px; - color: var(--text-4); - font-style: italic; - } - - .key-separator { - height: 1px; - background: var(--border-dim); - margin: var(--s-2) 0; - } .tx-label { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: rgba(82, 200, 219, 0.15); border: 1px solid rgba(82, 200, 219, 0.3); border-radius: 4px; font-size: 0.75rem; color: #52c8db; } From e48e960d34cd9f1a315da70c087ea271a0b8bd70 Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Sat, 2 May 2026 01:33:25 -0400 Subject: [PATCH 06/16] chore(explorer): remove unused simulator status selector Remove a stale nested selector so Svelte dev/build logs stay clean. --- frontend/src/routes/Explorer.svelte | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/routes/Explorer.svelte b/frontend/src/routes/Explorer.svelte index ec8dd4e..0f1e5a9 100644 --- a/frontend/src/routes/Explorer.svelte +++ b/frontend/src/routes/Explorer.svelte @@ -6679,8 +6679,4 @@ .landing-status-simulator { color: var(--status-warn, #fbbf24); } - - .landing-status-simulator .landing-status-icon { - color: var(--status-warn, #fbbf24); - } From aec7cac41b901ef9027dfa3adf574a7dfe7a9ff9 Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Sat, 2 May 2026 03:39:35 -0400 Subject: [PATCH 07/16] fix(wallet): add Clear All modal and fix recent wallets refresh Replace native confirm() with HOLOGRAM-styled confirmation modal for clearing recent wallets. Add horizontal red gradient treatment matching the notification banner language. Fix recent wallets list not refreshing after opening/closing a wallet by centralizing refresh logic. --- frontend/src/routes/Wallet.svelte | 176 +++++++++++++++++++++++++----- 1 file changed, 151 insertions(+), 25 deletions(-) diff --git a/frontend/src/routes/Wallet.svelte b/frontend/src/routes/Wallet.svelte index 04a6212..73399e6 100644 --- a/frontend/src/routes/Wallet.svelte +++ b/frontend/src/routes/Wallet.svelte @@ -8,7 +8,7 @@ Wallet, Plus, RotateCcw, AlertTriangle, Check, FolderOpen, Pickaxe, LayoutDashboard, QrCode, History, Coins, Users, FileSignature, RefreshCw, Loader2, Download, Search, ChevronRight, ExternalLink, Edit, Trash2, Send, Shield, - Key, Eye, EyeOff + Key, Eye, EyeOff, X } from 'lucide-svelte'; import TokenPortfolio from '../lib/components/TokenPortfolio.svelte'; @@ -54,6 +54,8 @@ let error = null; let recentWallets = []; let recentWalletsInfo = []; + let showClearWalletsConfirm = false; + let clearingRecentWallets = false; // Test wallets (Simulator mode) let testWallets = []; @@ -315,25 +317,7 @@ dashboardLoading = false; } - // Load enhanced wallet info for recent wallets - try { - const infos = await GetRecentWalletsWithInfo(); - if (infos && infos.length > 0) { - recentWalletsInfo = infos; - recentWallets = infos.map(w => w.path); - } - } catch (e) { - // Fallback to simple list - const recents = await ListRecentWallets(); - if (recents && recents.length > 0) { - recentWallets = recents; - recentWalletsInfo = recents.map(p => ({ - path: p, - filename: getWalletFilename(p), - addressPrefix: '' - })); - } - } + await refreshRecentWallets(); // Load test wallets if in simulator mode if ($settingsState.network === 'simulator') { @@ -795,6 +779,33 @@ // ============================================ // WALLET MANAGEMENT // ============================================ + async function refreshRecentWallets() { + try { + const infos = await GetRecentWalletsWithInfo(); + if (infos) { + recentWalletsInfo = infos; + recentWallets = infos.map(w => w.path); + return; + } + } catch (e) { + // Fallback to simple list + } + + try { + const recents = await ListRecentWallets(); + recentWallets = recents || []; + recentWalletsInfo = recentWallets.map(p => ({ + path: p, + filename: getWalletFilename(p), + addressPrefix: '' + })); + } catch (e) { + console.error('Failed to refresh recent wallets:', e); + recentWallets = []; + recentWalletsInfo = []; + } + } + async function openWallet() { // Context-aware validation messages (Bug #33 fix) if (!walletPath && !password) { @@ -839,8 +850,7 @@ loadWalletPath(); SubscribeToWalletEvents().catch(() => {}); - const recents = await ListRecentWallets(); - if (recents) recentWallets = recents; + await refreshRecentWallets(); } else { error = handleBackendError(result, { showToast: false }) || 'Failed to open wallet'; } @@ -872,6 +882,7 @@ integratedPort = ''; integratedComment = ''; resetSendForm(); + await refreshRecentWallets(); } } catch (err) { console.error('Failed to close wallet:', err); @@ -898,17 +909,34 @@ } // Clear all recent wallets - async function handleClearRecentWallets() { - if (!confirm('Clear all recent wallets from the list?')) return; + function requestClearRecentWallets() { + showClearWalletsConfirm = true; + } + + function cancelClearRecentWallets() { + if (clearingRecentWallets) return; + showClearWalletsConfirm = false; + } + + async function confirmClearRecentWallets() { + if (clearingRecentWallets) return; + clearingRecentWallets = true; try { const result = await ClearRecentWallets(); if (result.success) { recentWalletsInfo = []; recentWallets = []; walletPath = ''; + showClearWalletsConfirm = false; + toast.success('Recent wallet list cleared'); + } else { + toast.error(result.error || 'Failed to clear recent wallets'); } } catch (err) { console.error('Failed to clear recent wallets:', err); + toast.error('Failed to clear recent wallets'); + } finally { + clearingRecentWallets = false; } } @@ -3020,7 +3048,7 @@ {/each} - @@ -3367,6 +3395,48 @@ kind="keys" /> +{#if showClearWalletsConfirm} + +{/if} +