1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
|
// Lightweight i18n. t('key', {vars}) interpolates {var} and falls back to the
// English string (then the key). The chosen language persists in localStorage;
// otherwise it's guessed from the browser. Arabic/Urdu render right-to-left.
//
// NOTE: non-English translations are machine-generated and would benefit from
// native-speaker review. The English table (en) is the source of truth.
export const LANGS = [
['en', 'English'],
['zh', '中文'],
['hi', 'हिन्दी'],
['es', 'Español'],
['fr', 'Français'],
['ar', 'العربية'],
['bn', 'বাংলা'],
['pt', 'Português'],
['ru', 'Русский'],
['ur', 'اردو'],
['id', 'Indonesia'],
['de', 'Deutsch'],
['ja', '日本語'],
['sw', 'Kiswahili'],
['mr', 'मराठी'],
['te', 'తెలుగు'],
['tr', 'Türkçe'],
['ta', 'தமிழ்'],
['vi', 'Tiếng Việt'],
['ko', '한국어'],
];
const RTL = new Set(['ar', 'ur']);
export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
appTitle: 'Hal',
home: 'Home',
logout: 'Logout',
accounts: 'Accounts',
addWallet: 'Add wallet',
clearAll: 'Clear all',
clearAllWarn: '⚠ This removes every wallet from this device. Any wallet whose recovery phrase or key you have not backed up will be lost for good.',
removeWalletTitle: 'Remove wallet',
removeWalletWarn: '⚠ Remove “{name}” from this device? It will be gone from this browser — make sure you have its recovery phrase. Other wallets aren’t affected.',
rename: 'Rename',
remove: 'Remove',
watchOnly: 'Watch-only',
watchOnlyTag: 'watch-only',
watchOnlyAdd: 'Add watch-only',
watchOnlyDesc: 'Watch any wallet from its account public key. You can receive and view balance and history, but not send.',
watchOnlyNote: 'This is a watch-only wallet. You can receive and view, but you cannot send.',
loadSeedTitle: 'Load recovery phrase',
loadSeedDesc: "Enter this wallet's recovery phrase (or private key) to spend from it — it must match this wallet.",
loadSeedBtn: 'Load recovery phrase',
seedPlaceholder: 'Recovery phrase or xprv',
bip39PassphraseOpt: 'BIP39 passphrase (optional)',
alsoSaveDevice: 'Also save to this device',
enterSeedToSpend: 'Enter your recovery phrase.',
seedMismatch: "That recovery phrase doesn't match this wallet.",
seedNeedsPrivate: "That's a public key — enter the recovery phrase or private key to spend.",
seedInvalid: 'Not a valid recovery phrase or key.',
watchOnlySendTitle: 'Load your seed to send',
watchOnlySendDesc: "You're viewing this wallet without its seed, so you can't spend yet. Load your recovery phrase to send.",
watchXpubLabel: 'Account xpub or zpub',
labelOptional: 'Label (optional)',
walletLabelN: 'Wallet {n}',
watchLabelN: 'Watch {n}',
saveDevice: 'Save on this device',
forgetDevice: 'Saved · forget',
savedTag: 'saved',
setPassword: 'Set a password (optional)',
setPasswordDesc: 'Encrypts this wallet on this device — leave it blank to save without a password. You will need it to unlock saved wallets later.',
passwordOptional: 'Password (optional)',
changePassword: 'Change password',
changePasswordDesc: 'Re-encrypt your saved-to-device wallets with a new password. Leave the new password blank to remove it.',
currentPassword: 'Current password',
pwChanged: 'Password changed',
enterPassword: 'Enter your password',
enterPasswordDesc: 'Unlock your saved wallets to continue.',
password: 'Password',
confirmPassword: 'Confirm password',
pwTooShort: 'Use at least 8 characters.',
pwMismatch: 'Passwords do not match.',
pwWrong: 'Wrong password.',
unlockSaved: 'Unlock saved wallets',
unlockSavedDesc: 'Enter your password to open the wallets saved on this device.',
unlock: 'Unlock',
useAnotherWallet: 'Use another wallet',
save: 'Save',
publicKey: 'Public key',
publicKeyDesc: 'Share this to watch this wallet read-only on another device or app. It reveals your addresses, not your keys.',
copyKey: 'Copy key',
showPublicKey: 'Show public key',
createNew: 'Create new',
importExisting: 'Import existing',
genIntro: 'Generate a new 12-word seed phrase. Write it down. It’s the only way to recover this wallet.',
generateSeed: 'Generate seed phrase',
writeDownWarn: '⚠ Write these 12 words down on paper, in order. Anyone with them can spend your coins.',
copyPhrase: 'Copy phrase',
regenerate: 'Regenerate',
verifyBackup: 'Verify backup',
skipVerification: 'Skip verification',
confirmBackupIntro: 'Confirm your backup by entering the requested words.',
wordN: 'Word #{n}',
reenterPassphrase: 'Re-enter passphrase',
back: '← Back',
cancel: 'Cancel',
paste: 'Paste',
openWallet: 'Open wallet',
wordsMismatch: 'Those words don’t match your phrase. Check your backup.',
passphraseMismatch: 'That passphrase doesn’t match the one you entered.',
seedPhrase: 'Seed phrase',
seedPlaceholder: 'enter your seed words separated by spaces',
passphrase: 'Passphrase',
show: 'Show',
hide: 'Hide',
invalidSeed: 'Invalid seed phrase — check the spelling and word order.',
importLabel: 'Recovery phrase or key',
importPlaceholder: 'Your seed words, or an xpub / xprv',
invalidImport: 'That is not a valid recovery phrase, xpub, or xprv.',
importedKey: 'Imported key',
importedKeyNote: 'This wallet was imported from an extended private key, so there is no recovery phrase. Back up that key itself to keep the wallet.',
keysNote: 'Keys never leave your browser. Works fully offline. Save this page and open it from your filesystem.',
howItWorks: 'How it works',
installApp: 'Install app',
hiwBasicsTitle: 'The basics',
hiwBasics1: 'When you make a wallet, the page generates twelve random words. Those words are the key to your wallet. Anyone who has them can spend the coins, so write them down on paper and keep them somewhere only you can get to.',
hiwBasics2: 'If you lose the twelve words and don’t have a backup, the coins are gone for good. There’s no reset link and no one to call, so back them up before you put money in.',
hiwBasics3: 'There’s no server and no account. The whole app is a single page that runs in your browser, so nobody holds your coins and nothing on a server ever sees your keys. To show your balance and history it reads from a public block explorer (mempool.space by default, or your own node if you set one in Settings).',
hiwBasics4: 'You can even save this page and open it straight from your computer with no internet at all. It keeps working offline.',
hiwBasics5: 'It follows the usual Bitcoin standards (a 12-word BIP39 phrase and BIP84 native SegWit keys), so if this site ever disappears you can restore the same phrase in Sparrow, Electrum, or any other standard wallet and your coins will be right there.',
hiwTribute: 'The name Hal Wallet is a tribute to Hal Finney (1956–2014).',
balance: 'Balance',
pending: 'Pending',
tabReceive: 'Receive',
tabSend: 'Send',
tabHistory: 'History',
tabSettings: 'Settings',
copyAddress: 'Copy address',
paymentReceived: 'Payment received!',
tapToProceed: 'Tap anywhere to proceed',
recipient: 'Recipient',
recipients: 'Recipients',
max: 'Max',
feeRate: 'Fee rate',
feeEconomy: 'Economy',
feeNormal: 'Normal',
feePriority: 'Priority',
feeCustom: 'Custom',
selectedRate: 'Selected rate: {n} sat/vB',
coinSelection: 'Coin selection',
automatic: 'Automatic',
manual: 'Manual',
noCoins: 'No coins available.',
nSelected: '{n} selected',
addRecipient: '+ Add recipient',
reviewTx: 'Review transaction',
remove: 'Remove',
enterRecipientAddr: 'Enter a recipient address.',
enterAddrForN: 'Enter an address for recipient {n}.',
enterValidAmtForN: 'Enter a valid amount for recipient {n}.',
selectCoin: 'Select at least one coin to spend.',
networkFee: 'Network fee',
offlineSignNote: 'Offline: sign now, then broadcast the signed transaction from an online device.',
unconfirmedInputWarn: 'This payment spends bitcoin that hasn’t confirmed yet, so it won’t confirm until that earlier transaction does.',
signBroadcast: 'Sign & broadcast',
signTx: 'Sign transaction',
signExport: 'Sign & export (broadcast elsewhere)',
scanQr: 'Scan QR',
scanHint: 'Point the camera at a QR code',
scanNoCamera: 'Could not access the camera.',
scanNoDecoder: 'QR scanning needs a connection the first time it’s used.',
scanUnrecognized: 'Couldn’t read a Bitcoin address from that QR.',
broadcastScanned: 'Broadcast transaction?',
broadcastNow: 'Broadcast',
bumpFee: 'Bump fee',
bumpConfirm: 'Replace with a higher fee?',
replaceTx: 'Replace',
bumpInsufficient: 'Not enough funds to bump at this rate. Try a lower rate.',
scanOffline: 'You’re offline — reconnect to broadcast this transaction.',
broadcastFailed: 'Broadcast failed: {msg}',
broadcastTimeout: 'Timed out reaching the network. The payment was not sent — try again.',
signingFailed: 'Signing failed: {msg}',
done: 'Done',
txSignedNote: 'Signed, but not sent yet. To actually send it, broadcast the code below from any online device (e.g. mempool.space → Broadcast). It will not send on its own.',
transactionId: 'Transaction ID',
signedTxRaw: 'Signed transaction (code)',
copyHex: 'Copy code',
downloadLabel: 'Download',
showQrAirgap: 'Show QR (for offline transfer)',
txBroadcast: '✓ Transaction broadcast!',
copyTxid: 'Copy transaction ID',
viewOnMempool: 'Block explorer ↗',
noTxYet: 'No transactions yet.',
loadingHistory: 'Loading transactions…',
received: 'Received',
sent: 'Sent',
pendingTag: '⏳ pending',
stuckTag: '⚠ stuck',
stuckNote: 'Fee too low — may not confirm',
stuckIncomingNote: '⚠ This payment’s fee is below the current network floor, so it may not confirm. Only the sender can speed it up (bump or re-send).',
stuckOutgoingNote: '⚠ This transaction’s fee is below the current network floor, so it may not confirm on its own. Bump the fee below to rebroadcast it at a higher rate.',
awaitingConfirmation: 'awaiting confirmation',
feeShort: 'fee {x}',
historyOffline: 'Transaction history is unavailable in offline mode.',
status: 'Status',
confirmed: 'Confirmed',
confirmedTag: 'confirmed',
block: 'Block',
date: 'Date',
copyId: 'Copy ID',
backToHistory: '← Back to history',
recoveryPhrase: 'Recovery phrase',
giftWelcome: 'You’ve been sent Bitcoin',
giftBackedUp: 'I’ve written it down',
giftLink: 'Gift some Bitcoin',
giftLinkDesc: 'Presign an amount as a link. Whoever opens it claims the Bitcoin into a brand-new wallet that only they control.',
giftLinkWarn: '⚠ Anyone who opens this link can claim the amount, so share it only with the person you are gifting. Keep amounts small.',
giftLinkReveal: 'Create gift link',
giftAmountLabel: 'Amount to gift',
giftAmountInvalid: 'Enter at least {n}.',
giftMinNote: 'Minimum {n}.',
giftAllNote: 'Gifts your whole balance; the recipient pays the claim fee.',
giftExceedsBalance: 'That’s more than your balance.',
giftNeedsHeadroom: 'Gift up to {n}, or tap Max to gift your whole balance.',
giftAnother: 'Create another',
giftLockPlaceholder: 'Lock to npub (optional)',
giftLockHint: 'Optionally lock this gift to a nostr account: the link stays public, but only they get the claim code — sent to them as a nostr DM.',
giftLockTo: 'For',
giftLockInvalid: 'Not a valid npub or hex pubkey.',
giftLockedTo: 'For',
giftForYou: 'A gift for you',
claimGift: 'Claim',
dismiss: 'Dismiss',
giftCodeHint: 'Check your nostr direct messages for the claim code, then paste it below.',
claimCodePlaceholder: 'Paste claim code',
claimCodeWrong: 'That code didn’t work — check your nostr DM and try again.',
giftDmText: "🎁 You've received a Bitcoin gift of {amount}! Claim it here: {link}\n\nClaim code: {code}",
giftDmSent: 'Claim code sent to them via nostr DM.',
giftDmSending: 'Sending the claim code…',
giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:',
giftCopyCode: 'Copy claim code',
giftQrTooLong: 'Too large for a QR code — share the link below instead.',
giftConsolidateExplain: 'This gift would bundle {count} coins, making it costly for the recipient to claim. Splitting one coin first (~{fee}) gives them a clean single-input gift.',
giftConsolidateFirst: 'Split into one coin first',
giftUseManyCoins: 'Use {count} coins anyway',
consolidateTitle: 'Consolidate coins',
consolidateDesc: 'Your balance is spread across {n} coins. Merging them into one makes future sends and gifts cheaper and smaller.',
consolidateAction: 'Merge {n} coins into one',
consolidateDone: 'Consolidating — your coins are merging into one.',
consolidateFailed: 'Consolidation failed.',
claimWithExtension: 'Claim with nostr extension',
orEnterCode: 'or paste the claim code',
extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.',
extNoNip44: 'Your nostr extension doesn’t support NIP-44 — paste the code instead.',
extDecryptFailed: 'Couldn’t decrypt with the extension — try the code.',
nostrKeyTitle: 'Nostr address',
nostrKeyDesc: 'Share this so others can lock a gift to you — the claim code is sent here as a nostr DM.',
giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.',
giftSplitFirst: 'Split a coin first',
giftLockWhole: 'Lock the whole coin ({n})',
giftSplitFailed: 'Could not split the coin.',
giftChecking: 'Opening your gift…',
giftTakenTitle: 'Already claimed',
giftTakenBody: 'Sorry, this gift has already been claimed.',
createWalletAnyway: 'Create a wallet anyway',
goToHome: 'Go to home',
newWalletTitle: 'Your new wallet',
newWalletBody: 'Back up your recovery phrase to keep it safe.',
giftHistoryTitle: 'Gift link',
giftUnclaimedTag: 'unclaimed',
giftCancel: 'Cancel',
viewAllGifts: 'View all gifts ({n})',
prevPage: '‹ Prev',
nextPage: 'Next ›',
pageXofY: 'Page {x} of {y}',
lockedInGifts: 'Locked in gifts',
giftReserved: 'Unclaimed gift links ({n})',
giftReclaim: 'Reclaim',
giftReclaimPrompt: 'Reclaim frees this coin for your next payment, but the link stays claimable until you spend it. Pay a network fee to revoke it right now instead?',
giftReclaimed: 'Coin reclaimed',
giftReclaimedTag: 'still claimable',
giftRevoke: 'Revoke now',
giftRevokeConfirm: 'Revoke this gift now? This spends the coin back into your wallet and disables the link.',
giftRevoked: 'Gift revoked',
copyLink: 'Copy link',
claimBody: 'Claim it into a brand-new wallet that only you control.',
claimBtn: 'Claim it',
claimFeeNote: '~{n} fee',
claimFailed: 'This gift has already been claimed or is no longer available.',
claimedTitle: 'Claimed!',
claimedBody: 'Your Bitcoin is on its way. Back up your recovery phrase to keep it safe.',
recoveryWarn: '⚠ Anyone who can see these words can steal your funds. Make sure nobody is watching before you reveal them.',
bip39Passphrase: 'Passphrase',
copyPassphrase: 'Copy passphrase',
revealRecovery: 'Reveal recovery phrase',
offlineTransfer: 'Offline transfer',
offlineTransferDesc: 'Export your coins on an online device, then import the file on an offline device to sign without internet.',
exportSnapshot: '⤓ Export snapshot',
importSnapshot: 'Import snapshot…',
rescan: 'Rescan',
rescanDesc: 'Your current address updates automatically. Rescan an individual address only if a payment was sent to an old (reused) one and isn’t showing.',
rescanWallet: 'Rescan wallet',
rescanAddresses: 'Rescan an address',
rescanAddrDesc: 'Tap Rescan next to the address a payment was sent to. Only that address is re-checked.',
rescanOne: 'Rescan',
rescanDone: 'Address rescanned',
rescanFailed: 'Rescan failed',
unusedTag: 'unused',
scanning: 'Scanning…',
explorer: 'Block explorer',
explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.',
explorerUrl: 'Server URL',
explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api',
walletName: 'Wallet name',
walletSettings: 'Wallet settings',
walletSettingsDesc: 'Name, recovery phrase, public key, and auto-logout for this wallet.',
autolockTitle: 'Log out automatically',
autolockDesc: "After the app has been in the background this long, log this wallet out — it's removed from this device (including from Save to device), so keep its recovery phrase. Other wallets aren't affected. The timer never runs while the app is open and focused.",
autolockNever: 'Never',
autolock1m: 'After 1 minute',
autolock5m: 'After 5 minutes',
autolock1h: 'After 1 hour',
autolock1d: 'After 1 day',
dataSource: 'Data source',
dataSourceDesc: 'Where Hal reads the chain and how it learns about incoming payments.',
modeCoinos: 'coinos (instant)',
modeCoinosDesc: "The default. Chain data and instant payments come from coinos's own Bitcoin node, with public servers as automatic backup.",
modeExplorer: 'Block explorer',
modeExplorerDesc: 'Reads from your chosen block explorer and polls for payments. A bit slower.',
modeElectrum: 'Electrum server',
backendElectrumDesc: 'Data and instant payments over one Electrum WebSocket. Point it at your own server for a fully self-hosted wallet.',
electrumUrl: 'Electrum server (wss://)',
electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004',
sourceUrl: 'Server URL',
sourceUrlHint: 'wss:// for an Electrum server, or an Esplora REST base (https://…/api). The type is detected from the URL.',
detectedElectrum: 'Electrum — data + instant payment notifications.',
detectedEsplora: 'Block explorer (Esplora) — data only; polls for payments.',
silentPaymentNote: 'Silent payment',
spReceiveTitle: 'Silent payment address',
spReceiveDesc: "A reusable address that can't be tracked.",
receiveAddressTab: 'Address',
receiveSpTab: 'Silent payment',
spScan: 'Scan for silent payments',
spScanning: 'Scanning…',
spWatching: 'Watching for payments',
spRescan: 'Rescan',
spReceived: 'Found {n} silent payment output(s): {amount}',
spIndexerTitle: 'Silent payment indexer',
spIndexerDesc: 'Where to scan for received silent payments. It only serves public tweak data — your scan key stays on-device — but a public one can see which blocks hold your outputs, so self-host for full privacy.',
spIndexerUrl: 'Indexer URL',
spIndexerHint: 'A BIP-352 tweak indexer (e.g. your own).',
deviceSync: 'Device sync',
deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.',
syncAcross: 'Sync across devices',
syncOn: 'On',
syncOff: 'Off',
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',
snapshotExported: 'Snapshot exported',
importedNCoins: 'Imported {n} coin(s)',
unmatchedSuffix: ' · {n} address(es) not in this wallet',
importFailed: 'Import failed: {msg}',
copy: 'Copy',
switchUnit: 'Switch BTC / sats',
footerMadeBy: 'made by',
footerSourceOn: 'source available on',
},
};
// --- locale loading -------------------------------------------------------
// Only English is bundled; other languages are fetched on demand from
// locales/<code>.json (emitted by the build) and cached, so each visitor only
// downloads the language they use. t() falls back to English until it loads.
const _inflight = new Map();
export function loadLocale(code) {
if (code === 'en' || STR[code] || !SUPPORTED.has(code)) return Promise.resolve();
if (_inflight.has(code)) return _inflight.get(code);
const p = fetch('locales/' + code + '.json')
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) STR[code] = d; })
.catch(() => {})
.finally(() => _inflight.delete(code));
_inflight.set(code, p);
return p;
}
// --- runtime --------------------------------------------------------------
const SUPPORTED = new Set(LANGS.map(([c]) => c));
const LANG_KEY = 'btc-wallet-lang';
function detect() {
try {
const q = new URLSearchParams(location.search).get('lang');
if (q && SUPPORTED.has(q)) return q;
} catch {}
try {
const saved = localStorage.getItem(LANG_KEY);
if (saved && SUPPORTED.has(saved)) return saved;
} catch {}
try {
for (const l of navigator.languages || [navigator.language || 'en']) {
const code = String(l).toLowerCase().split('-')[0];
if (SUPPORTED.has(code)) return code;
}
} catch {}
return 'en';
}
let lang = detect();
export const getLang = () => lang;
export function setLang(l) {
if (!SUPPORTED.has(l)) return;
lang = l;
try {
localStorage.setItem(LANG_KEY, l);
} catch {}
}
export function t(key, vars) {
let s = (STR[lang] && STR[lang][key]) ?? STR.en[key] ?? key;
if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', vars[k]);
return s;
}
|