From 5c661b68c61d53a4684e47c2f306ce43cc76c1c0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 1 Oct 2026 17:10:36 -0700 Subject: Sending on-chain from Spending states the fee and minimum up front. The form asks the ASP for a co-operative offboard quote (mining share + service fee, minimum = fee + dust) for the address on screen, disables Send for a balance below it and points to Lightning. A too-small amount reads in words instead of the manager's internal error, and the unilateral-exit note only appears when co-operation actually failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VP5Ljqk7EYcm5QQAbzDwog --- src/ark/manager.js | 22 +++++++- src/features/ark.js | 42 ++++++++++++--- src/i18n.js | 4 ++ src/locales/ar.json | 4 ++ src/locales/bn.json | 4 ++ src/locales/de.json | 4 ++ src/locales/es.json | 4 ++ src/locales/fr.json | 4 ++ src/locales/hi.json | 4 ++ src/locales/ja.json | 4 ++ src/locales/ko.json | 4 ++ src/locales/pt.json | 4 ++ src/locales/ru.json | 4 ++ src/locales/tr.json | 4 ++ src/locales/zh.json | 4 ++ tools/ark/offboard-minimum-test.js | 102 +++++++++++++++++++++++++++++++++++++ 16 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 tools/ark/offboard-minimum-test.js diff --git a/src/ark/manager.js b/src/ark/manager.js index b0985de..4b65b27 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -696,6 +696,23 @@ export class ArkManager { return action; } + // What a cooperative offboard to `spk` would cost right now, before any + // coin is touched: the miners' share at the ASP's current rate plus its + // service fee, and the smallest balance that still lands above dust. + async offboardQuote(spk, vtxoIds) { + const inputs = vtxoIds + ? vtxoIds.map((id) => this._vtxo(id)).filter(Boolean) + : this.state.vtxos.filter((v) => v.state === 'spendable'); + const tip = await this.chain.tipHeight(); + const satVkb = await getOffboardFeeRate(this.arkUrl); + const parts = offboardFeeParts({ + spkLen: spk.length, satVkb, fees: this.info.offboardFees, tip, + inputs: (inputs.length ? inputs : [{ amountSat: 0, expiryHeight: tip }]) + .map((v) => ({ amountSat: v.amountSat, expiryHeight: v.expiryHeight })), + }); + return { ...parts, minSat: parts.totalSat + P2TR_DUST }; + } + // ---- offboard (collaborative exit: spendable vtxos -> one on-chain output) ---- // Whole vtxos only — there is no change on this path. Default is everything // spendable; vtxoIds selects a subset (the feature splits an exact-amount @@ -721,7 +738,10 @@ export class ArkManager { }); const feeSat = feeParts.totalSat; const netSat = grossSat - feeSat; - if (netSat < P2TR_DUST) throw new Error('ark balance too small to offboard after fees'); + if (netSat < P2TR_DUST) { + throw Object.assign(new Error('ark balance too small to offboard after fees'), + { tooSmall: true, feeSat, minSat: feeSat + P2TR_DUST }); + } const action = { id: `offboard-${Date.now()}`, type: 'offboard', step: 'created', inputIds: inputs.map((v) => v.id), address, spkHex: hex.encode(spk), diff --git a/src/features/ark.js b/src/features/ark.js index 3577242..9d0f16f 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2903,16 +2903,39 @@ export function arkFeature(ctx) { // leaves Spending; fees come off what arrives in Savings. // Send-to-on-chain from Spending: amount in, exit out — an offboard whose // destination is whatever address was pasted. + // The cooperative fee, asked of the ASP for the address on screen: what + // the send costs and the smallest balance it can move, before anyone taps. + async function quoteOffboard(o, address) { + o.quoteFor = address; o.quote = null; + let quote = null; + try { quote = await (await connectArk()).offboardQuote(addrScript(address)); } + catch {} // no quote: the send itself still says what went wrong + if (o.quoteFor !== address) return; // the address changed meanwhile + o.quote = quote; o.quoteDone = true; + if (ui.arkOffboardSend === o) render(); + } function arkOffboardSendView() { const o = ui.arkOffboardSend; const spendable = arkBalance()?.spendableSat || 0; + if (o.quoteFor !== o.address) { + o.quote = null; o.quoteDone = false; o.quoteFor = o.address; + let ok = false; try { addrScript(o.address); ok = true; } catch {} + if (ok) quoteOffboard(o, o.address); else o.quoteDone = true; + } + const q = o.quote; + const tooSmall = !!q && spendable < q.minSat; return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')), h('label', { class: 'field' }, h('span', { class: 'lab' }, t('recipient')), h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none', - onInput: (e) => { o.address = e.target.value.trim(); } })), + onInput: (e) => { o.address = e.target.value.trim(); }, + onChange: () => render() })), // a new address gets its own quote h('div', { class: 'small faint' }, t('arkExitSendNote')), + q ? h('div', { class: 'small muted' }, t('arkOffboardQuote', { + fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat), + })) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null, + tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null, h('div', { class: 'input-group' }, h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, onInput: (e) => { o.amount = e.target.value; } }), @@ -2925,19 +2948,25 @@ export function arkFeature(ctx) { } }, t('back')), ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) - : h('button', { class: 'btn-primary grow', onClick: () => { + : h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => { const amount = String(o.amount ?? '').trim(); const sats = amount ? Number(amount) : spendable; if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; } try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; } + // checked before a partial amount is split off into its own coin + if (q && sats < q.minSat) { + ui.arkCoopFailed = false; + ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return; + } doArkOffboard(sats >= spendable ? 0 : sats, o.address); } }, t('arkSendBtn'))), // This send IS the cooperative exit. The trustless fallback only - // appears when cooperation actually failed — and it states, before + // appears when cooperation actually failed (not when the amount was + // just too small for the fees) — and it states, before // anything is confirmed, exactly what it does and what the mining // fees from Savings will be. (() => { - if (!ui.arkError || ui.arkBusy === 'offboard' || !ark) return null; + if (!ui.arkError || !ui.arkCoopFailed || ui.arkBusy === 'offboard' || !ark) return null; let feeEst = 0; try { feeEst = estimateExitFeeSat(ark); } catch {} const balance = arkBalance()?.spendableSat || 0; @@ -2966,7 +2995,7 @@ export function arkFeature(ctx) { } async function doArkOffboard(amountSat, destAddress = null) { - ui.arkBusy = 'offboard'; ui.arkError = ''; render(); + ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render(); try { const address = destAddress || wallet.freshReceive().address; const spk = addrScript(address); @@ -2997,7 +3026,8 @@ export function arkFeature(ctx) { ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat }; wallet.scan().catch(() => {}); // surface the incoming pending tx promptly } catch (e) { - ui.arkError = e.message; + ui.arkCoopFailed = !e.tooSmall; + ui.arkError = e.tooSmall ? t('arkOffboardAmtBelowMin', { min: fmtSats(e.minSat), fee: fmtSats(e.feeSat) }) : e.message; } ui.arkBusy = null; render(); } diff --git a/src/i18n.js b/src/i18n.js index 38d23c7..3a91723 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -521,6 +521,10 @@ const STR = { arkCoinsSchedOver: 'more than {span} left', arkCoinsSchedBetween: '{a} to {b} left', arkCoinsSchedFinal: 'less than {span} left', + arkOffboardQuote: 'Fee about {fee} sats right now ({chain} mining + {service} coinos), so it needs at least {min} sats.', + arkOffboardQuoting: 'Checking the fee…', + arkOffboardBelowMin: 'Your {n} sats are below the {min}-sat minimum for sending on-chain right now. Send them over Lightning instead: no mining fees.', + arkOffboardAmtBelowMin: 'Sending on-chain needs at least {min} sats right now (the fee is about {fee}).', arkUniUneconomical: 'A trustless on-chain exit would cost about {fee} sats in mining fees, more than the {n} sats it would move, so it isn’t offered. For a balance this size, send it out over Lightning instead: no mining fees at all.', arkUniFallbackBody: 'The server didn’t cooperate, but your money can still move without it: your whole Spending balance exits to Savings on-chain. It takes about a day, and roughly {fee} sats of mining fees will be paid from Savings. Funds land in Savings. Send them on from there once it completes.', arkUniFallbackShort: 'Savings holds {have} sats. About {need} sats are needed for the mining fees. Top up Savings first.', diff --git a/src/locales/ar.json b/src/locales/ar.json index 3305722..53fda34 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "أكثر من أكثر {span} يسار", "arkCoinsSchedBetween": "{a} إلى {b} يسار", "arkCoinsSchedFinal": "أقل من {span} يسار", + "arkOffboardQuote": "الرسوم الآن نحو {fee} ساتوشي ({chain} للتعدين + {service} لـ coinos)، لذا يلزم {min} ساتوشي على الأقل.", + "arkOffboardQuoting": "جارٍ التحقق من الرسوم…", + "arkOffboardBelowMin": "رصيدك {n} ساتوشي أقل من الحد الأدنى {min} ساتوشي للإرسال على السلسلة الآن. أرسله عبر Lightning بدلًا من ذلك: بلا رسوم تعدين.", + "arkOffboardAmtBelowMin": "الإرسال على السلسلة يحتاج الآن إلى {min} ساتوشي على الأقل (الرسوم نحو {fee}).", "arkUniUneconomical": "مخرج بدون ثقة سيكلف {fee} رسوم التعدين - أكثر من رسوم التعدين {n} وهو سيتحرك إذا لم يُعرض عليه. من أجل توازن بهذا الحجم، أرسله إلى البرق بدلاً من ذلك: لا رسوم تعدين على الإطلاق.", "arkUniFallbackBody": "ولم يتعاون الخادم، ولكن أموالكم لا تزال تتحرك بدونه: فكل رصيدكم المنفق يخرج إلى تحقيق وفورات في السلسلة. يستغرق الأمر يوماً تقريباً {fee} وستدفع رسوم التعدين من الوفورات. وتهبط الأموال في الادخار - ترسلها من هناك بمجرد اكتمالها.", "arkUniFallbackShort": "الادخار {have} الجلسات - حول {need} وهناك حاجة إلى عقد اجتماعات لرسوم التعدين. تقدموا للوفورات أولاً", diff --git a/src/locales/bn.json b/src/locales/bn.json index 60ac9ad..78f6b78 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "থেকে বেশি {span} বাঁদিকে", "arkCoinsSchedBetween": "{a} চিহ্নিত স্থানে চলুন {b} বাঁদিকে", "arkCoinsSchedFinal": "থেকে কম {span} বাঁদিকে", + "arkOffboardQuote": "এখন ফি প্রায় {fee} sats ({chain} মাইনিং + {service} coinos), তাই অন্তত {min} sats লাগবে।", + "arkOffboardQuoting": "ফি দেখা হচ্ছে…", + "arkOffboardBelowMin": "আপনার {n} sats এখন অন-চেইন পাঠানোর ন্যূনতম {min} sats-এর কম। Lightning দিয়ে পাঠান: কোনো মাইনিং ফি নেই।", + "arkOffboardAmtBelowMin": "এখন অন-চেইন পাঠাতে অন্তত {min} sats লাগবে (ফি প্রায় {fee})।", "arkUniUneconomical": "একটি আস্থাহীন প্রস্থানের খরচ হবে {fee} খনির ফিতে বসে থাকা, এর চেয়েও বেশি {n} এতে অনেক কাজ হবে। এই আকারের ভারসাম্যের জন্য, এর পরিবর্তে লাইটিং এর জায়গায় পাঠিয়ে দিন: কোন খনি ফি নেই।", "arkUniFallbackBody": "সার্ভার কোন সহযোগিতা করেনি, কিন্তু আপনার টাকা তা ছাড়া এখনো চলে যেতে পারে: আপনার সব খরচ সার-চাইনে সঞ্চয়ের জন্য। এটি একটি দিনের প্রয়োজন এবং প্রায় হয় {fee} মাইনিং ফির দাম সঞ্চয়ের মাধ্যমে পাওয়া যাবে। টাকাপয়সা সঞ্চয়ে সাহায্য করে, একবার সম্পূর্ণ হওয়ার পর সেখান থেকে তাদেরকে সেখানে পাঠিয়ে দেওয়া হয় ।", "arkUniFallbackShort": "সংরক্ষণ করা হচ্ছে {have} বসে, {need} খনির খরচের জন্য বসার প্রয়োজন। উপরে উঠে প্রথমে।", diff --git a/src/locales/de.json b/src/locales/de.json index eb11d94..8939a7f 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "mehr als {span} links", "arkCoinsSchedBetween": "{a} bis {b} links", "arkCoinsSchedFinal": "weniger als {span} links", + "arkOffboardQuote": "Gebühr derzeit etwa {fee} Sats ({chain} Mining + {service} coinos), also mindestens {min} Sats nötig.", + "arkOffboardQuoting": "Gebühr wird geprüft…", + "arkOffboardBelowMin": "Deine {n} Sats liegen unter dem Minimum von {min} Sats für eine On-Chain-Zahlung. Sende sie stattdessen über Lightning: keine Mining-Gebühren.", + "arkOffboardAmtBelowMin": "On-Chain-Senden braucht derzeit mindestens {min} Sats (Gebühr etwa {fee}).", "arkUniUneconomical": "Ein vertrauensloser On-Chain-Austritt würde ungefähr kosten {fee} sats in bergbau Gebühren mehr als die {n} sats es würde sich bewegen, so wird es nicht angeboten. Für ein Gleichgewicht dieser Größe, senden Sie es über Blitz statt: keine Bergbaugebühren überhaupt.", "arkUniFallbackBody": "Der Server hat nicht kooperiert, aber Ihr Geld kann noch ohne es bewegen: Ihr gesamtes Spenden Gleichgewicht beendet sich zu Sparen on-chain. Es dauert etwa einen Tag und ungefähr {fee} sats der Bergbaugebühren werden von Savings bezahlt. Funds land in Savings Senden Sie sie von dort, sobald es abgeschlossen.", "arkUniFallbackShort": "Sparkonten {have} Sats {need} Sats werden für die Bergbaugebühren benötigt. Erhöhe zuerst die Ersparnisse.", diff --git a/src/locales/es.json b/src/locales/es.json index b8e77d5..ae6e42e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "más que más {span} izquierda", "arkCoinsSchedBetween": "{a} a {b} izquierda", "arkCoinsSchedFinal": "menos que {span} izquierda", + "arkOffboardQuote": "Comisión de unos {fee} sats ahora mismo ({chain} de minería + {service} de coinos), así que necesita al menos {min} sats.", + "arkOffboardQuoting": "Consultando la comisión…", + "arkOffboardBelowMin": "Tus {n} sats están por debajo del mínimo de {min} sats para enviar on-chain ahora mismo. Envíalos por Lightning: sin comisiones de minería.", + "arkOffboardAmtBelowMin": "Enviar on-chain requiere al menos {min} sats ahora mismo (la comisión es de unos {fee}).", "arkUniUneconomical": "Una salida sin confianza en cadena costaría alrededor {fee} sats in mining fees, more than the {n} sentados se movería, por lo que no se ofrece. Para un equilibrio de este tamaño, envíelo por Lightning en su lugar: no hay cuotas de minería en absoluto.", "arkUniFallbackBody": "El servidor no cooperó, pero su dinero todavía puede moverse sin él: todo su balance de gasto sale a Ahorros en cadena. Lleva alrededor de un día, y aproximadamente {fee} sats of mining fees will be paid from Savings. Los fondos aterrizan en Ahorros, enviarlos desde allí una vez que termine.", "arkUniFallbackShort": "Ahorros {have} sats - sobre {need} sats son necesarios para los gastos de minería. Subir Ahorros primero.", diff --git a/src/locales/fr.json b/src/locales/fr.json index 1cd6e10..31906d2 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "supérieur à {span} gauche", "arkCoinsSchedBetween": "{a} à {b} gauche", "arkCoinsSchedFinal": "inférieur à {span} gauche", + "arkOffboardQuote": "Frais d’environ {fee} sats en ce moment ({chain} de minage + {service} coinos), il faut donc au moins {min} sats.", + "arkOffboardQuoting": "Vérification des frais…", + "arkOffboardBelowMin": "Vos {n} sats sont sous le minimum de {min} sats pour un envoi on-chain en ce moment. Envoyez-les plutôt par Lightning : aucuns frais de minage.", + "arkOffboardAmtBelowMin": "Un envoi on-chain demande au moins {min} sats en ce moment (frais d’environ {fee}).", "arkUniUneconomical": "Une sortie sans confiance sur la chaîne coûterait environ {fee}, plus que les {n} Sats il se déplacerait, donc il n'est pas offert. Pour un équilibre de cette taille, envoyez-le sur Lightning à la place: pas de frais miniers du tout.", "arkUniFallbackBody": "Le serveur n'a pas coopéré, mais votre argent peut toujours se déplacer sans lui: tout votre solde de dépenses sort à l'épargne en chaîne. Il faut environ un jour, et à peu près {fee} Les frais d'exploitation seront payés à partir de l'épargne. Financer les terres en épargne, les envoyer dès qu'elle sera terminée.", "arkUniFallbackShort": "Économies détenues {have} sats, environ {need} Les frais d'exploitation sont nécessaires. Rup up Savings d'abord.", diff --git a/src/locales/hi.json b/src/locales/hi.json index c6d133e..5e97147 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "अधिक {span} बाएं", "arkCoinsSchedBetween": "{a} to {b} बाएं", "arkCoinsSchedFinal": "कम से कम {span} बाएं", + "arkOffboardQuote": "अभी शुल्क लगभग {fee} sats है ({chain} माइनिंग + {service} coinos), इसलिए कम से कम {min} sats चाहिए।", + "arkOffboardQuoting": "शुल्क देखा जा रहा है…", + "arkOffboardBelowMin": "आपके {n} sats अभी ऑन-चेन भेजने की न्यूनतम सीमा {min} sats से कम हैं। इन्हें Lightning से भेजें: कोई माइनिंग शुल्क नहीं।", + "arkOffboardAmtBelowMin": "अभी ऑन-चेन भेजने के लिए कम से कम {min} sats चाहिए (शुल्क लगभग {fee})।", "arkUniUneconomical": "एक भरोसेमंद ऑन-चेन निकास के बारे में खर्च होगा {fee} खनन शुल्क में बैठे - अधिक से अधिक {n} इसलिए यह पेशकश नहीं की जाती है। इस आकार को संतुलित करने के लिए, इसे इसके बजाय लाइटनिंग पर भेजें: कोई खनन शुल्क नहीं।", "arkUniFallbackBody": "सर्वर ने सहयोग नहीं किया, लेकिन आपका पैसा अभी भी इसके बिना चल सकता है: आपका पूरा खर्च संतुलन बचत करने के लिए बाहर निकलता है। यह लगभग एक दिन लेता है, और मोटे तौर पर {fee} बचत से खनन शुल्क का भुगतान किया जाएगा। धन बचत में भूमि - उन्हें एक बार जब यह पूरा हो जाता है तब से भेजें।", "arkUniFallbackShort": "बचत धारक {have} - के बारे में {need} खनन शुल्क के लिए आवश्यक हैं। सबसे पहले बचत।", diff --git a/src/locales/ja.json b/src/locales/ja.json index e71625b..fda7279 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "以上 {span} 左から", "arkCoinsSchedBetween": "{a} お問い合わせ {b} 左から", "arkCoinsSchedFinal": "より少しより {span} 左から", + "arkOffboardQuote": "現在の手数料は約{fee} sats(マイニング{chain} + coinos {service})なので、最低{min} satsが必要です。", + "arkOffboardQuoting": "手数料を確認中…", + "arkOffboardBelowMin": "{n} satsは現在のオンチェーン送金の最低額{min} satsに足りません。Lightningで送ってください(マイニング手数料なし)。", + "arkOffboardAmtBelowMin": "現在、オンチェーン送金には最低{min} satsが必要です(手数料は約{fee})。", "arkUniUneconomical": "信頼できないオンチェーン出口は費用がかかります {fee} マイニング手数料に座る - 以上 {n} 座って動くので、提供されていません。 このサイズの残高については、代わりにLightningに送信してください。マイニング手数料はまったくありません。", "arkUniFallbackBody": "サーバが連携していなかったが、あなたのお金はまだそれなしで移動することができます:あなたの全体のSpendingバランスの出口は、オンチェーンを保存します。 一日ほどかかります。 {fee} マイニング手数料の座席は貯蓄から支払われます。 貯蓄の土地 - 完了したら、そこからそれらを送信します。", "arkUniFallbackShort": "貯蓄の保持 {have} 土 - について {need} マイニング手数料はサットが必要です。 最初に貯蓄を上げます。", diff --git a/src/locales/ko.json b/src/locales/ko.json index 99862ef..bfb8e82 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "더 보기 {span} 뚱 베어", "arkCoinsSchedBetween": "{a} 으로 {b} 뚱 베어", "arkCoinsSchedFinal": "더 적은 {span} 뚱 베어", + "arkOffboardQuote": "현재 수수료는 약 {fee} sats(채굴 {chain} + coinos {service})이므로 최소 {min} sats가 필요합니다.", + "arkOffboardQuoting": "수수료 확인 중…", + "arkOffboardBelowMin": "{n} sats는 현재 온체인 전송 최소 금액 {min} sats보다 적습니다. 라이트닝으로 보내세요: 채굴 수수료가 없습니다.", + "arkOffboardAmtBelowMin": "현재 온체인 전송에는 최소 {min} sats가 필요합니다(수수료 약 {fee}).", "arkUniUneconomical": "믿을 수 없는 온체인 출구는 대략 요할 것입니다 {fee} 광산 수수료에 대한 통계 - more than {n} sats 그것은 이동, 그래서 그것은 제공되지 않습니다. 이 크기를 균형에 대 한, 대신 번개에 전송: 전혀 마이닝 수수료.", "arkUniFallbackBody": "서버는 협력하지 않았습니다, 그러나 당신의 돈은 여전히 그것 없이 이동할 수 있습니다: 당신의 전체적인 둥근 균형은 사슬을 저축하기 위하여 출구를 폐쇄합니다. 하루 정도 걸립니다. {fee} 광산 수수료의 통계는 저축에서 지불됩니다. 저축에 기금 토지 - 한 번에 그들을 보내.", "arkUniFallbackShort": "관심상품 {have} sats -에 대해 {need} sats는 광산 수수료가 필요합니다. 첫 번째 저축.", diff --git a/src/locales/pt.json b/src/locales/pt.json index b49b9d2..979b21f 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "mais de {span} esquerda", "arkCoinsSchedBetween": "{a} para {b} esquerda", "arkCoinsSchedFinal": "inferior a {span} esquerda", + "arkOffboardQuote": "Taxa de cerca de {fee} sats agora ({chain} de mineração + {service} da coinos), então são necessários pelo menos {min} sats.", + "arkOffboardQuoting": "Verificando a taxa…", + "arkOffboardBelowMin": "Seus {n} sats estão abaixo do mínimo de {min} sats para enviar on-chain agora. Envie pela Lightning: sem taxas de mineração.", + "arkOffboardAmtBelowMin": "Enviar on-chain exige pelo menos {min} sats agora (a taxa é de cerca de {fee}).", "arkUniUneconomical": "Uma saída de cadeia sem confiança custaria cerca de {fee} sats em taxas de mineração, mais do que {n} sats se moveria, por isso não é oferecido. Para um equilíbrio deste tamanho, envie-o sobre Lightning em vez disso: sem taxas de mineração em tudo.", "arkUniFallbackBody": "O servidor não cooperou, mas o seu dinheiro ainda pode mover-se sem ele: todo o seu saldo de gastos sai para Poupança on-chain. Leva cerca de um dia, e aproximadamente {fee} Sats de taxas de mineração serão pagos da Poupança. Os fundos aterram em Poupança, enviem-nos de lá assim que terminar.", "arkUniFallbackShort": "Poupança {have} sats, aproximadamente {need} sats são necessários para as taxas de mineração. Aumentar a poupança primeiro.", diff --git a/src/locales/ru.json b/src/locales/ru.json index 720de70..eeac956 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "более чем {span} левый", "arkCoinsSchedBetween": "{a} то {b} левый", "arkCoinsSchedFinal": "меньше чем {span} левый", + "arkOffboardQuote": "Комиссия сейчас около {fee} сат ({chain} майнерам + {service} coinos), поэтому нужно не меньше {min} сат.", + "arkOffboardQuoting": "Проверяем комиссию…", + "arkOffboardBelowMin": "Ваши {n} сат меньше минимума в {min} сат для отправки в блокчейн сейчас. Отправьте их через Lightning: без комиссии майнерам.", + "arkOffboardAmtBelowMin": "Для отправки в блокчейн сейчас нужно не меньше {min} сат (комиссия около {fee}).", "arkUniUneconomical": "Безнадежный выход на цепочке будет стоить {fee} плата за майнинг, больше, чем {n} Он будет двигаться, поэтому его не предлагают. Чтобы сбалансировать этот размер, отправьте его вместо Lightning: никаких сборов за майнинг.", "arkUniFallbackBody": "Сервер не сотрудничал, но ваши деньги все еще могут двигаться без него: весь ваш баланс расходов выходит в Сбережения на цепочке. Это занимает около суток и примерно {fee} Плата за майнинг будет выплачиваться из сбережений. Средства на землю в сбережении, отправьте их оттуда, как только она будет завершена.", "arkUniFallbackShort": "Сбережения остаются {have} SATS - о чем {need} Саты необходимы для оплаты майнинга. Сначала повышайте сбережения.", diff --git a/src/locales/tr.json b/src/locales/tr.json index 55c0c9c..6c1bbe2 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "Daha fazlası {span} Sol sol sol sol soldan ayrıldı", "arkCoinsSchedBetween": "{a} toklanmak için {b} Sol sol sol sol soldan ayrıldı", "arkCoinsSchedFinal": "Daha az daha az {span} Sol sol sol sol soldan ayrıldı", + "arkOffboardQuote": "Şu an ücret yaklaşık {fee} sat ({chain} madencilik + {service} coinos), bu yüzden en az {min} sat gerekir.", + "arkOffboardQuoting": "Ücret kontrol ediliyor…", + "arkOffboardBelowMin": "{n} sat, şu anki zincir üstü gönderim alt sınırı olan {min} satın altında. Bunun yerine Lightning ile gönderin: madencilik ücreti yok.", + "arkOffboardAmtBelowMin": "Zincir üstü gönderim şu an en az {min} sat gerektiriyor (ücret yaklaşık {fee}).", "arkUniUneconomical": "Zincir çıkışına güvenmeyen bir güvensiz, maliyetle mal olur {fee} Madencilik ücretlerinde oturdu - daha fazlası {n} Bu hareket edecekti, bu yüzden teklif edilmedi. Bu büyüklükte bir denge için, bunun yerine Lightning üzerinden gönderin: hiç madencilik ücreti yok.", "arkUniFallbackBody": "Sunucu işbirliği yapmadı, ancak paranız hala olmadan hareket edebilir: tüm harcama bakiyenizin zincirde tasarruf etmesi. Bir gün sürer ve kabaca {fee} Madencilik ücretlerinin oturdukları tasarruflardan ödenecektir. Tasarruf halindeki araziler - onları bir kez tamamen tamamlar.", "arkUniFallbackShort": "Tasarruflar tutar {have} Satmak - hakkında {need} Madencilik ücretleri için misafirlere sunulmaktadır. İlk önce Top up saves.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 3c9335c..1ca0f39 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -194,6 +194,10 @@ "arkCoinsSchedOver": "超过 {span} 左边", "arkCoinsSchedBetween": "{a} 改为 {b} 左边", "arkCoinsSchedFinal": "小于 {span} 左边", + "arkOffboardQuote": "当前手续费约 {fee} 聪(矿工 {chain} + coinos {service}),所以至少需要 {min} 聪。", + "arkOffboardQuoting": "正在查询手续费…", + "arkOffboardBelowMin": "你的 {n} 聪低于当前链上发送的最低额 {min} 聪。请改用闪电网络发送:没有矿工费。", + "arkOffboardAmtBelowMin": "当前链上发送至少需要 {min} 聪(手续费约 {fee})。", "arkUniUneconomical": "失去信任的链上退出成本约为 {fee} 采矿费,比 {n} 却不提供。 为了平衡这种规模,把它送出闪电号:完全不收取采矿费。", "arkUniFallbackBody": "服务器没有合作, 但你的钱仍然可以移动 没有它: 整个支出余额退出储蓄链。 大约需要一天时间,而且大概 {fee} 部分采矿费将从储蓄中支付。 储蓄基金,一旦完成,就从那里发放。", "arkUniFallbackShort": "储蓄 {have} 大约 {need} 采矿费则需缴纳采矿费。 先把储蓄加起来", diff --git a/tools/ark/offboard-minimum-test.js b/tools/ark/offboard-minimum-test.js new file mode 100644 index 0000000..9593089 --- /dev/null +++ b/tools/ark/offboard-minimum-test.js @@ -0,0 +1,102 @@ +// The on-chain send from Spending states the co-operative fee and minimum up +// front, refuses a balance below it in words, and keeps the unilateral-exit +// note for when the ASP actually failed to co-operate. Local manager stub. +// Run: bun tools/ark/offboard-minimum-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, + plugins: [{ name: 'cooperative-exit-test', setup(build) { + build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` +window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } }; +` })); + build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: + (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + window.coopFeature = { setManager: (mgr) => { ark = mgr; }, offboard: doArkOffboard }; + return {\n id: 'ark',`) })); + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `
`; +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' + ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + const cdp = await page.createCDPSession(); + await cdp.send('Network.enable'); + await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'off'); + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.coopApp?.ready); + await page.evaluate(async (mn) => { + await coopApp.enterWallet(mn, '', { fresh: true, generated: true }); + coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + coopApp.wallet.scan = async () => {}; + window.coopCalls = []; + window.coopManager = { + state: { vtxos: [{ id: 'coin-a', amountSat: 1000, state: 'spendable' }], actions: [], movements: [] }, + async offboardQuote() { coopCalls.push({ type: 'quote' }); return { chainSat: 736, serviceSat: 204, totalSat: 940, minSat: 1270 }; }, + info: { network: 'regtest' }, _tipH: 100, + vtxos() { return this.state.vtxos; }, + _decoded() { throw new Error('no exit transaction fixture'); }, + refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {}, + address() { return 'test-ark-address'; }, + async send(address, amount) { coopCalls.push({ type: 'split', address, amount }); throw new Error('unexpected split'); }, + async startOffboard(spk, address, ids) { + coopCalls.push({ type: 'offboard', address, ids: ids || null }); + throw window.coopFail || new Error('unexpected offboard'); + }, + }; + coopFeature.setManager(coopManager); + coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'receive'; coopApp.render(); + }, generateMnemonic(wordlist)); + const open = async () => { + await page.evaluate(() => { + const { ui, render } = coopApp; + ui.arkOffboarded = null; ui.arkBusy = null; ui.arkError = ''; ui.arkCoopFailed = false; + ui.screen = 'wallet'; ui.tab = 'send'; + ui.arkOffboardSend = { address: coopApp.wallet.derive(0, 1).address, amount: '' }; + render(); + }); + await page.waitForSelector('input.mono-input'); + }; + const card = () => page.evaluate(() => { + const c = document.querySelector('input.mono-input').closest('.card'); + return { uni: /trustless|didn’t cooperate/i.test(c.innerText), text: c.innerText, sendDisabled: c.querySelector('.btn-primary').disabled, err: c.querySelector('.notice.err')?.textContent || '' }; + }); + await open(); + await page.waitForFunction(() => /Fee about/.test(document.querySelector('input.mono-input').closest('.card').innerText)); + let c = await card(); + assert.match(c.text, /Fee about 940 sats right now \(736 mining \+ 204 coinos\), so it needs at least 1,270 sats/); + assert.match(c.text, /Your 1,000 sats are below the 1,270-sat minimum/); + assert(c.sendDisabled, 'send is off below the minimum'); + assert(!c.uni, 'no unilateral-exit talk for a balance that is merely small'); + console.log('✓ the co-operative fee and minimum show before any tap; a balance below it disables Send and points to Lightning'); + + // the manager's own guard (fees moved since the quote): plain words, no fallback + await page.evaluate(() => { window.coopFail = Object.assign(new Error('ark balance too small to offboard after fees'), { tooSmall: true, feeSat: 950, minSat: 1280 }); }); + await page.evaluate(() => coopFeature.offboard(0, coopApp.wallet.derive(0, 1).address)); + c = await card(); + assert.equal(c.err, 'Sending on-chain needs at least 1,280 sats right now (the fee is about 950).'); + assert(!c.uni && !/ark balance too small/.test(c.text)); + console.log('✓ a too-small offboard reads in words and does not offer the unilateral exit'); + + // the ASP refusing is what the unilateral exit is for + await page.evaluate(() => { window.coopFail = new Error('server unavailable'); }); + await page.evaluate(() => coopFeature.offboard(0, coopApp.wallet.derive(0, 1).address)); + c = await card(); + assert.equal(c.err, 'server unavailable'); + assert(c.uni, 'a failed co-operation still explains the unilateral exit: ' + c.text); + console.log('✓ a failed co-operation still shows the unilateral-exit note'); + assert.deepEqual(errors, []); +} finally { await browser.close(); server.stop(true); } -- cgit v1.2.3