This commit is contained in:
Xavier Fiechter 2025-12-12 23:47:04 +01:00
parent b5c0b7531f
commit fff7ac548c
14 changed files with 952 additions and 204 deletions

View file

@ -27,6 +27,9 @@ def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None):
if buf in EOLSTOP:
break
data = json.loads(buf)
logger.info(f"Parsed data: {data}")
data["labelbase"] = labelbase.id
serializer = LabelSerializer(data=data)
if serializer.is_valid():

View file

@ -2,6 +2,7 @@ from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django_cryptography.fields import encrypt
from django.utils.safestring import mark_safe
from pymempool import MempoolAPI
@ -298,3 +299,102 @@ class Label(models.Model):
except:
pass
return ""
def get_fee_health_status(self):
"""
Calculate fee health status for this label if it's a spendable unspent output.
"""
# Only calculate for spendable outputs
if self.type != self.TYPE_OUTPUT or not self.spendable:
return {
'status': None,
'fee_sats': None,
'value_sats': None,
'fee_percentage': None,
'threshold_healthy': None,
'threshold_warning': None,
'threshold_high': None
}
try:
value_sats = int(self.value) if self.value else None
except (ValueError, TypeError):
value_sats = None
if not value_sats or value_sats <= 0:
return {
'status': None,
'fee_sats': None,
'value_sats': value_sats,
'fee_percentage': None,
'threshold_healthy': None,
'threshold_warning': None,
'threshold_high': None
}
# Get user's fee rate from profile
user_fee_rate = self.labelbase.user.profile.my_fee # sats per vbyte
threshold_adjustment = self.labelbase.user.profile.my_fee_rate_threshold # percentage points
# Use P2WPKH as default - most common modern type
# Simple 1-in, 2-out transaction
from finances.tx_math import calculate_transaction_size, calculate_fee
inputs = [{'input_script': 'P2WPKH'}]
output_counts = {'p2wpkh': 2}
tx_size = calculate_transaction_size(inputs, output_counts)
fee_sats = calculate_fee(tx_size['txVBytes'], user_fee_rate)
# Calculate fee as percentage of output value
fee_percentage = (fee_sats / value_sats) * 100
# Define thresholds (base + user adjustment)
threshold_healthy = 1.0 + threshold_adjustment
threshold_warning = 3.0 + threshold_adjustment
# Determine status
if fee_percentage < threshold_healthy:
status = 'green'
elif fee_percentage < threshold_warning:
status = 'yellow'
else:
status = 'red'
return {
'status': status,
'fee_sats': fee_sats,
'value_sats': value_sats,
'fee_percentage': round(fee_percentage, 3),
'threshold_healthy': threshold_healthy,
'threshold_warning': threshold_warning,
'threshold_high': threshold_warning
}
@property
def get_fee_health_status_display(self):
"""
Returns text representation of fee health status for DataTables display.
"""
health = self.get_fee_health_status()
if not health['status']:
return ''
status_map = {
'green': '🟢',
'yellow': '🟡',
'red': '🔴'
}
# FIXME: escaping in data tables
#status_map = {
# 'green': '<span data-feather="circle-check" class="align-text-bottom"></span>',
# 'yellow': '<span data-feather="alert-circle" class="align-text-bottom"></span>',
# 'red': '<span color:red; data-feather="alert-triangle" class="align-text-bottom"></span>'
#}
emoji = status_map.get(health['status'], '')
return f"{emoji} {health['fee_percentage']}%"

View file

