diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 19:39:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 19:39:50 -0700 |
| commit | 9e2737e6736f9dd582d083c6d6fd669a28a99316 (patch) | |
| tree | 94e45183fdc14a165b2e442a7bdfaf4dded5b648 /src | |
| parent | 83e559684eed07d1a11fd0748ff571f8b8896921 (diff) | |
| download | coinosv3-9e2737e6736f9dd582d083c6d6fd669a28a99316.tar.gz coinosv3-9e2737e6736f9dd582d083c6d6fd669a28a99316.zip | |
Add dark mode; gift amount unit toggle; multi-input gifts
- Dark mode: theme variables + a footer "Dark mode/Light mode" toggle,
persisted and applied pre-paint via a small head script (defaults to the
OS preference). Hardcoded surfaces moved to CSS variables.
- Gift amount: the unit label is now the clickable sats/BTC toggle, and the
amount is parsed in the selected unit.
- Gifts can now span multiple coins: createGift selects several confirmed
coins (first input SIGHASH_SINGLE to lock the change, the rest
SIGHASH_NONE), so a gift larger than any single coin works. Claim/preview
sum all inputs and size the fee by input count. Verified end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 28 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/locales/ar.json | 4 | ||||
| -rw-r--r-- | src/locales/bn.json | 4 | ||||
| -rw-r--r-- | src/locales/de.json | 4 | ||||
| -rw-r--r-- | src/locales/es.json | 4 | ||||
| -rw-r--r-- | src/locales/fr.json | 4 | ||||
| -rw-r--r-- | src/locales/hi.json | 4 | ||||
| -rw-r--r-- | src/locales/id.json | 4 | ||||
| -rw-r--r-- | src/locales/ja.json | 4 | ||||
| -rw-r--r-- | src/locales/ko.json | 4 | ||||
| -rw-r--r-- | src/locales/mr.json | 4 | ||||
| -rw-r--r-- | src/locales/pt.json | 4 | ||||
| -rw-r--r-- | src/locales/ru.json | 4 | ||||
| -rw-r--r-- | src/locales/sw.json | 4 | ||||
| -rw-r--r-- | src/locales/ta.json | 4 | ||||
| -rw-r--r-- | src/locales/te.json | 4 | ||||
| -rw-r--r-- | src/locales/tr.json | 4 | ||||
| -rw-r--r-- | src/locales/ur.json | 4 | ||||
| -rw-r--r-- | src/locales/vi.json | 4 | ||||
| -rw-r--r-- | src/locales/zh.json | 4 | ||||
| -rw-r--r-- | src/style.css | 37 | ||||
| -rw-r--r-- | src/wallet.js | 58 |
23 files changed, 152 insertions, 49 deletions
@@ -123,12 +123,32 @@ function footer() { installPrompt ? h('span', {}, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp'))) - : null + : null, + h('span', { class: 'faint' }, ' · '), + h('button', { class: 'linklike', style: 'font-weight:400', onClick: toggleTheme }, resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode')) ), h('div', { style: 'margin-top:8px' }, languagePicker()) ); } +const THEME_KEY = 'btc-wallet-theme'; +function resolvedTheme() { + try { + const s = localStorage.getItem(THEME_KEY); + if (s === 'dark' || s === 'light') return s; + } catch {} + try { return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch {} + return 'light'; +} +function applyTheme() { + try { document.documentElement.dataset.theme = resolvedTheme(); } catch {} +} +function toggleTheme() { + try { localStorage.setItem(THEME_KEY, resolvedTheme() === 'dark' ? 'light' : 'dark'); } catch {} + applyTheme(); + render(); +} + // PWA install. Chrome fires beforeinstallprompt when the app qualifies; we stash // the event and reveal an "Install app" link, then replay it on a user tap (the // browser requires a gesture). beforeinstallprompt only fires when not already @@ -958,7 +978,7 @@ function giftRate() { function createGiftLink() { const rate = giftRate(); const min = giftMinimum(rate); - const amt = Math.round(Number(ui.giftAmount)); + const amt = parseAmount(ui.giftAmount, unit); // entered in the current display unit if (!amt || amt < min) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(min) + ' ' + unitLabel() }); render(); return; } try { ui.giftCode = wallet.createGift(amt, rate).code; @@ -987,9 +1007,9 @@ function giftCard() { ) : h('div', { class: 'col gap6' }, h('div', { class: 'input-group' }, - h('input', { type: 'number', min: String(giftMinimum(giftRate())), inputmode: 'numeric', placeholder: t('giftAmountLabel'), + h('input', { type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), value: ui.giftAmount, onInput: (e) => (ui.giftAmount = e.target.value) }), - h('span', { class: 'small muted', style: 'align-self:center' }, unitLabel()) + h('div', { style: 'display:flex;align-items:center' }, unitTag()) ), h('div', { class: 'small faint' }, t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), diff --git a/src/i18n.js b/src/i18n.js index 18d579a..28fb827 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -233,6 +233,8 @@ const STR = { relaysLabel: 'Relays', relaysHint: 'One wss:// URL per line.', language: 'Language', + darkMode: 'Dark mode', + lightMode: 'Light mode', offlineBanner: "Can't reach the network — working offline. Import a snapshot on the Settings tab to load coins.", retry: 'Retry', copied: 'Copied to clipboard', diff --git a/src/locales/ar.json b/src/locales/ar.json index b891397..07e881b 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -213,5 +213,7 @@ "importedKeyNote": "تم استيراد هذه المحفظة من مفتاح خاص ممتد، لذا لا توجد عبارة استرداد. احتفظ بنسخة من هذا المفتاح نفسه للحفاظ على المحفظة.", "clearAll": "مسح الكل", "clearAllWarn": "⚠ يؤدي هذا إلى إزالة كل المحافظ من هذا الجهاز. أي محفظة لم تنسخ احتياطيًا عبارة استردادها أو مفتاحها ستُفقد إلى الأبد.", - "rename": "إعادة تسمية" + "rename": "إعادة تسمية", + "darkMode": "الوضع الداكن", + "lightMode": "الوضع الفاتح" }
\ No newline at end of file diff --git a/src/locales/bn.json b/src/locales/bn.json index 48c1d5c..0d1e9bd 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -211,5 +211,7 @@ "importedKeyNote": "এই ওয়ালেটটি একটি বর্ধিত প্রাইভেট কী থেকে আমদানি করা হয়েছে, তাই কোনো রিকভারি ফ্রেজ নেই। ওয়ালেট রাখতে সেই কী-টিরই ব্যাকআপ নিন।", "clearAll": "সব মুছুন", "clearAllWarn": "⚠ এটি এই ডিভাইস থেকে সব ওয়ালেট সরিয়ে দেয়। যে ওয়ালেটের রিকভারি ফ্রেজ বা কী আপনি ব্যাকআপ করেননি, তা চিরতরে হারিয়ে যাবে।", - "rename": "নাম পরিবর্তন" + "rename": "নাম পরিবর্তন", + "darkMode": "ডার্ক মোড", + "lightMode": "লাইট মোড" }
\ No newline at end of file diff --git a/src/locales/de.json b/src/locales/de.json index 803bbd0..1cb6769 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -213,5 +213,7 @@ "importedKeyNote": "Diese Wallet wurde aus einem erweiterten privaten Schlüssel importiert und hat daher keine Wiederherstellungsphrase. Sichere diesen Schlüssel selbst, um die Wallet zu behalten.", "clearAll": "Alle löschen", "clearAllWarn": "⚠ Dies entfernt alle Wallets von diesem Gerät. Jede Wallet, deren Wiederherstellungsphrase oder Schlüssel du nicht gesichert hast, ist für immer verloren.", - "rename": "Umbenennen" + "rename": "Umbenennen", + "darkMode": "Dunkelmodus", + "lightMode": "Hellmodus" }
\ No newline at end of file diff --git a/src/locales/es.json b/src/locales/es.json index 35fbafb..23b1ad8 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -213,5 +213,7 @@ "importedKeyNote": "Esta cartera se importó desde una clave privada extendida, así que no tiene frase de recuperación. Guarda esa clave para conservar la cartera.", "clearAll": "Borrar todo", "clearAllWarn": "⚠ Esto elimina todas las carteras de este dispositivo. Cualquier cartera cuya frase de recuperación o clave no hayas respaldado se perderá para siempre.", - "rename": "Renombrar" + "rename": "Renombrar", + "darkMode": "Modo oscuro", + "lightMode": "Modo claro" }
\ No newline at end of file diff --git a/src/locales/fr.json b/src/locales/fr.json index 4710a93..67b9c44 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -213,5 +213,7 @@ "importedKeyNote": "Ce portefeuille a été importé depuis une clé privée étendue, il n’a donc pas de phrase de récupération. Sauvegardez cette clé pour conserver le portefeuille.", "clearAll": "Tout effacer", "clearAllWarn": "⚠ Ceci supprime tous les portefeuilles de cet appareil. Tout portefeuille dont vous n’avez pas sauvegardé la phrase de récupération ou la clé sera perdu définitivement.", - "rename": "Renommer" + "rename": "Renommer", + "darkMode": "Mode sombre", + "lightMode": "Mode clair" }
\ No newline at end of file diff --git a/src/locales/hi.json b/src/locales/hi.json index 81b6e43..488dc6d 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -213,5 +213,7 @@ "importedKeyNote": "यह वॉलेट एक विस्तारित निजी कुंजी से आयात किया गया था, इसलिए कोई रिकवरी वाक्यांश नहीं है। वॉलेट बनाए रखने के लिए उस कुंजी का बैकअप लें।", "clearAll": "सब हटाएँ", "clearAllWarn": "⚠ यह इस डिवाइस से सभी वॉलेट हटा देता है। जिस भी वॉलेट का रिकवरी वाक्यांश या कुंजी आपने बैकअप नहीं किया है, वह हमेशा के लिए खो जाएगा।", - "rename": "नाम बदलें" + "rename": "नाम बदलें", + "darkMode": "डार्क मोड", + "lightMode": "लाइट मोड" }
\ No newline at end of file diff --git a/src/locales/id.json b/src/locales/id.json index efd336c..d84476d 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -213,5 +213,7 @@ "importedKeyNote": "Dompet ini diimpor dari kunci privat yang diperluas, jadi tidak ada frasa pemulihan. Cadangkan kunci itu sendiri untuk menyimpan dompet.", "clearAll": "Hapus semua", "clearAllWarn": "⚠ Ini menghapus semua dompet dari perangkat ini. Dompet mana pun yang frasa pemulihan atau kuncinya belum Anda cadangkan akan hilang selamanya.", - "rename": "Ganti nama" + "rename": "Ganti nama", + "darkMode": "Mode gelap", + "lightMode": "Mode terang" }
\ No newline at end of file diff --git a/src/locales/ja.json b/src/locales/ja.json index 2d92f42..b2b9edb 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -213,5 +213,7 @@ "importedKeyNote": "このウォレットは拡張秘密鍵からインポートされたため、復元フレーズはありません。ウォレットを保持するにはその鍵自体をバックアップしてください。", "clearAll": "すべて消去", "clearAllWarn": "⚠ この端末からすべてのウォレットを削除します。復元フレーズまたは鍵をバックアップしていないウォレットは永久に失われます。", - "rename": "名前を変更" + "rename": "名前を変更", + "darkMode": "ダークモード", + "lightMode": "ライトモード" }
\ No newline at end of file diff --git a/src/locales/ko.json b/src/locales/ko.json index 15aadcb..57948f6 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -213,5 +213,7 @@ "importedKeyNote": "이 지갑은 확장 개인 키에서 가져왔으므로 복구 문구가 없습니다. 지갑을 유지하려면 그 키 자체를 백업하세요.", "clearAll": "모두 지우기", "clearAllWarn": "⚠ 이 기기에서 모든 지갑을 제거합니다. 복구 문구나 키를 백업하지 않은 지갑은 영구히 사라집니다.", - "rename": "이름 변경" + "rename": "이름 변경", + "darkMode": "다크 모드", + "lightMode": "라이트 모드" }
\ No newline at end of file diff --git a/src/locales/mr.json b/src/locales/mr.json index 63af900..f48ab5a 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -213,5 +213,7 @@ "importedKeyNote": "हे वॉलेट विस्तारित खाजगी कीवरून आयात केले होते, त्यामुळे पुनर्प्राप्ती वाक्यांश नाही. वॉलेट टिकवण्यासाठी तीच की बॅकअप करा.", "clearAll": "सर्व साफ करा", "clearAllWarn": "⚠ हे या डिव्हाइसवरील सर्व वॉलेट काढून टाकते. ज्या वॉलेटचा रिकव्हरी वाक्यांश किंवा की तुम्ही बॅकअप घेतलेला नाही, तो कायमचा गमावला जाईल.", - "rename": "नाव बदला" + "rename": "नाव बदला", + "darkMode": "डार्क मोड", + "lightMode": "लाइट मोड" }
\ No newline at end of file diff --git a/src/locales/pt.json b/src/locales/pt.json index 9795d7a..e2b317e 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -213,5 +213,7 @@ "importedKeyNote": "Esta carteira foi importada de uma chave privada estendida, então não há frase de recuperação. Faça backup dessa chave para manter a carteira.", "clearAll": "Apagar tudo", "clearAllWarn": "⚠ Isto remove todas as carteiras deste dispositivo. Qualquer carteira cuja frase de recuperação ou chave você não tenha feito backup será perdida para sempre.", - "rename": "Renomear" + "rename": "Renomear", + "darkMode": "Modo escuro", + "lightMode": "Modo claro" }
\ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index dd441d6..aa534fc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -213,5 +213,7 @@ "importedKeyNote": "Этот кошелёк импортирован из расширенного приватного ключа, поэтому у него нет фразы восстановления. Сохраните сам этот ключ, чтобы не потерять кошелёк.", "clearAll": "Очистить всё", "clearAllWarn": "⚠ Это удалит все кошельки с этого устройства. Любой кошелёк, фразу восстановления или ключ которого вы не сохранили, будет потерян навсегда.", - "rename": "Переименовать" + "rename": "Переименовать", + "darkMode": "Тёмная тема", + "lightMode": "Светлая тема" }
\ No newline at end of file diff --git a/src/locales/sw.json b/src/locales/sw.json index 361d5f5..fba93fc 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -213,5 +213,7 @@ "importedKeyNote": "Pochi hii iliagizwa kutoka kwa ufunguo wa faragha uliopanuliwa, kwa hiyo hakuna kifungu cha kurejesha. Hifadhi ufunguo huo wenyewe ili kuendelea kuwa na pochi.", "clearAll": "Futa zote", "clearAllWarn": "⚠ Hii inaondoa pochi zote kwenye kifaa hiki. Pochi yoyote ambayo kifungu chake cha kurejesha au ufunguo haukuhifadhi nakala itapotea milele.", - "rename": "Badilisha jina" + "rename": "Badilisha jina", + "darkMode": "Hali ya giza", + "lightMode": "Hali ya mwanga" }
\ No newline at end of file diff --git a/src/locales/ta.json b/src/locales/ta.json index 43deba7..4169258 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -213,5 +213,7 @@ "importedKeyNote": "இந்த வாலெட் ஒரு நீட்டிக்கப்பட்ட தனிப்பட்ட விசையிலிருந்து இறக்குமதி செய்யப்பட்டது, எனவே மீட்பு சொற்றொடர் இல்லை. வாலெட்டை வைத்திருக்க அந்த விசையையே காப்புப் பிரதி எடுக்கவும்.", "clearAll": "அனைத்தையும் அழி", "clearAllWarn": "⚠ இது இந்த சாதனத்திலிருந்து அனைத்து வாலெட்களையும் நீக்குகிறது. மீட்பு சொற்றொடர் அல்லது விசையை நீங்கள் காப்புப் பிரதி எடுக்காத எந்த வாலெட்டும் நிரந்தரமாக இழக்கப்படும்.", - "rename": "பெயர் மாற்று" + "rename": "பெயர் மாற்று", + "darkMode": "இருண்ட பயன்முறை", + "lightMode": "ஒளி பயன்முறை" }
\ No newline at end of file diff --git a/src/locales/te.json b/src/locales/te.json index 6563547..fcb52b6 100644 --- a/src/locales/te.json +++ b/src/locales/te.json @@ -211,5 +211,7 @@ "importedKeyNote": "ఈ వాలెట్ విస్తరించిన ప్రైవేట్ కీ నుండి దిగుమతి చేయబడింది, కాబట్టి రికవరీ పదబంధం లేదు. వాలెట్ను ఉంచుకోవడానికి ఆ కీనే బ్యాకప్ చేయండి.", "clearAll": "అన్నీ తొలగించు", "clearAllWarn": "⚠ ఇది ఈ పరికరం నుండి అన్ని వాలెట్లను తొలగిస్తుంది. మీరు రికవరీ పదబంధం లేదా కీని బ్యాకప్ చేయని ఏ వాలెట్ అయినా శాశ్వతంగా పోతుంది.", - "rename": "పేరు మార్చు" + "rename": "పేరు మార్చు", + "darkMode": "డార్క్ మోడ్", + "lightMode": "లైట్ మోడ్" }
\ No newline at end of file diff --git a/src/locales/tr.json b/src/locales/tr.json index eae3437..87b5452 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -213,5 +213,7 @@ "importedKeyNote": "Bu cüzdan genişletilmiş bir özel anahtardan içe aktarıldı, bu yüzden kurtarma ifadesi yok. Cüzdanı korumak için o anahtarın kendisini yedekleyin.", "clearAll": "Tümünü temizle", "clearAllWarn": "⚠ Bu, bu cihazdaki tüm cüzdanları kaldırır. Kurtarma ifadesini veya anahtarını yedeklemediğiniz her cüzdan kalıcı olarak kaybolur.", - "rename": "Yeniden adlandır" + "rename": "Yeniden adlandır", + "darkMode": "Karanlık mod", + "lightMode": "Aydınlık mod" }
\ No newline at end of file diff --git a/src/locales/ur.json b/src/locales/ur.json index 259a3da..ab58a35 100644 --- a/src/locales/ur.json +++ b/src/locales/ur.json @@ -213,5 +213,7 @@ "importedKeyNote": "یہ والیٹ ایک توسیعی نجی کلید سے درآمد کیا گیا تھا، اس لیے کوئی ریکوری فقرہ نہیں ہے۔ والیٹ برقرار رکھنے کے لیے اسی کلید کا بیک اپ رکھیں۔", "clearAll": "سب صاف کریں", "clearAllWarn": "⚠ یہ اس ڈیوائس سے تمام والیٹ ہٹا دیتا ہے۔ جس بھی والیٹ کا ریکوری فقرہ یا کلید آپ نے بیک اپ نہیں کیا، وہ ہمیشہ کے لیے کھو جائے گا۔", - "rename": "نام تبدیل کریں" + "rename": "نام تبدیل کریں", + "darkMode": "ڈارک موڈ", + "lightMode": "لائٹ موڈ" }
\ No newline at end of file diff --git a/src/locales/vi.json b/src/locales/vi.json index 43f1c52..17af899 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -213,5 +213,7 @@ "importedKeyNote": "Ví này được nhập từ một khóa riêng tư mở rộng, nên không có cụm từ khôi phục. Hãy sao lưu chính khóa đó để giữ ví.", "clearAll": "Xóa tất cả", "clearAllWarn": "⚠ Thao tác này xóa mọi ví khỏi thiết bị này. Bất kỳ ví nào mà bạn chưa sao lưu cụm từ khôi phục hoặc khóa sẽ mất vĩnh viễn.", - "rename": "Đổi tên" + "rename": "Đổi tên", + "darkMode": "Chế độ tối", + "lightMode": "Chế độ sáng" }
\ No newline at end of file diff --git a/src/locales/zh.json b/src/locales/zh.json index 9b110bd..c5b04a6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -213,5 +213,7 @@ "importedKeyNote": "该钱包是从扩展私钥导入的,因此没有恢复短语。请备份该密钥本身以保留钱包。", "clearAll": "清除全部", "clearAllWarn": "⚠ 这将从此设备移除所有钱包。任何你尚未备份恢复短语或密钥的钱包都将永久丢失。", - "rename": "重命名" + "rename": "重命名", + "darkMode": "深色模式", + "lightMode": "浅色模式" }
\ No newline at end of file diff --git a/src/style.css b/src/style.css index b563ca3..6533854 100644 --- a/src/style.css +++ b/src/style.css @@ -16,11 +16,40 @@ --red: #c43b3b; --red-soft: #fbeaea; --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 10px 30px rgba(16, 24, 40, 0.07); + --surface2: #f7f8fa; /* subtle raised surface (hover, mnemonic cells) */ + --tab-bg: #e9ebef; /* tab strip background */ + --info: #3949ab; + --info-soft: #eef2ff; + --info-line: #d4ddfb; --radius: 16px; --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } +/* Dark theme — applied via data-theme="dark" on <html>. */ +:root[data-theme="dark"] { + --bg: #0f1216; + --paper: #181b21; + --ink: #e7e9ee; + --muted: #9aa1ac; + --faint: #6f7782; + --line: #2a2f37; + --line-strong: #3a414b; + --accent: #f7931a; + --accent-ink: #f7a942; + --accent-soft: #2e2412; + --green: #2dbd80; + --green-soft: #14271d; + --red: #e66a6a; + --red-soft: #2c1a1a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 30px rgba(0, 0, 0, 0.5); + --surface2: #20242b; + --tab-bg: #20242b; + --info: #9db4ff; + --info-soft: #1a2030; + --info-line: #2c3a5e; +} + * { box-sizing: border-box; } html, body { @@ -102,7 +131,7 @@ summary { cursor: pointer; } .balance .split .v.pending { color: var(--accent-ink); } /* ---- tabs ---- */ -.tabs { display: flex; gap: 4px; background: #e9ebef; border-radius: 12px; padding: 4px; margin: 16px 0; } +.tabs { display: flex; gap: 4px; background: var(--tab-bg); border-radius: 12px; padding: 4px; margin: 16px 0; } .tabs button { flex: 1; border: 0; background: transparent; padding: 9px 4px; border-radius: 9px; font: inherit; font-weight: 600; font-size: 14px; color: var(--muted); cursor: pointer; @@ -115,7 +144,7 @@ button, .btn { border: 1px solid var(--line-strong); background: var(--paper); color: var(--ink); padding: 11px 16px; transition: filter .12s, background .12s, border-color .12s; } -button:hover { background: #f7f8fa; } +button:hover { background: var(--surface2); } button:active { transform: translateY(1px); } button:disabled { opacity: .5; cursor: not-allowed; } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } @@ -164,7 +193,7 @@ textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-si /* ---- mnemonic grid ---- */ .words { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .words .w { - display: flex; align-items: baseline; gap: 7px; background: #f7f8fa; + display: flex; align-items: baseline; gap: 7px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 9px 11px; font-family: var(--mono); } .words .w .n { color: var(--faint); font-size: 12px; width: 18px; text-align: right; } @@ -203,7 +232,7 @@ textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-si .notice { border-radius: 11px; padding: 11px 13px; font-size: 14px; } .notice.err { background: var(--red-soft); color: var(--red); border: 1px solid #f1c9c9; } .notice.ok { background: var(--green-soft); color: var(--green); border: 1px solid #c4e7d4; } -.notice.info { background: #eef2ff; color: #3949ab; border: 1px solid #d4ddfb; } +.notice.info { background: var(--info-soft); color: var(--info); border: 1px solid var(--info-line); } .spinner { width: 18px; height: 18px; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; display: inline-block; vertical-align: middle; diff --git a/src/wallet.js b/src/wallet.js index 82bdae6..194b9dc 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -874,29 +874,42 @@ export class Wallet { createGift(amountSats, feeRate) { const gift = BigInt(Math.round(amountSats)); const rate = Math.max(1, Math.round(feeRate)); - const claimFee = BigInt(Math.ceil((11 + 68 + 31 * 2) * rate)); // claimer's 1-in 2-out tx const DUST = 294n; - // Require dust + one claim fee of headroom so the recipient stays well above - // dust even if fees rise before they claim. if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - const need = gift + claimFee + DUST; - const coin = this.utxos + + // Select confirmed coins (largest first, to minimize inputs) until they + // cover the gift + the claimer's fee (which grows with input count) + a dust + // change. The change output goes back to us and is protected; the rest is + // the claimer's to direct. + const claimFeeFor = (n) => BigInt(Math.ceil((11 + 68 * n + 31 * 2) * rate)); + const pool = this.utxos .filter((u) => u.confirmed && !this.isReserved(utxoId(u))) - .sort((a, b) => a.value - b.value) - .find((u) => BigInt(u.value) >= need); - if (!coin) throw new Error('No single confirmed coin is large enough for that amount.'); + .sort((a, b) => b.value - a.value); + const sel = []; + let sum = 0n; + for (const u of pool) { + sel.push(u); + sum += BigInt(u.value); + if (sum >= gift + claimFeeFor(sel.length) + DUST) break; + } + const claimFee = claimFeeFor(sel.length); + if (sum < gift + claimFee + DUST) throw new Error('Not enough confirmed funds for that gift amount.'); - const V = BigInt(coin.value); - const change = V - gift - claimFee; // back to us, >= DUST by construction - const pay = p2wpkh(this.derive(coin.chain, coin.index).pubkey, this.netCfg.net); + const change = sum - gift - claimFee; // back to us, >= DUST by construction const t = new btc.Transaction({ allowUnknownOutputs: true }); - t.addInput({ ...pay, txid: coin.txid, index: coin.vout, sighashType: btc.SigHash.SINGLE, witnessUtxo: { script: pay.script, amount: V } }); + // First input commits the change output (SIGHASH_SINGLE on output 0); the + // rest commit only the inputs (SIGHASH_NONE). Together: inputs and our + // change are locked, while the claimer is free to add their own output. + sel.forEach((u, i) => { + const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net); + t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE, witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); + }); t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); - t.signIdx(this.node(coin.chain, coin.index).privateKey, 0, [btc.SigHash.SINGLE]); + sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE])); - this.reservedSet().add(utxoId(coin)); + for (const u of sel) this.reservedSet().add(utxoId(u)); this._saveReserved(); - return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(gift), reserved: utxoId(coin) }; + return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(gift), reserved: sel.map(utxoId) }; } // --- realtime (mempool.space WebSocket) --------------------------------- @@ -1402,12 +1415,15 @@ export function giftMinimum(feeRate) { return Math.max(546, 294 + claimFee); } +function _sumInputs(tx) { + let inAmt = 0n; + for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount; + return inAmt; +} export function previewGift(code) { try { const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); - const inAmt = Number(tx.getInput(0).witnessUtxo.amount); - const change = Number(tx.getOutput(0).amount); - return { room: inAmt - change }; // claimer gets room − their chosen fee + return { room: Number(_sumInputs(tx) - tx.getOutput(0).amount) }; // room minus the claimer fee } catch { return null; } @@ -1415,10 +1431,8 @@ export function previewGift(code) { export function buildClaimTx(code, toAddress, feeRate, net) { const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); - const inAmt = tx.getInput(0).witnessUtxo.amount; - const change = tx.getOutput(0).amount; - const room = inAmt - change; - const fee = BigInt(Math.max(1, Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(feeRate))))); + const room = _sumInputs(tx) - tx.getOutput(0).amount; + const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 31 * 2) * Math.max(1, Math.round(feeRate))))); const out = room - fee; if (out < 294n) throw new Error('Gift is too small to claim at this fee rate.'); tx.addOutputAddress(toAddress, out, net); |
