ADD: display bip47 payment code contact on tx details screen

This commit is contained in:
overtorment 2024-05-05 20:27:43 +01:00
parent 1384c8453f
commit 438a671299
6 changed files with 135 additions and 52 deletions

View File

@ -591,6 +591,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
tx.value += new BigNumber(vout.value).multipliedBy(100000000).toNumber(); tx.value += new BigNumber(vout.value).multipliedBy(100000000).toNumber();
} }
} }
if (this.allowBIP47() && this.isBIP47Enabled()) {
tx.counterparty = this.getBip47CounterpartyByTx(tx);
}
ret.push(tx); ret.push(tx);
} }
@ -1557,16 +1561,28 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
} }
/** /**
* return BIP47 payment code of the counterparty of this transaction (someone paid us, or we paid someone) * return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid)
* or false if it was a non-BIP47 transaction * or undefined if it was a non-BIP47 transaction
*/ */
getSenderByTxid(txid: string): string | false { getBip47CounterpartyByTxid(txid: string): string | undefined {
const foundTx = this.getTransactions().find(tx => tx.txid === txid);
if (foundTx) {
return this.getBip47CounterpartyByTx(foundTx);
}
return undefined;
}
/**
* return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid)
* or undefined if it was a non-BIP47 transaction
*/
getBip47CounterpartyByTx(tx: Transaction): string | undefined {
for (const pc of Object.keys(this._txs_by_payment_code_index)) { for (const pc of Object.keys(this._txs_by_payment_code_index)) {
// iterating all payment codes // iterating all payment codes
for (const txs of Object.values(this._txs_by_payment_code_index[pc])) { for (const txs of Object.values(this._txs_by_payment_code_index[pc])) {
for (const tx of txs) { for (const tx2 of txs) {
if (tx.txid === txid) { if (tx2.txid === tx.txid) {
return pc; // found it! return pc; // found it!
} }
} }
@ -1576,19 +1592,17 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
// checking txs we sent to counterparties // checking txs we sent to counterparties
for (const pc of this._send_payment_codes) { for (const pc of this._send_payment_codes) {
for (const tx of this.getTransactions().filter(transaction => transaction.txid === txid)) { for (const out of tx.outputs) {
for (const out of tx.outputs) { for (const address of out.scriptPubKey?.addresses ?? []) {
for (const address of out.scriptPubKey?.addresses ?? []) { if (this._addresses_by_payment_code_send[pc] && Object.values(this._addresses_by_payment_code_send[pc]).includes(address)) {
if (this._addresses_by_payment_code_send[pc] && Object.values(this._addresses_by_payment_code_send[pc]).includes(address)) { // found it!
// found it! return pc;
return pc;
}
} }
} }
} }
} }
return false; // found nothing return undefined; // found nothing
} }
createBip47NotificationTransaction(utxos: CreateTransactionUtxo[], receiverPaymentCode: string, feeRate: number, changeAddress: string) { createBip47NotificationTransaction(utxos: CreateTransactionUtxo[], receiverPaymentCode: string, feeRate: number, changeAddress: string) {

View File

@ -78,6 +78,17 @@ export type TransactionOutput = {
}; };
}; };
export type LightningTransaction = {
memo?: string;
type?: 'user_invoice' | 'payment_request' | 'bitcoind_tx' | 'paid_invoice';
payment_hash?: string | { data: string };
category?: 'receive';
timestamp?: number;
expire_time?: number;
ispaid?: boolean;
walletID?: string;
};
export type Transaction = { export type Transaction = {
txid: string; txid: string;
hash: string; hash: string;
@ -94,6 +105,11 @@ export type Transaction = {
blocktime: number; blocktime: number;
received?: number; received?: number;
value?: number; value?: number;
/**
* if known, who is on the other end of the transaction (BIP47 payment code)
*/
counterparty?: string;
}; };
export type TWallet = export type TWallet =

View File

@ -1,5 +1,4 @@
/* eslint react/prop-types: "off" */ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { useState, useMemo, useCallback, useContext, useEffect, useRef } from 'react';
import { Linking, StyleSheet, View } from 'react-native'; import { Linking, StyleSheet, View } from 'react-native';
import Clipboard from '@react-native-clipboard/clipboard'; import Clipboard from '@react-native-clipboard/clipboard';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@ -20,8 +19,15 @@ import TransactionPendingIcon from '../components/icons/TransactionPendingIcon';
import { useTheme } from './themes'; import { useTheme } from './themes';
import ListItem from './ListItem'; import ListItem from './ListItem';
import { useSettings } from './Context/SettingsContext'; import { useSettings } from './Context/SettingsContext';
import { LightningTransaction, Transaction } from '../class/wallets/types';
export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUnit.BTC, walletID }) => { interface TransactionListItemProps {
itemPriceUnit: BitcoinUnit;
walletID: string;
item: Transaction & LightningTransaction; // using type intersection to have less issues with ts
}
export const TransactionListItem: React.FC<TransactionListItemProps> = React.memo(({ item, itemPriceUnit = BitcoinUnit.BTC, walletID }) => {
const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1); const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1);
const { colors } = useTheme(); const { colors } = useTheme();
const { navigate } = useNavigation(); const { navigate } = useNavigation();
@ -39,17 +45,22 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
[colors.lightBorder], [colors.lightBorder],
); );
const shortenContactName = (addr: string): string => {
return addr.substr(0, 5) + '...' + addr.substr(addr.length - 4, 4);
};
const title = useMemo(() => { const title = useMemo(() => {
if (item.confirmations === 0) { if (item.confirmations === 0) {
return loc.transactions.pending; return loc.transactions.pending;
} else { } else {
return transactionTimeToReadable(item.received); return transactionTimeToReadable(item.received!);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.confirmations, item.received, language]); }, [item.confirmations, item.received, language]);
const txMemo = txMetadata[item.hash]?.memo ?? '';
const txMemo = (item.counterparty ? `[${shortenContactName(item.counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
const subtitle = useMemo(() => { const subtitle = useMemo(() => {
let sub = item.confirmations < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : ''; let sub = Number(item.confirmations) < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : '';
if (sub !== '') sub += ' '; if (sub !== '') sub += ' ';
sub += txMemo; sub += txMemo;
if (item.memo) sub += item.memo; if (item.memo) sub += item.memo;
@ -58,12 +69,12 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
const rowTitle = useMemo(() => { const rowTitle = useMemo(() => {
if (item.type === 'user_invoice' || item.type === 'payment_request') { if (item.type === 'user_invoice' || item.type === 'payment_request') {
if (isNaN(item.value)) { if (isNaN(Number(item.value))) {
item.value = '0'; item.value = 0;
} }
const currentDate = new Date(); const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp + item.expire_time; const invoiceExpiration = item.timestamp! + item.expire_time!;
if (invoiceExpiration > now) { if (invoiceExpiration > now) {
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
@ -86,7 +97,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
if (item.type === 'user_invoice' || item.type === 'payment_request') { if (item.type === 'user_invoice' || item.type === 'payment_request') {
const currentDate = new Date(); const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp + item.expire_time; const invoiceExpiration = item.timestamp! + item.expire_time!;
if (invoiceExpiration > now) { if (invoiceExpiration > now) {
color = colors.successColor; color = colors.successColor;
@ -97,7 +108,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
color = '#9AA0AA'; color = '#9AA0AA';
} }
} }
} else if (item.value / 100000000 < 0) { } else if (item.value! / 100000000 < 0) {
color = colors.foregroundColor; color = colors.foregroundColor;
} }
@ -112,7 +123,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
const avatar = useMemo(() => { const avatar = useMemo(() => {
// is it lightning refill tx? // is it lightning refill tx?
if (item.category === 'receive' && item.confirmations < 3) { if (item.category === 'receive' && item.confirmations! < 3) {
return ( return (
<View style={styles.iconWidth}> <View style={styles.iconWidth}>
<TransactionPendingIcon /> <TransactionPendingIcon />
@ -140,7 +151,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
if (!item.ispaid) { if (!item.ispaid) {
const currentDate = new Date(); const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp + item.expire_time; const invoiceExpiration = item.timestamp! + item.expire_time!;
if (invoiceExpiration < now) { if (invoiceExpiration < now) {
return ( return (
<View style={styles.iconWidth}> <View style={styles.iconWidth}>
@ -163,7 +174,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
<TransactionPendingIcon /> <TransactionPendingIcon />
</View> </View>
); );
} else if (item.value < 0) { } else if (item.value! < 0) {
return ( return (
<View style={styles.iconWidth}> <View style={styles.iconWidth}>
<TransactionOutgoingIcon /> <TransactionOutgoingIcon />
@ -183,8 +194,10 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
}, [subtitle]); }, [subtitle]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
menuRef?.current?.dismissMenu(); // @ts-ignore: idk how to fix
menuRef?.current?.dismissMenu?.();
if (item.hash) { if (item.hash) {
// @ts-ignore: idk how to fix
navigate('TransactionStatus', { hash: item.hash, walletID }); navigate('TransactionStatus', { hash: item.hash, walletID });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') { } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID); const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
@ -192,13 +205,14 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
try { try {
// is it a successful lnurl-pay? // is it a successful lnurl-pay?
const LN = new Lnurl(false, AsyncStorage); const LN = new Lnurl(false, AsyncStorage);
let paymentHash = item.payment_hash; let paymentHash = item.payment_hash!;
if (typeof paymentHash === 'object') { if (typeof paymentHash === 'object') {
paymentHash = Buffer.from(paymentHash.data).toString('hex'); paymentHash = Buffer.from(paymentHash.data).toString('hex');
} }
const loaded = await LN.loadSuccessfulPayment(paymentHash); const loaded = await LN.loadSuccessfulPayment(paymentHash);
if (loaded) { if (loaded) {
NavigationService.navigate('ScanLndInvoiceRoot', { NavigationService.navigate('ScanLndInvoiceRoot', {
// @ts-ignore: idk how to fix
screen: 'LnurlPaySuccess', screen: 'LnurlPaySuccess',
params: { params: {
paymentHash, paymentHash,
@ -212,6 +226,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
console.log(e); console.log(e);
} }
// @ts-ignore: idk how to fix
navigate('LNDViewInvoice', { navigate('LNDViewInvoice', {
invoice: item, invoice: item,
walletID: lightningWallet[0].getID(), walletID: lightningWallet[0].getID(),
@ -244,18 +259,18 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
}, [item.hash]); }, [item.hash]);
const onToolTipPress = useCallback( const onToolTipPress = useCallback(
id => { (id: any) => {
if (id === TransactionListItem.actionKeys.CopyAmount) { if (id === actionKeys.CopyAmount) {
handleOnCopyAmountTap(); handleOnCopyAmountTap();
} else if (id === TransactionListItem.actionKeys.CopyNote) { } else if (id === actionKeys.CopyNote) {
handleOnCopyNote(); handleOnCopyNote();
} else if (id === TransactionListItem.actionKeys.OpenInBlockExplorer) { } else if (id === actionKeys.OpenInBlockExplorer) {
handleOnViewOnBlockExplorer(); handleOnViewOnBlockExplorer();
} else if (id === TransactionListItem.actionKeys.ExpandNote) { } else if (id === actionKeys.ExpandNote) {
handleOnExpandNote(); handleOnExpandNote();
} else if (id === TransactionListItem.actionKeys.CopyBlockExplorerLink) { } else if (id === actionKeys.CopyBlockExplorerLink) {
handleCopyOpenInBlockExplorerPress(); handleCopyOpenInBlockExplorerPress();
} else if (id === TransactionListItem.actionKeys.CopyTXID) { } else if (id === actionKeys.CopyTXID) {
handleOnCopyTransactionID(); handleOnCopyTransactionID();
} }
}, },
@ -273,36 +288,36 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
const actions = []; const actions = [];
if (rowTitle !== loc.lnd.expired) { if (rowTitle !== loc.lnd.expired) {
actions.push({ actions.push({
id: TransactionListItem.actionKeys.CopyAmount, id: actionKeys.CopyAmount,
text: loc.transactions.details_copy_amount, text: loc.transactions.details_copy_amount,
icon: TransactionListItem.actionIcons.Clipboard, icon: actionIcons.Clipboard,
}); });
} }
if (subtitle) { if (subtitle) {
actions.push({ actions.push({
id: TransactionListItem.actionKeys.CopyNote, id: actionKeys.CopyNote,
text: loc.transactions.details_copy_note, text: loc.transactions.details_copy_note,
icon: TransactionListItem.actionIcons.Clipboard, icon: actionIcons.Clipboard,
}); });
} }
if (item.hash) { if (item.hash) {
actions.push( actions.push(
{ {
id: TransactionListItem.actionKeys.CopyTXID, id: actionKeys.CopyTXID,
text: loc.transactions.details_copy_txid, text: loc.transactions.details_copy_txid,
icon: TransactionListItem.actionIcons.Clipboard, icon: actionIcons.Clipboard,
}, },
{ {
id: TransactionListItem.actionKeys.CopyBlockExplorerLink, id: actionKeys.CopyBlockExplorerLink,
text: loc.transactions.details_copy_block_explorer_link, text: loc.transactions.details_copy_block_explorer_link,
icon: TransactionListItem.actionIcons.Clipboard, icon: actionIcons.Clipboard,
}, },
[ [
{ {
id: TransactionListItem.actionKeys.OpenInBlockExplorer, id: actionKeys.OpenInBlockExplorer,
text: loc.transactions.details_show_in_block_explorer, text: loc.transactions.details_show_in_block_explorer,
icon: TransactionListItem.actionIcons.Link, icon: actionIcons.Link,
}, },
], ],
); );
@ -311,9 +326,9 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
if (subtitle && subtitleNumberOfLines === 1) { if (subtitle && subtitleNumberOfLines === 1) {
actions.push([ actions.push([
{ {
id: TransactionListItem.actionKeys.ExpandNote, id: actionKeys.ExpandNote,
text: loc.transactions.expand_note, text: loc.transactions.expand_note,
icon: TransactionListItem.actionIcons.Note, icon: actionIcons.Note,
}, },
]); ]);
} }
@ -326,6 +341,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
<View style={styles.container}> <View style={styles.container}>
<ToolTipMenu ref={menuRef} actions={toolTipActions} onPressMenuItem={onToolTipPress} onPress={onPress}> <ToolTipMenu ref={menuRef} actions={toolTipActions} onPressMenuItem={onToolTipPress} onPress={onPress}>
<ListItem <ListItem
// @ts-ignore wtf
leftAvatar={avatar} leftAvatar={avatar}
title={title} title={title}
subtitleNumberOfLines={subtitleNumberOfLines} subtitleNumberOfLines={subtitleNumberOfLines}
@ -342,7 +358,7 @@ export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUn
); );
}); });
TransactionListItem.actionKeys = { const actionKeys = {
CopyTXID: 'copyTX_ID', CopyTXID: 'copyTX_ID',
CopyBlockExplorerLink: 'copy_blockExplorer', CopyBlockExplorerLink: 'copy_blockExplorer',
ExpandNote: 'expandNote', ExpandNote: 'expandNote',
@ -351,7 +367,7 @@ TransactionListItem.actionKeys = {
CopyNote: 'copyNote', CopyNote: 'copyNote',
}; };
TransactionListItem.actionIcons = { const actionIcons = {
Eye: { Eye: {
iconType: 'SYSTEM', iconType: 'SYSTEM',
iconValue: 'eye', iconValue: 'eye',

View File

@ -373,6 +373,8 @@
"status_cancel": "Cancel Transaction", "status_cancel": "Cancel Transaction",
"transactions_count": "Transactions Count", "transactions_count": "Transactions Count",
"txid": "Transaction ID", "txid": "Transaction ID",
"from": "From: {counterparty}",
"to": "To: {counterparty}",
"updating": "Updating..." "updating": "Updating..."
}, },
"wallets": { "wallets": {

View File

@ -426,7 +426,14 @@ const TransactionsStatus = () => {
} }
}; };
const shortenCounterpartyName = (addr: string): string => {
if (addr.length < 20) return addr;
return addr.substr(0, 10) + '...' + addr.substr(addr.length - 10, 10);
};
const renderTXMetadata = () => { const renderTXMetadata = () => {
const counterparty = tx.counterparty ? shortenCounterpartyName(tx.counterparty) : false;
if (txMetadata[tx.hash]) { if (txMetadata[tx.hash]) {
if (txMetadata[tx.hash].memo) { if (txMetadata[tx.hash].memo) {
return ( return (
@ -434,6 +441,20 @@ const TransactionsStatus = () => {
<Text selectable style={styles.memoText}> <Text selectable style={styles.memoText}>
{txMetadata[tx.hash].memo} {txMetadata[tx.hash].memo}
</Text> </Text>
{counterparty ? (
<View>
<BlueSpacing10 />
<Text selectable style={styles.memoText}>
{tx.value < 0
? loc.formatString(loc.transactions.to, {
counterparty,
})
: loc.formatString(loc.transactions.from, {
counterparty,
})}
</Text>
</View>
) : null}
<BlueSpacing20 /> <BlueSpacing20 />
</View> </View>
); );

View File

@ -207,7 +207,7 @@ describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
); );
assert.strictEqual( assert.strictEqual(
w.getSenderByTxid('64058a49bb75481fc0bebbb0d84a4aceebe319f9d32929e73cefb21d83342e9f'), w.getBip47CounterpartyByTxid('64058a49bb75481fc0bebbb0d84a4aceebe319f9d32929e73cefb21d83342e9f'),
'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo', 'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo',
); // we got paid ); // we got paid
@ -240,8 +240,22 @@ describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
); );
assert.strictEqual( assert.strictEqual(
w.getSenderByTxid('73a2ac70858c5b306b101a861d582f40c456a692096a4e4805aa739258c4400d'), w.getBip47CounterpartyByTxid('73a2ac70858c5b306b101a861d582f40c456a692096a4e4805aa739258c4400d'),
'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo', 'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo',
); // we paid sparrow ); // we paid sparrow
let txWithCounterparty = w.getTransactions().find(tx => tx.txid === '73a2ac70858c5b306b101a861d582f40c456a692096a4e4805aa739258c4400d');
assert(txWithCounterparty);
assert.strictEqual(
txWithCounterparty.counterparty,
'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo',
);
txWithCounterparty = w.getTransactions().find(tx => tx.txid === '64058a49bb75481fc0bebbb0d84a4aceebe319f9d32929e73cefb21d83342e9f');
assert(txWithCounterparty);
assert.strictEqual(
txWithCounterparty.counterparty,
'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo',
);
}); });
}); });