aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js22
-rw-r--r--src/features/ark.js42
-rw-r--r--src/i18n.js4
-rw-r--r--src/locales/ar.json4
-rw-r--r--src/locales/bn.json4
-rw-r--r--src/locales/de.json4
-rw-r--r--src/locales/es.json4
-rw-r--r--src/locales/fr.json4
-rw-r--r--src/locales/hi.json4
-rw-r--r--src/locales/ja.json4
-rw-r--r--src/locales/ko.json4
-rw-r--r--src/locales/pt.json4
-rw-r--r--src/locales/ru.json4
-rw-r--r--src/locales/tr.json4
-rw-r--r--src/locales/zh.json4
-rw-r--r--tools/ark/offboard-minimum-test.js102
16 files changed, 211 insertions, 7 deletions
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 = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+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); }