Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed
- **A TELA INDEX can be updated on the simulator again.** `update-index` refused every attempt with *"Your wallet is not the owner of this INDEX"*, including from the wallet that had just installed it. The check compared the author string stored in the INDEX against the address the wallet renders — but those two strings come from two different renderers. The stored author is what the contract's `address()` returned, which is the DVM's `ADDRESS_STRING`, which builds it with `rpc.NewAddressFromKeys` and leaves `Mainnet` true; consensus cannot depend on which network a node thinks it is on, so a contract records `dero1...` on every chain, the simulator included. walletapi renders the same key through the wallet's network flag and says `deto1...` there. A DERO address is bech32 — the prefix names the network and the checksum is computed over it — so both halves differ and the compare read one wallet as two, always. Ownership is now decided on the decoded public key. The contract's own owner gate was never affected (it compares `address()` to `address()`), and neither was mainnet, where both renderings agree. The same correction applies to the ownership flag reported by INDEX info, which read `false` for the owner's own INDEX on the simulator.
- **A contract deployed by a dApp is signed by the wallet that dApp is connected to.** A dApp can deploy by sending `transfer` with an `sc` field, the way Engram and the TELA CLI accept it. HOLOGRAM handed that to the generic install path, which decided the signer again on its own: in simulator mode it took wallet #0 regardless of which wallet was open, while `GetAddress`, `GetBalance` and `scinvoke` on the same connection all answered for the open wallet. A contract that records its deployer — `STORE("owner", SIGNER())`, the ordinary pattern — therefore recorded a wallet the dApp had never connected to, and refused its own creator on the next call. The deployment is now signed by the wallet the call already resolved. The primary-wallet rule it replaced was justified by funding, but on the simulator every registered wallet is credited the same amount and none of the pre-seeded wallets is premined, so wallet #0 held no advantage; off simulator the rule never applied. The Studio deploy screen and the simulator developer deploy now resolve their signer the same way every TELA deployment already did: the open wallet, with the primary as a fallback when none is open.
- **A deployment arriving from a dApp no longer freezes the wallet off simulator.** The same handoff called `GetWallet()` from inside a function that holds the wallet manager's write lock for its whole body. `sync.RWMutex` is not reentrant, so that read lock could never be acquired by the goroutine already holding the write lock: the call never returned, the deferred unlock never ran, and every later wallet operation blocked behind it. The install core no longer touches the wallet manager at all — it signs with the wallet it is given.

### Changed
- **The approval prompt names the wallet that will sign, and says when a deployment is what is being approved.** A `transfer` carrying `sc` has no destination, no SCID and no entrypoint, so it reached the prompt as an empty request while a contract was about to be deployed under a signature the user could not see. The prompt now shows the signing wallet for every signing request, and announces a contract deployment with the size of the code.

## [1.0.8] - 2026-08-20

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,11 @@
sc_token_deposit: req.params?.sc_token_deposit,
sc_token_deposit_scid: req.params?.sc_token_deposit_scid,
fees: req.params?.fees,
// A transfer carrying `sc` deploys a contract. It has no destination, no SCID and
// no entrypoint, so without these two the modal shows an empty request while a
// contract is being deployed. The code itself is not surfaced — its size is.
scDeploy: req.scDeploy === true,
scCodeBytes: req.scCodeBytes,
};
}

Expand All @@ -444,6 +449,10 @@
// while approving actually decrypted the wallet and signed a challenge.
description: req.description,
isSignIn: req.isSignIn === true,
// The wallet that will sign this request. The app holds the wallet, not the dApp,
// so which one signs is the app's to disclose — approving without seeing it is
// approving blind, and a deployment records its signer permanently.
walletAddress: req.walletAddress,
// true = the browser vouched for the origin; false = the app named itself
originVerified: req.originVerified === true
},
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/lib/components/WalletModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,29 @@
<h3 class="modal-section-title">Transaction Details</h3>