@ -1,8 +1,9 @@
import json
from rest_framework import serializers
from labelbase.models import Labelbase, Label
class LabelSerializer(serializers.ModelSerializer):
class LabelSerializer_v1(serializers.ModelSerializer):
class Meta:
model = Label
fields = [
@ -19,6 +20,169 @@ class LabelSerializer(serializers.ModelSerializer):
]
class LabelSerializer(serializers.ModelSerializer):
# Additional BIP-329 fields
height = serializers.IntegerField(required=False, allow_null=True)
time = serializers.CharField(required=False, allow_null=True, allow_blank=True)
fee = serializers.IntegerField(required=False, allow_null=True)
value = serializers.IntegerField(required=False, allow_null=True)
rate = serializers.JSONField(required=False, allow_null=True)
keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True)
fmv = serializers.JSONField(required=False, allow_null=True)
heights = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True
)
class Meta:
model = Label
fields = [
"id",
"labelbase",
"type",
"ref",
"label",
"origin",
"spendable",
# Additional BIP-329 fields
"height",
"time",
"fee",
"value",
"rate",
"keypath",
"fmv",
"heights",
]
read_only_fields = [
"id",
]
def validate(self, data):
"""Validate BIP-329 field combinations based on type"""
label_type = data.get('type')
# Define valid fields per type (from BIP-329 spec)
valid_fields = {
'tx': {'height', 'time', 'fee', 'value', 'rate'},
'addr': {'keypath', 'heights'},
'pubkey': {'keypath'},
'input': {'keypath', 'value', 'fmv', 'height', 'time'},
'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'},
'xpub': set()
}
# Get allowed additional fields for this type
allowed = valid_fields.get(label_type, set())
# Check for invalid field combinations
additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'}
for field in additional_fields:
if field in data and data[field] is not None:
# Allow origin for all types
if field == 'origin':
continue
# Check if field is valid for this type
if field not in allowed and field in additional_fields - {'origin'}:
# Remove invalid field instead of raising error (for compatibility)
data.pop(field, None)
return data
def create(self, validated_data):
"""Override create to convert data types for storage"""
# Convert integers to strings for storage
if 'height' in validated_data and validated_data['height'] is not None:
validated_data['height'] = str(validated_data['height'])
if 'fee' in validated_data and validated_data['fee'] is not None:
validated_data['fee'] = str(validated_data['fee'])
if 'value' in validated_data and validated_data['value'] is not None:
validated_data['value'] = str(validated_data['value'])
# Convert JSON objects to strings
if 'rate' in validated_data and validated_data['rate'] is not None:
validated_data['rate'] = json.dumps(validated_data['rate'])
if 'fmv' in validated_data and validated_data['fmv'] is not None:
validated_data['fmv'] = json.dumps(validated_data['fmv'])
if 'heights' in validated_data and validated_data['heights'] is not None:
validated_data['heights'] = json.dumps(validated_data['heights'])
instance = super().create(validated_data)
return instance
def update(self, instance, validated_data):
"""Override update to convert data types for storage"""
# Convert integers to strings for storage
if 'height' in validated_data and validated_data['height'] is not None:
validated_data['height'] = str(validated_data['height'])
if 'fee' in validated_data and validated_data['fee'] is not None:
validated_data['fee'] = str(validated_data['fee'])
if 'value' in validated_data and validated_data['value'] is not None:
validated_data['value'] = str(validated_data['value'])
# Convert JSON objects to strings
if 'rate' in validated_data and validated_data['rate'] is not None:
validated_data['rate'] = json.dumps(validated_data['rate'])
if 'fmv' in validated_data and validated_data['fmv'] is not None:
validated_data['fmv'] = json.dumps(validated_data['fmv'])
if 'heights' in validated_data and validated_data['heights'] is not None:
validated_data['heights'] = json.dumps(validated_data['heights'])
return super().update(instance, validated_data)
def to_representation(self, instance):
"""Convert stored data back to API format"""
data = super().to_representation(instance)
# Convert string integers back to integers
if data.get('height'):
try:
data['height'] = int(data['height'])
except (ValueError, TypeError):
data['height'] = None
if data.get('fee'):
try:
data['fee'] = int(data['fee'])
except (ValueError, TypeError):
data['fee'] = None
if data.get('value'):
try:
data['value'] = int(data['value'])
except (ValueError, TypeError):
data['value'] = None
# Convert JSON strings back to objects
if data.get('rate'):
try:
data['rate'] = json.loads(data['rate'])
except (json.JSONDecodeError, TypeError):
data['rate'] = None
if data.get('fmv'):
try:
data['fmv'] = json.loads(data['fmv'])
except (json.JSONDecodeError, TypeError):
data['fmv'] = None
if data.get('heights'):
try:
data['heights'] = json.loads(data['heights'])
except (json.JSONDecodeError, TypeError):
data['heights'] = None
return data
class LabelbaseSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super(LabelbaseSerializer, self).__init__(*args, **kwargs)

View file

