Skip to content

Watch-only wallets ​

A viewing key gives read access to a shielded account without the ability to spend. A watch-only wallet created from one supports sync, notes, balances, and state; spending methods are absent from its type.

Use cases include balance displays on devices that must not hold spending keys, access for accountants and auditors, and monitoring services.

Key hierarchy ​

The spending key nsk derives every other key:

nsk ─┬─ ivk ─┬─ d     diversifier of each address, one per index
     │       ├─ pk    binds the note commitment, per address
     │       ├─ pk_d  ECDH target of incoming notes, per address
     │       └─ dk    FMD detection secret, one for the account
     └─ nk           derives nullifiers

ivk covers every address of the account, so a viewing key reads notes sent to all of them.

KeyReads incoming notesKnows which notes are spentCan spend
Incoming viewing key (lelantosivk1…)yesnono
Full viewing key (lelantosfvk1…)yesyesno
Spending keyyesyesyes

A full viewing key adds nk, which computes nullifiers. nk derives from nsk, not ivk, so an incoming viewing key cannot be upgraded to a full viewing key.

An incoming viewing key reports received totals

Without nk the wallet cannot tell spent notes from unspent ones. With an incoming viewing key, balances are the total ever received, not the current balance. Check spentKnown.

Viewing keys cannot be revoked

A viewing key is fixed by nsk. Its holder can decrypt every past and future note received by the account. The only way to end that access is to move funds to a new account.

Exporting a key ​

A spending wallet exposes both encodings on keys. Neither contains nsk.

ts
const 
ivk
=
wallet
.
keys
.
viewingKey
; // "lelantosivk1…": incoming notes only
const
fvk
=
wallet
.
keys
.
fullViewingKey
; // "lelantosfvk1…": also settles which notes are spent

Both are bech32m; the prefix identifies the tier. To encode a raw key object instead, use encodeViewingKey or encodeFullViewingKey (both root); isFullViewingKey narrows what decodeViewingKey returns.

Creating a watch-only wallet ​

connectWatch lives on its own subpath, so a viewer's bundle never reaches the prover, the relayer submitter, or the coin selector.

ts
import { 
formatAmount
} from "@lelantos-org/sdk";
import {
connectWatch
} from "@lelantos-org/sdk/watch";
await using
watch
= await
connectWatch
({
network
: "base",
rpcUrl
: "https://base-rpc.example.com", // for asset metadata; omit to read notes only
viewingKey
:
fvk
,
}); await
watch
.
sync
();
watch
.
address
; // the watched account's address at index 0, derived from the key
await
watch
.
addressAt
(3); // any other index; the same address the spending wallet returns
watch
.
spentKnown
; // true for a full viewing key, false for an incoming one
watch
.
keys
.
tier
; // "full" | "incoming"
const
usdc
= await
watch
.
balance
("USDC");
console
.
log
(
formatAmount
(
usdc
.
total
,
usdc
.
asset
, {
symbol
: true }));

connectWatch returns a ReadOnlyWalletApi (exported from the root). It accepts an encoded key of either tier, or a decoded ViewingKey / FullViewingKey; the tier is detected from the key.

OptionDescription
network, rpcUrlas for connect; rpcUrl is optional here
viewingKeyeither tier, encoded or decoded
readera pre-built ChainReader for asset metadata, instead of rpcUrl
scanner, http, denominations, wasm, runtimeas for connect
storage{ notes?, nullifiers? }; a watch wallet has no tree
syncStrategydefault full; matches also requires allowDetectionKeyRelease: true

Without rpcUrl or reader, no RPC is contacted: sync, notes, and state work, while asset, assets, and balance reject WALLET_CONFIG. state().balances still reports unspent totals per asset id.

For explicit configuration, createWatchWallet(key, config) in @lelantos-org/sdk/advanced is the counterpart of createWallet.

Confirming a payment against the pool ​

Notes come from the indexer, and anyone who knows an address can encrypt a well-formed note to it. A note in notes() shows what the indexer served, not that the pool holds it. Before crediting a payment, ask the pool:

ts
const 
paid
= await
watch
.
confirmCommitment
(
receipt
.
commitment
,
receipt
.
txHash
);

confirmCommitment reads the transaction's receipt over the wallet's own RPC and resolves true only if the pool published that commitment in it. The note gives the asset and value; this gives its existence. A deposit's escrow resolves false until flushed, since it can still be cancelled.

It never resolves false for a transaction it could not read. With the default reader, a hash the node holds no receipt for is waited on for 15 seconds and then rejects TX_MINING, which is retryable. Without rpcUrl or reader it rejects UNSUPPORTED_OPERATION.

Differences from a spending wallet ​

Spending walletWatch-only wallet
Entry pointconnect from @lelantos-org/sdkconnectWatch from @lelantos-org/sdk/watch
ReturnsWalletApiReadOnlyWalletApi
Spend and quote methodspresentabsent from the type (compile-time error)
Prover, submitter, coin selectorloaded on demandnot in the module graph
Merkle treesynced with scope: "full"never synced; every sync is "notes"
Chain accessrequiredoptional; needed only for asset metadata and balance
matches sync strategyallowedrequires allowDetectionKeyRelease: true

A watch-only wallet requires allowDetectionKeyRelease: true for the matches strategy because registering a detection key permanently exposes the owner's incoming notes to the server. That decision belongs to the account owner, not the viewer. See Sync strategies.

Next ​