aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 14:46:02 -0700
committerAdam Soltys <[email protected]>2026-06-27 14:46:02 -0700
commiteeceebb09878a544b18926a3cd164e581b0c9869 (patch)
treeded51a2f259dbe878d52334ca57e6e89416491c7
parent1beae75ebd34142cc63342fbf7c8a798af45bb5a (diff)
downloadcoinosv3-eeceebb09878a544b18926a3cd164e581b0c9869.tar.gz
coinosv3-eeceebb09878a544b18926a3cd164e581b0c9869.zip
Wire SP foundations into the wallet: configurable indexer + keys/address
api.js: per-network SP indexer config (presets + custom, stored per network) with getSpIndexerConfig/spIndexerUrl/spReceiveEnabled — regtest defaults to the local indexer, others to custom; a hosted mainnet default slots in once we run one. wallet.js: silentPaymentKeys() derives BIP-352 scan/spend keys from the master seed (needs the mnemonic / depth-0 xprv), and silentPaymentAddress() returns the sp1…/tsp1… address. Verified it matches the vector-validated e2e address. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--src/api.js34
-rw-r--r--src/wallet.js24
2 files changed, 57 insertions, 1 deletions
diff --git a/src/api.js b/src/api.js
index 2095d51..5287e13 100644
--- a/src/api.js
+++ b/src/api.js
@@ -106,6 +106,40 @@ function netDefaults(net) { return NET_DEFAULTS[net] || NET_DEFAULTS.mainnet; }
// get a ":<net>" suffix.
function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; }
+// Silent-payment (BIP-352) tweak indexer — a separate, optional endpoint used
+// only for SP receiving. It serves public tweak/UTXO data (the scan key never
+// leaves the device), but a public one can observe which blocks hold your
+// outputs, so self-hosting is the privacy-max option. Per network; an empty URL
+// means SP receiving is unavailable on that network.
+const SP_INDEXER_PRESETS_BY_NET = {
+ mainnet: [{ id: 'custom', label: 'Custom', url: '' }], // a hosted default goes here once we run one
+ testnet: [{ id: 'custom', label: 'Custom', url: '' }],
+ mutinynet: [{ id: 'custom', label: 'Custom', url: '' }],
+ regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }],
+};
+const SP_INDEXER_DEFAULT = { mainnet: 'custom', testnet: 'custom', mutinynet: 'custom', regtest: 'local' };
+const SP_INDEXER_KEY = 'btc-wallet-sp-indexer';
+export function spIndexerPresets(net = getNetwork()) { return SP_INDEXER_PRESETS_BY_NET[net] || SP_INDEXER_PRESETS_BY_NET.mainnet; }
+export function getSpIndexerConfig(net = getNetwork()) {
+ try {
+ const c = JSON.parse(localStorage.getItem(nk(SP_INDEXER_KEY, net)) || 'null');
+ if (c && c.server) return { server: c.server, url: c.url || '' };
+ } catch {}
+ return { server: SP_INDEXER_DEFAULT[net] || 'custom', url: '' };
+}
+export function setSpIndexerConfig({ server, url }, net = getNetwork()) {
+ try { localStorage.setItem(nk(SP_INDEXER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {}
+}
+// The resolved indexer base URL for a network, or '' if none is configured.
+export function spIndexerUrl(net = getNetwork()) {
+ const cfg = getSpIndexerConfig(net);
+ if (cfg.server === 'custom') return cfg.url.trim().replace(/\/+$/, '');
+ const p = spIndexerPresets(net).find((x) => x.id === cfg.server);
+ return p && p.url ? p.url.replace(/\/+$/, '') : '';
+}
+// SP receiving is available only when an indexer is configured for this network.
+export function spReceiveEnabled(net = getNetwork()) { return !!spIndexerUrl(net); }
+
// Resolve the configured explorer to the host list the Api tries in order.
function resolveHosts(net) {
const cfg = getExplorerConfig(net);
diff --git a/src/wallet.js b/src/wallet.js
index 46b9f5c..219b0f5 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -24,7 +24,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js';
import { Api, pool, getBackend, explorerWeb, electrumCandidates } from './api.js';
import { ElectrumApi } from './electrum.js';
import { NostrSync, getSyncConfig } from './nostr.js';
-import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js';
+import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan } from './silentpay.js';
// No look-ahead: stop scanning a chain at the first unused address. This wallet
// only ever exposes ONE unused address at a time (freshReceive = first unused;
@@ -255,6 +255,28 @@ export class Wallet {
return this.account().publicExtendedKey;
}
+ // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so
+ // they need the mnemonic (or a depth-0 master xprv); watch-only and account-
+ // level imported keys can't do silent payments. Cached per account.
+ silentPaymentKeys() {
+ if (this.watchOnly) return null;
+ const coin = NETS[this.netName].coin;
+ const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`;
+ if (this._spKeys && this._spKeysKey === ck) return this._spKeys;
+ let master = null;
+ if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase));
+ else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; }
+ this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null;
+ this._spKeysKey = ck;
+ return this._spKeys;
+ }
+
+ // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP.
+ silentPaymentAddress() {
+ const k = this.silentPaymentKeys();
+ return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null;
+ }
+
node(chain, index) {
// Relative derivation from the account node (chain/index are non-hardened).
return this.account().deriveChild(chain).deriveChild(index);