{ "lockfileVersion": 1, "configVersion": 0, "workspaces": { "": { "name": "cold2", "dependencies": { "@jsr/fiatjaf__promenade-trusted-dealer": "^0.4.3", "@noble/ciphers": "^2.1.1", "@scure/base": "^1.2.6", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", "jsqr": "^1.4.0", "mediabunny": "^1.61.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4", }, "devDependencies": { "puppeteer-core": "^25.1.0", }, }, }, "packages": { "@jsr/fiatjaf__promenade-trusted-dealer": ["@jsr/fiatjaf__promenade-trusted-dealer@0.4.3", "https://npm.jsr.io/~/11/@jsr/fiatjaf__promenade-trusted-dealer/0.4.3.tgz", { "dependencies": { "@jsr/henrygd__semaphore": "^0.0.2", "@jsr/nostr__tools": "2.16.2", "@noble/curves": "^1.6.0" } }, "sha512-Eyo7TXFtdHM41KI7p30K7osxWE0tNVibNUNIUl5CS2FJD7bxIcWvGXukJ16BBUdxtGy5+mI/CGhrKusJU14PzA=="], "@jsr/henrygd__semaphore": ["@jsr/henrygd__semaphore@0.0.2", "https://npm.jsr.io/~/11/@jsr/henrygd__semaphore/0.0.2.tgz", {}, "sha512-nrwZ8HaqU1Agb2ij8omIxaOCAsKkDHWcwS9hTRumPhZuptwh6/0BJExBd8+eClTYM7jBnZxK+cP4WJRTcHBvCA=="], "@jsr/nostr__tools": ["@jsr/nostr__tools@2.16.2", "https://npm.jsr.io/~/11/@jsr/nostr__tools/2.16.2.tgz", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" } }, "sha512-QK1XwHvAnqEwbimD+ywbLQ3T2iI+/qE/zrRgOhmtjoEGlCWgtbPTNJ6Y/MEunXr6H/MnuHV+s400i/Yk4suvGQ=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/curves": ["@noble/curves@1.9.2", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@puppeteer/browsers": ["@puppeteer/browsers@3.0.4", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^17.7.2" }, "peerDependencies": { "proxy-agent": ">=8.0.1" }, "optionalPeers": ["proxy-agent"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA=="], "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], "@scure/btc-signer": ["@scure/btc-signer@1.8.1", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5", "micro-packed": "~0.7.3" } }, "sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ=="], "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.12", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="], "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "devtools-protocol": ["devtools-protocol@0.0.1624250", "", {}, "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], "mediabunny": ["mediabunny@1.61.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A=="], "micro-packed": ["micro-packed@0.7.3", "", { "dependencies": { "@scure/base": "~1.2.5" } }, "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], "nostr-tools": ["nostr-tools@2.23.5", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw=="], "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], "puppeteer-core": ["puppeteer-core@25.1.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.4", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1624250", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ=="], "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jsr/nostr__tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "@jsr/nostr__tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], "@jsr/nostr__tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], "@jsr/nostr__tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], "@jsr/nostr__tools/@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], "@jsr/nostr__tools/@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], "nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], "nostr-tools/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], "nostr-tools/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], "nostr-tools/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], "nostr-tools/@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], "@jsr/nostr__tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@jsr/nostr__tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], "@jsr/nostr__tools/@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@jsr/nostr__tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@jsr/nostr__tools/@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], } } eferenceError; stop dialing dead relays' href='/coinosv3.git/commit/src/nostr.js?id=59d0991115465106d960d1983fdd1b7c94aef7e6'>59d0991 ^
// Encrypted cross-device state sync via Nostr (opt-out, configurable relays).
//
// 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 the configured relays — so any device with the seed can pull the latest
// state without re-scanning, and a relay only ever keeps the newest copy.
//
// Sync is on by default (relay.coinos.io) but can be turned off or pointed at
// other relays in Settings; the preference lives in localStorage.
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19';
import { SimplePool } from 'nostr-tools/pool';
import { randomBytes } from '@noble/hashes/utils';
import { base64urlnopad } from '@scure/base';
// One shared pool for all relay I/O — it manages connection lifecycles (and the
// CLOSE-after-EOSE handshake) cleanly, so we never send on a half-closed socket.
// Ping + reconnect matter: without them a quietly dropped socket (proxy idle
// timeout, network blip) takes every live subscription with it and nothing
// re-subscribes — sync stops merging and NWC stops answering until a reload.
// On reconnect the pool re-fires open subs with since = last event seen.
const pool = new SimplePool({ enablePing: true, enableReconnect: true });
// Subscribe / publish on an explicit relay set, independent of the sync
// feature's configuration. NWC needs this: the relays it advertises in a
// connection URI must be exactly the ones it listens on, or a client's
// request lands somewhere the wallet isn't and it waits forever.
// ---- relay health ---------------------------------------------------------
// A relay that refuses connections (damus has been down for days at a time)
// otherwise gets retried on every profile fetch, every sync, every chat
// subscribe — each attempt logging a browser-level WebSocket error we cannot
// catch from JS. So we remember failures and stop dialing for a while.
const SICK_MS = 10 * 60_000;
const sick = new Map(); // url -> until
export function markRelaySick(url, why) {
if (!url) return;
if (!sick.has(url)) console.warn(`nostr: ${url} unreachable (${why || 'connect failed'}) — skipping for 10 min`);
sick.set(url, Date.now() + SICK_MS);
}
// Drop known-bad relays, but never hand back an empty set: if everything is
// sick the network probably is, and one doomed attempt beats doing nothing.
function liveRelays(list) {
const now = Date.now();
for (const [u, until] of sick) if (until < now) sick.delete(u);
const ok = (list || []).filter((u) => !sick.has(u));
return ok.length ? ok : (list || []);
}
// Dial a relay once in the background to learn whether it's reachable; a
// rejection here is the only reliable signal nostr-tools gives us.
const probed = new Set();
function probeRelays(list) {
for (const url of list || []) {
if (probed.has(url) || sick.has(url)) continue;
probed.add(url);
Promise.resolve(pool.ensureRelay(url, { connectionTimeout: 6000 }))
.then(() => {})
.catch((e) => markRelaySick(url, e && e.message))
.finally(() => setTimeout(() => probed.delete(url), SICK_MS));
}
}
export function subscribeOn(relays, filter, onEvent, extra = {}) {
try {
// nostr-tools ≥2.23 takes a single filter object here; wrapping it in an
// array serializes as ["REQ",id,[{...}]] which every relay rejects with
// "provided filter is not an object" — and the subscription dies silently.
const live = liveRelays(relays);
probeRelays(relays);
const sub = pool.subscribeMany(live, filter, { onevent: onEvent, ...extra });
return () => { try { sub.close(); } catch {} };
} catch { return () => {}; }
}
export async function queryOn(relays, filter, maxWait = 1500) {
probeRelays(relays);
try { return await pool.querySync(liveRelays(relays), filter, { maxWait }); } catch { return []; }
}
export async function publishOn(relays, evt) {
try {
const live = liveRelays(relays);
const results = await Promise.allSettled(pool.publish(live, evt));
// A rejected publish is a relay refusing the event (policy, rate limit,
// ban). Swallowing that silently once hid a relay-side rejection behind
// what looked like a client bug — always say which relay refused and why.
results.forEach((r, i) => {
if (r.status === 'rejected') {
const msg = r.reason?.message || String(r.reason || '');
// "relay connection errored/closed" means the socket never came up —
// that's a sick relay, not a rejected event.
if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg);
else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
}
});
return results.some((r) => r.status === 'fulfilled');
} catch { return false; }
}
// NIP-47 wallet services sign with their own per-connection key and must
// speak whichever encryption the client asked for, so export both schemes.
export { nip04, nip44 };
export { getPublicKey, finalizeEvent, generateSecretKey };
export function parseNostrPubkey(input) {
const s = (input || '').trim();
if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase();
try { const d = nip19decode(s); if (d.type === 'npub') return d.data; } catch {}
return null;
}
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } }
// Profiles live across the network, so look them up on popular relays (broader
// than the sync relays).
export const PROFILE_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays.
export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
let events;
try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; }
const best = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!best) return null;
try {
const m = JSON.parse(best.content);
return {
name: m.display_name || m.name || null,
picture: m.picture || null,
about: typeof m.about === 'string' ? m.about : null,
nip05: m.nip05 || null,
// NIP-57/LUD-16/06: where to send a Lightning zap. lud16 is a
// lightning address (name@domain); lud06 is a bech32 lnurl.
lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null,
lud06: typeof m.lud06 === 'string' ? m.lud06.trim() : null,
};
} catch { return null; }
}
// Where to deliver a DM to a pubkey: prefer their NIP-17 DM relay list (kind
// 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back).
export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) {
let events;
try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; }
const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
const dm = newest(10050);
if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; }
const nip65 = newest(10002);
if (nip65) { const r = nip65.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'read')).map((t) => t[1]); if (r.length) return r; }
return [];
}
const DTAG = 'bitcoin-wallet';
// Each network syncs under its own d-tag base — a shared one would let a signet
// snapshot overwrite mainnet state (and vice versa). Mainnet keeps the bare tag
// existing wallets already publish under.
export const syncDtag = (netName) => (!netName || netName === 'mainnet' ? DTAG : `${DTAG}:${netName}`);
// Per-DEVICE d-tag: every device publishes to its OWN replaceable-event slot
// (`<base>:d:<deviceId>`), so no device can ever clobber another's snapshot.
// Readers pull ALL of the identity's kind-30078 events and merge across them,
// so a device sees every other device's coins. The legacy shared-tag events
// (from before this) are still read and merged — they just never get written
// to again.
const DEVICE_KEY = 'btc-wallet-device-id';
function deviceId() {
try {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) { id = bytesToHexLocal(randomBytes(8)); localStorage.setItem(DEVICE_KEY, id); }
return id;
} catch { return 'ephemeral'; }
}
const bytesToHexLocal = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
export const deviceDtag = (netName) => `${syncDtag(netName)}:d:${deviceId()}`;
// Per-DOMAIN slot (`<base>:x:<deviceId>:<domain>`): the snapshot is split
// into independent pieces (core wallet, ark, nwc, …) so each event stays far
// below common relay size limits and only changed domains republish. The
// `:x:` marker is deliberately NOT `:d:` — clients from before the split
// don't match it, so they never mistake a domain fragment for a full
// snapshot and wipe their core state with it.
export const domainDtag = (netName, domain) => `${syncDtag(netName)}:x:${deviceId()}:${domain}`;
// True if `evtDtag` is one of OUR slots for `netName` (our device, another
// device, a domain slot, or the legacy shared tag) — selects events to merge.
export const isOurDtag = (evtDtag, netName) => {
const base = syncDtag(netName);
return evtDtag === base || evtDtag.startsWith(base + ':d:') || evtDtag.startsWith(base + ':x:');
};
// True if the event carries CORE wallet fields (legacy full snapshots and the
// split 'core' domain) — the only events safe to apply as a full snapshot.
export const isCoreDtag = (evtDtag, netName) => {
const base = syncDtag(netName);
if (evtDtag === base || evtDtag.startsWith(base + ':d:')) return true;
return evtDtag.startsWith(base + ':x:') && evtDtag.endsWith(':core');
};
// True if the slot belongs to THIS device (legacy or domain form).
export const isOwnDeviceDtag = (evtDtag, netName) =>
evtDtag === deviceDtag(netName) || evtDtag.startsWith(`${syncDtag(netName)}:x:${deviceId()}:`);
const SYNC_KEY = 'btc-wallet-sync';
export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io'];
// --- sync preference (enabled + relays), global, persisted in localStorage ---
export function getSyncConfig() {
try {
const raw = localStorage.getItem(SYNC_KEY);
if (raw) {
const c = JSON.parse(raw);
const relays = Array.isArray(c.relays) ? c.relays : [];
// Always on — the toggle is gone; relays follow the user's NIP-65
// (adoptSyncRelays in the sync feature), coinos relay as the default.
return { enabled: true, relays: relays.length ? relays : DEFAULT_SYNC_RELAYS };
}
} catch {}
return { enabled: true, relays: DEFAULT_SYNC_RELAYS }; // default: on, coinos relay
}
export function setSyncConfig({ enabled, relays }) {
try {
localStorage.setItem(SYNC_KEY, JSON.stringify({ enabled: !!enabled, relays: relays || DEFAULT_SYNC_RELAYS }));
} catch {}
}
export class NostrSync {
constructor() {
this.sk = null;
this.pk = null;
this.ck = null; // self conversation key for NIP-44
this.relays = DEFAULT_SYNC_RELAYS;
this.dtag = DTAG;
}
setDtag(d) { this.dtag = d || DTAG; }
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; }
setRelays(relays) {
this.relays = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS;
}
// Encrypt + publish the latest state to every relay (best-effort). The d-tag
// is passed in so a debounced publish keeps the tag of the network the
// snapshot was taken on, even if the wallet switched networks meanwhile.
async publish(stateObj, dtag = this.dtag) {
if (!this.sk) return;
let evt;
try {
const content = nip44.encrypt(JSON.stringify(stateObj), this.ck);
evt = finalizeEvent({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag]], content }, this.sk);
} catch { return false; }
const results = await Promise.allSettled(pool.publish(this.relays, evt));
// A refused snapshot means devices silently stop syncing — the failure
// that once hid behind "my other device shows no history". Say so.
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn(`nostr: state snapshot (${evt.content.length}b) refused by ${this.relays[i]}:`, r.reason?.message || r.reason);
}
});
return results.some((r) => r.status === 'fulfilled');
}
// Publish an arbitrary event (finalized here with our key) to the relays.
// Used by features that speak their own event kinds (e.g. ark zaps).
async publishEvent(partial) {
if (!this.sk) return null;
const evt = this.signEvent(partial);
if (!evt) return null;
await Promise.allSettled(pool.publish(this.relays, evt));
return evt;
}
// Sign an event with our key WITHOUT publishing it. A NIP-57 zap request
// (kind 9734) is signed by the sender but handed to the recipient's LNURL
// server — never broadcast to relays by us — so it needs signing alone.
signEvent(partial) {
if (!this.sk) return null;
try {
return finalizeEvent({ created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...partial }, this.sk);
} catch { return null; }
}
// Publish an event signed by some other key (the NWC service key). The
// wallet's own key must not sign wallet-service traffic — connections are
// revocable and shouldn't be tied to the user's nostr identity.
async publishSigned(evt, relays = this.relays) {
try { await Promise.allSettled(pool.publish(relays, evt)); return true; } catch { return false; }
}
// Fetch events matching a filter from the relays (best-effort).
async fetchEvents(filter, maxWait = 5000) {
try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; }
}
// Live subscription; returns an unsubscribe function.
subscribeEvents(filter, onEvent) {
try {
// single filter object — see subscribeOn for the array-wrapping trap
const sub = pool.subscribeMany(this.relays, filter, { onevent: onEvent });
return () => { try { sub.close(); } catch {} };
} catch { return () => {}; }
}
// Live subscription to ALL our devices' state events (no #d filter), each
// decrypted and delivered. A save on any device — under any per-device tag —
// arrives here within seconds. The caller filters by network.
subscribeStates(onState) {
if (!this.pk) return () => {};
return this.subscribeEvents(
{ kinds: [30078], authors: [this.pk] },
(ev) => {
try {
const v = JSON.parse(nip44.decrypt(ev.content, this.ck));
const dtag = (ev.tags.find((t) => t[0] === 'd') || [])[1] || '';
if (v) onState(v, dtag);
} catch {}
});
}
// Fetch the newest decrypted state (kept for callers that want a single
// best snapshot). Prefers our own device's slot, else the newest of ours.
async fetch() {
const all = await this.fetchAllStates();
// a domain fragment (ark/nwc slice) is not a usable "best snapshot"
const fulls = all.filter((s) => !s.dtag.includes(':x:') || s.dtag.endsWith(':core'));
return fulls.length ? fulls[0].state : null;
}
// All of this identity's decrypted state snapshots, newest first, each with
// its d-tag. The reader merges ark state across every device and applies the
// newest full snapshot for the (rescannable) on-chain state.
async fetchAllStates() {
if (!this.sk) return [];
let events;
try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk] }, { maxWait: 6000 }); } catch { return []; }
const out = [];
for (const e of events.sort((a, b) => b.created_at - a.created_at)) {
try {
const state = JSON.parse(nip44.decrypt(e.content, this.ck));
if (state) out.push({ state, dtag: (e.tags.find((t) => t[0] === 'd') || [])[1] || '', created_at: e.created_at });
} catch {}
}
return out;
}
}