{ "404": { "404": "404", "pageNotFound": "Diese Seite konnte nicht gefunden werden." }, "about": { "header": "Über uns", "p1": "Coinos ist eine Open-Source-Bitcoin-Bezahl-App. Im September 2012 starteten wir die Entwicklung in Vancouver, um lokalen Händlern eine bequeme Möglichkeit zu bieten, Bitcoin-Zahlungen zu akzeptieren.", "p2": "Sie können den Quellcode herunterladen, um die Coinos-App mit Ihrem eigenen Bitcoin- und Lightning-Node zu verbinden. Gerne helfen wir Ihnen dabei, ein wertvoller Dienstleister sowie Zahlungsanbieter für Ihre Kunden zu werden.", "p3": "Transaktionen zwischen Nutzerkonten auf unserem Server werden Off-Chain durchgeführt. Diese werden sofort abgewickelt und sind völlig kostenlos sowie unlimitiert.", "team": { "header": "Das Team", "adam": { "name": "Adam Soltys", "position": "Chief Technical Officer", "bio": "Adam ist ein Webentwickler und Bitcoin-Enthusiast, der davon überzeugt ist, dass hartes Geld der Schlüssel zur Steigerung des globalen Wohlstands ist. Er lebt mit seiner Frau Chelsea und seinen zwei Kindern in North Vancouver." }, "secondl1ght": { "name": "secondl1ght", "position": "Web Developer", "bio": "Secondl1ght hat sich das Programmieren selbst beigebracht, ist kopfüber in den Bitcoin-Kaninchenbau gesprungen und hat seinen Fiat-Job aufgegeben, um sich voll und ganz auf die Bitcoin-Entwicklung zu konzentrieren. Er wirkt bei Open-Source-Projekten mit, ist ein Verfechter der Privatsphäre und ein Freiheitsliebhaber." }, "karnage": { "name": "Karnage", "position": "Product Designer", "bio": "Meine Mission ist es, Startup-Gründern zum Erfolg zu verhelfen. Ich entwickle Produkte, die dabei helfen, dieses Ziel zu erreichen." }, "shauna": { "name": "Shauna", "position": "Operations & Marketing", "bio": "Strategie- & Projektmanagement. Unser Universalgenie in allen Bereichen. Findet immer Zeit, um sich auf einen Kaffee zu treffen." }, "cole": { "name": "Cole", "position": "Bitcoin Experte", "bio": "Als zertifizierter Bitcoin-Experte ist Cole ein engagierter Mensch mit großer Leidenschaft für Freiheit. Er entdeckte Bitcoin im Jahr 2016 und beschäftigte sich eingehend damit. Jetzt möchte er das Lightning-Netzwerk so vielen Nutzern wie möglich näherbringen." }, "john": { "name": "John Barratt", "position": "Full Stack Web Developer", "bio": "Wenn er nicht gerade programmiert, findet man John in der Natur beim Entdecken der vielen, großartigen Wanderwege, die BC zu bieten hat." } }, "locations": { "header": "Standorte", "description": "Hier finden Sie einige Unternehmen, welche bereits Zahlungen mit Bitcoin über Coinos akzeptieren. Wir hoffen, dass Ihr Unternehmen das nächste sein wird!" } }, "error": { "header": "Error", "message": "Bitte versuchen Sie es erneut oder kontaktieren Sie unseren Support.", "signIn": "Bitte melden Sie sich an, um fortzufahren." }, "faq": { "header": "FAQ", "cost": { "question": "Welche Gebühren fallen an?", "answer": "Die Registrierung eines Kontos sowie Zahlungen über das Lightning-Netzwerk zu erhalten ist vollkommen kostenlos. Wir erheben eine Gebühr von 0.1%, um Lightning-Bitcoin in Bitcoin umzuwandeln." }, "compatibility": { "question": "Benötige ich ein bestimmtes Gerät oder eine spezielle Software dafür?", "answer": "Nein. Coinos ist web-basiert, das bedeutet, solange Sie über eine aktive Internetverbindung verfügen, können Sie Coinos auf jedem Endgerät nutzen. Einzelhändlern empfehlen wir, ein Telefon oder Tablet zu verwenden." }, "safety": { "question": "Wann wird mein Guthaben ausgezahlt?", "answer": "Lightning-Zahlungen werden sofort abgewickelt. Sie können Ihr Guthaben somit unmittelbar nach Zahlungseingang auf eine andere Wallet übertragen." } }, "footer": { "company": "Unternehmen", "followUs": "Folge uns", "github": "GitHub", "jobs": "Jobs", "support": "Support", "telegram": "Telegram", "twitter": "Twitter", "news": "Neuigkeiten" }, "howItWorks": { "header": "So funktioniert's", "step1": { "header": "1. Erstellen Sie Ihr Konto", "description": "Sie benötigen nur einen Benutzernamen und ein Passwort - und schon geht's los!" }, "step2": { "header": "2. Zahlungen anfordern", "description": "Ganz gleich, wie Sie es normalerweise machen würden, allerdings mit Bitcoin." }, "step3": { "header": "3. Das war's!", "description": "Bezahlen Sie mit Ihrem Bitcoin-Bestand bei anderen Händlern oder sparen Sie diese für schlechtere Zeiten - Sie allein haben die Wahl." } }, "invoice": { "addTip": "Trinkgeld hinzufügen", "addTipq": "Trinkgeld hinzufügen?", "done": "Fertig", "paymentSuccessful": "Zahlung erhalten!", "invoice": "Rechnung", "total": "Gesamtbetrag", "tip": "Trinkgeld" }, "jobs": { "header": "Jobs", "subheader": "Offene Stellen", "pitch1": "Wir sind stets auf der Suche nach Entwicklern, die unser wachsendes Team verstärken möchten (Vollzeit oder Teilzeit, Homeoffice möglich).", "pitch2": "Möchten Sie mit uns an Open-Source-Bitcoin-Projekten arbeiten? Dann werfen Sie doch einen Blick auf unsere offenen Stellen auf", "github": "GitHub", "pitch3": "Wenn wir Ihr Interesse geweckt haben, würden wir uns freuen, von Ihnen zu hören. Schicken Sie uns gerne Ihren Lebenslauf an" }, "landing": { "header_before_icon": "Der", "header_with_icon": "einfachste", "header_after_icon": "Weg um Bitcoin in Ihrem Unternehmen zu akzeptieren.", "subheader": "Erweitern Sie Ihren Kundenstamm und Ihren Umsatz, indem Sie Bitcoin mit einer einfach zu bedienenden Web-App akzeptieren. Keine Software oder Hardware ist dafür erforderlich.", "info1": { "title": "Sofortzahlungen über Lightning", "description": "Unmittelbar nachdem die Zahlung von Ihrem Kunden abgeschlossen wurde, ist diese auch bei Ihnen ersichtlich. Sie müssen sich weder um Rücklastschriften kümmern, noch auf Banküberweisungen warten. Sekundenschnelle Abrechnung - und das Beste daran: Es fallen keine 3% Kreditkartenbearbeitungsgebühren an." }, "info2": { "title": "Einfache Handhabung", "description1": "Geben Sie einfach einen Betrag in Ihrer Landeswährung ein, teilen Sie den QR-Code mit Ihrem Kunden und schon sind Sie fertig.", "description2": "Wir nehmen Ihnen die ganze Arbeit ab, damit Sie sich auf Ihr Geschäft konzentrieren können.", "description3": "Sie haben jederzeit die Möglichkeit, selbst über Ihr Geld zu verfügen und es sofort abzuheben." }, "info3": { "title": "Gestalten Sie Ihre Übersicht nach Ihrem Geschmack", "description": "Passen Sie die Übersicht dem Branding Ihres Unternehmens an oder nutzen Sie unser Standard-Design." } }, "login": { "optional": "Optional", "token": "2FA Token", "username": "Benutzername", "password": "Passwort", "email": "E-Mail", "passwordRecommendation": "Wir empfehlen ein langes, zufälliges Passwort, welches mit einem Passwortgenerator erstellt wurde.", "forgotUserOrPassword": "Benutzername oder Passwort vergessen?", "haveAccount": "Bereits registriert?", "noAccount": "Noch nicht registriert?", "register": "Registrieren", "signIn": "Einloggen", "failed": "Login fehlgeschlagen" }, "nav": { "profile": "Profil", "startInSeconds": "Jetzt loslegen", "account": "Konto", "signIn": "Einloggen", "signOut": "Ausloggen", "dashboard": "Übersicht", "settings": "Einstellungen", "support": "Support" }, "newSiteHeader": "Kommen Sie zu unserer Eröffnungsfeier in Vancouver am 26. Oktober! Auf der Suche nach der alten Coinos-Version? Besuchen Sie classic.coinos.io", "news": { "header": "News", "subheader": "Letzte Updates", "update1": "" }, "user": { "bitcoin": "Bitcoin", "lightning": "Lightning", "invoice": "Rechnung", "unfollow": "Nicht mehr folgen", "following": "Folgen", "follow": "Folgen", "remove": "Entfernen", "scanToPayBTC": "Den QR-Code scannen um mit Bitcoin zu zahlen", "this": "Das", "lightningAddress": "Lightning Adresse", "likeAnEmail": "ist wie eine E-Mail Adresse für Bitcoin. Sie können diese auf Ihrer Website oder auf Ihren Visitenkarten veröffentlichen und mehrfach verwenden.", "dashboard": { "go": "Los", "dashboard": "Übersicht", "accountBalance": "Kontostand", "bitcoinPrice": "Bitcoin Preis:", "conversionFeeWarning": "Es wird eine Abhebungsgebühr von 1% des Gesambetrags erhoben.", "dataFromBinance": "Daten von Binance.", "fetchingRate": "Abfragen...", "optionalNote": "Notiz (optional)", "send": "Senden", "receive": "Erhalten", "withdraw": "Senden", "deposit": "Einzahlung", "voucher": "Beleg" }, "receive": { "lessThan1BTCWarning": "Bitte geben Sie einen Betrag unter 1 Bitcoin ein.", "showQR": "QR-Code zeigen" }, "send": { "contacts": "Kontakte", "scan": "Scannen", "paste": "Einfügen", "next": "Weiter", "placeholder": "Geben Sie eine Bitcoin-Adresse, eine Lightning-Rechnung oder einen Coinos-Benu
// Run: bun tools/sync-outbox-test.js. No live wallet or network required.
import assert from 'node:assert/strict';
import { SyncOutbox } from '../src/sync-outbox.js';
import { NostrSync } from '../src/nostr.js';
import { installSyncWallet } from '../src/features/sync.js';
import { mergeArkStates, adoptArkSnapshot, arkFeature } from '../src/features/ark.js';
import { createHash } from 'node:crypto';

class Storage {
  data = new Map();
  get length() { return this.data.size; }
  key(i) { return [...this.data.keys()][i] ?? null; }
  getItem(k) { return this.data.get(k) ?? null; }
  setItem(k, v) { this.data.set(k, String(v)); }
  removeItem(k) { this.data.delete(k); }
}
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const signer = (passphrase = '') => { const n = new NostrSync(); n.load(mnemonic, passphrase); return n; };
const a = signer(), b = signer('different wallet');
const storage = new Storage();
globalThis.localStorage = storage;
let now = 1788758000000;
let nextTimer = 0;
const timers = new Map();
const scheduled = [];
const deps = {
  storage, now: () => now,
  schedule: (fn, delay) => { const id = ++nextTimer; timers.set(id, fn); scheduled.push(delay); return id; },
  cancel: id => timers.delete(id), warn: () => {},
};
const enqueue = (box, n, state, dtag = 'bitcoin-wallet:x:test:ark') => box.enqueue({
  pubkey: n.pk, dtag, digest: createHash('sha256').update(JSON.stringify(state)).digest('hex'), relays: ['wss://test.invalid'],
  sign: ts => n.stateEvent(state, dtag, ts),
});
const balance = state => state.vtxos.filter(v => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0);
const restored = {
  netName: 'mainnet', savedAt: now, arkServer: 'asp', arkState: {
    vtxos: [{ id: 'deposit', amountSat: 5000, state: 'spent' }, { id: 'change', amountSat: 4499, state: 'spendable', bytes: 'signed-coin' }],
    actions: [], movements: [{ id: 'payment', type: 'ln-send', status: 'complete', amountSat: 501, inputIds: ['deposit'] }], nextKeyIndex: 19,
  },
};

// The exact outage: save a payment, relay refuses it, page dies. A fresh
// outbox retries the same signed event and a new device recovers the change.
const offline = new SyncOutbox({ ...deps, send: async () => false });
enqueue(offline, a, restored);
await offline.flush();
assert.equal(offline.records()[0].acknowledged, false);
assert.ok(scheduled.at(-1) >= 5000);
const received = [];
const restarted = new SyncOutbox({ ...deps, send: async event => { received.push(event); return true; } });
await restarted.flush();
assert.equal(restarted.records()[0].acknowledged, true);
assert.equal(balance(a.decodeStateEvents(received)[0].state.arkState), 4499);
assert.equal(a.decodeStateEvents(received)[0].state.arkState.nextKeyIndex, 19);
console.log('✓ rejected publish survives restart and restores change/history');

// Delivery does not need the live signing key, and never switches to the
// account activated while the relay was offline.
enqueue(restarted, b, { netName: 'mainnet', protected: 'private-nwc-connection' });
const bpk = b.pk;
b.unload();
await restarted.flush();
assert.equal(received.at(-1).pubkey, bpk);
assert.equal(a.decodeStateEvents([received.at(-1)]).length, 0);
const raw = [...storage.data.values()].join('');
assert.ok(!raw.includes('private-nwc-connection') && !raw.includes('signed-coin') && !raw.includes(mnemonic));
console.log('✓ queued snapshots stay encrypted and bound to their original account');

// A relay acceptance for the old event must not erase a newer queued update.
now += 2000;
let release;
const racing = new SyncOutbox({ ...deps, send: () => new Promise(r => { release = r; }) });
enqueue(racing, a, { ...restored, savedAt: now });
const inFlight = racing.flush();
enqueue(racing, a, { ...restored, savedAt: now + 1 });
const latestId = racing.events(a.pk)[0].id;
release(true);
await inFlight;
assert.equal(racing.records().find(r => r.event.id === latestId).acknowledged, false);
assert.ok(racing.events(a.pk)[0].created_at > received[0].created_at);
console.log('✓ late acceptance cannot discard a newer snapshot');

// Changes inside a second coalesce until their timestamp is safe to publish.
const future = racing.events(a.pk)[0].created_at;
enqueue(racing, a, { ...restored, savedAt: now + 2 });
assert.equal(racing.events(a.pk)[0].created_at, future);
let calls = 0;
const timed = new SyncOutbox({ ...deps, send: async () => { calls++; return true; } });
await timed.flush();
assert.equal(calls, 0);
now = future * 1000;
await timed.flush();
assert.equal(calls, 1);
console.log('✓ successive updates obey relay replacement ordering');

// Exercise the actual feature save/restore hooks, including stopping realtime
// immediately after a payment and restoring from a stale deposit-only cache.
function walletStub() {
  const hooks = { saved: [], realtime: [] };
  const wallet = {
    hooks, mnemonic, offline: false, netName: 'mainnet', _savedAt: now,
    txs: [], _cacheExtensions: [],
    registerCacheSavedHook: fn => hooks.saved.push(fn),
    registerLoadHook: () => {}, registerRealtimeHook: h => hooks.realtime.push(h),
    saveCache: () => {}, emit: () => {}, _sortTxs: () => {},
    _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
    _applySnapshot: () => {},
  };
  return wallet;
}
const featureStorage = new Storage();
globalThis.localStorage = featureStorage;
const featureBox = new SyncOutbox({ ...deps, storage: featureStorage, send: async () => false });
const wallet = walletStub();
installSyncWallet(wallet, { outbox: featureBox });
wallet.nostr.load(mnemonic);
let liveArk = restored.arkState;
wallet._cacheExtensions = [{ domain: 'ark', save: () => ({ arkState: liveArk, arkServer: 'asp' }) }];
wallet.hooks.saved[0]({ ...restored, txs: [], utxos: [] });
for (const hook of wallet.hooks.realtime) hook.stop?.();
assert.equal(featureBox.events(a.pk).length, 2);
const captured = featureBox.events(a.pk).map(e => e.id);
wallet.hooks.saved[0]({ ...restored, savedAt: now + 50, txs: [], utxos: [] });
assert.deepEqual(featureBox.events(a.pk).map(e => e.id), captured, 'timestamp-only saves must not republish');
liveArk = { vtxos: [{ id: 'foreign-coin', amountSat: 99 }] };
wallet.nostr.load(mnemonic, 'different wallet');
const state = a.decodeStateEvents(featureBox.events(a.pk)).find(s => s.state.arkState).state.arkState;
assert.equal(balance(state), 4499, 'extension state is captured before account switch');
console.log('✓ realtime stop preserves snapshots; account switches cannot mix domains');

const fresh = walletStub();
installSyncWallet(fresh, { outbox: featureBox });
fresh.nostr.load(mnemonic);
fresh.arkState = { vtxos: [{ id: 'deposit', state: 'spendable', amountSat: 5000 }], movements: [], actions: [] };
fresh.nostr.fetchAllStates = async () => [];
assert.equal(await fresh.syncFromNostr(), true);
assert.equal(balance(fresh.arkState), 4499);
assert.equal(fresh.arkState.movements[0].amountSat, 501);
assert.equal(fresh.arkState.nextKeyIndex, 19);
console.log('✓ fresh session merges encrypted local recovery while relay is unavailable');

// Do not apply a fetched snapshot if the user switched accounts during I/O.
let finishFetch;
fresh.nostr.fetchAllStates = () => new Promise(resolve => { finishFetch = resolve; });
const fetching = fresh.syncFromNostr();
fresh.nostr.load(mnemonic, 'different wallet');
finishFetch([{ state: restored, dtag: 'bitcoin-wallet:x:test:ark', created_at: now / 1000 }]);
assert.equal(await fetching, false);
console.log('✓ account switch during fetch rejects the previous wallet result');

// Stored or relayed events must pass signatures as well as decryption.
const corrupt = { ...JSON.parse(JSON.stringify(received[0])), sig: '00'.repeat(64) };
assert.deepEqual(a.decodeStateEvents([corrupt]), []);
console.log('✓ invalid recovery signatures are rejected');

// The receiving manager is already connected when the relay supplies change.
// Update its live balance and history, keeping references held by an awaiting
// payment driver intact. A second identical snapshot must not trigger a loop.
const pendingCoin = { id: 'pending', amountSat: 100, state: 'pending' };
const pendingAction = { id: 'in-flight', type: 'ln-pay', step: 'initiated' };
const managerState = { vtxos: [pendingCoin, { id: 'deposit', state: 'spendable', amountSat: 5000 }], actions: [pendingAction], movements: [] };
let saves = 0;
const mgr = { state: managerState, _save: () => { saves++; } };
assert.equal(adoptArkSnapshot(mgr, restored.arkState), true);
assert.equal(balance(mgr.state), 4499);
assert.equal(mgr.state.movements[0].amountSat, 501);
assert.equal(mgr.state, managerState);
assert.equal(mgr.state.vtxos.find(v => v.id === 'pending'), pendingCoin);
assert.equal(mgr.state.actions[0], pendingAction);
pendingAction.step = 'done';
pendingCoin.state = 'spent';
assert.equal(mgr.state.actions[0].step, 'done');
assert.equal(mgr.state.vtxos.find(v => v.id === 'pending').state, 'spent');
assert.equal(adoptArkSnapshot(mgr, restored.arkState), false);
assert.equal(saves, 1);
console.log('✓ connected wallet adopts change without restarting or breaking active payments');

// Before the ASP handshake, every device snapshot is parked under its server
// key. Older snapshots arriving last must not overwrite newer change coins.
let extension;
const parkingWallet = { _cacheKey: () => 'parking-test', registerCacheExtension: e => { extension = e; } };
arkFeature({ wallet: parkingWallet, ui: {}, render: () => {} });
const serverPubkey = '02' + 'ab'.repeat(32);
const freshState = { ...restored.arkState, serverPubkey };
extension.load({ arkState: freshState });
extension.load({ arkState: { serverPubkey, vtxos: [], actions: [], movements: [] } });
const held = JSON.parse(localStorage.getItem(parkingWallet._arkHoldKey(serverPubkey)));
assert.equal(balance(held), 4499);
assert.equal(held.movements[0].amountSat, 501);
console.log('✓ older snapshots cannot overwrite recovery while the ASP is connecting');
featureBox.clear();
assert.equal(featureBox.records().length, 0);
console.log('✓ explicit forget-all removes encrypted recovery copies');
process.exit(0); // the feature schedules a lazy-connect callback; no live ASP in this test