Adds crypto wallet balance widget

This commit is contained in:
Alicia Sykes 2022-01-03 12:32:00 +00:00
parent 2ee01f603c
commit 710b3ea7ad
5 changed files with 277 additions and 4 deletions

View File

@ -13,6 +13,7 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
- [Weather Forecast](#weather-forecast)
- [Crypto Watch List](#crypto-watch-list)
- [Crypto Price History](#crypto-token-price-history)
- [Crypto Wallet Balance](#wallet-balance)
- [RSS Feed](#rss-feed)
- [Code Stats](#code-stats)
- [Vulnerability Feed](#vulnerability-feed)
@ -238,6 +239,38 @@ Shows recent price history for a given crypto asset, using price data fetched fr
---
### Wallet Balance
Keep track of your crypto balances and see recent transactions. Data is fetched from [BlockCypher](https://www.blockcypher.com/dev/)
<p align="center"><img width="600" src="https://i.ibb.co/27HG4nj/wallet-balances.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`coin`** | `string` | Required | Symbol of coin or asset, e.g. `btc`, `eth` or `doge`
**`address`** | `string` | Required | Address to monitor. This is your wallet's **public** / receiving address
**`network`** | `string` | _Optional_ | To use a different network, other than mainnet. Defaults to `main`
**`limit`** | `number` | _Optional_ | Limit the number of transactions to display. Defaults to `10`, set to large number to show all
##### Example
```yaml
- type: wallet-balance
options:
coin: btc
address: 3853bSxupMjvxEYfwGDGAaLZhTKxB2vEVC
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟢 Not Required
- **Price**: 🟢 Free
- **Privacy**: _See [BlockCypher Privacy Policy](https://www.blockcypher.com/privacy.html)_
---
### RSS Feed
Display news and updates from any RSS-enabled service.

View File

@ -0,0 +1,228 @@
<template>
<div class="wallet-balance-wrapper">
<p class="wallet-title">{{ getCoinNameFromSymbol(coin) }} Wallet</p>
<a v-if="metaInfo" :href="metaInfo.explorer" class="wallet-address">{{ address }}</a>
<div class="balance-inner">
<img v-if="metaInfo" :src="metaInfo.qrCode" alt="QR Code" class="wallet-qr" />
<div v-if="balances" class="balances-section">
<p class="main-balance" v-tooltip="makeBalanceTooltip(balances)">{{ balances.current }}</p>
<div class="balance-info">
<div class="balance-info-row">
<span class="label">Total In</span>
<span class="amount">+ {{ balances.totalReceived }}</span>
</div>
<div class="balance-info-row">
<span class="label">Total Out:</span>
<span class="amount">- {{ balances.totalSent }}</span>
</div>
<div class="balance-info-row">
<span class="label">Last Activity:</span>
<span class="amount">{{ balances.lastTransaction }}</span>
</div>
</div>
</div>
</div>
<div class="transactions" v-if="transactions">
<p class="transactions-title">Recent Transactions</p>
<a class="transaction-row"
v-for="transaction in transactions"
:key="transaction.hash"
:href="transaction.url"
v-tooltip="makeTransactionTooltip(transaction)"
>
<span class="date">{{ transaction.date }}</span>
<span :class="`amount ${transaction.incoming ? 'in' : 'out'}`">
{{ transaction.incoming ? '+' : '-'}}{{ transaction.amount }}
</span>
</a>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, timestampToTime, getTimeAgo } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
computed: {
coin() {
if (!this.options.coin) this.error('You must specify a coin, e.g. \'BTC\'');
return this.options.coin.toLowerCase();
},
address() {
if (!this.options.address) this.error('You must specify a public address');
return this.options.address;
},
network() {
return this.options.network || 'main';
},
limit() {
return this.options.limit || 10;
},
endpoint() {
return `${widgetApiEndpoints.walletBalance}/`
+ `${this.coin}/${this.network}/addrs/${this.address}`;
},
divisionFactor() {
switch (this.coin) {
case ('btc'): return 100000000;
case ('eth'): return 1000000000000000000;
default: return 1;
}
},
},
data() {
return {
balances: null,
metaInfo: null,
transactions: null,
};
},
methods: {
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
processData(data) {
const formatAmount = (amount) => {
const symbol = this.coin.toUpperCase();
if (!amount) return `0 ${symbol}`;
return `${(amount / this.divisionFactor).toFixed(6)} ${symbol}`;
};
this.balances = {
current: formatAmount(data.balance),
unconfirmed: formatAmount(data.unconfirmed_balance),
final: formatAmount(data.final_balance),
totalSent: formatAmount(data.total_sent),
totalReceived: formatAmount(data.total_received),
lastTransaction: data.txrefs ? getTimeAgo(data.txrefs[0].confirmed) : 'Never',
};
const transactions = [];
data.txrefs.forEach((transaction) => {
transactions.push({
hash: transaction.tx_hash,
amount: formatAmount(transaction.value),
date: timestampToDate(transaction.confirmed),
time: timestampToTime(transaction.confirmed),
confirmations: transaction.confirmations,
blockHeight: transaction.block_height,
balance: formatAmount(transaction.ref_balance),
incoming: transaction.tx_input_n === -1,
url: `https://live.blockcypher.com/${this.coin}/tx/${transaction.tx_hash}/`,
});
});
this.transactions = transactions.slice(0, this.limit);
},
getCoinNameFromSymbol(symbol) {
const coins = {
btc: 'Bitcoin',
dash: 'Dash',
doge: 'Doge',
ltc: 'Litecoin',
eth: 'Ethereum',
bhc: 'BitcoinCash',
xmr: 'Monero',
ada: 'Cardano',
bcy: 'BlockCypher',
};
if (!symbol || !Object.keys(coins).includes(symbol.toLowerCase())) return '';
return coins[symbol.toLowerCase()];
},
makeBalanceTooltip(balances) {
return this.tooltip(
`<b>Unconfirmed:</b> ${balances.unconfirmed}<br><b>Final:</b> ${balances.final}`,
true,
);
},
makeTransactionTooltip(transaction) {
return this.tooltip(
`At ${transaction.time}<br>`
+ `<b>BlockHeight:</b> ${transaction.blockHeight}<br>`
+ `<b>Confirmations:</b> ${transaction.confirmations}<br>`
+ `<b>Balance After:</b> ${transaction.balance}`,
true,
);
},
makeMetaInfo() {
const explorer = `https://live.blockcypher.com/${this.coin}/address/${this.address}/`;
const coin = this.getCoinNameFromSymbol(this.coin).toLowerCase();
const qrCode = `${widgetApiEndpoints.walletQrCode}/`
+ `?style=${coin.toLowerCase()}&color=11&address=${this.address}`;
return { explorer, coin, qrCode };
},
},
mounted() {
this.metaInfo = this.makeMetaInfo();
},
};
</script>
<style scoped lang="scss">
.wallet-balance-wrapper {
max-width: 30rem;
margin: 0 auto;
a.wallet-address {
display: block;
margin: 0.5rem 0;
overflow: hidden;
text-overflow: ellipsis;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
.balance-inner {
display: flex;
justify-content: space-around;
img.wallet-qr {
max-width: 7rem;
margin: 0.5rem 0;
border-radius: var(--curve-factor);
}
.balances-section {
p {
color: var(--widget-text-color);
font-family: var(--font-monospace);
cursor: default;
margin: 0.5rem;
}
p.main-balance {
font-size: 1.5rem;
}
.balance-info .balance-info-row {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
display: flex;
justify-content: space-between;
font-size: 0.8rem;
margin: 0.2rem 0.5rem;
span.amount {
font-family: var(--font-monospace);
}
}
}
}
p.wallet-title, p.transactions-title {
color: var(--widget-text-color);
margin: 0.5rem 0 0.25rem;
font-size: 1.2rem;
font-weight: bold;
}
.transactions .transaction-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
text-decoration: none;
span {
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
span.amount {
&.in { color: var(--success); }
&.out { color: var(--danger); }
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

@ -228,8 +228,8 @@
@error="handleError"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
<WalletBalance
v-else-if="widgetType === 'wallet-balance'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
@ -249,6 +249,13 @@
@error="handleError"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<!-- No widget type specified -->
<div v-else>{{ handleError('Widget type was not found') }}</div>
</div>
@ -302,6 +309,7 @@ export default {
StockPriceChart: () => import('@/components/Widgets/StockPriceChart.vue'),
SystemInfo: () => import('@/components/Widgets/SystemInfo.vue'),
TflStatus: () => import('@/components/Widgets/TflStatus.vue'),
WalletBalance: () => import('@/components/Widgets/WalletBalance.vue'),
Weather: () => import('@/components/Widgets/Weather.vue'),
WeatherForecast: () => import('@/components/Widgets/WeatherForecast.vue'),
XkcdComic: () => import('@/components/Widgets/XkcdComic.vue'),

View File

@ -57,8 +57,10 @@ const WidgetMixin = {
this.finishLoading();
},
/* Used as v-tooltip, pass text content in, and will show on hover */
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
tooltip(content, html = false) {
return {
content, html, trigger: 'hover focus', delay: 250,
};
},
/* Makes data request, returns promise */
makeRequest(endpoint, options) {

View File

@ -228,6 +228,8 @@ module.exports = {
sportsScores: 'https://www.thesportsdb.com/api/v1/json',
stockPriceChart: 'https://www.alphavantage.co/query',
tflStatus: 'https://api.tfl.gov.uk/line/mode/tube/status',
walletBalance: 'https://api.blockcypher.com/v1',
walletQrCode: 'https://www.bitcoinqrcodemaker.com/api',
weather: 'https://api.openweathermap.org/data/2.5/weather',
weatherForecast: 'https://api.openweathermap.org/data/2.5/forecast/daily',
xkcdComic: 'https://xkcd.vercel.app/',