@ -10,10 +10,13 @@ from userprofile.views import (ProfileView,
ProfileAvatarUpdateView,
ProfileCurrencyUpdateView,
MempoolUpdateView,
ProfileFeeUpdateView,
ElectrumInfoUpdateView)
from userprofile.views import APIKeyView
from userprofile.views import has_seen_welcome_popup
from hashtags.views import HashtagListView, HashtagUpdateView, LabelbaseProxyView
@ -46,8 +49,13 @@ from .views import (
#LabelbasePortfolioView,
OutputStatUpdateRedirectView,
BitcoinAddressDatatableView,
CurrencySyncView,
CurrencySyncActionView,
FillMissingDataView,
FillMissingDataActionView
)
from importer.views import upload_labels
from django.contrib.auth import views as auth_views
@ -93,6 +101,11 @@ urlpatterns = [
login_required(MempoolUpdateView.as_view()),
name="userprofile_mempool",
),
path(
"account/userprofile-mempool-fees/",
login_required(ProfileFeeUpdateView.as_view()),
name="userprofile_fees",
),
path(
"account/userprofile-currency/",
login_required(ProfileCurrencyUpdateView.as_view()),
@ -150,6 +163,27 @@ urlpatterns = [
login_required(LabelbaseMergeView.as_view()),
name="labelbase_merge"
),
path(
'labelbase/<int:labelbase_id>/currency-sync/',
CurrencySyncView.as_view(),
name='currency_sync'
),
path(
'labelbase/<int:labelbase_id>/currency-sync/action/',
CurrencySyncActionView.as_view(),
name='currency_sync_action'
),
path(
'labelbase/<int:labelbase_id>/fill-missing-data/',
FillMissingDataView.as_view(),
name='fill_missing_data'
),
path(
'labelbase/<int:labelbase_id>/fill-missing-data/action/',
FillMissingDataActionView.as_view(),
name='fill_missing_data_action'
),
#path(
# "labelbase/<int:labelbase_id>/portfolio/",
# login_required(LabelbasePortfolioView.as_view()),

View file

@ -1,6 +1,11 @@
import logging
from datetime import datetime
from decimal import Decimal
import os
import time
import re
import tempfile
import json
from django.conf import settings
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import redirect, resolve_url
@ -27,20 +32,17 @@ from labelbase.forms import LabelForm, LabelbaseForm
from labelbase.forms import ExportLabelsForm
from finances.models import OutputStat
from finances.tasks import check_all_outputs
from finances.models import HistoricalPrice
from .utils import hashtag_to_badge, extract_fiat_value
from embit import bip32, script
from embit.networks import NETWORKS
from django.http import JsonResponse
from django_datatables_view.base_datatable_view import BaseDatatableView
from embit import bip32, script
from embit.networks import NETWORKS
logger = logging.getLogger('labelbase')
DEFAULT_DERIVE_ADDRESS_COUNT = 100
class BitcoinAddressDatatableView(BaseDatatableView):
@ -300,6 +302,7 @@ class LabelbaseDatatableView(BaseDatatableView):
def filter_queryset(self, qs):
search = self.request.GET.get('search[value]', None)
type_filter = self.request.GET.get('type', None)
if search:
# Due to encryption, we need to use a super slow process here...
res_ids = []
@ -317,7 +320,9 @@ class LabelbaseDatatableView(BaseDatatableView):
if record.origin and search in record.origin.lower():
res_ids.append(record.id)
continue
return qs.filter(id__in=res_ids)
qs = qs.filter(id__in=res_ids)
if type_filter and type_filter != 'all':
qs = qs.filter(type=type_filter)
return qs
@ -724,6 +729,210 @@ class FixAndMergeLabelsView(View):
})
class CurrencySyncView(View):
template_name = "currency_sync.html"
def get(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
# Get all output and input labels (types that support fmv)
labels = Label.objects.filter(
labelbase_id=labelbase_id,
type__in=['output', 'input']
)
# Categorize
text_only = [] # Has currency in label, no FMV
fmv_only = [] # Has FMV, no currency in label
conflicts = [] # Both exist but don't match
synced = [] # Both exist and match
for label in labels:
label_currency = extract_fiat_value(label.label) # (value, currency)
fmv_data = self._parse_fmv(label.fmv) # Parse JSON
has_label_currency = label_currency[0] > 0
has_fmv = fmv_data is not None
if has_label_currency and not has_fmv:
text_only.append(label)
elif has_fmv and not has_label_currency:
fmv_only.append(label)
elif has_label_currency and has_fmv:
if self._currencies_match(label_currency, fmv_data):
synced.append(label)
else:
conflicts.append(label)
return render(request, self.template_name, {
'labelbase': labelbase,
'text_only': text_only,
'fmv_only': fmv_only,
'conflicts': conflicts,
'synced': synced,
'active_labelbase_id': labelbase_id,
})
def _parse_fmv(self, fmv_str):
"""Parse FMV JSON string"""
if not fmv_str or not fmv_str.strip():
return None
try:
return json.loads(fmv_str)
except (json.JSONDecodeError, ValueError):
return None
def _currencies_match(self, label_currency, fmv_data):
"""Check if label currency matches FMV"""
value, currency = label_currency
if currency not in fmv_data:
return False
# Get FMV value and convert to Decimal if it's a string
fmv_value = fmv_data[currency]
if isinstance(fmv_value, str):
try:
fmv_value = Decimal(fmv_value)
except (ValueError, TypeError):
return False
else:
fmv_value = Decimal(str(fmv_value))
# Convert label value to Decimal if needed
if not isinstance(value, Decimal):
value = Decimal(str(value))
# Compare with small tolerance for rounding differences
return abs(fmv_value - value) < Decimal('0.01')
#import json
#from django.views import View
#from django.shortcuts import get_object_or_404, redirect
#from django.http import HttpResponseRedirect
#from django.urls import reverse
#from django.contrib import messages
#from labelbase.models import Label
#from labellabor.utils import extract_fiat_value
class CurrencySyncActionView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
label_id = request.POST.get('label_id')
action = request.POST.get('action')
if action == 'sync_all_text_to_fmv':
# Batch sync: text → FMV
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=request.user.id,
type__in=['output', 'input']
)
count = 0
for label in labels:
value, currency = extract_fiat_value(label.label)
if value > 0 and currency:
# Convert Decimal to string, then create dict, then JSON
fmv_dict = {currency: str(value)}
label.fmv = json.dumps(fmv_dict)
label.save()
count += 1
messages.success(request, f"Synced {count} labels from text to FMV field.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
elif action == 'sync_all_fmv_to_text':
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=request.user.id,
type__in=['output', 'input']
)
count = 0
for label in labels:
if label.fmv and label.fmv.strip():
try:
fmv_data = json.loads(label.fmv)
if fmv_data:
currency, value_str = list(fmv_data.items())[0]
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
existing_value, existing_currency = extract_fiat_value(label.label)
if existing_value == 0 or not existing_currency:
if label.label:
label.label = f"{label.label} {currency} {value:.2f}".strip()
else:
label.label = f"{currency} {value:.2f}".strip()
label.save()
count += 1
except (json.JSONDecodeError, ValueError) as e:
logging.warning(f"Error processing label {label.id}: {e}")
continue
messages.success(request, f"Synced {count} labels from FMV to text field.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
# Single label actions
if not label_id:
messages.error(request, "No label specified.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
label = get_object_or_404(
Label,
id=label_id,
labelbase__user_id=request.user.id
)
if action == 'text_to_fmv':
# Extract from label text and populate FMV
value, currency = extract_fiat_value(label.label)
if value > 0 and currency:
# Store as string to preserve precision
fmv_dict = {currency: str(value)}
label.fmv = json.dumps(fmv_dict)
label.save()
messages.success(request, f"Synced currency from label text to FMV field.")
else:
messages.warning(request, "No valid currency found in label text.")
elif action == 'fmv_to_text':
# Extract from FMV and update label text
if label.fmv and label.fmv.strip():
try:
fmv_data = json.loads(label.fmv)
if fmv_data:
currency, value_str = list(fmv_data.items())[0]
# Parse value back from string
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
# Check if currency already in label
existing_value, existing_currency = extract_fiat_value(label.label)
if existing_value == 0 or not existing_currency: # No currency in label
# Append to label with proper decimal formatting
if label.label:
label.label = f"{label.label} {currency} {value:.2f}".strip()
else:
label.label = f"{currency} {value:.2f}".strip()
label.save()
messages.success(request, f"Synced currency from FMV to label text.")
else:
# Pattern to match currency and value like "CHF 615.00" or "USD 1000.50"
pattern = r'\b' + re.escape(existing_currency) + r'\s+\d+\.?\d*\b'
new_text = f"{currency} {value:.2f}"
label.label = re.sub(pattern, new_text, label.label)
label.save()
messages.success(request, f"Updated currency in label text from FMV.")
except (json.JSONDecodeError, ValueError) as e:
messages.error(request, f"Error parsing FMV data: {e}")
else:
messages.warning(request, "No FMV data found.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
class ExportLabelsView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
@ -783,11 +992,54 @@ class ExportLabelsView(View):
"ref": label.ref,
"label": label.label,
}
if label.origin and label.type == "tx":
label_entry["origin"] = label.origin
if label.spendable in [True, False] and label.type == "output":
label_entry["spendable"] = label.spendable
# Additional BIP-329 fields with robust error handling
# Integer fields (height, fee, value)
if label.height:
try:
label_entry["height"] = int(label.height)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid height value for label {label.id}: {e}")
if label.time:
label_entry["time"] = label.time
if label.fee:
try:
label_entry["fee"] = int(label.fee)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid fee value for label {label.id}: {e}")
if label.value:
try:
label_entry["value"] = int(label.value)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid value for label {label.id}: {e}")
# JSON fields (rate, fmv, heights)
if label.rate and label.rate.strip():
try:
label_entry["rate"] = json.loads(label.rate)
except json.JSONDecodeError as e:
logging.warning(f"Invalid rate JSON for label {label.id}: {e}")
if label.keypath:
label_entry["keypath"] = label.keypath
if label.fmv and label.fmv.strip():
try:
label_entry["fmv"] = json.loads(label.fmv)
except json.JSONDecodeError as e:
logging.warning(f"Invalid fmv JSON for label {label.id}: {e}")
if label.heights and label.heights.strip():
try:
label_entry["heights"] = json.loads(label.heights)
except json.JSONDecodeError as e:
logging.warning(f"Invalid heights JSON for label {label.id}: {e}")
# Write the label entry to the file
label_writer.write_label(label_entry)
@ -971,3 +1223,128 @@ class OutputStatUpdateRedirectView(View):
)
label.save() # will trigger a check agains Electrum
return redirect('edit_label', pk=label_id)
class FillMissingDataView(View):
template_name = "fill_missing_data.html"
def get(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
# Get all output labels (only type that uses OutputStat)
output_labels = Label.objects.filter(
labelbase_id=labelbase_id,
type='output'
)
can_fill_from_outputstat = []
already_complete = []
for label in output_labels:
missing_fields = self._get_missing_fields(label)
if not missing_fields:
already_complete.append(label)
continue
# Check if OutputStat exists
output_stat = OutputStat.objects.filter(
user=label.labelbase.user,
type_ref_hash=label.type_ref_hash,
network=label.labelbase.network
).first()
if output_stat:
can_fill_from_outputstat.append({
'label': label,
'missing_fields': missing_fields,
'output_stat': output_stat
})
return render(request, self.template_name, {
'labelbase': labelbase,
'can_fill_from_outputstat': can_fill_from_outputstat,
'already_complete': already_complete,
'active_labelbase_id': labelbase_id,
})
def _get_missing_fields(self, label):
"""Return list of fields that are missing for output type"""
missing = []
# Only check fields applicable to outputs
applicable_fields = ['height', 'time', 'value']
for field in applicable_fields:
value = getattr(label, field, None)
if not value or (isinstance(value, str) and not value.strip()):
missing.append(field)
return missing
class FillMissingDataActionView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
action = request.POST.get('action')
label_id = request.POST.get('label_id')
if action == 'fill_all_from_outputstat':
count = self._fill_all_from_outputstat(request.user.id, labelbase_id)
messages.success(request, f"Filled {count} labels from OutputStat data.")
elif action == 'fill_single_from_outputstat':
label = get_object_or_404(Label, id=label_id, labelbase__user_id=request.user.id)
if self._fill_label_from_outputstat(label):
messages.success(request, "Filled label data from OutputStat.")
else:
messages.error(request, "Could not fill label data.")
return HttpResponseRedirect(reverse('fill_missing_data', kwargs={'labelbase_id': labelbase_id}))
def _fill_all_from_outputstat(self, user_id, labelbase_id):
"""Fill all output labels that have OutputStat data"""
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=user_id,
type='output'
)
count = 0
for label in labels:
if self._fill_label_from_outputstat(label):
count += 1
return count
def _fill_label_from_outputstat(self, label):
"""Fill a single label from OutputStat data"""
output_stat = OutputStat.objects.filter(
user=label.labelbase.user,
type_ref_hash=label.type_ref_hash,
network=label.labelbase.network
).first()
if not output_stat:
return False
updated = False
# Fill height (only if empty)
if not label.height and output_stat.confirmed_at_block_height:
label.height = str(output_stat.confirmed_at_block_height)
updated = True
# Fill time (only if empty)
if not label.time and output_stat.confirmed_at_block_time:
# Convert Unix timestamp to ISO-8601
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
updated = True
# Fill value (only if empty)
if not label.value and output_stat.value:
label.value = str(output_stat.value)
updated = True
if updated:
label.save()
return updated

View file

@ -41,7 +41,7 @@ six==1.16.0
sqlparse==0.5.0
typing_extensions==4.1.1
uritemplate==4.1.1
urllib3==1.26.18
urllib3>=2.6.0
zipp==3.6.0
bip329==1.0.0
pymempool==0.0.5

View file

@ -184,11 +184,12 @@
{% endif %}
{% endwith %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="git-merge" class="align-text-bottom"></span> Fix & Manage</a></li>
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "fill_missing_data" labelbase_id=labelbase.id %}"><span data-feather="refresh-cw" class="align-text-bottom"></span> Sync Fields</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
{% if request.user.profile.use_fiatfinances %}
<li><a class="nav-link" style="font-size: .875rem;"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "currency_sync" labelbase_id=labelbase.id %}"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
{% endif %}
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal"><span data-feather="upload-cloud" class="align-text-bottom"></span> Import</a></li>
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#exportLabelbaseModal"><span data-feather="download-cloud" class="align-text-bottom"></span> Export</a></li>

