mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2025-11-05 11:56:37 +00:00
draft implementation of optional bip329 fields
This commit is contained in:
parent
1140a678ad
commit
20d3f07059
4 changed files with 180 additions and 35 deletions
|
|
@ -1369,7 +1369,7 @@ public class AppController implements Initializable {
|
||||||
public void exportWallet(ActionEvent event) {
|
public void exportWallet(ActionEvent event) {
|
||||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||||
if(selectedWalletForm != null) {
|
if(selectedWalletForm != null) {
|
||||||
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm);
|
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm, getSelectedWalletForms());
|
||||||
dlg.initOwner(rootStack.getScene().getWindow());
|
dlg.initOwner(rootStack.getScene().getWindow());
|
||||||
Optional<Wallet> wallet = dlg.showAndWait();
|
Optional<Wallet> wallet = dlg.showAndWait();
|
||||||
if(wallet.isPresent()) {
|
if(wallet.isPresent()) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ import java.util.List;
|
||||||
public class WalletExportDialog extends Dialog<Wallet> {
|
public class WalletExportDialog extends Dialog<Wallet> {
|
||||||
private Wallet wallet;
|
private Wallet wallet;
|
||||||
|
|
||||||
public WalletExportDialog(WalletForm walletForm) {
|
public WalletExportDialog(WalletForm selectedWalletForm, List<WalletForm> allWalletForms) {
|
||||||
this.wallet = walletForm.getWallet();
|
this.wallet = selectedWalletForm.getWallet();
|
||||||
|
|
||||||
EventManager.get().register(this);
|
EventManager.get().register(this);
|
||||||
setOnCloseRequest(event -> {
|
setOnCloseRequest(event -> {
|
||||||
|
|
@ -45,10 +45,10 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||||
|
|
||||||
List<WalletExport> exporters;
|
List<WalletExport> exporters;
|
||||||
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
||||||
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
|
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
|
||||||
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
||||||
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
|
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
|
||||||
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
|
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
|
||||||
} else {
|
} else {
|
||||||
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,40 @@
|
||||||
package com.sparrowwallet.sparrow.io;
|
package com.sparrowwallet.sparrow.io;
|
||||||
|
|
||||||
import com.csvreader.CsvReader;
|
import com.csvreader.CsvReader;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.*;
|
||||||
import com.sparrowwallet.drongo.KeyPurpose;
|
import com.sparrowwallet.drongo.KeyPurpose;
|
||||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||||
import com.sparrowwallet.drongo.Utils;
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||||
import com.sparrowwallet.drongo.wallet.*;
|
import com.sparrowwallet.drongo.wallet.*;
|
||||||
import com.sparrowwallet.sparrow.AppServices;
|
import com.sparrowwallet.sparrow.AppServices;
|
||||||
import com.sparrowwallet.sparrow.EventManager;
|
import com.sparrowwallet.sparrow.EventManager;
|
||||||
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
|
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
|
||||||
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
|
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
|
||||||
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
|
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
|
||||||
|
import com.sparrowwallet.sparrow.net.ExchangeSource;
|
||||||
import com.sparrowwallet.sparrow.wallet.*;
|
import com.sparrowwallet.sparrow.wallet.*;
|
||||||
|
import org.apache.commons.lang3.time.DateUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.text.DateFormat;
|
||||||
|
import java.text.ParseException;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class WalletLabels implements WalletImport, WalletExport {
|
public class WalletLabels implements WalletImport, WalletExport {
|
||||||
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
|
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
|
||||||
|
private static final long ONE_DAY = 24*60*60*1000L;
|
||||||
|
|
||||||
private final List<WalletForm> walletForms;
|
private final List<WalletForm> walletForms;
|
||||||
|
|
||||||
public WalletLabels() {
|
|
||||||
this.walletForms = Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public WalletLabels(List<WalletForm> walletForms) {
|
public WalletLabels(List<WalletForm> walletForms) {
|
||||||
this.walletForms = walletForms;
|
this.walletForms = walletForms;
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +57,9 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||||
@Override
|
@Override
|
||||||
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
|
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
|
||||||
List<Label> labels = new ArrayList<>();
|
List<Label> labels = new ArrayList<>();
|
||||||
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
|
Map<Date, Double> fiatRates = getFiatRates(walletForms);
|
||||||
for(Wallet exportWallet : allWallets) {
|
for(WalletForm exportWalletForm : walletForms) {
|
||||||
|
Wallet exportWallet = exportWalletForm.getWallet();
|
||||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
|
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
|
||||||
String origin = outputDescriptor.toString(true, false, false);
|
String origin = outputDescriptor.toString(true, false, false);
|
||||||
|
|
||||||
|
|
@ -61,34 +69,38 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
|
WalletTransactionsEntry walletTransactionsEntry = exportWalletForm.getWalletTransactionsEntry();
|
||||||
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
|
for(Entry entry : walletTransactionsEntry.getChildren()) {
|
||||||
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
|
TransactionEntry txEntry = (TransactionEntry)entry;
|
||||||
}
|
BlockTransaction blkTx = txEntry.getBlockTransaction();
|
||||||
|
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
|
||||||
|
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
|
||||||
|
blkTx.getFee() == null || blkTx.getFee() == 0 ? null : blkTx.getFee(), txEntry.getValue(),
|
||||||
|
getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
|
||||||
}
|
}
|
||||||
|
|
||||||
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
|
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
|
||||||
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
|
labels.add(new AddressLabel(addressNode.getAddress().toString(), addressNode.getLabel(), origin, addressNode.getDerivationPath().substring(1),
|
||||||
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
|
addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).map(BlockTransactionHash::getHeight).toList()));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
|
for(Map.Entry<BlockTransactionHashIndex, WalletNode> txoEntry : exportWallet.getWalletTxos().entrySet()) {
|
||||||
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
|
BlockTransactionHashIndex txo = txoEntry.getKey();
|
||||||
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
|
WalletNode addressNode = txoEntry.getValue();
|
||||||
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
|
Boolean spendable = (txo.isSpent() ? null : txo.getStatus() != Status.FROZEN);
|
||||||
} else if(!txo.isSpent()) {
|
labels.add(new InputOutputLabel(Type.output, txo.toString(), txo.getLabel(), origin, spendable, addressNode.getDerivationPath().substring(1),
|
||||||
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
|
txo.getValue(), txo.getHeight(), txo.getDate(), getFiatValue(txo, fiatRates)));
|
||||||
}
|
|
||||||
|
|
||||||
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
|
if(txo.isSpent()) {
|
||||||
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
|
BlockTransactionHashIndex txi = txo.getSpentBy();
|
||||||
|
labels.add(new InputOutputLabel(Type.input, txi.toString(), txi.getLabel(), origin, null, addressNode.getDerivationPath().substring(1),
|
||||||
|
txi.getValue(), null, null, getFiatValue(txi, fiatRates)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Gson gson = new Gson();
|
Gson gson = new GsonBuilder().registerTypeAdapter(Date.class, new GsonUTCDateAdapter()).create();
|
||||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
|
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
|
||||||
|
|
||||||
for(Label label : labels) {
|
for(Label label : labels) {
|
||||||
|
|
@ -247,11 +259,11 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||||
addChangedEntry(changedWalletEntries, txioEntry);
|
addChangedEntry(changedWalletEntries, txioEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(label.type == Type.output && !reference.isSpent()) {
|
if(label.type == Type.output && !reference.isSpent() && label.spendable != null) {
|
||||||
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
|
if(!label.spendable && reference.getStatus() != Status.FROZEN) {
|
||||||
reference.setStatus(Status.FROZEN);
|
reference.setStatus(Status.FROZEN);
|
||||||
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
||||||
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
|
} else if(label.spendable && reference.getStatus() == Status.FROZEN) {
|
||||||
reference.setStatus(null);
|
reference.setStatus(null);
|
||||||
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
||||||
}
|
}
|
||||||
|
|
@ -324,12 +336,77 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<Date, Double> getFiatRates(List<WalletForm> walletForms) {
|
||||||
|
ExchangeSource exchangeSource = getExchangeSource();
|
||||||
|
Currency fiatCurrency = getFiatCurrency();
|
||||||
|
Map<Date, Double> fiatRates = new HashMap<>();
|
||||||
|
if(fiatCurrency != null) {
|
||||||
|
long min = Long.MAX_VALUE;
|
||||||
|
long max = Long.MIN_VALUE;
|
||||||
|
|
||||||
|
for(WalletForm walletForm : walletForms) {
|
||||||
|
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
|
||||||
|
if(!walletTransactionsEntry.getChildren().isEmpty()) {
|
||||||
|
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
|
||||||
|
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.summarizingLong(Date::getTime));
|
||||||
|
min = Math.min(min, stats.getMin());
|
||||||
|
max = Math.max(max, stats.getMax());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(max > min) {
|
||||||
|
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fiatRates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExchangeSource getExchangeSource() {
|
||||||
|
return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Currency getFiatCurrency() {
|
||||||
|
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Currency, BigDecimal> getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
|
||||||
|
return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Currency, BigDecimal> getFiatValue(BlockTransactionHashIndex ref, Map<Date, Double> fiatRates) {
|
||||||
|
return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Currency, BigDecimal> getFiatValue(Date date, long value, Map<Date, Double> fiatRates) {
|
||||||
|
Currency fiatCurrency = getFiatCurrency();
|
||||||
|
if(fiatCurrency != null) {
|
||||||
|
Double dayRate = null;
|
||||||
|
if(date == null) {
|
||||||
|
if(AppServices.getFiatCurrencyExchangeRate() != null) {
|
||||||
|
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dayRate = fiatRates.get(DateUtils.truncate(date, Calendar.DAY_OF_MONTH));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(dayRate != null) {
|
||||||
|
BigDecimal fiatValue = BigDecimal.valueOf(dayRate * value / Transaction.SATOSHIS_PER_BITCOIN);
|
||||||
|
return Map.of(fiatCurrency, fiatValue.setScale(fiatCurrency.getDefaultFractionDigits(), RoundingMode.HALF_UP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private enum Type {
|
private enum Type {
|
||||||
tx, addr, pubkey, input, output, xpub
|
tx, addr, pubkey, input, output, xpub
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class Label {
|
private static class Label {
|
||||||
public Label(Type type, String ref, String label, String origin, String spendable) {
|
public Label(Type type, String ref, String label, String origin, Boolean spendable) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.ref = ref;
|
this.ref = ref;
|
||||||
this.label = label;
|
this.label = label;
|
||||||
|
|
@ -341,6 +418,74 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||||
String ref;
|
String ref;
|
||||||
String label;
|
String label;
|
||||||
String origin;
|
String origin;
|
||||||
String spendable;
|
Boolean spendable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TransactionLabel extends Label {
|
||||||
|
public TransactionLabel(String ref, String label, String origin, Integer height, Date time, Long fee, Long value, Map<Currency, BigDecimal> rate) {
|
||||||
|
super(Type.tx, ref, label, origin, null);
|
||||||
|
this.height = height;
|
||||||
|
this.time = time;
|
||||||
|
this.fee = fee;
|
||||||
|
this.value = value;
|
||||||
|
this.rate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer height;
|
||||||
|
Date time;
|
||||||
|
Long fee;
|
||||||
|
Long value;
|
||||||
|
Map<Currency, BigDecimal> rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class AddressLabel extends Label {
|
||||||
|
public AddressLabel(String ref, String label, String origin, String keypath, List<Integer> heights) {
|
||||||
|
super(Type.addr, ref, label, origin, null);
|
||||||
|
this.keypath = keypath;
|
||||||
|
this.heights = heights;
|
||||||
|
}
|
||||||
|
|
||||||
|
String keypath;
|
||||||
|
List<Integer> heights;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class InputOutputLabel extends Label {
|
||||||
|
public InputOutputLabel(Type type, String ref, String label, String origin, Boolean spendable, String keypath, Long value, Integer height, Date time, Map<Currency, BigDecimal> fmv) {
|
||||||
|
super(type, ref, label, origin, spendable);
|
||||||
|
this.keypath = keypath;
|
||||||
|
this.value = value;
|
||||||
|
this.height = height;
|
||||||
|
this.time = time;
|
||||||
|
this.fmv = fmv;
|
||||||
|
}
|
||||||
|
|
||||||
|
String keypath;
|
||||||
|
Long value;
|
||||||
|
Integer height;
|
||||||
|
Date time;
|
||||||
|
Map<Currency, BigDecimal> fmv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GsonUTCDateAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
|
||||||
|
private final DateFormat dateFormat;
|
||||||
|
|
||||||
|
public GsonUTCDateAdapter() {
|
||||||
|
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonElement serialize(Date src, java.lang.reflect.Type typeOfSrc, JsonSerializationContext context) {
|
||||||
|
return new JsonPrimitive(dateFormat.format(src));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Date deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||||
|
try {
|
||||||
|
return dateFormat.parse(json.getAsString());
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw new JsonParseException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -591,7 +591,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||||
}
|
}
|
||||||
|
|
||||||
if(walletForm instanceof SettingsWalletForm settingsWalletForm) {
|
if(walletForm instanceof SettingsWalletForm settingsWalletForm) {
|
||||||
WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm());
|
WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm(), List.of(settingsWalletForm.getAppWalletForm()));
|
||||||
dlg.initOwner(export.getScene().getWindow());
|
dlg.initOwner(export.getScene().getWindow());
|
||||||
dlg.showAndWait();
|
dlg.showAndWait();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue