diff options
| author | Adam Soltys <[email protected]> | 2026-10-05 22:35:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-05 22:35:56 -0700 |
| commit | f571e46937d423d9ba44154a92aeb4136466daaa (patch) | |
| tree | 658553eb48d4d85e632499f6711eda76071c9ba1 /src | |
| parent | a08f48d67ad61eba5077fdb1a01d19fe5eea0fdb (diff) | |
| download | coinosv3-f571e46937d423d9ba44154a92aeb4136466daaa.tar.gz coinosv3-f571e46937d423d9ba44154a92aeb4136466daaa.zip | |
README rewritten for today's coinos v3; build switches are COINOS_* (HAL_* still accepted); silent-payment leftovers removed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 11 | ||||
| -rw-r--r-- | src/build-flags.js | 2 | ||||
| -rw-r--r-- | src/features/index.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/locales/ar.json | 1 | ||||
| -rw-r--r-- | src/locales/bn.json | 1 | ||||
| -rw-r--r-- | src/locales/de.json | 1 | ||||
| -rw-r--r-- | src/locales/es.json | 1 | ||||
| -rw-r--r-- | src/locales/fr.json | 1 | ||||
| -rw-r--r-- | src/locales/hi.json | 1 | ||||
| -rw-r--r-- | src/locales/ja.json | 1 | ||||
| -rw-r--r-- | src/locales/ko.json | 1 | ||||
| -rw-r--r-- | src/locales/pt.json | 1 | ||||
| -rw-r--r-- | src/locales/ru.json | 1 | ||||
| -rw-r--r-- | src/locales/tr.json | 1 | ||||
| -rw-r--r-- | src/locales/zh.json | 1 | ||||
| -rw-r--r-- | src/silentpay.js | 341 |
17 files changed, 5 insertions, 364 deletions
@@ -5498,12 +5498,7 @@ function reviewView() { { class: 'summary col', style: 'gap:0' }, ...outs.map((o) => h('div', { class: 'line' }, - o.silent - ? h('span', { class: 'k col', style: 'gap:2px;align-items:flex-start' }, - h('span', { class: 'mono break' }, shortAddr(o.silent, 12, 8)), - h('span', { class: 'small faint' }, t('silentPaymentNote')) - ) - : h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)), + h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)), h('span', { class: 'v' }, fmtAmount(o.amount), ' ', unitTag()) ) ), @@ -5623,7 +5618,7 @@ function txHistoryItem(tx) { return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => openTx(tx.txid) }, - // rail glyph in the direction-tinted circle: feature decoration (ark/sp/ + // rail glyph in the direction-tinted circle: feature decoration (ark/ // gift) if any, else the Bitcoin mark for a plain on-chain tx. Direction // is carried by the tint + signed amount. deco @@ -5734,7 +5729,7 @@ async function openTx(txid) { ui.txDetail = txid; render(); const tx = wallet.history.find((t) => t.txid === txid); - if (!tx || tx.sp || tx.fee || wallet.offline) return; + if (!tx || tx.fee || wallet.offline) return; try { const full = await wallet.api.getTx(txid); if (!full || ui.txDetail !== txid) return; diff --git a/src/build-flags.js b/src/build-flags.js index e466da2..46060b2 100644 --- a/src/build-flags.js +++ b/src/build-flags.js @@ -1,5 +1,5 @@ // Build-time flags. This module ships its defaults (a production build); the -// deploy hook builds the staging webroot with HAL_TARGET=staging and build.js +// deploy hook builds the staging webroot with COINOS_TARGET=staging and build.js // swaps these values in the bundle (same mechanism as features/index.js) — so // staging behavior lives on master instead of a perpetually-rebased branch. // diff --git a/src/features/index.js b/src/features/index.js index 26324dc..0471c6a 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -1,6 +1,6 @@ // Which optional features this build includes. In dev (and by default in // builds) everything is on. Release builds can exclude features entirely: -// build.js replaces this module's contents based on HAL_FEATURES, so a +// build.js replaces this module's contents based on COINOS_FEATURES, so a // disabled feature's code — and its network endpoints — never enters the // bundle. See featureIndexSource() in build.js. // diff --git a/src/i18n.js b/src/i18n.js index bd2c440..1ff5baa 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -916,7 +916,6 @@ const STR = { sourceUrlHint: 'wss:// for an Electrum server, or an Esplora REST base (https://…/api). The type is detected from the URL.', detectedElectrum: 'Electrum: data + instant payment notifications.', detectedEsplora: 'Block explorer (Esplora): data only; polls for payments.', - silentPaymentNote: 'Silent payment', receiveAddressTab: 'Savings', receiveLnTab: 'Lightning', arkReceiveIntro: 'Receive instantly and free, straight into Spending.', diff --git a/src/locales/ar.json b/src/locales/ar.json index 52be78b..bf7bcc6 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss: for an Electrum server, or an Esplora REST base (https://.../api). ويُكتشف هذا النوع من المتفجرات من مخلفات الحرب.", "detectedElectrum": "Electrum - data + immediately payment notifications.", "detectedEsplora": "مستكشف القفل )اسبورا( - البيانات فقط؛ عمليات الاقتراع للمدفوعات.", - "silentPaymentNote": "مدفوعات صامتة", "receiveAddressTab": "الوفورات", "receiveLnTab": "البرق", "receiveSpTab": "مدفوعات صامتة", diff --git a/src/locales/bn.json b/src/locales/bn.json index 0ae389a..2689e34 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -477,7 +477,6 @@ "sourceUrlHint": "এসএপ্লারাম সার্ভার বা এসপিআরআরআরটি (http://http://. URL থেকে ধরন পাওয়া গিয়েছে।", "detectedElectrum": "ইলেক্ট্রম, তথ্য + তাৎক্ষনিকভাবে দানের বিজ্ঞপ্তি", "detectedEsplora": "ব্লক (এম্পোরা), কেবলমাত্র তথ্য; পেমেন্টের জন্য ভোট।", - "silentPaymentNote": "নিঃশব্দ", "receiveAddressTab": "সংরক্ষণ করা হচ্ছে", "receiveLnTab": "হালকা বৃষ্টি", "receiveSpTab": "নিঃশব্দ", diff --git a/src/locales/de.json b/src/locales/de.json index 796255a..eb64335 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// für einen Electrum-Server oder eine Esplora-REST-Basis (https://.../api). Der Typ wird von der URL erkannt.", "detectedElectrum": "Electrum Daten + sofortige Zahlungsmeldungen.", "detectedEsplora": "Block Explorer (Esplora) Nur Daten; Umfragen für Zahlungen.", - "silentPaymentNote": "Stille Zahlung", "receiveAddressTab": "Einsparungen", "receiveLnTab": "Blitz", "receiveSpTab": "Stille Zahlung", diff --git a/src/locales/es.json b/src/locales/es.json index c600f11..42d3e0a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// for an Electrum server, or an Esplora REST base (https://.../api). El tipo se detecta desde la URL.", "detectedElectrum": "Electrum, datos + notificaciones de pago instantáneo.", "detectedEsplora": "Block explorer (Esplora), datos solamente; encuestas para pagos.", - "silentPaymentNote": "Pago silencioso", "receiveAddressTab": "Ahorros", "receiveLnTab": "Rayos", "receiveSpTab": "Pago silencioso", diff --git a/src/locales/fr.json b/src/locales/fr.json index 742e450..2ebc60c 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// pour un serveur Electrum, ou une base Esplora REST (https://.../api). Le type est détecté depuis l'URL.", "detectedElectrum": "Electrum, données + notifications de paiement instantanées.", "detectedEsplora": "Block Explorer (Esplora), données uniquement; sondages pour les paiements.", - "silentPaymentNote": "Paiement silencieux", "receiveAddressTab": "Économies", "receiveLnTab": "Lumière", "receiveSpTab": "Paiement silencieux", diff --git a/src/locales/hi.json b/src/locales/hi.json index fe0ceda..fdaa332 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -477,7 +477,6 @@ "sourceUrlHint": "एक Electrum सर्वर, या एक Esplora REST बेस (https://.../api). प्रकार यूआरएल से पता लगाया जाता है।", "detectedElectrum": "Electrum, डेटा + तत्काल भुगतान सूचनाएं।", "detectedEsplora": "ब्लॉक एक्सप्लोरर (Esplora) - डेटा केवल; भुगतान के लिए मतदान।", - "silentPaymentNote": "भुगतान", "receiveAddressTab": "बचत", "receiveLnTab": "बिजली", "receiveSpTab": "भुगतान", diff --git a/src/locales/ja.json b/src/locales/ja.json index e9aa821..b8d9fb9 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// Electrum サーバ、または Esplora REST ベース (https://.../api) 用。 URLからタイプが検出されます。", "detectedElectrum": "Electrum, データ + 即時決済通知。", "detectedEsplora": "探索者(Esplora)をブロック, データのみ; 支払いの投票。", - "silentPaymentNote": "無声支払い", "receiveAddressTab": "節約", "receiveLnTab": "ライトニング", "receiveSpTab": "無声支払い", diff --git a/src/locales/ko.json b/src/locales/ko.json index c6b77e3..b81a617 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -476,7 +476,6 @@ "sourceUrlHint": "wss:// Electrum 서버, 또는 Esplora REST베이스 (https://.../api). 유형은 URL에서 감지됩니다.", "detectedElectrum": "Electrum - 데이터 + 즉시 결제 알림.", "detectedEsplora": "Block Explorer (Esplora) - 데이터 만; 지불에 대한 설문 조사.", - "silentPaymentNote": "공급 능력", "receiveAddressTab": "제품정보", "receiveLnTab": "연락처", "receiveSpTab": "공급 능력", diff --git a/src/locales/pt.json b/src/locales/pt.json index 10d1c32..ceb6c36 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// para um servidor Electrum, ou uma base Esplora REST (https://.../api). O tipo é detectado a partir do URL.", "detectedElectrum": "Electrum, dados + notificações de pagamento instantâneas.", "detectedEsplora": "Explorador de bloco (Espora), apenas dados; sondagens para pagamentos.", - "silentPaymentNote": "Pagamento silencioso", "receiveAddressTab": "Poupança", "receiveLnTab": "Raios", "receiveSpTab": "Pagamento silencioso", diff --git a/src/locales/ru.json b/src/locales/ru.json index cc82269..72f1d1b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss:// для сервера Electrum или базы Esplora REST (https://.../api). Тип определяется по URL.", "detectedElectrum": "Electrum, данные + уведомления о мгновенных платежах.", "detectedEsplora": "Block explorer (Esplora), данные только; опросы по платежам.", - "silentPaymentNote": "Безмолвный платеж", "receiveAddressTab": "Экономия", "receiveLnTab": "Молния", "receiveSpTab": "Безмолвный платеж", diff --git a/src/locales/tr.json b/src/locales/tr.json index b2dede8..e0d87c6 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -476,7 +476,6 @@ "sourceUrlHint": "wss https:// for an Electrum server, or an Esplora REST üssü (https://.../api). Tür URL'den tespit edilir.", "detectedElectrum": "Electrum - veri + anında ödeme bildirimleri.", "detectedEsplora": "Block Explorer (Esplora) - veriler sadece; ödemeler için anketler.", - "silentPaymentNote": "Sessiz ödeme", "receiveAddressTab": "Tasarruf Tasarrufları", "receiveLnTab": "Lightning Lightning Lightning", "receiveSpTab": "Sessiz ödeme", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5679fc4..9493ab8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -477,7 +477,6 @@ "sourceUrlHint": "wss://用于Electrum服务器,或Esplora REST基础(https://./api). 从 URL 中检测到该类型 。", "detectedElectrum": "Electrum,数据+即时付款通知.", "detectedEsplora": "Block Explora (Esplora),仅提供数据;支付投票.", - "silentPaymentNote": "无声付款", "receiveAddressTab": "节余", "receiveLnTab": "闪电", "receiveSpTab": "无声付款", diff --git a/src/silentpay.js b/src/silentpay.js deleted file mode 100644 index eeabc1a..0000000 --- a/src/silentpay.js +++ /dev/null @@ -1,341 +0,0 @@ -// Silent Payments (BIP-352) — sending and receiving. -// -// A silent payment address (sp1…) is a static, reusable address the receiver -// publishes once. The sender derives a unique one-time taproot output for each -// payment from the receiver's address plus the sender's own input keys (ECDH), -// so there's no on-chain link between payments and no address reuse — without -// any interaction with the receiver. -// -// Sending (silentPaymentScripts) derives the output; we only spend compressed -// P2WPKH inputs, so each input contributes its private key as-is. Receiving (see -// the second half) can't scan the chain in a browser, so an indexing server -// supplies per-tx tweaks + taproot UTXOs and we match them on-device. -// -// Validated against the official BIP-352 send + receive test vectors. - -import { secp256k1 } from '@noble/curves/secp256k1'; -import { sha256 } from '@noble/hashes/sha256'; -import { ripemd160 } from '@noble/hashes/ripemd160'; -import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; -import { bech32m } from '@scure/base'; - -const hash160 = (b) => ripemd160(sha256(b)); - -const Point = secp256k1.ProjectivePoint; -const G = Point.BASE; -const N = secp256k1.CURVE.n; - -const modN = (x) => ((x % N) + N) % N; -const toInt = (bytes) => BigInt('0x' + bytesToHex(bytes)); -// tagged hash: sha256(sha256(tag) || sha256(tag) || msg) -const tagged = (tag, msg) => { - const t = sha256(new TextEncoder().encode(tag)); - return sha256(concatBytes(t, t, msg)); -}; -const le32 = (n) => { - const b = new Uint8Array(4); - new DataView(b.buffer).setUint32(0, n >>> 0, true); - return b; -}; -const be32 = (n) => { - const b = new Uint8Array(4); - new DataView(b.buffer).setUint32(0, n >>> 0, false); - return b; -}; -// A taproot scriptPubKey for a 32-byte x-only key: OP_1 (0x51) PUSH32 (0x20) key. -const taprootScript = (xonly) => concatBytes(Uint8Array.of(0x51, 0x20), xonly); - -export function isSilentPaymentAddress(addr) { - if (typeof addr !== 'string') return false; - const a = addr.toLowerCase(); - if (!a.startsWith('sp1') && !a.startsWith('tsp1')) return false; - try { - decodeSilentPaymentAddress(addr); - return true; - } catch { - return false; - } -} - -// Decode sp1…/tsp1… → { scan, spend } (33-byte compressed pubkeys each) + net. -export function decodeSilentPaymentAddress(addr) { - const { prefix, words } = bech32m.decode(addr.toLowerCase(), 1023); - if (prefix !== 'sp' && prefix !== 'tsp') throw new Error('Not a silent payment address.'); - if (words[0] !== 0) throw new Error('Unsupported silent payment version.'); - const data = bech32m.fromWords(words.slice(1)); - if (data.length !== 66) throw new Error('Malformed silent payment address.'); - return { scan: data.slice(0, 33), spend: data.slice(33, 66), testnet: prefix === 'tsp' }; -} - -// Derive the one-time taproot output scripts for a set of silent-payment outputs -// that this transaction's inputs are funding. -// inputs: [{ txid (display hex), vout, priv (Uint8Array 32), taproot? }] — ALL -// eligible inputs of the tx; the derivation commits to every one. Set -// taproot:true for P2TR inputs (e.g. our own silent-payment coins) so -// their key is taken with even Y, per BIP-352. -// outputs: [{ scan, spend }] — one entry per desired output (repeat the same -// recipient to pay it multiple times; the k counter is handled here). -// Returns a taproot scriptPubKey (Uint8Array) for each entry, in order. -export function silentPaymentScripts(inputs, outputs) { - if (!inputs.length) throw new Error('Silent payment needs at least one input.'); - - // a = Σ input private keys (mod n); A = a·G. A taproot input contributes the - // key for its even-Y x-only pubkey, so negate when the key gives odd Y. - let a = 0n; - for (const i of inputs) { - let d = toInt(i.priv); - if (i.taproot && Number(G.multiply(d).toRawBytes(true)[0]) === 0x03) d = modN(-d); - a = modN(a + d); - } - if (a === 0n) throw new Error('Silent payment input key sum is zero.'); - const A = G.multiply(a); - - // input_hash = H(smallest_outpoint || A); outpoint = txid(wire order) || vout(LE) - let smallest = null; - for (const i of inputs) { - const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); - if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; - } - const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, A.toRawBytes(true))))); - - const counters = new Map(); // recipient → next k - return outputs.map(({ scan, spend }) => { - const rkey = bytesToHex(scan) + bytesToHex(spend); - const k = counters.get(rkey) || 0; - counters.set(rkey, k + 1); - // ecdh = input_hash·a·B_scan ; t_k = H(ecdh || ser32(k)) ; P = B_spend + t_k·G - const ecdh = Point.fromHex(scan).multiply(modN(inputHash * a)).toRawBytes(true); - const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); - const Pout = Point.fromHex(spend).add(G.multiply(tk)); - return taprootScript(Pout.toRawBytes(true).slice(1)); // x-only - }); -} - -// A unique, valid placeholder taproot scriptPubKey used to size a silent-payment -// output during coin selection, before the real script can be derived (which -// needs the final input set). Swapped out for the real one afterward. -export function silentPaymentPlaceholder(index) { - return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index)))); -} - -// =========================================================================== -// Receiving (BIP-352). -// -// A browser light client can't scan the chain itself, so an indexing server (an -// SP "oracle") provides, per block, the tweaks — one 33-byte point (input_hash·A, -// the sum of a tx's eligible input pubkeys folded with its input hash) per -// eligible transaction — plus the taproot UTXOs created in that block. The -// receiver then does the private part on-device: ecdh = b_scan · tweak, then for -// k = 0,1,… derives P_k = B_spend + t_k·G and matches it against the block's -// outputs. The scan key never leaves the device. -// -// The first half here (input pubkey extraction + tweak) is what the indexer -// computes from raw blocks; the second half (scan, output key, key derivation, -// address) is what the wallet uses. They share one implementation so the math -// is validated once, against the official BIP-352 vectors. -// =========================================================================== - -const numTo32 = (x) => hexToBytes(x.toString(16).padStart(64, '0')); - -// secp256k1 NUMS point H (BIP-341): a taproot input whose script-path reveals -// this as the internal key is provably unspendable and NOT an eligible input. -const NUMS_H = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; - -// Parse a serialized witness (hex): compact-size item count, then each item as -// compact-size length + bytes. -function parseWitness(hex) { - if (!hex) return []; - const b = hexToBytes(hex); - let o = 0; - const rd = () => { - let n = b[o++]; - if (n === 0xfd) { n = b[o] | (b[o + 1] << 8); o += 2; } - else if (n === 0xfe) { n = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } - return n; - }; - const count = rd(); - const items = []; - for (let i = 0; i < count; i++) { const len = rd(); items.push(b.slice(o, o + len)); o += len; } - return items; -} - -// Witness stack items as byte arrays. Accepts either a serialized witness (hex -// string, as in the BIP-352 vectors) or an array of hex stack items (as Core's -// getblock RPC returns in txinwitness). -function witnessItems(witness) { - if (Array.isArray(witness)) return witness.map(hexToBytes); - return parseWitness(witness); -} - -// Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey -// from P2PKH or the redeemScript from P2SH (push opcodes only). -function scriptPushes(hex) { - if (!hex) return []; - const b = hexToBytes(hex); - let o = 0; - const pushes = []; - while (o < b.length) { - const op = b[o++]; - let len = -1; - if (op >= 0x01 && op <= 0x4b) len = op; - else if (op === 0x4c) len = b[o++]; - else if (op === 0x4d) { len = b[o] | (b[o + 1] << 8); o += 2; } - else if (op === 0x4e) { len = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } - else continue; // non-push opcode - if (len < 0 || o + len > b.length) break; - pushes.push(b.slice(o, o + len)); o += len; - } - return pushes; -} - -// Only compressed pubkeys are eligible — uncompressed-key inputs are skipped. -const looksLikePubKey = (b) => !!b && b.length === 33 && (b[0] === 0x02 || b[0] === 0x03); - -// Recover the BIP-352-eligible input public key (33-byte compressed) from a tx -// input, or null if the input type isn't eligible. Handles P2PKH, P2WPKH, -// P2SH-P2WPKH and P2TR (key-path) inputs. -export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) { - try { - const spk = hexToBytes(scriptPubKey); - // P2TR: OP_1 PUSH32 <x-only> - if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { - let w = witnessItems(witness); - if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex - if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path - return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift - } - // P2WPKH: OP_0 PUSH20 - if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { - const w = witnessItems(witness); - const pk = w[w.length - 1]; - return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; - } - // P2SH-P2WPKH: scriptSig pushes redeemScript 0014<20>; witness = [sig, pubkey] - if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { - const rs = scriptPushes(scriptSig)[0]; - if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; - const w = witnessItems(witness); - const pk = w[w.length - 1]; - return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; - } - // P2PKH: OP_DUP OP_HASH160 PUSH20 <hash> ... — the eligible pubkey is the - // scriptSig push whose hash160 matches <hash> (handles malleated scriptSigs). - if (spk.length === 25 && spk[0] === 0x76 && spk[1] === 0xa9 && spk[2] === 0x14) { - const target = bytesToHex(spk.slice(3, 23)); - const pushes = scriptPushes(scriptSig); - for (let i = pushes.length - 1; i >= 0; i--) { - const pk = pushes[i]; - if (looksLikePubKey(pk) && bytesToHex(hash160(pk)) === target) return Point.fromHex(pk).toRawBytes(true); - } - return null; - } - } catch {} - return null; -} - -// The oracle tweak for a transaction: 33-byte point input_hash·A, where A is the -// sum of the eligible input pubkeys and input_hash = H_BIP0352/Inputs(outpoint_L -// || A) with outpoint_L the smallest outpoint among ALL the tx's inputs. -// inputs: [{ txid (display hex), vout, pubkey? (Uint8Array 33) }] — every input -// of the tx; `pubkey` present only for BIP-352-eligible inputs. -// Returns null if the tx has no eligible inputs (so it can't be a silent payment). -export function silentPaymentTweak(inputs) { - let A = null; - for (const i of inputs) if (i.pubkey) A = A ? A.add(Point.fromHex(i.pubkey)) : Point.fromHex(i.pubkey); - if (!A) return null; - let Abytes; - try { Abytes = A.toRawBytes(true); } catch { return null; } // A is the point at infinity → not a silent payment - let smallest = null; - for (const i of inputs) { - const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); - if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; - } - const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, Abytes)))); - return A.multiply(inputHash).toRawBytes(true); -} - -// Scan a transaction's taproot outputs for payments to us. -// scanPriv: Uint8Array(32) our scan private key (b_scan) -// spendPub: Uint8Array(33) our spend public key (B_spend) -// tweak: Uint8Array(33) the oracle tweak for this tx (input_hash·A) -// outputs: array of 32-byte x-only output pubkeys (hex) in the tx -// Returns the matched outputs: [{ output (x-only hex), k, tweak (t_k hex 32) }]; -// `tweak` (t_k) is added to the spend private key to spend the coin. -export function silentPaymentScan({ scanPriv, spendPub, tweak, outputs, maxK = 30 }) { - const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); - const B = Point.fromHex(spendPub); - const remaining = new Set(outputs.map((o) => o.toLowerCase())); - const found = []; - for (let k = 0; k < maxK && remaining.size; k++) { - const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); - const xonly = bytesToHex(B.add(G.multiply(tk)).toRawBytes(true).slice(1)); - if (!remaining.has(xonly)) break; // no output at this k → done with this tx - found.push({ output: xonly, k, tweak: bytesToHex(numTo32(tk)) }); - remaining.delete(xonly); - } - return found; -} - -// Spend private key for a found SP output: d = (spend_priv + t_k) mod n. Used as -// the taproot key-path key (the signer negates for odd-Y per BIP-340). -export function silentPaymentOutputPrivKey(spendPriv, tweakK) { - return numTo32(modN(toInt(spendPriv) + toInt(tweakK))); -} - -// Derive the BIP-352 scan + spend keys from a BIP32 master node. -// spend: m/352'/coin'/account'/0'/0 scan: m/352'/coin'/account'/1'/0 -export function deriveSilentPaymentKeys(master, coin, account = 0) { - const spend = master.derive(`m/352'/${coin}'/${account}'/0'/0`); - const scan = master.derive(`m/352'/${coin}'/${account}'/1'/0`); - return { - scanPriv: scan.privateKey, scanPub: scan.publicKey, - spendPriv: spend.privateKey, spendPub: spend.publicKey, - }; -} - -// Encode an sp1…/tsp1… address from scan + spend public keys (33 bytes each). -export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false } = {}) { - const hrp = testnet ? 'tsp' : 'sp'; - const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; - return bech32m.encode(hrp, words, 1023); -} - -// ---- catch-up scaling: candidate keys + per-block Bloom filters ----------- -// For a large historical scan, downloading every block's outputs is wasteful. -// The indexer gives, per block, the tweaks + a Bloom filter over that block's -// taproot output keys. The client derives its candidate key P_0 from each tweak -// and tests the filter; only on a hit does it pull the block's outputs and run -// the full scan. (P_0 always exists if we're paid, so testing it alone suffices.) - -// Our candidate output key (P_0 = B_spend + t_0·G, x-only hex) for one tweak. -export function silentPaymentCandidate({ scanPriv, spendPub, tweak }) { - const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); - const t0 = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(0))))); - return bytesToHex(Point.fromHex(spendPub).add(G.multiply(t0)).toRawBytes(true).slice(1)); -} - -// k bit indices for a 32-byte x-only key (hex) in an m-bit filter, via -// double-hashing one sha256(key): idx_i = (h1 + i·h2) mod m. -function* bloomIndices(keyHex, m, k) { - const h = sha256(hexToBytes(keyHex)); - const u32 = (o) => (h[o] | (h[o + 1] << 8) | (h[o + 2] << 16) | (h[o + 3] * 0x1000000)) >>> 0; - const h1 = u32(0); - const h2 = u32(4) | 1; // odd, so it's coprime-ish with m - for (let i = 0; i < k; i++) yield ((h1 + i * h2) >>> 0) % m; -} - -// Build a Bloom filter over x-only keys (hex). { m (bits), k (hashes), bits (hex) }. -export function buildBloom(keysHex, bitsPerKey = 10, k = 7) { - const m = Math.max(8, keysHex.length * bitsPerKey); - const bytes = new Uint8Array(Math.ceil(m / 8)); - for (const key of keysHex) for (const idx of bloomIndices(key, m, k)) bytes[idx >> 3] |= 1 << (idx & 7); - return { m, k, bits: bytesToHex(bytes) }; -} - -// Is an x-only key (hex) possibly in the filter? (false = definitely not.) -export function bloomHas(filter, keyHex) { - if (!filter || !filter.m) return true; // no filter ⇒ can't rule out → scan it - const bytes = hexToBytes(filter.bits); - for (const idx of bloomIndices(keyHex, filter.m, filter.k)) if (!(bytes[idx >> 3] & (1 << (idx & 7)))) return false; - return true; -} |