View file

@ -304,7 +304,34 @@
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">BIP-329 Extended Fields</h3>
<p>Full support for BIP-329 additional fields including transaction height, timestamp, fees, values, exchange rates, and derivation paths. Enrich your labels with comprehensive transaction metadata.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Currency Sync Tool</h3>
<p>Seamlessly synchronize between legacy currency annotations in label text and structured FMV fields. Automatically detect and resolve conflicts to maintain data consistency across formats.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Auto-Fill Missing Data</h3>
<p>Automatically populate transaction metadata from OutputStat records. Fill block heights, timestamps, and values for your outputs with one click, saving time and ensuring accuracy.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Fee Health Monitoring</h3>
<p>Instantly visualize the cost-effectiveness of spending your UTXOs. Labelbase calculates and displays fee health status for each spendable output, color-coded to show if transaction fees would consume a healthy percentage of the output value.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis"><br> <br> </h3>

View file

@ -5,43 +5,12 @@
{% load backgroundtask_tags %}
{% load attachments_tags %}
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
{% comment %}<!--
<li class="nav-item">
<a class="nav-link {% if action == "detail" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Transaction Detail</a>
</li>
<li class="nav-item">
<a class="nav-link {% if action == "history" %}active" aria-current="page"{% else %}"{% endif %} href="{#% url 'edit_label' object.id %#}">Label History</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Transaction Output Labeling</a>
</li>
<li class="nav-item">
<a class="nav-link {% if action == "labeling" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Derived Addresses Labeling</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin Value</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin History</a>
</li>
<div class="col-{% if action == "derive-addresses-void" %}10{% else %}12{% endif %} float-start">
-->{% endcomment %}
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
{% block content %}
{% get_attachments_for object.get_label_attachment as my_attachments %}
<div class="row">
<ul class="nav nav-tabs" style="padding-top: 2rem;">
<li class="nav-item">
@ -120,51 +89,6 @@
</div>
{% endif %}
{% comment %}
{% is_label_id_in_queue object.id as is_in_queue %}
{% if is_in_queue %}
<div class="alert bd-callout bd-callout-info">
<!--div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-danger dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">Verify output status</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=true">Mark output as spent</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=false">Mark output as unspent</a></li>
</ul>
</div-->
<strong>Output in queue!</strong> This output is currently in the processing queue. It will be checked shortly.
</div>
{% else %}
{% if object.type == "output" %}
{% switch output.get_spent_status %}
{% case "spent" %}
<div class="bd-callout bd-callout-warning">
<strong>Output spent!</strong> Blockchain records indicate that this output has been spent in another transaction.
</div>
{% case "unspent" %}
<div class="bd-callout bd-callout-good">
<strong>Output unspent!</strong> Blockchain records indicate that this output has not been spent yet.
</div>
{% case "unconfirmed" %}
<div class="alert bd-callout bd-callout-info">
<div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-info dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">
Verify output status</a></li>
</ul>
</div>
<strong>Output unconfirmed!</strong> Blockchain records indicate that this output has not been confirmed yet.
</div>
{% endswitch %}
{% endif %}
{% endif %}
{% endcomment %}
{% is_label_id_in_queue object.id as is_in_queue %}
{% if is_in_queue %}
<div class="alert bd-callout bd-callout-info">
@ -204,32 +128,11 @@
{% endif %}
{% endif %}
<!--div class="bd-callout bd-callout-warning">
<strong>Heads up!</strong> There are multiple records for this transaction output. <a href="">Review & merge</a>
</div-->
{% comment %}
{% if object.type == "addr" %}
<div class="bd-callout bd-callout-warning">
<strong>Heads up!</strong> There are multiple transaction outputs sent to this address. For privacy reasons, do not reuse addresses.
</div>
{% endif %}
{% endcomment %}
{% block label_edit_content %}
{% endblock %}
</div>
<div class="modal" tabindex="-1" id="deleteLabelModal">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@ -252,107 +155,160 @@
</div>
</form>
</div>
</div
</div>
</div>
<script>
{% addtoblock "js" %}
// BIP-329 field visibility based on type
const fieldsByType = {
'tx': ['origin', 'height', 'time', 'fee', 'value', 'rate'],
'addr': ['origin', 'keypath', 'heights'],
'pubkey': ['origin', 'keypath'],
'input': ['origin', 'keypath', 'value', 'fmv', 'height', 'time'],
'output': ['origin', 'spendable', 'keypath', 'value', 'fmv', 'height', 'time'],
'xpub': ['origin']
};
function updateFieldVisibility() {
const typeField = document.getElementById('id_type');
if (!typeField) return;
const selectedType = typeField.value;
const allowedFields = fieldsByType[selectedType] || [];
<script>
{% addtoblock "js" %}
// All additional fields (excluding core fields: type, ref, label)
const allAdditionalFields = ['origin', 'spendable', 'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'];
// Validate rate field (JSON object with currency codes)
const rateField = document.getElementById('id_rate');
if (rateField) {
rateField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('Rate must be a JSON object like {"USD": 105620.00}');
return;
}
// Validate values are numeric
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'number') {
alert(`Rate value for ${key} must be numeric`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for rate field');
}
});
}
allAdditionalFields.forEach(fieldName => {
const field = document.getElementById(`id_${fieldName}`);
if (field) {
// Find the parent form-group/control-group div
const wrapper = field.closest('.form-group') || field.closest('.control-group') || field.closest('.mb-3') || field.parentElement.parentElement;
// Validate fmv field (JSON object with currency codes)
const fmvField = document.getElementById('id_fmv');
if (fmvField) {
fmvField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('FMV must be a JSON object like {"USD": 1233.45}');
return;
}
// Validate values are numeric
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'number') {
alert(`FMV value for ${key} must be numeric`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for fmv field');
}
});
}
if (wrapper) {
if (allowedFields.includes(fieldName)) {
wrapper.style.display = '';
field.removeAttribute('disabled');
} else {
wrapper.style.display = 'none';
field.setAttribute('disabled', 'disabled');
field.value = ''; // Clear hidden fields
}
}
}
});
}
// Validate heights field (JSON array of integers)
const heightsField = document.getElementById('id_heights');
if (heightsField) {
heightsField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (!Array.isArray(parsed)) {
alert('Heights must be a JSON array like [123456, 789012]');
return;
}
// Validate all values are integers
for (const height of parsed) {
if (!Number.isInteger(height)) {
alert('All heights must be integers');
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for heights field');
}
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
updateFieldVisibility();
// Validate integer fields (height, fee, value)
const integerFields = ['id_height', 'id_fee', 'id_value'];
integerFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
if (this.value && this.value.trim()) {
if (!/^-?\d+$/.test(this.value.trim())) {
alert(`${fieldId.replace('id_', '')} must be an integer`);
}
}
});
}
});
// Run when type changes
const typeField = document.getElementById('id_type');
if (typeField) {
typeField.addEventListener('change', updateFieldVisibility);
}
});
{% endaddtoblock %}
</script>
// Validate fmv field (JSON object with currency codes)
const fmvField = document.getElementById('id_fmv');
if (fmvField) {
fmvField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('FMV must be a JSON object like {"USD": 1233.45}');
return;
}
// Validate values are numeric (accept both numbers and numeric strings)
for (const [key, value] of Object.entries(parsed)) {
// Check if it's a number OR a numeric string
const isNumeric = typeof value === 'number' ||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
if (!isNumeric) {
alert(`FMV value for ${key} must be numeric (got: ${typeof value})`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for fmv field');
}
});
}
// Validate rate field (JSON object with currency codes)
const rateField = document.getElementById('id_rate');
if (rateField) {
rateField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('Rate must be a JSON object like {"USD": 105620.00}');
return;
}
// Validate values are numeric (accept both numbers and numeric strings)
for (const [key, value] of Object.entries(parsed)) {
// Check if it's a number OR a numeric string
const isNumeric = typeof value === 'number' ||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
if (!isNumeric) {
alert(`Rate value for ${key} must be numeric (got: ${typeof value})`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for rate field');
}
});
}
// Validate heights field (JSON array of integers)
const heightsField = document.getElementById('id_heights');
if (heightsField) {
heightsField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (!Array.isArray(parsed)) {
alert('Heights must be a JSON array like [123456, 789012]');
return;
}
// Validate all values are integers
for (const height of parsed) {
if (!Number.isInteger(height)) {
alert('All heights must be integers');
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for heights field');
}
});
}
// Validate integer fields (height, fee, value)
const integerFields = ['id_height', 'id_fee', 'id_value'];
integerFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
if (this.value && this.value.trim()) {
if (!/^-?\d+$/.test(this.value.trim())) {
alert(`${fieldId.replace('id_', '')} must be an integer`);
}
}
});
}
});
{% endaddtoblock %}
</script>
{% endblock %}

View file

@ -23,6 +23,31 @@
<div class="table-responsive d-none d-md-block" style="padding-top:1em">
{% if label_list %}
<ul class="nav nav-tabs" id="typeFilterTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if not request.GET.type or request.GET.type == 'all' %}active{% endif %}" data-type="all">All</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'tx' %}active{% endif %}" data-type="tx">Transactions</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'output' %}active{% endif %}" data-type="output">Outputs</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'input' %}active{% endif %}" data-type="input">Inputs</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'addr' %}active{% endif %}" data-type="addr">Addresses</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'pubkey' %}active{% endif %}" data-type="pubkey">Pubkeys</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'xpub' %}active{% endif %}" data-type="xpub">XPubs</button>
</li>
</ul>
<br>
<table id="bip329labels" class="table table-striped table-sm">
<thead>
<tr>
@ -32,6 +57,8 @@
<th scope="col">label</th>
<th scope="col">origin</th>
<th scope="col">spendable</th>
<th scope="col">health</th>
</tr>
</thead>
<tbody>
@ -123,6 +150,7 @@
<script>
{% addtoblock "js" %}
$(document).ready(function () {
window.currentTypeFilter = 'all';
const dt_table = $('#bip329labels').DataTable({
order: [[0, "asc"]],
columns: [
@ -131,15 +159,19 @@
{ data: 'ref', orderable: true, searchable: true },
{ data: 'label', orderable: true, searchable: true },
{ data: 'origin', orderable: true, searchable: true },
{ data: 'spendable', orderable: true, searchable: true }
{ data: 'spendable', orderable: true, searchable: true },
{ data: 'get_fee_health_status_display', orderable: false, searchable: false}
],
searching: true,
processing: false,
serverSide: true,
stateSave: true,
responsive: true,
ajax: {
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
// url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}&type={{ request.GET.type|default:'all' }}",
type: 'GET',
dataSrc: 'data'
},
@ -151,6 +183,21 @@
}
});
// Tab click handler - full page reload
$('#typeFilterTabs button').on('click', function() {
const typeFilter = $(this).data('type');
const urlParams = new URLSearchParams(window.location.search);
if (typeFilter === 'all') {
urlParams.delete('type');
} else {
urlParams.set('type', typeFilter);
}
// Full page reload with new URL
window.location.search = urlParams.toString();
});
function createCard(data) {
let originText = '';
if (data.origin) {
@ -160,6 +207,12 @@
if (data.type === '<tt>output</tt>' && (data.spendable === "<tt>true</tt>" || data.spendable === "<tt>false</tt>")) {
spendableText = `<tr><td> <strong>Spendable:</strong></td><td> ${data.spendable === "<tt>true</tt>" ? 'true' : 'false'}</td></tr>`;
}
let healthText = '';
if (data.get_fee_health_status_display) {
healthText = `<tr><td><strong>Health:</strong></td><td>${data.get_fee_health_status_display}</td></tr>`;
}
return `
<div class="col-12 mb-3">
<div class="card">
@ -184,6 +237,7 @@
</tr>
${originText ? `<tr><td colspan="2">${originText}</td></tr>` : ''}
${spendableText ? `${spendableText}` : ''}
${healthText}
</tbody>
</table>
</div>
@ -193,6 +247,7 @@
}
function renderMobileView(data, start, length, totalRecords, totalRecordsAll) {
$('#mobileLabels').empty();
data.forEach(function(item) {

View file

@ -17,6 +17,10 @@
{% url 'userprofile_mempool' as mempool_url %}
<a class="nav-link {% if request.path == mempool_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_url }}">Mempool</a>
</li>
<li class="nav-item">
{% url 'userprofile_fees' as mempool_fees_url %}
<a class="nav-link {% if request.path == mempool_fees_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_fees_url }}">Mempool Fees</a>
</li>
{% if request.user.profile.use_fiatfinances %}
<li class="nav-item">
{% url 'userprofile_currency' as my_currency_url %}

View file

@ -41,4 +41,11 @@ class ProfileCurrencyForm(forms.ModelForm):
fields = ['my_currency']
class ProfileFeeForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['my_fee', "my_fee_rate_threshold"]
#

View file

@ -49,7 +49,10 @@ class Profile(models.Model):
choices=CURRENCY_CHOICES,
default='USD')
my_fee = models.IntegerField(default=1, help_text="Fee used when broadcasting transactions")
my_fee_rate_threshold = models.IntegerField(default=1, help_text="Overwrite healthy fee")
has_seen_welcome_popup = models.BooleanField(default=False)
def labelbases(self):
return Labelbase.objects.filter(user_id=self.user_id)

View file

@ -11,7 +11,8 @@ from .forms import (
ProfileAvatarForm,
ProfileCurrencyForm,
ElectrumServerInfoForm,
MempoolForm)
MempoolForm,
ProfileFeeForm)
@ -135,3 +136,19 @@ class ProfileCurrencyUpdateView(UpdateView):
def form_valid(self, form):
messages.success(self.request, "<strong>Success!</strong> Currency updated successfully.")
return super().form_valid(form)
class ProfileFeeUpdateView(UpdateView):
model = Profile
form_class = ProfileFeeForm
template_name = 'profile_update_fees.html'
def get_success_url(self):
return reverse_lazy('userprofile_fees')
def get_object(self, queryset=None):
return get_object_or_404(Profile, user=self.request.user)
def form_valid(self, form):
messages.success(self.request, "<strong>Success!</strong> Fees updated successfully.")
return super().form_valid(form)