<div class="modal-tx-details-card">
<!-- A `transfer` carrying `sc` is a contract deployment: no destination, no
SCID, no entrypoint, so every block below would render nothing. Say what
it is, and say which wallet the contract will record as its deployer. -->
{#if request.payload.scDeploy}
<div class="wallet-tx-sc-header">
<div class="modal-tx-field">
<div class="modal-tx-label">CONTRACT DEPLOYMENT</div>
<div class="modal-tx-entrypoint">
New contract{#if request.payload.scCodeBytes} — {request.payload.scCodeBytes.toLocaleString()} bytes of code{/if}
</div>
</div>
</div>
{/if}

{#if request.walletAddress}
<div class="modal-tx-field">
<div class="modal-tx-label">SIGNING WALLET</div>
<div class="modal-tx-scid" title={request.walletAddress}>
{request.walletAddress.slice(0, 10)}...{request.walletAddress.slice(-6)}
</div>
</div>
{/if}

<!-- Smart Contract Info (show first if present) -->
{#if request.payload.scid || request.payload.entrypoint}
<div class="wallet-tx-sc-header">
Expand Down
47 changes: 35 additions & 12 deletions sc_function_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,24 +460,41 @@ func (a *App) invokeSCViaXSWD(params InvokeSCFunctionParams) map[string]interfac
}
}

// InstallSmartContract deploys DVM code, signed by the wallet HOLOGRAM uses for a
// deployment: the one open in the interface, falling back to the simulator's primary
// wallet when nothing is open. That is getWalletForDeployment -- the same policy the
// TELA deployment paths use. It previously took the primary wallet unconditionally in
// simulator mode, which signed with wallet #0 while every other call the session made
// (GetAddress, scinvoke, transfer) answered for the open wallet.
func (a *App) InstallSmartContract(code string, anonymous bool) map[string]interface{} {
a.logToConsole("[SC] Installing smart contract...")

isSimulator := a.simulatorManager != nil && a.simulatorManager.isInitialized

// In simulator mode, use the simulator wallet manager's primary wallet
// (same as TELA deployment) rather than the user-facing walletManager.
// GetWallet() returns the wallet opened via the UI, but it may not have
// a properly synced encrypted balance in simulator mode.
var wallet *walletapi.Wallet_Disk
if isSimulator {
if a.simulatorManager != nil && a.simulatorManager.walletManager != nil {
wallet = a.simulatorManager.walletManager.GetPrimaryWallet()
wallet := a.getWalletForDeployment(isSimulator)
if wallet == nil {
errMsg := "No wallet available. Open a wallet first."
if isSimulator {
errMsg = "Simulator wallet not available. Restart simulator mode."
}
return map[string]interface{}{
"success": false,
"error": errMsg,
}
} else {
wallet = GetWallet()
}

return a.installSmartContractWith(wallet, code, anonymous)
}

// installSmartContractWith installs a contract with a caller-supplied signer and takes no
// wallet decision of its own -- it must never reach walletManager, directly or through a
// helper. Callers that already hold a wallet use this: the XSWD `transfer`-carrying-`sc`
// branch resolves the connected wallet and runs under walletManager's write lock, so a
// GetWallet() underneath it would both substitute a different signer and take RLock on a
// mutex this goroutine already holds for writing (sync.RWMutex is not reentrant).
func (a *App) installSmartContractWith(wallet *walletapi.Wallet_Disk, code string, anonymous bool) map[string]interface{} {
a.logToConsole("[SC] Installing smart contract...")

isSimulator := a.simulatorManager != nil && a.simulatorManager.isInitialized

if wallet == nil {
errMsg := "No wallet available. Open a wallet first."
if isSimulator {
Expand All @@ -489,6 +506,12 @@ func (a *App) InstallSmartContract(code string, anonymous bool) map[string]inter
}
}

// Name the signer in the log: a contract records its deployer, and a deployment
// attributed to the wrong wallet is not recoverable after the fact.
if signer := wallet.GetAddress().String(); len(signer) > 16 {
a.logToConsole(fmt.Sprintf("[SC] Signing wallet: %s...%s", signer[:10], signer[len(signer)-6:]))
}

if code == "" {
return map[string]interface{}{
"success": false,
Expand Down
18 changes: 10 additions & 8 deletions simulator_developer.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ func (a *App) DeployToSimulator(code string) map[string]interface{} {
}
}

// Check wallet (use primary wallet #0)
wallet := a.simulatorManager.walletManager.GetPrimaryWallet()
// Sign with the wallet open in the interface, falling back to the primary wallet when
// none is open -- the same policy as every other deployment path. Signing with #0 while
// the user has another wallet open records a deployer they never chose.
wallet := a.getWalletForDeployment(true)
if wallet == nil {
return map[string]interface{}{
"success": false,
Expand All @@ -48,7 +50,7 @@ func (a *App) DeployToSimulator(code string) map[string]interface{} {

// Deploy the SC using wallet transfer with SC code
// For simulator, we use the XSWD server to install
txid, err := a.deployRawSC(code)
txid, err := a.deployRawSC(wallet, code)
if err != nil {
return map[string]interface{}{
"success": false,
Expand All @@ -75,11 +77,11 @@ func (a *App) DeployToSimulator(code string) map[string]interface{} {
}
}

// deployRawSC deploys raw SC code using the simulator's primary wallet.
// Pauses Gnomon and uses a temporary connect/disconnect pattern to avoid
// crashing the simulator daemon's single WebSocket slot.
func (a *App) deployRawSC(code string) (string, error) {
wallet := a.simulatorManager.walletManager.GetPrimaryWallet()
// deployRawSC deploys raw SC code signed by the caller-supplied wallet. Pauses Gnomon and
// uses a temporary connect/disconnect pattern to avoid crashing the simulator daemon's
// single WebSocket slot. The signer is a parameter so this cannot silently disagree with
// the wallet its caller checked and showed.
func (a *App) deployRawSC(wallet *walletapi.Wallet_Disk, code string) (string, error) {
if wallet == nil {
return "", fmt.Errorf("no wallet available")
}
Expand Down
8 changes: 7 additions & 1 deletion wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -2094,7 +2094,13 @@ func (a *App) InternalWalletCall(method string, params map[string]interface{}, p
if rs, ok := params["ringsize"].(float64); ok && rs >= 2 {
ringsize = uint64(rs)
}
deployResult := a.InstallSmartContract(scCode, ringsize >= 16)
// Sign with the wallet this call already resolved -- the connected one, the same
// wallet GetAddress and scinvoke answer for. Calling InstallSmartContract here
// instead would re-decide the signer: in simulator mode it took wallet #0, so the
// contract recorded a deployer the dApp had never connected to and then refused
// its own creator; off simulator it called GetWallet(), taking RLock on the mutex
// this function already holds for writing, which never returns.
deployResult := a.installSmartContractWith(wallet, scCode, ringsize >= 16)
if success, ok := deployResult["success"].(bool); ok && success {
return map[string]interface{}{
"success": true,
Expand Down
162 changes: 162 additions & 0 deletions xswd_sc_deploy_signer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/deroproject/derohe/cryptography/crypto"
"github.com/deroproject/derohe/walletapi"
)

// A dApp connected over XSWD talks to one wallet: GetAddress, GetBalance, scinvoke and
// transfer all answer for the wallet open in the app. A `transfer` carrying `sc` deploys a
// contract, and the contract records SIGNER() forever. These tests hold the line that the
// deployment is signed by that same wallet, that the install core takes no wallet decision
// of its own, and that the approval prompt says who signs before it signs.

// funcBody returns the source of the named top-level func in file, from its signature to
// the next top-level declaration. Enough to assert what a single function does or does not
// reach — the repo already uses source-level sentinels for contracts like this one.
func funcBody(t *testing.T, file, signature string) string {
t.Helper()

data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
src := string(data)

start := strings.Index(src, signature)
if start < 0 {
t.Fatalf("%s: %q not found — the function was renamed or removed", file, signature)
}

rest := src[start+len(signature):]
if end := strings.Index(rest, "\nfunc "); end >= 0 {
return rest[:end]
}
return rest
}

// TestXSWDDeploySignsWithTheConnectedWallet pins the fix. InternalWalletCall resolves the
// open wallet and holds walletManager's write lock for its whole body. Handing the deploy
// to InstallSmartContract made that wallet irrelevant: in simulator mode the install took
// wallet #0, so the contract recorded a deployer the dApp had never connected to and then
// refused its own creator; off simulator it called GetWallet(), whose RLock can never be
// taken by the goroutine already holding Lock() — sync.RWMutex is not reentrant, so the
// call never returns and the wallet stays locked for the rest of the process.
func TestXSWDDeploySignsWithTheConnectedWallet(t *testing.T) {
body := funcBody(t, "wallet.go", "func (a *App) InternalWalletCall(")

if !strings.Contains(body, "a.installSmartContractWith(wallet,") {
t.Fatal("the XSWD transfer-with-sc branch no longer passes its own resolved wallet to " +
"installSmartContractWith — the deployment would be signed by whichever wallet the " +
"install path picks, not by the wallet the dApp is connected to")
}

if strings.Contains(body, "a.InstallSmartContract(") {
t.Fatal("InternalWalletCall calls InstallSmartContract again: that re-decides the signer " +
"(wallet #0 in simulator mode) and, off simulator, takes walletManager.RLock() while " +
"this function holds Lock() — a deadlock that never releases the wallet")
}
}

// TestInstallCoreTakesNoWalletDecision keeps the core reachable from under the lock. Any
// walletManager access inside it reintroduces both defects at once.
func TestInstallCoreTakesNoWalletDecision(t *testing.T) {
body := funcBody(t, "sc_function_parser.go", "func (a *App) installSmartContractWith(")

for _, forbidden := range []string{"walletManager", "GetWallet()", "GetPrimaryWallet("} {
if strings.Contains(body, forbidden) {
t.Fatalf("installSmartContractWith reaches %s: it must sign with the wallet it is "+
"given. Deciding here overrides callers that already resolved a wallet, and any "+
"walletManager lock deadlocks the XSWD path, which calls it under the write lock",
forbidden)
}
}
}

// TestDeploymentPathsShareOneWalletPolicy: every deployment resolves its signer through
// getWalletForDeployment — open wallet first, primary as the fallback when nothing is open.
// A path that reaches for wallet #0 on its own signs as a wallet the user did not choose.
func TestDeploymentPathsShareOneWalletPolicy(t *testing.T) {
for _, file := range []string{"sc_function_parser.go", "simulator_developer.go"} {
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
src := string(data)

if strings.Contains(src, "GetPrimaryWallet(") {
t.Fatalf("%s selects the primary wallet directly; use getWalletForDeployment so the "+
"wallet open in the interface signs, with the primary only as a fallback", file)
}
if !strings.Contains(src, "getWalletForDeployment(") {
t.Fatalf("%s no longer resolves its signer through getWalletForDeployment", file)
}
}
}

// TestDeploymentSignerIsTheOpenWallet is the behavioural half: with a wallet open, the
// deployment policy returns that wallet, in simulator mode included.
func TestDeploymentSignerIsTheOpenWallet(t *testing.T) {
wallet, err := walletapi.Create_Encrypted_Wallet(
filepath.Join(t.TempDir(), "deploy_signer.db"), "", crypto.RandomScalarBNRed())
if err != nil {
t.Fatalf("create test wallet: %v", err)
}
defer wallet.Close_Encrypted_Wallet()

walletManager.Lock()
originalWallet, originalIsOpen := walletManager.wallet, walletManager.isOpen
walletManager.wallet, walletManager.isOpen = wallet, true
walletManager.Unlock()

defer func() {
walletManager.Lock()
walletManager.wallet, walletManager.isOpen = originalWallet, originalIsOpen
walletManager.Unlock()
}()

app := &App{settings: make(map[string]interface{}), consoleLogs: make([]ConsoleLog, 0)}

if got := app.getWalletForDeployment(true); got != wallet {
t.Fatal("simulator deployment did not resolve to the open wallet — a contract would " +
"record a deployer other than the one the user opened")
}
if got := app.getWalletForDeployment(false); got != wallet {
t.Fatal("mainnet deployment did not resolve to the open wallet")
}
}

// TestSigningRequestNamesItsSigner: the app holds the wallet, so the app must say which one
// signs. Approving a prompt that does not name the signer is approving blind.
func TestSigningRequestNamesItsSigner(t *testing.T) {
event := signingRequestEvent("sign-1", "scinvoke",
map[string]interface{}{"scid": "deadbeef"}, "An App", "https://example.invalid", "deto1qy...")

if event["walletAddress"] != "deto1qy..." {
t.Fatalf("approval event does not carry the signing wallet: %v", event["walletAddress"])
}
if _, marked := event["scDeploy"]; marked {
t.Fatal("a plain scinvoke must not be announced as a contract deployment")
}
}

// TestDeployRequestIsAnnouncedAsADeployment: a transfer carrying `sc` has no destination, no
// SCID and no entrypoint. Unannounced, it reaches the modal as an empty request while a
// contract is being deployed.
func TestDeployRequestIsAnnouncedAsADeployment(t *testing.T) {
code := "Function Initialize() Uint64\n10 RETURN 0\nEnd Function"
event := signingRequestEvent("sign-2", "transfer",
map[string]interface{}{"sc": code, "ringsize": float64(2)}, "An App", "Websocket", "deto1qy...")

if event["scDeploy"] != true {
t.Fatal("a transfer carrying sc is a contract deployment and must be announced as one")
}
if event["scCodeBytes"] != len(code) {
t.Fatalf("deployment size not disclosed: %v", event["scCodeBytes"])
}
}
Loading
Loading