Notifications

This commit is contained in:
koalasat
2025-07-25 17:33:19 +02:00
parent ab05579fc1
commit 77df638340
17 changed files with 336 additions and 60 deletions

View File

@ -71,10 +71,14 @@ dependencies {
implementation(libs.material)
implementation(libs.okhttp)
implementation(libs.kmp.tor)
implementation(libs.quartz)
implementation(libs.ammolite)
implementation(libs.quartz) {
exclude("net.java.dev.jna")
}
implementation(libs.ammolite) {
exclude("net.java.dev.jna")
}
implementation(libs.jna) { artifact { type = "aar" } }
implementation(libs.security.crypto.ktx)
// Add the KMP Tor binary dependency (contains the native .so files)
implementation(libs.kmp.tor.binary)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)

View File

@ -32,6 +32,8 @@ import com.robosats.models.EncryptedStorage
import com.robosats.services.NotificationsService
import com.robosats.tor.TorKmp
import com.robosats.tor.TorKmpManager
import com.robosats.tor.TorKmpManager.getTorKmpObject
import com.vitorpamplona.ammolite.service.HttpClientManager
class MainActivity : AppCompatActivity() {
private val requestCodePostNotifications: Int = 1
@ -39,6 +41,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var torKmp: TorKmp
private lateinit var loadingContainer: ConstraintLayout
private lateinit var statusTextView: TextView
private lateinit var intentData: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -74,8 +77,27 @@ class MainActivity : AppCompatActivity() {
requestCodePostNotifications,
)
}
val intent = intent
if (intent != null) {
val orderId = intent.getStringExtra("order_id")
if (orderId?.isNotEmpty() == true) {
intentData = orderId
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.let {
val orderId = intent.getStringExtra("order_id")
if (orderId?.isNotEmpty() == true) {
intentData = orderId
}
}
}
/**
* Initialize Notifications service
*/
@ -158,6 +180,8 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
updateStatus("Tor connected successfully. Setting up secure browser...")
HttpClientManager.setDefaultProxy(getTorKmpObject().proxy)
// Now that Tor is connected, set up the WebView
setupWebView()
}
@ -296,6 +320,14 @@ class MainActivity : AppCompatActivity() {
webView.loadUrl("file:///android_asset/index.html")
initializeNotifications()
webView.post {
try {
webView.evaluateJavascript("javascript:window.AndroidDataRobosats = { navigateToPage: '$intentData' }", null)
} catch (e: Exception) {
Log.e("NavigateToPage", "Error evaluating JavaScript: $e")
}
}
}
} catch (e: Exception) {
Log.e("WebViewSetup", "Security error in WebView setup: ${e.message}", e)

View File

@ -7,6 +7,7 @@ import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import com.robosats.models.EncryptedStorage
import com.robosats.models.NostrClient
import com.robosats.tor.TorKmpManager.getTorKmpObject
import okhttp3.Call
import okhttp3.Callback
@ -166,11 +167,10 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
try {
Log.d(TAG, "WebSocket opening: " + path)
Log.d(TAG, "WebSocket opening: $path")
val client: OkHttpClient = Builder()
.connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout
.readTimeout(30, TimeUnit.SECONDS) // Set read timeout
.proxy(getTorKmpObject().proxy)
.build()
@ -236,7 +236,7 @@ class WebAppInterface(private val context: Context, private val webView: WebView
return
}
val websocket = webSockets.get(path)
val websocket = webSockets[path]
if (websocket != null) {
websocket.send(message)
resolvePromise(uuid, "true")
@ -356,6 +356,8 @@ class WebAppInterface(private val context: Context, private val webView: WebView
EncryptedStorage.setEncryptedStorage(sanitizedKey, sanitizedValue)
if (key == "federation_relays") NostrClient.refresh()
// Safely encode and return the result
resolvePromise(uuid, key)
}

View File

@ -7,6 +7,9 @@ import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayPool
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import org.json.JSONArray
import org.json.JSONObject
object NostrClient {
@ -25,6 +28,15 @@ object NostrClient {
subscribeToInbox()
}
fun refresh() {
val federationRelays = EncryptedStorage.getEncryptedStorage("federation_relays")
val relayPool = RelayPool.getAll().map { it.url }
if (federationRelays.toSet() != relayPool.toSet()) {
stop()
start()
}
}
fun checkRelaysHealth() {
if (RelayPool.getAll().isEmpty()) {
stop()
@ -41,45 +53,101 @@ object NostrClient {
}
}
private fun connectRelays() {
fun garagePubKeys(): List<String> {
val garageString = EncryptedStorage.getEncryptedStorage("garage_slots")
var authors = emptyList<String>()
val garage = JSONObject(garageString)
if (garageString.isNotEmpty()) {
val garage = JSONObject(garageString)
val relays = emptyList<String>()
val keys = garage.keys()
while (keys.hasNext()) {
val key = keys.next()
val slot = garage.getJSONObject(key) // Get the value associated with the key
val hexPubKey = slot.getString("nostrPubKey")
if (hexPubKey.isNotEmpty()) {
authors = authors.plus(hexPubKey)
}
}
}
relays.forEach {
Client.sendFilterOnlyIfDisconnected()
if (RelayPool.getRelays(it).isEmpty()) {
RelayPool.addRelay(
Relay(
it,
read = true,
write = false,
forceProxy = false,
activeTypes = COMMON_FEED_TYPES,
),
)
return authors
}
fun getRobotKeyPair(hexPubKey: String): KeyPair {
val garageString = EncryptedStorage.getEncryptedStorage("garage_slots")
var privKey = ""
var pubKey = ""
if (garageString.isNotEmpty()) {
val garage = JSONObject(garageString)
val keys = garage.keys()
while (keys.hasNext() && privKey == "") {
val key = keys.next()
val slot = garage.getJSONObject(key)
val slotPubKey = slot.getString("nostrPubKey")
if (slotPubKey == hexPubKey) {
pubKey = slotPubKey
val nostrSecKeyJson = slot.getJSONObject("nostrSecKey")
val byteArray = ByteArray(nostrSecKeyJson.length()) { index ->
nostrSecKeyJson.getInt(index.toString()).toByte()
}
privKey = byteArray.joinToString("") { byte -> "%02x".format(byte) }
}
}
}
return KeyPair(
Hex.decode(privKey),
Hex.decode(pubKey),
)
}
private fun connectRelays() {
val federationRelays = EncryptedStorage.getEncryptedStorage("federation_relays")
if (federationRelays.isNotEmpty()) {
val relaysUrls = JSONArray(federationRelays)
for (i in 0 until relaysUrls.length()) {
val url = relaysUrls.getString(i)
Client.sendFilterOnlyIfDisconnected()
if (RelayPool.getRelays(url).isEmpty()) {
RelayPool.addRelay(
Relay(
url,
read = true,
write = false,
forceProxy = true,
activeTypes = COMMON_FEED_TYPES,
),
)
}
}
}
}
private fun subscribeToInbox() {
val authors = emptyList<String>()
val garageString = EncryptedStorage.getEncryptedStorage("garage_slots")
if (authors.isNotEmpty()) {
Client.sendFilter(
subscriptionNotificationId,
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = SincePerRelayFilter(
kinds = listOf(1, 4, 6, 7, 9735),
tags = mapOf("p" to authors),
if (garageString.isNotEmpty()) {
val authors = garagePubKeys()
if (authors.isNotEmpty()) {
Client.sendFilter(
subscriptionNotificationId,
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = SincePerRelayFilter(
kinds = listOf(1059),
tags = mapOf("p" to authors),
),
),
),
),
)
)
}
}
}
}

View File

@ -2,12 +2,20 @@ package com.robosats.services
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.IBinder
import android.util.Base64
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.app.NotificationChannelCompat
@ -16,18 +24,29 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.robosats.Connectivity
import com.robosats.R
import com.robosats.RoboIdentities
import com.robosats.models.EncryptedStorage
import com.robosats.models.NostrClient
import com.robosats.models.NostrClient.garagePubKeys
import com.robosats.models.NostrClient.getRobotKeyPair
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.ConcurrentHashMap
import androidx.core.graphics.createBitmap
import com.robosats.MainActivity
class NotificationsService : Service() {
private var channelRelaysId = "RelaysConnections"
@ -35,6 +54,7 @@ class NotificationsService : Service() {
private lateinit var notificationGroup: NotificationChannelGroupCompat
private val roboIdentities = RoboIdentities()
private val timer = Timer()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val processedEvents = ConcurrentHashMap<String, Boolean>()
@ -47,12 +67,24 @@ class NotificationsService : Service() {
relay: Relay,
afterEOSE: Boolean,
) {
if (processedEvents.putIfAbsent(event.id, true) == null) {
if (event is GiftWrapEvent && processedEvents.putIfAbsent(event.id, true) == null) {
Log.d("RobosatsNotifications", "Relay Event: ${relay.url} - $subscriptionId - ${event.toJson()}")
val notify = true
val firstTaggedUser = event.firstTaggedUser()
val authors = garagePubKeys()
if (notify) {
Log.d("RobosatsNotifications", "Relay Event: ${relay.url} - $subscriptionId - Broadcast")
if (firstTaggedUser?.isNotEmpty() == true && authors.contains(firstTaggedUser)) {
Log.d("RobosatsNotifications", "Relay Event: ${relay.url} - $subscriptionId")
var nostrSigner = NostrSignerInternal(getRobotKeyPair(firstTaggedUser))
event.unwrap(nostrSigner) { gift ->
if (gift is SealedGossipEvent) {
gift.unseal(nostrSigner) { rumor ->
if (rumor is ChatMessageEvent) {
displayOrderNotification(rumor, firstTaggedUser)
}
}
}
}
}
}
}
@ -211,4 +243,74 @@ class NotificationsService : Service() {
notificationManager.notify(1, build)
return build
}
private fun displayOrderNotification(event: ChatMessageEvent, hexPubKey: String) {
val notificationManager =
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val orderId = event.firstTag("order_id")
val intent = Intent(applicationContext, MainActivity::class.java).apply {
putExtra("order_id", orderId)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
val garageString = EncryptedStorage.getEncryptedStorage("garage_slots")
var bitmap: Bitmap? = null
if (garageString.isNotEmpty()) {
val garage = JSONObject(garageString)
val keys = garage.keys()
while (keys.hasNext() && bitmap == null) {
val key = keys.next()
val slot = garage.getJSONObject(key)
val slotHexPubKey = slot.getString("nostrPubKey")
if (hexPubKey.isNotEmpty() && hexPubKey == slotHexPubKey) {
val hashId = slot.getString("hashId")
if (hashId.isNotEmpty()) {
val base64Image = roboIdentities.generateRobohash("$hashId;80")
val imageBytes = Base64.decode(base64Image, Base64.DEFAULT)
val rawBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
bitmap = getRoundedBitmap(rawBitmap)
}
}
}
}
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(
applicationContext,
channelNotificationsId,
)
.setContentTitle(orderId?.replace("/", "#")?.replaceFirstChar { it.uppercase() })
.setContentText(event.content)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(bitmap)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
notificationManager.notify(event.id.hashCode(), builder.build())
}
private fun getRoundedBitmap(bitmap: Bitmap): Bitmap {
val output = createBitmap(bitmap.width, bitmap.height)
val canvas = Canvas(output)
val paint = Paint()
val path = Path()
// Create a rounded rectangle path
path.addRoundRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(),
bitmap.width / 2f, bitmap.height / 2f, Path.Direction.CW)
canvas.clipPath(path)
canvas.drawBitmap(bitmap, 0f, 0f, paint)
return output
}
}

View File

@ -12,6 +12,7 @@ activity = "1.10.1"
constraintlayout = "2.2.1"
kmpTor= "4.8.10-0-1.4.5"
kmpTorBinary= "4.8.10-0"
jna = "5.14.0"
okhttp = "5.0.0-alpha.14"
quartz = "0.92.7"
@ -30,6 +31,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
quartz = { module = "com.github.vitorpamplona.amethyst:quartz", version.ref = "quartz" }
ammolite = { module = "com.github.vitorpamplona.amethyst:ammolite", version.ref = "quartz" }
security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@ -12,18 +12,19 @@ const Routes: React.FC = () => {
const { page, navigateToPage } = useContext<UseAppStoreType>(AppContext);
useEffect(() => {
window.addEventListener('navigateToPage', (event) => {
const orderId: string = event?.order_id;
const coordinator: string = event?.coordinator;
if (window.AndroidDataRobosats && garage.currentSlot) {
const orderPath = window.AndroidDataRobosats.navigateToPage ?? '';
const [coordinator, orderId] = orderPath.replace('#', '/').split('/');
window.AndroidDataRobosats = undefined;
if (orderId && coordinator) {
const slot = garage.getSlotByOrder(coordinator, parseInt(orderId, 10));
if (slot?.token) {
garage.setCurrentSlot(slot?.token);
navigateToPage(`order/${coordinator}/${orderId}`, navigate);
}
if (slot?.token) garage.setCurrentSlot(slot.token);
navigateToPage(`order/${coordinator}/${orderId}`, navigate);
}
});
}, []);
}
}, [garage.currentSlot]);
useEffect(() => {
// change tab (page) into the current route

View File

@ -9,7 +9,7 @@ const SelfhostedAlert = (): React.JSX.Element => {
useEffect(() => {
systemClient.getItem('selfhosted-alert').then((result) => {
if (result) setShow(true);
if (!result) setShow(true);
});
}, []);

View File

@ -42,7 +42,7 @@ const UnsafeAlert = (): React.JSX.Element => {
useEffect(() => {
systemClient.getItem('unsafe-alert').then((result) => {
if (result) setShow(true);
if (!result) setShow(true);
});
}, []);

View File

@ -81,7 +81,7 @@ const EncryptedChat: React.FC<Props> = ({
try {
const recipient = {
publicKey,
relayUrl: coordinator.getRelayUrl(coordinator.url),
relayUrl: coordinator.getRelayUrl(),
};
const wrappedEvent = nip17.wrapEvent(slot?.nostrSecKey, recipient, content);

View File

@ -137,11 +137,11 @@ export const AppContextProvider = ({ children }: AppContextProviderProps): React
initialAppContext.acknowledgedWarning,
);
const navigateToPage: (newPage: Page, navigate: NavigateFunction) => void = (
const navigateToPage: (newPage: Page | string, navigate: NavigateFunction) => void = (
newPage,
navigate,
) => {
const pathPage: Page | string = newPage.split('/')[0];
const pathPage: Page | string = newPage.replace('#', '/').split('/')[0];
if (isPage(pathPage)) {
setPage(pathPage);
navigate(`/${newPage}`);

View File

@ -281,8 +281,8 @@ export class Coordinator {
this.book = {};
};
getRelayUrl = (hostUrl: string): string => {
const protocol = hostUrl.includes('https') ? 'wss://' : 'ws://';
getRelayUrl = (): string => {
const protocol = this.url.includes('https') ? 'wss://' : 'ws://';
return this.url.replace(/^https?:\/\//, protocol) + '/relay/';
};
}

View File

@ -13,6 +13,7 @@ import { coordinatorDefaultValues } from './Coordinator.model';
import { updateExchangeInfo } from './Exchange.model';
import eventToPublicOrder from '../utils/nostr';
import RoboPool from '../services/RoboPool';
import { systemClient } from '../services/System';
type FederationHooks = 'onFederationUpdate';
@ -61,6 +62,11 @@ export class Federation {
if (tesnetHost) this.network = 'testnet';
this.connection = null;
this.roboPool = new RoboPool(settings);
if (settings.client === 'mobile') {
const federationUrls = Object.values(this.coordinators).map((c) => c.getRelayUrl());
systemClient.setItem('federation_relays', JSON.stringify(federationUrls));
}
}
private coordinators: Record<string, Coordinator>;

View File

@ -42,12 +42,14 @@ class Garage {
save = (): void => {
systemClient.setItem('garage_slots', JSON.stringify(this.slots));
if (this.currentSlot) systemClient.setItem('garage_current_slot', this.currentSlot);
};
delete = (): void => {
this.slots = {};
this.currentSlot = null;
systemClient.deleteItem('garage_slots');
systemClient.deleteItem('garage_current_slot');
this.triggerHook('onSlotUpdate');
};
@ -73,9 +75,11 @@ class Garage {
);
this.slots[rawSlot.token].updateSlotFromOrder(new Order(rawSlot.lastOrder));
this.slots[rawSlot.token].updateSlotFromOrder(new Order(rawSlot.activeOrder));
this.currentSlot = rawSlot?.token;
}
});
this.currentSlot =
(await systemClient.getItem('garage_current_slot')) ?? Object.keys(rawSlots)[0];
console.log('Robot Garage was loaded from local storage');
this.triggerHook('onSlotUpdate');
}

View File

@ -1,11 +1,16 @@
declare global {
interface Window {
AndroidDataRobosats?: AndroidDataRobosats;
AndroidAppRobosats?: AndroidAppRobosats;
AndroidRobosats?: AndroidRobosats;
RobosatsSettings: 'web-basic' | 'web-pro' | 'selfhosted-basic' | 'selfhosted-pro';
}
}
interface AndroidDataRobosats {
navigateToPage: string;
}
interface AndroidAppRobosats {
getEncryptedStorage: (uuid: string, key: string) => void;
setEncryptedStorage: (uuid: string, key: string, value: string) => void;
@ -38,6 +43,10 @@ class AndroidRobosats {
}
> = {};
public navigateToPage: (orderPath: string) => void = (orderPath) => {
console.log('orderPath', orderPath);
};
public storePromise: (
uuid: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -86,15 +95,15 @@ class AndroidRobosats {
};
public onWSMessage: (path: string, message: string) => void = (path, message) => {
this.WSConnections[path](message);
this.WSConnections[path]?.(message);
};
public onWsError: (path: string) => void = (path) => {
this.WSError[path]();
this.WSError[path]?.();
};
public onWsClose: (path: string) => void = (path) => {
this.WSClose[path]();
this.WSClose[path]?.();
};
}

View File

@ -25,9 +25,9 @@ class RoboPool {
updateRelays = (hostUrl: string, coordinators: Coordinator[]) => {
this.close();
this.relays = [];
const federationRelays = coordinators.map((coord) => coord.getRelayUrl(hostUrl));
// const hostRelay = federationRelays.find((relay) => relay.includes(hostUrl));
const hostRelay = 'ws://45gzfolhp3dcfv6w7a4p2iwekvurdjcf4p2onhnmvyhauwxfsx7kguad.onion/relay/';
const federationRelays = coordinators.map((coord) => coord.getRelayUrl());
const hostRelay = federationRelays.find((relay) => relay.includes(hostUrl));
if (hostRelay) this.relays.push(hostRelay);
while (this.relays.length < 3) {

View File

@ -279,5 +279,51 @@
"mainnetNodesPubkeys": ["028059662c65626f3002290dd3151186bc1952726c03f56012406f3ceadb81cacc"],
"testnetNodesPubkeys": [],
"federated": true
},
"satstralia": {
"longAlias": "Satstralia",
"shortAlias": "satstralia",
"identifier": "satstralia",
"description": "A Decentralized, deflationary currency enthusiast. Hoping to add liquidity to a non-KYC means for purchasing and selling bitcoin. Robosats P2P solves many issues with the modern day fiat system, and FreedomSats is happy to aid in its decentralization. The focus of FreedomSats will be to ensure data privacy and uptime-consistency.",
"motto": "Libertys Network Above",
"color": "#5d2365",
"established": "2025-06-30",
"nostrHexPubkey": "116bf3493edd2136f5e0c6a11b6c24d0eb34bbd5bf8ede965a9548726de710c7",
"contact": {
"email": "FreedomSatoshis@proton.me",
"telegram": "@freedomsats",
"simplex": "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fh--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw%3D%40smp15.simplex.im%2FLjWY29_NGy7vujmzRCmnJocepWWMAk2D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAWRfXNPT2xQihjv0Ef2sV51J2YiAmboLcmE0adlEAv2c%253D%26q%3Dc%26srv%3Doauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
"pgp": "/static/federation/pgp/9D3D3BAF4744305EE3F0B837331AB575DD78D930.asc",
"nostr": "npub1mmfacq4p4xmpeevaz86fv5uuk073tuqry6sk73l9lrtkh2azf0dsykwxuj",
"website": null,
"fingerprint": "9D3D3BAF4744305EE3F0B837331AB575DD78D930"
},
"badges": {
"isFounder": false,
"donatesToDevFund": 20,
"hasGoodOpSec": true,
"hasLargeLimits": false
},
"policies": {
"Privacy Policy 1": "Dispute Evidence Submission: If a dispute arises, you will be asked to provide only the transaction-related documentation needed to evaluate your claim—for example, transaction IDs, paymentconfirmation screenshots, or other pertinent records. Please keep a record of all relevant information.",
"Privacy Policy 2": "Limited Retention Period: All sensitive data gathered for the purpose of resolving a dispute will be retained strictly for the duration of that process. Once the issue is fully resolved, this information will be irrevocably and securely deleted",
"Data Policy 1": "No Third-Party Data Sharing: RoboSats is deliberately designed to collect no user data—no IP addresses, geolocation, or personal identifiers—and will never share information with outside parties.",
"Data Policy 2": "Ephemeral Log Retention: All operational logs required to run the coordinator are automatically deleted after seven days, unless theyre essential for an active dispute.",
"Rule 1:": "Please do not share any personal information through the chat, if possible share as little revealing information as possible.",
"Rule 2:": "Email is provided for convenience but other means of communication are recommended. Like Nostr/SimpleX/Telegram"
},
"mainnet": {
"onion": "http://45gzfolhp3dcfv6w7a4p2iwekvurdjcf4p2onhnmvyhauwxfsx7kguad.onion",
"clearnet": null,
"i2p": null
},
"testnet": {
"onion": null,
"clearnet": null,
"i2p": null
},
"mainnetNodesPubkeys": ["028059662c65626f3002290dd3151186bc1952726c03f56012406f3ceadb81cacc"],
"testnetNodesPubkeys": [],
"federated": true
}
}