From 36cc20bf45a438dfb8fc4bedbfc3365fc6198431 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 16 Jan 2026 18:39:53 +0100 Subject: [PATCH] Add com.micropythonos.nostr Initial commit, not ready for release. --- .../META-INF/MANIFEST.JSON | 23 ++ .../assets/confetti.py | 182 ++++++++++++ .../assets/fullscreen_qr.py | 22 ++ .../com.micropythonos.nostr/assets/nostr.py | 219 ++++++++++++++ .../assets/nostr_client.py | 281 ++++++++++++++++++ .../com.micropythonos.nostr/assets/payment.py | 43 +++ .../assets/unique_sorted_list.py | 43 +++ .../res/drawable-mdpi/confetti0.png | Bin 0 -> 5361 bytes .../res/drawable-mdpi/confetti1.png | Bin 0 -> 3888 bytes .../res/drawable-mdpi/confetti2.png | Bin 0 -> 1611 bytes .../res/drawable-mdpi/confetti3.png | Bin 0 -> 2829 bytes .../res/drawable-mdpi/confetti4.png | Bin 0 -> 2711 bytes .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 5864 bytes scripts/bundle_apps.sh | 1 + 14 files changed, 814 insertions(+) create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti2.png create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti3.png create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti4.png create mode 100644 internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..8ba7214e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Nostr", +"publisher": "MicroPythonOS", +"short_description": "Nostr", +"long_description": "Notest and Other Stuff Transmitted by Relays", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/icons/com.micropythonos.nostr_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/mpks/com.micropythonos.nostr_0.1.0.mpk", +"fullname": "com.micropythonos.nostr", +"version": "0.1.0", +"category": "communication", +"activities": [ + { + "entrypoint": "assets/nostr.py", + "classname": "Nostr", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py new file mode 100644 index 00000000..ba781142 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py @@ -0,0 +1,182 @@ +import time +import random +import lvgl as lv +import mpos.ui + + +class Confetti: + """Manages confetti animation with physics simulation.""" + + def __init__(self, screen, icon_path, asset_path, duration=10000): + """ + Initialize the Confetti system. + + Args: + screen: The LVGL screen/display object + icon_path: Path to icon assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/mipmap-mdpi/") + asset_path: Path to confetti assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/drawable-mdpi/") + max_confetti: Maximum number of confetti pieces to display + """ + self.screen = screen + self.icon_path = icon_path + self.asset_path = asset_path + self.duration = duration + self.max_confetti = 21 + + # Physics constants + self.GRAVITY = 100 # pixels/sec² + + # Screen dimensions + self.screen_width = screen.get_display().get_horizontal_resolution() + self.screen_height = screen.get_display().get_vertical_resolution() + + # State + self.is_running = False + self.last_time = time.ticks_ms() + self.confetti_pieces = [] + self.confetti_images = [] + self.used_img_indices = set() + + # Spawn control + self.spawn_timer = 0 + self.spawn_interval = 0.15 # seconds + self.animation_start = 0 + + + # Pre-create LVGL image objects + self._init_images() + + def _init_images(self): + """Pre-create LVGL image objects for confetti.""" + iconimages = 2 + for _ in range(iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.icon_path}icon_64x64.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + for i in range(self.max_confetti - iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.asset_path}confetti{random.randint(0, 4)}.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + def start(self): + """Start the confetti animation.""" + if self.is_running: + return + + self.is_running = True + self.last_time = time.ticks_ms() + self._clear_confetti() + + # Staggered spawn control + self.spawn_timer = 0 + self.animation_start = time.ticks_ms() / 1000.0 + + # Initial burst + for _ in range(10): + self._spawn_one() + + # Register update callback + mpos.ui.task_handler.add_event_cb(self._update_frame, 1) + + # Stop spawning after 15 seconds + lv.timer_create(self.stop, self.duration, None).set_repeat_count(1) + + def stop(self, timer=None): + """Stop the confetti animation.""" + self.is_running = False + + def _clear_confetti(self): + """Clear all confetti pieces from the screen.""" + for img in self.confetti_images: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_pieces = [] + self.used_img_indices.clear() + + def _update_frame(self, a, b): + """Update frame for confetti animation. Called by task handler.""" + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # === STAGGERED SPAWNING === + if self.is_running: + self.spawn_timer += delta_time + if self.spawn_timer >= self.spawn_interval: + self.spawn_timer = 0 + for _ in range(random.randint(1, 2)): + if len(self.confetti_pieces) < self.max_confetti: + self._spawn_one() + + # === UPDATE ALL PIECES === + new_pieces = [] + for piece in self.confetti_pieces: + # Physics + piece['age'] += delta_time + piece['x'] += piece['vx'] * delta_time + piece['y'] += piece['vy'] * delta_time + piece['vy'] += self.GRAVITY * delta_time + piece['rotation'] += piece['spin'] * delta_time + piece['scale'] = max(0.3, 1.0 - (piece['age'] / piece['lifetime']) * 0.7) + + # Render + img = self.confetti_images[piece['img_idx']] + img.remove_flag(lv.obj.FLAG.HIDDEN) + img.set_pos(int(piece['x']), int(piece['y'])) + img.set_rotation(int(piece['rotation'] * 10)) + orig = img.get_width() + if orig >= 64: + img.set_scale(int(256 * piece['scale'] / 1.5)) + elif orig < 32: + img.set_scale(int(256 * piece['scale'] * 1.5)) + else: + img.set_scale(int(256 * piece['scale'])) + + # Death check + dead = ( + piece['x'] < -60 or piece['x'] > self.screen_width + 60 or + piece['y'] > self.screen_height + 60 or + piece['age'] > piece['lifetime'] + ) + + if dead: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.used_img_indices.discard(piece['img_idx']) + else: + new_pieces.append(piece) + + self.confetti_pieces = new_pieces + + # Full stop when empty and paused + if not self.confetti_pieces and not self.is_running: + print("Confetti finished") + mpos.ui.task_handler.remove_event_cb(self._update_frame) + + def _spawn_one(self): + """Spawn a single confetti piece.""" + if not self.is_running: + return + + # Find a free image slot + for idx, img in enumerate(self.confetti_images): + if img.has_flag(lv.obj.FLAG.HIDDEN) and idx not in self.used_img_indices: + break + else: + return # No free slot + + piece = { + 'img_idx': idx, + 'x': random.uniform(-50, self.screen_width + 50), + 'y': random.uniform(50, 100), # Start above screen + 'vx': random.uniform(-80, 80), + 'vy': random.uniform(-150, 0), + 'spin': random.uniform(-500, 500), + 'age': 0.0, + 'lifetime': random.uniform(5.0, 10.0), # Long enough to fill 10s + 'rotation': random.uniform(0, 360), + 'scale': 1.0 + } + self.confetti_pieces.append(piece) + self.used_img_indices.add(idx) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py new file mode 100644 index 00000000..0941c855 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py @@ -0,0 +1,22 @@ +import lvgl as lv + +from mpos import Activity, min_resolution + +class FullscreenQR(Activity): + # No __init__() so super.__init__() will be called automatically + + def onCreate(self): + receive_qr_data = self.getIntent().extras.get("receive_qr_data") + qr_screen = lv.obj() + qr_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + qr_screen.set_scroll_dir(lv.DIR.NONE) + qr_screen.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) + big_receive_qr = lv.qrcode(qr_screen) + big_receive_qr.set_size(min_resolution()) + big_receive_qr.set_dark_color(lv.color_black()) + big_receive_qr.set_light_color(lv.color_white()) + big_receive_qr.center() + big_receive_qr.set_style_border_color(lv.color_white(), 0) + big_receive_qr.set_style_border_width(0, 0); + big_receive_qr.update(receive_qr_data, len(receive_qr_data)) + self.setContentView(qr_screen) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py new file mode 100644 index 00000000..4e9063f7 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py @@ -0,0 +1,219 @@ +import lvgl as lv + +from mpos import Activity, Intent, ConnectivityManager, MposKeyboard, pct_of_display_width, pct_of_display_height, SharedPreferences, SettingsActivity +from mpos.ui.anim import WidgetAnimator + +from fullscreen_qr import FullscreenQR + +class Nostr(Activity): + + wallet = None + receive_qr_data = None + destination = None + balance_mode = 0 # 0=sats, 1=bits, 2=μBTC, 3=mBTC, 4=BTC + payments_label_current_font = 2 + payments_label_fonts = [ lv.font_montserrat_10, lv.font_unscii_8, lv.font_montserrat_16, lv.font_montserrat_24, lv.font_unscii_16, lv.font_montserrat_28_compressed, lv.font_montserrat_40] + + # screens: + main_screen = None + + # widgets + balance_label = None + receive_qr = None + payments_label = None + + # activities + fullscreenqr = FullscreenQR() # need a reference to be able to finish() it + + def onCreate(self): + self.prefs = SharedPreferences("com.micropythonos.nostr") + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(10, 0) + # This line needs to be drawn first, otherwise it's over the balance label and steals all the clicks! + balance_line = lv.line(self.main_screen) + balance_line.set_points([{'x':0,'y':35},{'x':200,'y':35}],2) + balance_line.add_flag(lv.obj.FLAG.CLICKABLE) + self.balance_label = lv.label(self.main_screen) + self.balance_label.set_text("") + self.balance_label.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.balance_label.set_style_text_font(lv.font_montserrat_24, 0) + self.balance_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.balance_label.set_width(pct_of_display_width(75)) # 100 - receive_qr + self.balance_label.add_event_cb(self.balance_label_clicked_cb,lv.EVENT.CLICKED,None) + self.receive_qr = lv.qrcode(self.main_screen) + self.receive_qr.set_size(pct_of_display_width(20)) # bigger QR results in simpler code (less error correction?) + self.receive_qr.set_dark_color(lv.color_black()) + self.receive_qr.set_light_color(lv.color_white()) + self.receive_qr.align(lv.ALIGN.TOP_RIGHT,0,0) + self.receive_qr.set_style_border_color(lv.color_white(), 0) + self.receive_qr.set_style_border_width(1, 0); + self.receive_qr.add_flag(lv.obj.FLAG.CLICKABLE) + self.receive_qr.add_event_cb(self.qr_clicked_cb,lv.EVENT.CLICKED,None) + self.payments_label = lv.label(self.main_screen) + self.payments_label.set_text("") + self.payments_label.align_to(balance_line,lv.ALIGN.OUT_BOTTOM_LEFT,0,10) + self.update_payments_label_font() + self.payments_label.set_width(pct_of_display_width(75)) # 100 - receive_qr + self.payments_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.payments_label.add_event_cb(self.payments_label_clicked,lv.EVENT.CLICKED,None) + settings_button = lv.button(self.main_screen) + settings_button.set_size(lv.pct(20), lv.pct(25)) + settings_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + settings_button.add_event_cb(self.settings_button_tap,lv.EVENT.CLICKED,None) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.set_style_text_font(lv.font_montserrat_24, 0) + settings_label.center() + if False: # send button disabled for now, not implemented + send_button = lv.button(self.main_screen) + send_button.set_size(lv.pct(20), lv.pct(25)) + send_button.align_to(settings_button, lv.ALIGN.OUT_TOP_MID, 0, -pct_of_display_height(2)) + send_button.add_event_cb(self.send_button_tap,lv.EVENT.CLICKED,None) + send_label = lv.label(send_button) + send_label.set_text(lv.SYMBOL.UPLOAD) + send_label.set_style_text_font(lv.font_montserrat_24, 0) + send_label.center() + self.setContentView(self.main_screen) + + def onStart(self, main_screen): + self.main_ui_set_defaults() + + def onResume(self, main_screen): + super().onResume(main_screen) + cm = ConnectivityManager.get() + cm.register_callback(self.network_changed) + self.network_changed(cm.is_online()) + + def onPause(self, main_screen): + if self.wallet and self.destination != FullscreenQR: + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + self.destination = None + cm = ConnectivityManager.get() + cm.unregister_callback(self.network_changed) + + def network_changed(self, online): + print("displaywallet.py network_changed, now:", "ONLINE" if online else "OFFLINE") + if online: + self.went_online() + else: + self.went_offline() + + def went_online(self): + if self.wallet and self.wallet.is_running(): + print("wallet is already running, nothing to do") # might have come from the QR activity + return + try: + from nwc_wallet import NWCWallet + self.wallet = NWCWallet(self.prefs.get_string("nwc_url")) + self.wallet.static_receive_code = self.prefs.get_string("nwc_static_receive_code") + self.redraw_static_receive_code_cb() + except Exception as e: + self.error_cb(f"Couldn't initialize NWC Wallet because: {e}") + return + self.balance_label.set_text(lv.SYMBOL.REFRESH) + self.payments_label.set_text(f"\nConnecting to {wallet_type} backend.\n\nIf this takes too long, it might be down or something's wrong with the settings.") + # by now, self.wallet can be assumed + self.wallet.start(self.balance_updated_cb, self.redraw_payments_cb, self.redraw_static_receive_code_cb, self.error_cb) + + def went_offline(self): + if self.wallet: + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + self.payments_label.set_text(f"WiFi is not connected, can't talk to wallet...") + + def update_payments_label_font(self): + self.payments_label.set_style_text_font(self.payments_label_fonts[self.payments_label_current_font], 0) + + def payments_label_clicked(self, event): + self.payments_label_current_font = (self.payments_label_current_font + 1) % len(self.payments_label_fonts) + self.update_payments_label_font() + + def float_to_string(self, value): + # Format float to string with fixed-point notation, up to 6 decimal places + s = "{:.8f}".format(value) + # Remove trailing zeros and decimal point if no decimals remain + return s.rstrip("0").rstrip(".") + + def display_balance(self, balance): + #print(f"displaying balance {balance}") + if self.balance_mode == 0: # sats + #balance_text = "丰 " + str(balance) # font doesnt support it + balance_text = str(balance) + " sat" + if balance > 1: + balance_text += "s" + elif self.balance_mode == 1: # bits (1 bit = 100 sats) + balance_bits = balance / 100 + balance_text = self.float_to_string(balance_bits) + " bit" + if balance_bits != 1: + balance_text += "s" + elif self.balance_mode == 2: # micro-BTC (1 μBTC = 100 sats) + balance_ubtc = balance / 100 + balance_text = self.float_to_string(balance_ubtc) + " micro-BTC" + elif self.balance_mode == 3: # milli-BTC (1 mBTC = 100000 sats) + balance_mbtc = balance / 100000 + balance_text = self.float_to_string(balance_mbtc) + " milli-BTC" + elif self.balance_mode == 4: # BTC (1 BTC = 100000000 sats) + balance_btc = balance / 100000000 + #balance_text = "₿ " + str(balance) # font doesnt support it - although it should https://fonts.google.com/specimen/Montserrat + balance_text = self.float_to_string(balance_btc) + " BTC" + self.balance_label.set_text(balance_text) + #print("done displaying balance") + + def balance_updated_cb(self, sats_added=0): + print(f"balance_updated_cb(sats_added={sats_added})") + if self.fullscreenqr.has_foreground(): + self.fullscreenqr.finish() + balance = self.wallet.last_known_balance + print(f"balance: {balance}") + + def redraw_payments_cb(self): + # this gets called from another thread (the wallet) so make sure it happens in the LVGL thread using lv.async_call(): + self.payments_label.set_text(str(self.wallet.payment_list)) + + def redraw_static_receive_code_cb(self): + # this gets called from another thread (the wallet) so make sure it happens in the LVGL thread using lv.async_call(): + self.receive_qr_data = self.wallet.static_receive_code + if self.receive_qr_data: + self.receive_qr.update(self.receive_qr_data, len(self.receive_qr_data)) + else: + print("Warning: redraw_static_receive_code_cb() was called while self.wallet.static_receive_code is None...") + + def error_cb(self, error): + if self.wallet and self.wallet.is_running(): + self.payments_label.set_text(str(error)) + + def should_show_setting(self, setting): + wallet_type = self.prefs.get_string("wallet_type") + if wallet_type != "lnbits" and setting["key"].startswith("lnbits_"): + return False + if wallet_type != "nwc" and setting["key"].startswith("nwc_"): + return False + return True + + def settings_button_tap(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + {"title": "Wallet Type", "key": "wallet_type", "ui": "radiobuttons", "ui_options": [("LNBits", "lnbits"), ("Nostr Wallet Connect", "nwc")]}, + {"title": "LNBits URL", "key": "lnbits_url", "placeholder": "https://demo.lnpiggy.com", "should_show": self.should_show_setting}, + {"title": "LNBits Read Key", "key": "lnbits_readkey", "placeholder": "fd92e3f8168ba314dc22e54182784045", "should_show": self.should_show_setting}, + {"title": "Optional LN Address", "key": "lnbits_static_receive_code", "placeholder": "Will be fetched if empty.", "should_show": self.should_show_setting}, + {"title": "Nost Wallet Connect", "key": "nwc_url", "placeholder": "nostr+walletconnect://69effe7b...", "should_show": self.should_show_setting}, + {"title": "Optional LN Address", "key": "nwc_static_receive_code", "placeholder": "Optional if present in NWC URL.", "should_show": self.should_show_setting}, + ]) + self.startActivity(intent) + + def main_ui_set_defaults(self): + self.balance_label.set_text("Welcome!") + self.payments_label.set_text(lv.SYMBOL.REFRESH) + + def balance_label_clicked_cb(self, event): + print("Balance clicked") + self.balance_mode = (self.balance_mode + 1) % 5 + self.display_balance(self.wallet.last_known_balance) + + def qr_clicked_cb(self, event): + print("QR clicked") + if not self.receive_qr_data: + return + self.destination = FullscreenQR + self.startActivity(Intent(activity_class=self.fullscreenqr).putExtra("receive_qr_data", self.receive_qr_data)) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py new file mode 100644 index 00000000..52c817e6 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py @@ -0,0 +1,281 @@ +import ssl +import json +import time + +from mpos.util import urldecode +from mpos import TaskManager + +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.filter import Filter, Filters +from nostr.event import EncryptedDirectMessage +from nostr.key import PrivateKey + +from payment import Payment +from unique_sorted_list import UniqueSortedList + +class NWCWallet(): + + PAYMENTS_TO_SHOW = 6 + PERIODIC_FETCH_BALANCE_SECONDS = 60 # seconds + + relays = [] + secret = None + wallet_pubkey = None + + def __init__(self, nwc_url): + super().__init__() + self.nwc_url = nwc_url + if not nwc_url: + raise ValueError('NWC URL is not set.') + self.connected = False + self.relays, self.wallet_pubkey, self.secret, self.lud16 = self.parse_nwc_url(self.nwc_url) + if not self.relays: + raise ValueError('Missing relay in NWC URL.') + if not self.wallet_pubkey: + raise ValueError('Missing public key in NWC URL.') + if not self.secret: + raise ValueError('Missing "secret" in NWC URL.') + #if not self.lud16: + # raise ValueError('Missing lud16 (= lightning address) in NWC URL.') + + def getCommentFromTransaction(self, transaction): + comment = "" + try: + comment = transaction["description"] + if comment is None: + return comment + json_comment = json.loads(comment) + for field in json_comment: + if field[0] == "text/plain": + comment = field[1] + break + else: + print("text/plain field is missing from JSON description") + except Exception as e: + print(f"Info: comment {comment} is not JSON, this is fine, using as-is ({e})") + comment = super().try_parse_as_zap(comment) + return comment + + async def async_wallet_manager_task(self): + if self.lud16: + self.handle_new_static_receive_code(self.lud16) + + self.private_key = PrivateKey(bytes.fromhex(self.secret)) + self.relay_manager = RelayManager() + for relay in self.relays: + self.relay_manager.add_relay(relay) + + print(f"DEBUG: Opening relay connections") + await self.relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) + self.connected = False + nrconnected = 0 + for _ in range(100): + await TaskManager.sleep(0.1) + nrconnected = self.relay_manager.connected_or_errored_relays() + #print(f"Waiting for relay connections, currently: {nrconnected}/{len(self.relays)}") + if nrconnected == len(self.relays) or not self.keep_running: + break + if nrconnected == 0: + self.handle_error("Could not connect to any Nostr Wallet Connect relays.") + return + if not self.keep_running: + print(f"async_wallet_manager_task does not have self.keep_running, returning...") + return + + print(f"{nrconnected} relays connected") + + # Set up subscription to receive response + self.subscription_id = "micropython_nwc_" + str(round(time.time())) + print(f"DEBUG: Setting up subscription with ID: {self.subscription_id}") + self.filters = Filters([Filter( + #event_ids=[self.subscription_id], would be nice to filter, but not like this + kinds=[23195, 23196], # NWC reponses and notifications + authors=[self.wallet_pubkey], + pubkey_refs=[self.private_key.public_key.hex()] + )]) + print(f"DEBUG: Subscription filters: {self.filters.to_json_array()}") + self.relay_manager.add_subscription(self.subscription_id, self.filters) + print(f"DEBUG: Creating subscription request") + request_message = [ClientMessageType.REQUEST, self.subscription_id] + request_message.extend(self.filters.to_json_array()) + print(f"DEBUG: Publishing subscription request") + self.relay_manager.publish_message(json.dumps(request_message)) + print(f"DEBUG: Published subscription request") + + last_fetch_balance = time.time() - self.PERIODIC_FETCH_BALANCE_SECONDS + while True: # handle incoming events and do periodic fetch_balance + #print(f"checking for incoming events...") + await TaskManager.sleep(0.1) + if not self.keep_running: + print("NWCWallet: not keep_running, closing connections...") + await self.relay_manager.close_connections() + break + + if time.time() - last_fetch_balance >= self.PERIODIC_FETCH_BALANCE_SECONDS: + last_fetch_balance = time.time() + try: + await self.fetch_balance() + except Exception as e: + print(f"fetch_balance got exception {e}") # fetch_balance got exception 'NoneType' object isn't iterable?! + + start_time = time.ticks_ms() + if self.relay_manager.message_pool.has_events(): + print(f"DEBUG: Event received from message pool after {time.ticks_ms()-start_time}ms") + event_msg = self.relay_manager.message_pool.get_event() + event_created_at = event_msg.event.created_at + print(f"Received at {time.localtime()} a message with timestamp {event_created_at} after {time.ticks_ms()-start_time}ms") + try: + # This takes a very long time, even for short messages: + decrypted_content = self.private_key.decrypt_message( + event_msg.event.content, + event_msg.event.public_key, + ) + print(f"DEBUG: Decrypted content: {decrypted_content} after {time.ticks_ms()-start_time}ms") + response = json.loads(decrypted_content) + print(f"DEBUG: Parsed response: {response}") + result = response.get("result") + if result: + if result.get("balance") is not None: + new_balance = round(int(result["balance"]) / 1000) + print(f"Got balance: {new_balance}") + self.handle_new_balance(new_balance) + elif result.get("transactions") is not None: + print("Response contains transactions!") + new_payment_list = UniqueSortedList() + for transaction in result["transactions"]: + amount = transaction["amount"] + amount = round(amount / 1000) + comment = self.getCommentFromTransaction(transaction) + epoch_time = transaction["created_at"] + paymentObj = Payment(epoch_time, amount, comment) + new_payment_list.add(paymentObj) + if len(new_payment_list) > 0: + # do them all in one shot instead of one-by-one because the lv_async() isn't always chronological, + # so when a long list of payments is added, it may be overwritten by a short list + self.handle_new_payments(new_payment_list) + else: + notification = response.get("notification") + if notification: + amount = notification["amount"] + amount = round(amount / 1000) + type = notification["type"] + if type == "outgoing": + amount = -amount + elif type == "incoming": + new_balance = self.last_known_balance + amount + self.handle_new_balance(new_balance, False) # don't trigger full fetch because payment info is in notification + epoch_time = notification["created_at"] + comment = self.getCommentFromTransaction(notification) + paymentObj = Payment(epoch_time, amount, comment) + self.handle_new_payment(paymentObj) + else: + print(f"WARNING: invalid notification type {type}, ignoring.") + else: + print("Unsupported response, ignoring.") + except Exception as e: + print(f"DEBUG: Error processing response: {e}") + import sys + sys.print_exception(e) # Full traceback on MicroPython + else: + #print(f"pool has no events after {time.ticks_ms()-start_time}ms") # completes in 0-1ms + pass + + def fetch_balance(self): + try: + if not self.keep_running: + return + # Create get_balance request + balance_request = { + "method": "get_balance", + "params": {} + } + print(f"DEBUG: Created balance request: {balance_request}") + print(f"DEBUG: Creating encrypted DM to wallet pubkey: {self.wallet_pubkey}") + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(balance_request), + kind=23194 + ) + print(f"DEBUG: Signing DM {json.dumps(dm)} with private key") + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print(f"DEBUG: Publishing encrypted DM") + self.relay_manager.publish_event(dm) + except Exception as e: + print(f"inside fetch_balance exception: {e}") + + def fetch_payments(self): + if not self.keep_running: + return + # Create get_balance request + list_transactions = { + "method": "list_transactions", + "params": { + "limit": self.PAYMENTS_TO_SHOW + } + } + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(list_transactions), + kind=23194 + ) + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print("\nPublishing DM to fetch payments...") + self.relay_manager.publish_event(dm) + + def parse_nwc_url(self, nwc_url): + """Parse Nostr Wallet Connect URL to extract pubkey, relays, secret, and lud16.""" + print(f"DEBUG: Starting to parse NWC URL: {nwc_url}") + try: + # Remove 'nostr+walletconnect://' or 'nwc:' prefix + if nwc_url.startswith('nostr+walletconnect://'): + print(f"DEBUG: Removing 'nostr+walletconnect://' prefix") + nwc_url = nwc_url[22:] + elif nwc_url.startswith('nwc:'): + print(f"DEBUG: Removing 'nwc:' prefix") + nwc_url = nwc_url[4:] + else: + print(f"DEBUG: No recognized prefix found in URL") + raise ValueError("Invalid NWC URL: missing 'nostr+walletconnect://' or 'nwc:' prefix") + print(f"DEBUG: URL after prefix removal: {nwc_url}") + # urldecode because the relay might have %3A%2F%2F etc + nwc_url = urldecode(nwc_url) + print(f"after urldecode: {nwc_url}") + # Split into pubkey and query params + parts = nwc_url.split('?') + pubkey = parts[0] + print(f"DEBUG: Extracted pubkey: {pubkey}") + # Validate pubkey (should be 64 hex characters) + if len(pubkey) != 64 or not all(c in '0123456789abcdef' for c in pubkey): + raise ValueError("Invalid NWC URL: pubkey must be 64 hex characters") + # Extract relay, secret, and lud16 from query params + relays = [] + lud16 = None + secret = None + if len(parts) > 1: + print(f"DEBUG: Query parameters found: {parts[1]}") + params = parts[1].split('&') + for param in params: + if param.startswith('relay='): + relay = param[6:] + print(f"DEBUG: Extracted relay: {relay}") + relays.append(relay) + elif param.startswith('secret='): + secret = param[7:] + print(f"DEBUG: Extracted secret: {secret}") + elif param.startswith('lud16='): + lud16 = param[6:] + print(f"DEBUG: Extracted lud16: {lud16}") + else: + print(f"DEBUG: No query parameters found") + if not pubkey or not len(relays) > 0 or not secret: + raise ValueError("Invalid NWC URL: missing required fields (pubkey, relay, or secret)") + # Validate secret (should be 64 hex characters) + if len(secret) != 64 or not all(c in '0123456789abcdef' for c in secret): + raise ValueError("Invalid NWC URL: secret must be 64 hex characters") + print(f"DEBUG: Parsed NWC data - Relay: {relays}, Pubkey: {pubkey}, Secret: {secret}, lud16: {lud16}") + return relays, pubkey, secret, lud16 + except Exception as e: + raise RuntimeError(f"Exception parsing NWC URL {nwc_url}: {e}") + + diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py new file mode 100644 index 00000000..c331f1bb --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py @@ -0,0 +1,43 @@ +# Payment class remains unchanged +class Payment: + def __init__(self, epoch_time, amount_sats, comment): + self.epoch_time = epoch_time + self.amount_sats = amount_sats + self.comment = comment + + def __str__(self): + sattext = "sats" + if self.amount_sats == 1: + sattext = "sat" + if not self.comment: + verb = "spent" + if self.amount_sats > 0: + verb = "received!" + return f"{self.amount_sats} {sattext} {verb}" + #return f"{self.amount_sats} {sattext} @ {self.epoch_time}: {self.comment}" + return f"{self.amount_sats} {sattext}: {self.comment}" + + def __eq__(self, other): + if not isinstance(other, Payment): + return False + return self.epoch_time == other.epoch_time and self.amount_sats == other.amount_sats and self.comment == other.comment + + def __lt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) < (other.epoch_time, other.amount_sats, other.comment) + + def __le__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) <= (other.epoch_time, other.amount_sats, other.comment) + + def __gt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) > (other.epoch_time, other.amount_sats, other.comment) + + def __ge__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) >= (other.epoch_time, other.amount_sats, other.comment) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py new file mode 100644 index 00000000..8f2dc4e5 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py @@ -0,0 +1,43 @@ +# keeps a list of items +# The .add() method ensures the list remains unique (via __eq__) +# and sorted (via __lt__) by inserting new items in the correct position. +class UniqueSortedList: + def __init__(self): + self._items = [] + + def add(self, item): + #print(f"before add: {str(self)}") + # Check if item already exists (using __eq__) + if item not in self._items: + # Insert item in sorted position for descending order (using __gt__) + for i, existing_item in enumerate(self._items): + if item > existing_item: + self._items.insert(i, item) + return + # If item is smaller than all existing items, append it + self._items.append(item) + #print(f"after add: {str(self)}") + + def __iter__(self): + # Return iterator for the internal list + return iter(self._items) + + def get(self, index_nr): + # Retrieve item at given index, raise IndexError if invalid + try: + return self._items[index_nr] + except IndexError: + raise IndexError("Index out of range") + + def __len__(self): + # Return the number of items for len() calls + return len(self._items) + + def __str__(self): + #print("UniqueSortedList tostring called") + return "\n".join(str(item) for item in self._items) + + def __eq__(self, other): + if len(self._items) != len(other): + return False + return all(p1 == p2 for p1, p2 in zip(self._items, other)) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png new file mode 100644 index 0000000000000000000000000000000000000000..220c65cbf762d1bf206c7000d2bb48a1ded52134 GIT binary patch literal 5361 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hE&ep7p)AUhmFR*zsbb)a@z9 z9FqmF|C=-Ox=h1tJLfr^I;l+CGrR9=@vQ6R`f`LdVj1Tx0g3c$!e^LPR55w8v~x4K zBpg%P)Dq}jae^s+a|<&w;}pY$i7Zj|2jBSXHt5b@Zg=cM_N))qD-VgTmB};S?7_vU zZG1qhfg^$G0h5mf!{g=KoERh|#2>^QC~W!4lKqo!pQyzRwuIoQ2L;BR|Bu!zGXJ}h zX~Wa`YaS$+OFnqqc}V(|<{h_H6Q5dos5P8pRJLYV!+N5`p}~)v?-=7dhC|FU@&!%? z>v_&|uXs|#%{DK4$<9mjMSW`SPpEDAcP{Y%dWLm-yJx(A$dy!)z3$_}w6@f%H#Vqv zI~uW-TojC3+R(!|cP>*R!)it&7Nv&yV)Gh*HwZJtaTZiFtY;Xw2C(*hw@3Nrf_%PKL*@IP4B8tU(KOx`WK zNd4W-r1~U=b^m1wA3i8c{cy6X>uYiD*-a))U1jbSa~ZW5KZ#Ay6dP`+h52_JHa9uAS0T@~4Z%uQ(QAcwKM7Wi4d)#m#N<8?NcZYlgsCj0?jM}iuJ4rwy59FuT57QuQzK|;RKxFm7gpKIFPqS5PI z-mkJ_5oKG>(-Hjaz}$1kR~V{rzdyKeecsV)``V*rKni7GZh)Ou{C& zn&Q9koG8I0DdY>`bp``dOd zub2M5Z-1fLdx5(g`#;K`e0bpgF`@j~x!wyGvns^0Oo~*PrM5-Q=-RwBZC_>mcNIO{ zeQnnJiA&!ed(}MC_jy;9+QP3l_XkMdxDw^6^|(!+!zBGq#;%p#;ynSGFN+M?ZN;7E z1SuZVV|x7g!NI&UZ);BeKJb#Q!DvSeWBHY&$17HFEh%;|;dU{;wNic-*D8Ox(kFjJ zG^%4Ve+LL2-<)8e75JrS53j0==A=7cuSCp{+i7p{t#tOZY5x_ct=YTcvDp036s8lu zK2-f@P`++=jIZ+diU}p}80=z16CRy?aPOPw!f^J(){B^$3-2&Jidusc(IG|2^p)3!@h6zROUxT>eeuf*t3U+`%9g^om~k#c zC^fF+BMz0(GxvAXvu)~~ zUz)wd{nIz*1l`-a_WNH`&b0xPu2r0pa5!e!WpVVmp3URUo35lRz4gv^PvMui=%{as z1?~qpXTH5J@#FaZZ??ibxf-g$>s(KIDuiBVm}9T}?78u_n8={SxR+mhJ&dH!GM!|y z?Ec+1^;PtC!I$h?C;F#G-dZ$em(afd`6mpgt@G`A6uZr1-4-qBhKVfMX^Cfpe>^z2 z-bvj3x%!_6_b=^I(YFgz=`9a5 z33fBGKlkaGP01~$a#Pm7>zyzBPqTPM%G-kP3tRKJh?sLtVYXzCO1O? z&QI0XcXwCIT{>+3<@3h;InoT5UbZbN%Q1{8Ju2(DQ1+D6+#PYMnwO6nU%RoqyX@A2 zHP&1C{~ubD?{;yzZ}K0bu)Fm~=2w-hzkTC!*{7ErDMbd;1V3}%+&=S3YrdSp-|2ri z-~G53`{UDWwYU|ml9E$R6mDAw?0g``z!S!-CB0}7qw}nr3$tsl?4MoA>+t69_SmGa zXY&KZri)iHrI;$@M_jZhJEQ40&)}9x$g*8tHb>^qUTBkBJ1xuI%tC$N=YXF_!qsL* ztzax^a+u68D)~|*uzjBV=#T;o}3dqY@hhu*?&A&Mqzb_^nsFeru?1m z$}8?DbvG@{V02Bndu`(4zB#v(d+ZX8>b^~#VZXJms+h(4#)bwKDH7e6(!Lp-IuoUo2YPQ zCaieva6+V2V@mAG)MHi`L)Ds(?Xz|HHPdH#>8a0~x7SKqn&t0je|yr#=HuDF25V!( zZm)G!jt`i5$zJQvXFf&MOT4<1%jVp$s66;`eq3B+b1{?f+&u?4f8So6xBn2E^pZ8F zMcD2hXWUjc<@P*L!xBab))_XeCzB0k7|dKe&-dYZqnB~)lJ`%%a`(4*`}erc%-G0D z*Fr8HmHpHx%onr9;ns{p`{SJTZQSoYOcr%ed$2&&=j@LSIzJ2VoRi@)=00FF!Bzdr z6vGqeBC;24_DyGGsK1iNu$R{=^4-?s>IsXTyPmA^+O^vu@zK%?H{EaC`1_B0=>vw2 zgDd<@wIV(K%sejCxTYuQWz1$rwuXzv3|$>JZO$d^_AUsy%_uxuG1xP*^jy@e3mebv z?v?n<9G^8?HTzw8e{JRhj=Pq>ZTOGODLNs+l>Nz1Q*Zs%y*V|GhNmW~=qpRxTwTc! zS+M$+L8Q;N7w?`t`(DVCTXK-0z+7p@w64(WN4@Rp_J1_6X3JjKG0*tbcbjt_KfF(@ zkg@t6xP|LTu=?hLt4#TUJSK(jZa&a_=r~O#TPbZ)$I{qst3FvVXELhue7&%&&S~dw ztAFijhu-M^k3D()&%K&iFJ0%pe$F7bUz{h!;zau?hN+q>D%|+)I#^0qoZLH4l3{Ur zHxG-XYr>Sy1*eat-eX&H?jY-$>$@+tDJw<`&8gLV$1mn3@TKfP%(|WHeMJ6q9Jm+# zFX9W2&i<(-?-phVd8dinPWCU_@Bizj;m4p<`+KgjYVE15{*L9Vs{b$^PN|XUVSac) z^-lb@BNv~&VLWjwONHD1c>4UbdmH^*3x2%3zwRe{67yeX^WsI@gKf@F3@bJGefaK^ z7SlMBs6&_aBR>_NTk~(Zp0gguovYI(_O-s?J8-7?+7fw**6YCvGBFd^?nxD1{r7=F z+#9(ce=XULzcvX{kbHM9;jV1C-|fckeX&-*o*s%i%WV^$z2QKI*_Wd$LSwt1SycQz zF=e*D{3-n(H`mlWioQJI&?6ta52}n2ucpjtJ^rAj$1q?!r@i=wEfod(HP_E*jE+lh zTVJ_5;^#W{fW6OMS8dBu-SQ{(#+}xQ{g0pRuel}pm;LRzsr(0|A{Ga&c+L{#kIMBbKYd!g>0vbc-;Mb_R*S^rJaZBx#ox7hec1aVB{hBPoMiqp zuOFT^UtE;<;B3zCg^}(adA9UQ`a_nFG=k&Df z7oS+~H+QFcjlyS^J4s9^Tob6Qq11m#%H2jivU<}y zy*INs#JBeq?k(VO@sCI?e-;>Iywhs=&V}-?uY@jppucm%nN8aFdDJ5~k4fJ7By{?Z z`-H8>a;tx>ncH}BOJI!Kokj1SBzp$jHBUY)dhOe~Ju(-2L_hCV6F9#;GECh|yjmkN z!t3lufve4$(Kn)tpO^Xm3htNF^{xHfaQ$BKgqbfmm8y13_$4_r&3EeD>ep|31ZQsl zw{6p&e~ar|8++?HjAVL**Hp^n{p=2SS^u#3^{%ehlId%%M1OP9aFh6Mw(6d5xQO%h zedpMmS0^m_x+ZjrzT1-I!yd+Zvv-QiXBOV=k}R3Z@z~|f2Gc4XW6#*@Urx{BuX=ed zz|1$I&Uxmqu={-P*dFjTbA%l}HQ#+(MCzT}%{OJ!*H^KuaFR;)d18@sG4SfL>$mnl z*yb<8^ig@5b@8d!!PnlNa2%1pD39ny!^zWEws`SRAv zg$ncEF(<1&O6@mvbnz;hePxZ=Wz9`um*<6jeIlB_bFN^6j>=TA`H>g4US!vh*(2w( zLxFil(}h`6bk^sl#rWOQyYnbBwWHG{>e7_R&`HtasrSnzxLLE=1)dn4?opFX?bR_l z^^=Iozw+b1!&>Lg?G;|bu)4=|Wr&t$ zP;%)KuB%})bHpnrMrX!f-T81jO9C_Zd6VMHptCm|2^% zt$#8;Pw@G$=c7SZbyO^`!`@{K3>#K%ohQ7!A$-QETdyCsh#IgLoK_Cmx!_pSQtN_G zg~j)n7#N(xTC27#bzSd1^=i2D4!IWf2Vdj*POVDb@o4Yk8`b@G`HT~`Ju#U5`)7)C z_2K4_xP6Q<90l9i^LDVhKIW9!pD(4g__GiLL&j2G?hxx9wbxcLJ0~^$yE=EbL&$*$ z$-4L74L=@!vxk*|iDzO-#66xxAKSEg*C_4JXO(#VL0dfe=2gD9|I`^6N~?BW^zmjr9d_`&G+Vjt58LPibFUv%vEJ}7_w$YKs#gCQN|eluZY|Nv z58wMDRX@jve-qjbjzu9)l5XIsk`Yvu0Q8BuomYSo8Lr5gIL+iy>9 zw*Jrb?hdE=oHh-~JA0>{lV&)txBa+y+)k4R9NXp@{?C%_$qDi{TR)w7dSAeQ)xR3N zXAWxSvzFI0sPQqc;ae;{!$#)e?)OWs&*9v2z}dt3)}}LxXN(ioc4W>id3@7 z*4hYP}H zLkbeb2Lc)dCcemyT)uzdUkez=NAe#NFF@Up<{le mH%Mjknt7rcbwv#S_?t?%sEXHrjA3A4VDNPHb6Mw<&;$TKb2_mA literal 0 HcmV?d00001 diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png new file mode 100644 index 0000000000000000000000000000000000000000..0d7ddbffb4c0bc958fe179bdb026402b8629151d GIT binary patch literal 3888 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 P1_lOCS3j3^P6~)oqaxEI#l4e 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

_*#O8c$!Vu*@z@Wx4LD#w2ZZ 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