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
|
// Encrypted cross-device state sync via Nostr.
//
// The wallet's Nostr identity is derived from the same seed (NIP-06,
// m/44'/1237'/0'/0/0). Wallet state is encrypted to ourselves (NIP-44) and
// published as a single parameterized-replaceable event (kind 30078, NIP-78)
// to relay.coinos.io — so any device with the seed can pull the latest state
// without re-scanning, and the relay only ever keeps the newest copy.
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { Relay } from 'nostr-tools/relay';
const RELAY = 'wss://relay.coinos.io';
const DTAG = 'bitcoin-wallet';
export class NostrSync {
constructor() {
this.sk = null;
this.pk = null;
this.ck = null; // self conversation key for NIP-44
this._relay = null;
}
load(mnemonic, passphrase = '') {
this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined);
this.pk = getPublicKey(this.sk);
this.ck = nip44.getConversationKey(this.sk, this.pk);
}
unload() {
this.sk = this.pk = this.ck = null;
if (this._relay) {
try {
this._relay.close();
} catch {}
this._relay = null;
}
}
async _conn() {
if (this._relay && this._relay.connected) return this._relay;
this._relay = await Relay.connect(RELAY);
return this._relay;
}
// Encrypt + publish the latest state (best-effort; never throws).
async publish(stateObj) {
if (!this.sk) return;
try {
const content = nip44.encrypt(JSON.stringify(stateObj), this.ck);
const evt = finalizeEvent(
{ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content },
this.sk
);
const relay = await this._conn();
await relay.publish(evt);
} catch {
/* offline / relay down — localStorage still has the state */
}
}
// Fetch + decrypt the latest state, or null.
async fetch() {
if (!this.sk) return null;
try {
const relay = await this._conn();
return await new Promise((resolve) => {
let result = null;
const sub = relay.subscribe([{ kinds: [30078], authors: [this.pk], '#d': [DTAG], limit: 1 }], {
onevent: (e) => {
try {
result = JSON.parse(nip44.decrypt(e.content, this.ck));
} catch {}
},
oneose: () => {
try {
sub.close();
} catch {}
resolve(result);
},
});
setTimeout(() => {
try {
sub.close();
} catch {}
resolve(result);
}, 6000);
});
} catch {
return null;
}
}
}
|