mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
New feature: Coin Selection
This opt-in feature allows you to select which utxos you want to use for a specific transaction.
This commit is contained in:
parent
c85fb3e89f
commit
d6c66d0c03
10 changed files with 279 additions and 8 deletions
|
@ -20,6 +20,10 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
var nbx = ExplorerClientProvider.GetExplorerClient(network);
|
||||
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
|
||||
if (sendModel.InputSelection)
|
||||
{
|
||||
psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList()?? new List<OutPoint>();
|
||||
}
|
||||
foreach (var transactionOutput in sendModel.Outputs)
|
||||
{
|
||||
var psbtDestination = new CreatePSBTDestination();
|
||||
|
|
|
@ -463,7 +463,36 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
decimal transactionAmountSum = 0;
|
||||
|
||||
if (command == "toggle-input-selection")
|
||||
{
|
||||
vm.InputSelection = !vm.InputSelection;
|
||||
}
|
||||
if (vm.InputSelection)
|
||||
{
|
||||
var schemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId);
|
||||
var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId);
|
||||
|
||||
var utxos = await _walletProvider.GetWallet(network).GetUnspentCoins(schemeSettings.AccountDerivation, cancellation);
|
||||
vm.InputsAvailable = utxos.Select(coin =>
|
||||
{
|
||||
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
|
||||
return new WalletSendModel.InputSelectionOption()
|
||||
{
|
||||
Outpoint = coin.OutPoint.ToString(),
|
||||
Amount = coin.Value.GetValue(network),
|
||||
Comment = info?.Comment,
|
||||
Labels = info == null? null :walletBlobAsync.GetLabels(info),
|
||||
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, coin.OutPoint.Hash.ToString())
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (command == "toggle-input-selection")
|
||||
{
|
||||
ModelState.Clear();
|
||||
return View(vm);
|
||||
}
|
||||
if (command == "add-output")
|
||||
{
|
||||
ModelState.Clear();
|
||||
|
|
|
@ -3,6 +3,8 @@ using System.Collections.Generic;
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using NBXplorer.Models;
|
||||
|
||||
namespace BTCPayServer.Models.WalletViewModels
|
||||
{
|
||||
|
@ -47,5 +49,17 @@ namespace BTCPayServer.Models.WalletViewModels
|
|||
public bool DisableRBF { get; set; }
|
||||
|
||||
public bool NBXSeedAvailable { get; set; }
|
||||
public bool InputSelection { get; set; }
|
||||
public InputSelectionOption[] InputsAvailable { get; set; }
|
||||
public IEnumerable<string> SelectedInputs { get; set; }
|
||||
|
||||
public class InputSelectionOption
|
||||
{
|
||||
public IEnumerable<Label> Labels { get; set; }
|
||||
public string Comment { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string Outpoint { get; set; }
|
||||
public string Link { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,18 @@
|
|||
|
||||
@RenderSection("HeadScripts", required: false)
|
||||
@RenderSection("HeaderContent", false)
|
||||
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
.hide-when-js{
|
||||
display:block !important;
|
||||
}
|
||||
.only-for-js{
|
||||
display:none !important;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
|
||||
<body id="page-top">
|
||||
|
|
182
BTCPayServer/Views/Wallets/CoinSelection.cshtml
Normal file
182
BTCPayServer/Views/Wallets/CoinSelection.cshtml
Normal file
|
@ -0,0 +1,182 @@
|
|||
@model WalletSendModel
|
||||
|
||||
<div id="coin-selection-app" v-cloak>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item list-group-item-heading d-flex justify-content-between align-items-center">
|
||||
<h3>Coin selection</h3>
|
||||
<span class="text-muted text-right" >
|
||||
<span>{{selectedItems.length}} selected</span>
|
||||
<span v-show="selectedItems.length > 0" > ({{selectedAmount}} @Model.CryptoCode) </span><br/>
|
||||
<span>{{items.length}} total UTXOs (@Model.CurrentBalance @Model.CryptoCode)</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<input type="text" v-model="filter" class="form-control" placeholder="Filter by transaction id, amount, label, comment.."/>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center cursor-pointer"
|
||||
v-for="item of filteredItems"
|
||||
:key="item.outpoint"
|
||||
v-bind:class="{ 'alert-success': item.selected }"
|
||||
v-on:click="toggleItem($event, item, !item.selected)">
|
||||
<a
|
||||
v-on:click="toggleItem($event, item, !item.selected)" class="text-truncate" v-tooltip="item.outpoint" style="max-width:50%" v-bind:href="item.link" target="_blank">{{item.outpoint}}</a>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span v-if="item.comment" data-toggle="tooltip" v-tooltip="item.comment" class="badge badge-info badge-pill" style="font-style: italic">i</span>
|
||||
<span v-if="item.labels"
|
||||
class="badge badge-primary badge-pill ml-1"
|
||||
v-for="label of item.labels"
|
||||
v-bind:style="{ 'background-color': label.color}"
|
||||
key="label.value">
|
||||
{{label.value}}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted ml-1">{{item.amount}}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<ul class="pagination float-left">
|
||||
<li class="page-item" v-bind:class="{'disabled' : pageStart == 0}">
|
||||
<a class="page-link" tabindex="-1" href="#" v-on:click="page = page -1">«</a>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">
|
||||
Showing {{pageStart+1}}-{{pageEnd}} of {{currentItems.length}}
|
||||
</span>
|
||||
</li>
|
||||
<li class="page-item" v-bind:class="{'disabled' : pageEnd>= currentItems.length}">
|
||||
<a class="page-link" href="#" v-on:click="page = page +1">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a href="#" v-on:click="showSelectedOnly = !showSelectedOnly" class="btn btn-link" v-bind:class="{'disabled' : selectedInputs.length === 0}">
|
||||
<template v-if="showSelectedOnly">Show all</template>
|
||||
<template v-else>Show selected only</template>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script src="~/bundles/wallet-coin-selection-bundle.min.js" type="text/javascript"></script>
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
Vue.directive('tooltip', function(el, binding){
|
||||
$(el).tooltip({
|
||||
title: binding.value,
|
||||
placement: binding.arg||"auto",
|
||||
trigger: 'hover'
|
||||
});
|
||||
});
|
||||
|
||||
function roundNumber(number, decimals) {
|
||||
var newnumber = new Number(number+'').toFixed(parseInt(decimals));
|
||||
return parseFloat(newnumber);
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#coin-selection-app',
|
||||
data: {
|
||||
filter: "",
|
||||
items: @Safe.Json(Model.InputsAvailable),
|
||||
selectedInputs: $("#SelectedInputs").val(),
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
showSelectedOnly: false
|
||||
},
|
||||
watch:{
|
||||
filter: function(){ this.handle();},
|
||||
showSelectedOnly: function(){ this.handle();},
|
||||
selectedInputs: function(){ this.handle();},
|
||||
|
||||
},
|
||||
computed: {
|
||||
currentItems: function(){
|
||||
return this.showSelectedOnly? this.selectedItems: this.items;
|
||||
},
|
||||
pageStart: function(){
|
||||
return this.page == 0? 0 : this.page * this.pageSize;
|
||||
},
|
||||
pageEnd: function(){
|
||||
var result = this.pageStart + this.pageSize;
|
||||
if(result > this.currentItems.length){
|
||||
result = this.currentItems.length;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
filteredItems: function(){
|
||||
|
||||
var result = [];
|
||||
if(!this.filter){
|
||||
var self = this;
|
||||
result = this.currentItems.map(function(currentItem){ return {...currentItem, selected: self.selectedInputs.indexOf(currentItem.outpoint) != -1}});
|
||||
}else{
|
||||
var f = this.filter.toLowerCase();
|
||||
for(var i = 0; i < this.currentItems.length; i++) {
|
||||
var currentItem = this.currentItems[i];
|
||||
if(currentItem.outpoint.indexOf(f) != -1 ||
|
||||
currentItem.amount.toString().indexOf(f) != -1||
|
||||
(currentItem.comment && currentItem.comment.toLowerCase().indexOf(f) != -1) ||
|
||||
(currentItem.labels && currentItem.labels.filter(function(l){ return l.value.toLowerCase().indexOf(f) != -1} ).length > 0)){
|
||||
result.push({...currentItem, selected: this.selectedInputs.indexOf(currentItem.outpoint) != -1});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.slice(this.pageStart, this.pageEnd);
|
||||
},
|
||||
selectedItems: function() {
|
||||
var result = [];
|
||||
for(var i = 0; i < this.items.length; i++) {
|
||||
var currentItem = this.items[i];
|
||||
|
||||
if(this.selectedInputs.indexOf(currentItem.outpoint) != -1){
|
||||
result.push(currentItem);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
selectedAmount: function(){
|
||||
var result = 0;
|
||||
for(let i = 0; i < this.selectedItems.length; i++) {
|
||||
result += this.selectedItems[i].amount;
|
||||
}
|
||||
return roundNumber(result,12);
|
||||
}
|
||||
},
|
||||
mounted: function() {
|
||||
var self = this;
|
||||
self.selectedInputs = $("#SelectedInputs").val();
|
||||
$(".crypto-balance-link").text(this.selectedAmount);
|
||||
$("#SelectedInputs").on("input change", function(){
|
||||
self.selectedInputs = $("#SelectedInputs").val();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
handle: function(){
|
||||
if(this.selectedInputs.length == 0){
|
||||
this.showSelectedOnly = false;
|
||||
}
|
||||
if(this.currentItems.length < this.pageEnd){
|
||||
this.page = 0;
|
||||
}
|
||||
},
|
||||
toggleItem: function(evt, item, toggle){
|
||||
if(evt.target.tagName == "A"){
|
||||
return;
|
||||
}
|
||||
var res= $("#SelectedInputs").val();
|
||||
if(toggle){
|
||||
res.push(item.outpoint);
|
||||
|
||||
}else{
|
||||
res.splice(this.selectedInputs.indexOf(item.outpoint),1);
|
||||
}
|
||||
|
||||
$("#SelectedInputs").val(res);
|
||||
this.selectedInputs = res;
|
||||
$(".crypto-balance-link").text(this.selectedAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
|
@ -14,8 +14,9 @@
|
|||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="@(Model.Outputs.Count==1? "col-lg-6 transaction-output-form": "col-lg-8")">
|
||||
<div class="@(!Model.InputSelection && Model.Outputs.Count==1? "col-lg-6 transaction-output-form": "col-lg-8")">
|
||||
<form method="post">
|
||||
<input type="hidden" asp-for="InputSelection" />
|
||||
<input type="hidden" asp-for="Divisibility" />
|
||||
<input type="hidden" asp-for="NBXSeedAvailable" />
|
||||
<input type="hidden" asp-for="Fiat" />
|
||||
|
@ -34,6 +35,19 @@
|
|||
}
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (Model.InputSelection)
|
||||
{
|
||||
|
||||
<select multiple="multiple" asp-for="SelectedInputs" class="hide-when-js">
|
||||
@foreach (var input in Model.InputsAvailable)
|
||||
{
|
||||
<option value="@input.Outpoint" asp-selected="@(Model.SelectedInputs?.Contains(input.Outpoint)??false)">@input.Outpoint <span>@input.Amount</span> </option>
|
||||
}
|
||||
</select>
|
||||
<partial name="CoinSelection"/>
|
||||
}
|
||||
|
||||
@if (Model.Outputs.Count == 1)
|
||||
{
|
||||
<div class="form-group">
|
||||
|
@ -150,6 +164,9 @@
|
|||
</a>
|
||||
</div>
|
||||
}
|
||||
<div class="form-group">
|
||||
<button type="submit" name="command" value="toggle-input-selection" class="btn btn-sm btn-secondary">Toggle coin selection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -179,5 +179,14 @@
|
|||
"wwwroot/vendor/bootstrap-vue/bootstrap-vue.css",
|
||||
"wwwroot/payment-request/**/*.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/bundles/wallet-coin-selection-bundle.min.js",
|
||||
"inputFiles": [
|
||||
"wwwroot/vendor/vuejs/vue.min.js",
|
||||
"wwwroot/vendor/babel-polyfill/polyfill.min.js",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
|
|
|
@ -35,7 +35,7 @@ $(function () {
|
|||
|
||||
$(".crypto-balance-link").on("click", function (elem) {
|
||||
var val = $(this).text();
|
||||
var parentContainer = $(this).parents(".transaction-output-form");
|
||||
var parentContainer = $(this).parents(".form-group");
|
||||
var outputAmountElement = parentContainer.find(".output-amount");
|
||||
outputAmountElement.val(val);
|
||||
parentContainer.find(".subtract-fees").prop('checked', true);
|
||||
|
|
|
@ -7,11 +7,13 @@ html {
|
|||
.logo {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.only-for-js,
|
||||
.hide-when-js,
|
||||
.input-group-clear {
|
||||
display: none;
|
||||
}
|
||||
.only-for-js {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wraptextAuto {
|
||||
max-width: 300px;
|
||||
|
@ -142,3 +144,8 @@ pre {
|
|||
.pin-button:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
[v-cloak] > * { display:none }
|
||||
[v-cloak]::before { content: "loading…" }
|
||||
.cursor-pointer{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -49,9 +49,6 @@
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
$(".only-for-js").show();
|
||||
|
||||
function handleInputGroupClearButtonDisplay(element) {
|
||||
var inputs = $(element).parents(".input-group").find("input");
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue