Skip to content

Commit c3de3be

Browse files
committed
emergency-withdraw: human-readable vUSDCx amounts + plainer copy
vUSDCx amounts were shown and entered as raw 1e12-scaled integers (e.g. "3000000000000"). They now display in human units ("3") and the Withdraw field accepts a plain decimal, converted internally — with a balance hint and percent shortcuts adjusted to match. The Withdraw and CommunitySunset descriptions are rewritten in plain depositor language; the validator-level detail moves into a collapsible "Technical details" section. EN / 繁體中文 / 日本語 all updated.
1 parent b9d1306 commit c3de3be

2 files changed

Lines changed: 100 additions & 35 deletions

File tree

emergency-withdraw/src/App.tsx

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,40 @@ function fmtMicro(n: bigint, dec: bigint = DECIMALS): string {
5555
return (neg ? '-' : '') + Number(head).toLocaleString() + '.' + tail
5656
}
5757

58+
/** vUSDCx is stored at 1e12 raw per display unit (≈ 1 USDCx of value). */
59+
const SHARE_SCALE = 1_000_000_000_000n
60+
61+
/** Format a raw vUSDCx amount as a human-readable string (up to 6 dp). */
62+
function fmtShares(n: bigint): string {
63+
const neg = n < 0n
64+
const abs = neg ? -n : n
65+
const whole = abs / SHARE_SCALE
66+
const frac = ((abs % SHARE_SCALE) / 1_000_000n).toString().padStart(6, '0').replace(/0+$/, '')
67+
return (neg ? '-' : '') + Number(whole).toLocaleString() + (frac ? '.' + frac : '')
68+
}
69+
70+
/** Raw vUSDCx → plain comma-free decimal string, for the input field. */
71+
function rawSharesToInput(n: bigint): string {
72+
if (n <= 0n) return ''
73+
const whole = n / SHARE_SCALE
74+
const frac = (n % SHARE_SCALE).toString().padStart(12, '0').replace(/0+$/, '')
75+
return whole.toString() + (frac ? '.' + frac : '')
76+
}
77+
78+
/** Parse a human vUSDCx input string → raw bigint. Returns -1n on invalid. */
79+
function parseShares(input: string): bigint {
80+
const s = input.trim()
81+
if (!s) return 0n
82+
if (!/^\d*\.?\d*$/.test(s) || s === '.') return -1n
83+
const [whole, frac = ''] = s.split('.')
84+
if (frac.length > 12) return -1n
85+
try {
86+
return BigInt(whole || '0') * SHARE_SCALE + BigInt((frac + '000000000000').slice(0, 12))
87+
} catch {
88+
return -1n
89+
}
90+
}
91+
5892
function fmtTime(ms: bigint, never: string): string {
5993
if (ms <= 0n) return never
6094
return new Date(Number(ms)).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
@@ -433,14 +467,7 @@ export default function App() {
433467
}
434468

435469
// ── Withdraw ──
436-
const sharesParsed = (() => {
437-
if (!sharesInput.trim()) return 0n
438-
try {
439-
return BigInt(sharesInput.trim())
440-
} catch {
441-
return -1n
442-
}
443-
})()
470+
const sharesParsed = parseShares(sharesInput)
444471
const sharesValid = sharesParsed > 0n && sharesParsed <= userShares
445472
const quote = (() => {
446473
if (!vault || !sharesValid) return null
@@ -456,12 +483,12 @@ export default function App() {
456483
const cap = vault.totalShares - 1n
457484
const base = userShares > cap ? cap : userShares
458485
const v = pct >= 100 ? base : (base * BigInt(pct)) / 100n
459-
setSharesInput(v.toString())
486+
setSharesInput(rawSharesToInput(v))
460487
}
461488

462489
async function handleWithdraw() {
463490
if (!lucid || !config || !vault || !networkResolved || !sharesValid) return
464-
if (!confirm(t('wd.confirmNative', { n: sharesParsed.toString() }))) return
491+
if (!confirm(t('wd.confirmNative', { n: fmtShares(sharesParsed) }))) return
465492
setBusy(true)
466493
setTxHash('')
467494
setStatus({ kind: 'info', text: t('st.buildingWd') })
@@ -721,7 +748,7 @@ export default function App() {
721748
<div className="text-xs uppercase tracking-wide text-slate-500">
722749
{t('vault.yourShares')}
723750
</div>
724-
<div className="font-mono text-lg sm:text-xl mt-0.5">{userShares.toString()}</div>
751+
<div className="font-mono text-lg sm:text-xl mt-0.5">{fmtShares(userShares)}</div>
725752
</div>
726753
<div className="text-right">
727754
<div className="text-xs uppercase tracking-wide text-slate-500">
@@ -736,7 +763,7 @@ export default function App() {
736763
<DataPanel className="space-y-2.5">
737764
<InfoRow label={t('vault.version')} value={`V${vault.vaultVersion.toString()}`} />
738765
<InfoRow label={t('vault.totalDeposited')} value={fmtMicro(vault.totalDeposited)} />
739-
<InfoRow label={t('vault.totalShares')} value={vault.totalShares.toString()} />
766+
<InfoRow label={t('vault.totalShares')} value={fmtShares(vault.totalShares)} />
740767
<InfoRow label={t('vault.idleBuffer')} value={fmtMicro(vault.idleBuffer)} />
741768
<InfoRow label={t('vault.nonDeposit')} value={fmtMicro(vault.nonDepositValue)} />
742769
<InfoRow
@@ -787,18 +814,31 @@ export default function App() {
787814
iconPath={ICON.download}
788815
accent="emerald"
789816
>
790-
<p className="text-xs sm:text-sm text-slate-400 leading-relaxed">{renderRich(t('wd.desc'))}</p>
817+
<div className="space-y-2">
818+
<p className="text-xs sm:text-sm text-slate-400 leading-relaxed">{t('wd.desc')}</p>
819+
<details className="group">
820+
<summary className="text-xs text-slate-500 cursor-pointer hover:text-slate-300 select-none">
821+
{t('wd.techToggle')}
822+
</summary>
823+
<p className="mt-2 text-xs text-slate-400 leading-relaxed">{renderRich(t('wd.techDetail'))}</p>
824+
</details>
825+
</div>
791826

792827
<div className="space-y-2">
793-
<label className="block text-xs uppercase tracking-wide text-slate-400">
794-
{t('wd.sharesLabel')}
795-
</label>
828+
<div className="flex items-baseline justify-between gap-3">
829+
<label className="text-xs uppercase tracking-wide text-slate-400">
830+
{t('wd.sharesLabel')}
831+
</label>
832+
<span className="text-xs text-slate-500">
833+
{t('wd.balanceHint', { bal: fmtShares(userShares) })}
834+
</span>
835+
</div>
796836
<input
797837
type="text"
798-
inputMode="numeric"
838+
inputMode="decimal"
799839
value={sharesInput}
800840
onChange={(e) => setSharesInput(e.target.value)}
801-
placeholder="0"
841+
placeholder="0.0"
802842
/>
803843
<div className="grid grid-cols-4 gap-1.5">
804844
{[25, 50, 75, 100].map((pct) => (
@@ -814,7 +854,7 @@ export default function App() {
814854
{sharesParsed === -1n && <p className="text-xs text-red-400">{t('wd.invalid')}</p>}
815855
{sharesParsed > userShares && (
816856
<p className="text-xs text-red-400">
817-
{t('wd.exceeds', { bal: userShares.toString() })}
857+
{t('wd.exceeds', { bal: fmtShares(userShares) })}
818858
</p>
819859
)}
820860
</div>
@@ -871,9 +911,19 @@ export default function App() {
871911
accent={sunset.available || sunset.alreadyTriggered ? 'amber' : 'slate'}
872912
danger={sunset.available || sunset.alreadyTriggered}
873913
>
874-
<p className="text-xs sm:text-sm text-slate-400 leading-relaxed">
875-
{renderRich(t('sunset.desc'), 'text-amber-400')}
876-
</p>
914+
<div className="space-y-2">
915+
<p className="text-xs sm:text-sm text-slate-400 leading-relaxed">
916+
{renderRich(t('sunset.desc'), 'text-amber-400')}
917+
</p>
918+
<details className="group">
919+
<summary className="text-xs text-slate-500 cursor-pointer hover:text-slate-300 select-none">
920+
{t('sunset.techToggle')}
921+
</summary>
922+
<p className="mt-2 text-xs text-slate-400 leading-relaxed">
923+
{renderRich(t('sunset.techDetail'), 'text-amber-400')}
924+
</p>
925+
</details>
926+
</div>
877927

878928
{/* Countdown headline */}
879929
{!sunset.alreadyTriggered && (
@@ -913,7 +963,7 @@ export default function App() {
913963
/>
914964
<InfoRow
915965
label={t('sunset.callerShares')}
916-
value={userShares.toString()}
966+
value={fmtShares(userShares)}
917967
tone={userShares > 0n ? 'good' : 'bad'}
918968
/>
919969
</DataPanel>

emergency-withdraw/src/i18n.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,13 @@ const translations: Record<Lang, Translations> = {
103103
wd: {
104104
step: 'Step 2',
105105
title: 'Withdraw',
106-
desc: 'Burns vUSDCx for a proportional share of vault USDCx via the V1 Withdraw-Zero path (proxy spend + `vault_user` staking withdrawal). The quote is `shares × TD ÷ TS` minus the early-withdraw fee — the fee is waived automatically once the keeper has been inactive 7+ days.',
107-
sharesLabel: 'Shares to burn — raw vUSDCx',
106+
desc: 'Burn your vUSDCx to receive a proportional share of USDCx from the vault. If the keeper has been inactive for 7+ days, the early-withdraw fee is waived automatically.',
107+
techToggle: 'Technical details',
108+
techDetail: 'V1 Withdraw-Zero path: proxy spend + `vault_user` staking withdrawal + vUSDCx burn. Quote = `shares × total_deposited ÷ total_shares` minus the early-withdraw fee.',
109+
sharesLabel: 'Amount of vUSDCx to withdraw',
110+
balanceHint: 'Balance: {bal}',
108111
max: 'Max',
109-
invalid: 'Invalid integer',
112+
invalid: 'Invalid number',
110113
exceeds: 'Exceeds your balance ({bal})',
111114
gross: 'Gross withdraw',
112115
fee: 'Early fee',
@@ -122,7 +125,9 @@ const translations: Record<Lang, Translations> = {
122125
eyeActive: 'Active',
123126
eyeAvail: 'Available now',
124127
eyeCountdown: 'Dead-man-switch · countdown',
125-
desc: 'The 90-day dead-man-switch. After ≥90 days of operational inactivity (no Compound, no realloc) any vUSDCx holder can flip `frozen=1 + community_sunset_triggered=1`, opening the permissionless `vault_recall.RecallFromLiqwid` + `vault_protocol.DeployToProtocol` Layer 2 paths so depositors can recover their full proportional USDCx share without keeper or governance intervention. *The flag is one-way — irreversible.*',
128+
desc: 'If the vault sees no operational activity for 90 days, the operator may have failed. At that point any vUSDCx holder can flip this dead-man-switch, letting every depositor recover their full USDCx share without the operator, keeper, or governance. *This is one-way — it cannot be undone.*',
129+
techToggle: 'Technical details',
130+
techDetail: 'The 90-day threshold is measured from `max(last_compound, last_realloc)`. Triggering sets `frozen=1 + community_sunset_triggered=1`, opening the permissionless `vault_recall.RecallFromLiqwid` + `vault_protocol.DeployToProtocol` Layer 2 paths.',
126131
cdReached: 'Threshold reached',
127132
cdLabel: 'Days until sunset unlocks',
128133
availNow: 'AVAILABLE NOW',
@@ -273,10 +278,13 @@ const translations: Record<Lang, Translations> = {
273278
wd: {
274279
step: '步驟 2',
275280
title: '提取',
276-
desc: '透過 V1 Withdraw-Zero 路徑(花費 proxy 加上 `vault_user` staking withdrawal)銷毀 vUSDCx,換回金庫 USDCx 中按比例的份額。報價為 `份額 × TD ÷ TS` 減去提前提取手續費;keeper 停擺達 7 天以上時,手續費會自動豁免。',
277-
sharesLabel: '要銷毀的份額(原始 vUSDCx)',
281+
desc: '燒掉你的 vUSDCx,按金庫目前的兌換比例換回等值的 USDCx。若 keeper 已停擺超過 7 天,提前提取手續費會自動免除。',
282+
techToggle: '技術細節',
283+
techDetail: 'V1 Withdraw-Zero 路徑:proxy spend + `vault_user` staking withdrawal + 銷毀 vUSDCx。報價 = `份額 × total_deposited ÷ total_shares` 減去提前提取手續費。',
284+
sharesLabel: '要提取的 vUSDCx 數量',
285+
balanceHint: '餘額:{bal}',
278286
max: '最大',
279-
invalid: '整數格式無效',
287+
invalid: '數字格式無效',
280288
exceeds: '超過你的餘額({bal})',
281289
gross: '提取總額',
282290
fee: '提前手續費',
@@ -292,7 +300,9 @@ const translations: Record<Lang, Translations> = {
292300
eyeActive: '已啟用',
293301
eyeAvail: '現可使用',
294302
eyeCountdown: 'dead-man-switch · 倒數中',
295-
desc: '這是為期 90 天的 dead-man-switch。當營運停擺達 90 天以上(無 Compound、無 realloc),任何 vUSDCx 持有者都可將 `frozen=1 + community_sunset_triggered=1` 設定生效,開啟無需許可的 `vault_recall.RecallFromLiqwid` 與 `vault_protocol.DeployToProtocol` Layer 2 路徑,讓存款人無需 keeper 或治理介入,即可依比例取回完整的 USDCx 份額。*此旗標為單向,無法復原。*',
303+
desc: '如果金庫連續 90 天沒有任何營運活動,代表營運方可能已經失效。屆時任何 vUSDCx 持有者都能啟動這個無人值守開關,讓每位存款人不需經過營運方、keeper 或治理,就能取回自己應得的全部 USDCx。*此操作為單向,無法復原。*',
304+
techToggle: '技術細節',
305+
techDetail: '90 天門檻以 `max(last_compound, last_realloc)` 計算。觸發後會設定 `frozen=1 + community_sunset_triggered=1`,開啟無需許可的 `vault_recall.RecallFromLiqwid` 與 `vault_protocol.DeployToProtocol` Layer 2 路徑。',
296306
cdReached: '已達門檻',
297307
cdLabel: '距 sunset 解鎖的天數',
298308
availNow: '現可使用',
@@ -443,10 +453,13 @@ const translations: Record<Lang, Translations> = {
443453
wd: {
444454
step: 'ステップ 2',
445455
title: '引出',
446-
desc: 'V1 Withdraw-Zero パス(proxy の消費と `vault_user` staking withdrawal)を通じて vUSDCx をバーンし、ヴォールト USDCx を比例配分で受け取ります。見積もりは `シェア × TD ÷ TS` から早期引出手数料を引いた額です。keeper が 7 日以上停止している場合、手数料は自動的に免除されます。',
447-
sharesLabel: 'バーンするシェア(生の vUSDCx)',
456+
desc: 'vUSDCx をバーンし、ヴォールトの USDCx から比例配分された分を受け取ります。keeper が 7 日以上停止している場合、早期引出手数料は自動的に免除されます。',
457+
techToggle: '技術詳細',
458+
techDetail: 'V1 Withdraw-Zero パス:proxy spend + `vault_user` staking withdrawal + vUSDCx バーン。見積もり = `シェア × total_deposited ÷ total_shares` から早期引出手数料を引いた額。',
459+
sharesLabel: '引き出す vUSDCx の数量',
460+
balanceHint: '残高:{bal}',
448461
max: '最大',
449-
invalid: '整数の形式が正しくありません',
462+
invalid: '数値の形式が正しくありません',
450463
exceeds: '残高を超えています({bal})',
451464
gross: '引出総額',
452465
fee: '早期手数料',
@@ -462,7 +475,9 @@ const translations: Record<Lang, Translations> = {
462475
eyeActive: '有効',
463476
eyeAvail: '利用可能',
464477
eyeCountdown: 'デッドマンスイッチ · カウントダウン',
465-
desc: 'これは 90 日間のデッドマンスイッチです。運用停止が 90 日以上(Compound なし・realloc なし)続くと、すべての vUSDCx 保有者が `frozen=1 + community_sunset_triggered=1` を立てられます。これにより許可不要の `vault_recall.RecallFromLiqwid` と `vault_protocol.DeployToProtocol` の Layer 2 パスが開き、預入者は keeper やガバナンスの介入なしに、比例配分された USDCx 全額を回収できます。*このフラグは一方向で、元に戻せません。*',
478+
desc: 'ヴォールトに 90 日間運用活動がない場合、運用者が機能を停止した可能性があります。その時点で、いずれの vUSDCx 保有者もこのデッドマンスイッチを作動させることができ、すべての預入者が運用者・keeper・ガバナンスを介さずに自分の USDCx 全額を回収できます。*これは一方向で、元に戻せません。*',
479+
techToggle: '技術詳細',
480+
techDetail: '90 日の閾値は `max(last_compound, last_realloc)` から計算されます。作動すると `frozen=1 + community_sunset_triggered=1` が設定され、許可不要の `vault_recall.RecallFromLiqwid` と `vault_protocol.DeployToProtocol` の Layer 2 パスが開きます。',
466481
cdReached: '閾値に到達',
467482
cdLabel: 'sunset 解放までの日数',
468483
availNow: '利用可能',

0 commit comments

Comments
 (0)