Support paying of hold lightning invoices

This commit is contained in:
minibits-cash
2023-11-14 23:44:14 +01:00
parent 53f6afc309
commit 0061b9a2b0
22 changed files with 584 additions and 203 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
"mobx": "^6.9.0",
"mobx-react-lite": "^3.4.3",
"mobx-state-tree": "^5.1.8",
"nostr-tools": "^1.14.0",
"nostr-tools": "^1.17.0",
"numbro": "^2.3.6",
"patch-package": "^7.0.0",
"postinstall-postinstall": "^2.1.0",
+44 -1
View File
@@ -281,7 +281,7 @@ index f6bc3b1..0bfe1dc 100644
method: 'POST',
requestBody: restorePayload
diff --git a/node_modules/@cashu/cashu-ts/src/CashuWallet.ts b/node_modules/@cashu/cashu-ts/src/CashuWallet.ts
index b7cf354..d3e9f0a 100644
index b7cf354..d0a9af2 100644
--- a/node_modules/@cashu/cashu-ts/src/CashuWallet.ts
+++ b/node_modules/@cashu/cashu-ts/src/CashuWallet.ts
@@ -1,7 +1,7 @@
@@ -315,6 +315,37 @@ index b7cf354..d3e9f0a 100644
/**
* Class that represents a Cashu wallet.
@@ -69,13 +69,16 @@ class CashuWallet {
* @param proofs (only the 'secret' field is required)
* @returns
*/
- async checkProofsSpent<T extends { secret: string }>(proofs: Array<T>): Promise<Array<T>> {
+ async checkProofsSpent<T extends { secret: string }>(proofs: Array<T>): Promise<{spent: Array<T>, pending: Array<T>}> {
const payload = {
//send only the secret
proofs: proofs.map((p) => ({ secret: p.secret }))
};
- const { spendable } = await this.mint.check(payload);
- return proofs.filter((_, i) => !spendable[i]);
+ const { spendable, pending: isPending } = await this.mint.check(payload);
+
+ const spent = proofs.filter((_, i) => !spendable[i]);
+ const pending = isPending && Array.isArray(isPending) ? proofs.filter((_, i) => isPending[i]) : [];
+ return {spent, pending}
}
/**
* Starts a minting process by requesting an invoice from the mint
@@ -512,8 +515,8 @@ class CashuWallet {
const secrets: Array<Uint8Array> = [];
const rs: Array<bigint> = [];
for (let i = 0; i < amounts.length; i++) {
- let deterministicR = undefined;
- let secret = undefined;
+ let deterministicR: bigint | undefined = undefined;
+ let secret: Uint8Array | undefined = undefined;
if (this._seed && count != undefined) {
secret = deriveSecret(this._seed, keysetId??this.keysetId, count + i);
deterministicR = bytesToNumber(deriveBlindingFactor(this._seed, keysetId??this.keysetId, count + i));
diff --git a/node_modules/@cashu/cashu-ts/src/DHKE.ts b/node_modules/@cashu/cashu-ts/src/DHKE.ts
index ca37559..02843b5 100644
--- a/node_modules/@cashu/cashu-ts/src/DHKE.ts
@@ -352,6 +383,18 @@ index b1f2c00..138c251 100644
export {
CashuMint,
diff --git a/node_modules/@cashu/cashu-ts/src/model/types/index.ts b/node_modules/@cashu/cashu-ts/src/model/types/index.ts
index 4855c33..4546acc 100644
--- a/node_modules/@cashu/cashu-ts/src/model/types/index.ts
+++ b/node_modules/@cashu/cashu-ts/src/model/types/index.ts
@@ -215,6 +215,7 @@ export type CheckSpendableResponse = {
* Ordered list for checked proofs. True if the secret has not been redeemed at the mint before
*/
spendable: Array<boolean>;
+ pending?: Array<boolean>;
} & ApiError;
/**
* blinded message for sending to the mint
diff --git a/node_modules/@cashu/cashu-ts/src/secrets.ts b/node_modules/@cashu/cashu-ts/src/secrets.ts
index ce70d91..4ebe676 100644
--- a/node_modules/@cashu/cashu-ts/src/secrets.ts
+5 -1
View File
@@ -17,7 +17,7 @@ export type MintsByHostname = {
export const MintsStoreModel = types
.model('MintsStore', {
mints: types.array(MintModel),
blockedMintUrls: types.array(types.string),
blockedMintUrls: types.array(types.string),
})
.views(self => ({
findByUrl: (mintUrl: string | URL) => {
@@ -66,6 +66,10 @@ export const MintsStoreModel = types
}
},
blockMint(mintToBeBlocked: Mint) {
if(self.blockedMintUrls.some(url => url === mintToBeBlocked.mintUrl)) {
return
}
self.blockedMintUrls.push(mintToBeBlocked.mintUrl)
log.debug('[blockMint]', 'Mint blocked in MintsStore')
},
+32 -5
View File
@@ -17,6 +17,7 @@ export const ProofsStoreModel = types
.model('Proofs', {
proofs: types.array(ProofModel),
pendingProofs: types.array(ProofModel),
pendingByMintSecrets: types.array(types.string),
})
.actions(withSetPropAction)
.views(self => ({
@@ -55,23 +56,34 @@ export const ProofsStoreModel = types
return proofInstance
},
alreadyExists(proof: Proof, isPending: boolean = false) {
const proofs = isPending ? self.pendingProofs : self.proofs
return proofs.some(p => p.secret === proof.secret) ? true : false
},
}))
.actions(self => ({
addProofs(newProofs: Proof[], isPending: boolean = false) {
addProofs(newProofs: Proof[], isPending: boolean = false): number {
try {
const proofs = isPending ? self.pendingProofs : self.proofs
let addedAmount: number = 0
for (const proof of newProofs) {
if(self.alreadyExists(proof)) {
log.warn('[addProofs]', `${isPending ? ' pending' : ''} proof already exists in the ProofsStore`, proof.secret)
continue
}
for (const proof of newProofs) {
if (isStateTreeNode(proof)) {
proofs.push(proof)
proofs.push(proof)
} else {
const proofInstance = ProofModel.create(proof)
proofs.push(proofInstance)
}
addedAmount += proof.amount
}
log.debug('[addProofs]', `Added new ${newProofs.length}${isPending ? ' pending' : ''} proofs to ProofsStore`,)
log.debug('[addProofs]', `Added new ${newProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`,)
const rootStore = getRootStore(self)
const {userSettingsStore} = rootStore
@@ -79,6 +91,8 @@ export const ProofsStoreModel = types
if (userSettingsStore.isLocalBackupOn === true) {
Database.addOrUpdateProofs(newProofs, isPending) // isSpent = false
}
return addedAmount
} catch (e: any) {
throw new AppError(Err.STORAGE_ERROR, e.message)
}
@@ -119,6 +133,19 @@ export const ProofsStoreModel = types
throw new AppError(Err.STORAGE_ERROR, e.message.toString())
}
},
addToPendingByMint(proof: Proof): boolean {
if(self.pendingByMintSecrets.some(s => s === proof.secret)) {
return false
}
self.pendingByMintSecrets.push(proof.secret)
log.trace('[addToPendingByMint]', 'Proof marked as pending by mint, secret', proof.secret)
return true
},
removeFromPendingByMint(proof: Proof) {
self.pendingByMintSecrets.remove(proof.secret)
log.trace('[removeFromPendingByMint]', 'Proof removed from pending by mint, secret', proof.secret)
},
}))
.views(self => ({
get proofsCount() {
+11 -4
View File
@@ -140,7 +140,14 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
style={$item}
onPress={gotoLocalRecovery}
/>
)}
)}
</>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.removeSpentCoins"
subTx="backupScreen.removeSpentCoinsDescription"
@@ -152,8 +159,8 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
/>
</>
}
/>
{isLoading && <Loading />}
/>
{isLoading && <Loading />}
</View>
<BottomModal
isVisible={isBackupModalVisible}
@@ -197,7 +204,7 @@ const $contentContainer: TextStyle = {
}
const $card: ViewStyle = {
marginBottom: 0,
marginBottom: spacing.small,
}
const $item: ViewStyle = {
+35 -14
View File
@@ -10,6 +10,10 @@ import {
useColorScheme,
} from 'react-native'
import {formatDistance, toDate} from 'date-fns'
import {
type TokenEntry as CashuTokenEntry,
type Proof as CashuProof,
} from '@cashu/cashu-ts'
import {useThemeColor, spacing, colors, typography} from '../theme'
import {
Button,
@@ -29,10 +33,11 @@ import {Database} from '../services'
import AppError from '../utils/AppError'
import {BackupProof, Proof} from '../models/Proof'
import { useStores } from '../models'
import { getMintForToken, getProofsAmount } from '../services/cashu/cashuUtils'
import { CashuUtils } from '../services/cashu/cashuUtils'
import JSONTree from 'react-native-json-tree'
import Clipboard from '@react-native-clipboard/clipboard'
import { getEncodedToken } from '@cashu/cashu-ts'
import { TokenEntry } from '../models/TokenEntry'
interface LocalRecoveryScreenProps
extends SettingsStackScreenProps<'LocalRecovery'> {}
@@ -127,30 +132,46 @@ export const LocalRecoveryScreen: FC<LocalRecoveryScreenProps> =
const copyEncodedTokens = function (proofs: BackupProof[]) {
try {
setIsLoading(true)
const encodedTokens: string[] = []
// TODO group by mints
for (const proof of proofs) {
const mint = getMintForToken(proof, mintsStore.allMints)
const { tId, isPending, isSpent, updatedAt, ...cleanedProof } = proof
for (const mint of mintsStore.allMints) {
let proofsByMint: CashuProof[] = []
if(mint) {
const encoded = getEncodedToken({
for (const proof of proofs) {
const proofMint = CashuUtils.getMintFromProof(proof, mintsStore.allMints)
const { tId, isPending, isSpent, updatedAt, ...cleanedProof } = proof
if (!proofMint) { continue }
if(mint.mintUrl === proofMint.mintUrl) {
proofsByMint.push(cleanedProof)
}
}
if (proofsByMint.length > 0) {
const tokenByMint = {
token: [
{
mint: mint.mintUrl,
proofs: [
cleanedProof
]
proofs: proofsByMint
}
]
})
}
encodedTokens.push(encoded)
log.trace(tokenByMint)
const encodedByMint = getEncodedToken(tokenByMint)
encodedTokens.push(encodedByMint)
}
}
}
Clipboard.setString(JSON.stringify(encodedTokens))
Clipboard.setString(JSON.stringify(encodedTokens))
setIsLoading(false)
} catch (e: any) {
setInfo(`Could not copy: ${e.message}`)
}
@@ -234,7 +255,7 @@ export const LocalRecoveryScreen: FC<LocalRecoveryScreenProps> =
ContentComponent={
<>
<Text
style={{color: dateColor, fontSize: 14}}
style={{textAlign: 'center', marginVertical: spacing.small}}
text="Backed up proofs"
/>
<JSONTree
@@ -11,6 +11,7 @@ import { NostrClient } from "../../services"
import { colors, spacing, typography, useThemeColor } from "../../theme"
import { getImageSource } from '../../utils/utils'
import { ContactListItem } from "../Contacts/ContactListItem"
import { CurrencyCode, CurrencySign } from "../Wallet/CurrencySign"
export interface PaymentRequestListProps {
@@ -38,11 +39,10 @@ export const PaymentRequestListItem = observer(function (props: PaymentRequestLi
preset='heading'
text={pr.amount.toLocaleString()}
/>
<Text
text='SATS'
size='xxs'
style={{color: hintColor, fontFamily: typography.primary?.light, marginBottom: spacing.small}}
/>
<CurrencySign
currencyCode={CurrencyCode.SATS}
containerStyle={{marginBottom: spacing.small}}
/>
</View>
}
ContentComponent={
+6 -8
View File
@@ -32,6 +32,7 @@ import {MintListItem} from './Mints/MintListItem'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { resolveTxt } from 'dns'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, CurrencySign } from './Wallet/CurrencySign'
export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
function ReceiveScreen({route, navigation}) {
@@ -207,10 +208,8 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
tx="receiveScreen.toReceive"
style={{color: 'white'}}
/>
<Text
text='SATS'
size='xxs'
style={{color: satsColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
/>
<Text
style={$amountToReceive}
@@ -225,10 +224,9 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
tx="receiveScreen.received"
style={{color: 'white'}}
/>
<Text
text='SATS'
size='xxs'
style={{color: satsColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
containerStyle={{marginTop: -5}}
/>
<Text
style={$amountToReceive}
+3 -4
View File
@@ -59,6 +59,7 @@ import { getImageSource, infoMessage } from '../utils/utils'
import { NotificationService } from '../services/notificationService'
import { SendOption } from './SendOptionsScreen'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, CurrencySign } from './Wallet/CurrencySign'
if (Platform.OS === 'android' &&
@@ -548,10 +549,8 @@ export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
style={{color: 'white'}}
/>
<View style={$amountContainer}>
<Text
text='SATS'
size='xxs'
style={{color: satsColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
/>
<TextInput
ref={amountInputRef}
+3 -4
View File
@@ -52,6 +52,7 @@ import { roundDown } from '../utils/number'
import { LnurlClient, LnurlWithdrawResult } from '../services/lnurlService'
import { update } from 'lodash'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, CurrencySign } from './Wallet/CurrencySign'
if (
Platform.OS === 'android' &&
@@ -551,10 +552,8 @@ export const TopupScreen: FC<WalletStackScreenProps<'Topup'>> = observer(
style={{color: 'white'}}
/>
<View style={$amountContainer}>
<Text
text='SATS'
size='xxs'
style={{color: satsColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
/>
<TextInput
ref={amountInputRef}
+12 -1
View File
@@ -782,11 +782,13 @@ const SendInfoBlock = function (props: {
/>
<Card
style={$dataCard}
ContentComponent={
ContentComponent={
<>
<TranItem
label="tranDetailScreen.sentFrom"
value={transaction.mint as string}
/>
</>
}
/>
{isDataParsable && (
@@ -1038,10 +1040,18 @@ const TransferInfoBlock = function (props: {
label="tranDetailScreen.lightningFee"
value={`${transaction.fee}`}
/>
{transaction.memo && (
<TranItem
label="tranDetailScreen.memoFromInvoice"
value={transaction.memo as string}
/>
)}
{transaction.sentTo && (
<TranItem
label="tranDetailScreen.sentTo"
value={transaction.sentTo as string}
/>
)}
<TranItem
label="tranDetailScreen.type"
value={transaction.type as string}
@@ -1201,6 +1211,7 @@ const $actionCard: ViewStyle = {
const $dataCard: ViewStyle = {
padding: spacing.medium,
marginBottom: spacing.extraSmall,
paddingTop: spacing.extraSmall,
}
const $item: ViewStyle = {
@@ -28,13 +28,13 @@ export const TransactionListItem = observer(function (props: {tx: Transaction, i
switch(tx.type) {
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE:
return 'You received'
return tx.sentFrom ? `Received from ${tx.sentFrom}` : 'You received'
case TransactionType.SEND:
return 'You paid'
return tx.sentTo ? `Sent to ${tx.sentTo}` : 'You sent'
case TransactionType.TOPUP:
return 'You funded your wallet'
return tx.sentFrom ? `Received from ${tx.sentFrom}` : 'You received'
case TransactionType.TRANSFER:
return 'You transfered'
return tx.sentTo ? `Paid to ${tx.sentTo}` : 'You paid'
default:
return 'Uknown transaction'
}
+35 -9
View File
@@ -44,6 +44,7 @@ import { LNURLPayParams } from 'js-lnurl'
import { roundDown, roundUp } from '../utils/number'
import { LnurlClient } from '../services/lnurlService'
import { moderateScale, scale, verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, CurrencySign } from './Wallet/CurrencySign'
if (
Platform.OS === 'android' &&
@@ -61,7 +62,7 @@ export const TransferScreen: FC<WalletStackScreenProps<'Transfer'>> = observer(
})
const amountInputRef = useRef<TextInput>(null)
const {proofsStore, mintsStore, paymentRequestsStore} = useStores()
const {proofsStore, mintsStore, paymentRequestsStore, transactionsStore} = useStores()
const [encodedInvoice, setEncodedInvoice] = useState<string>('')
const [invoice, setInvoice] = useState<DecodedLightningInvoice | undefined>()
@@ -401,10 +402,17 @@ const transfer = async function () {
const {status} = transaction as Transaction
setTransactionStatus(status)
if (error) {
if(transaction && lnurlPayParams && lnurlPayParams.address) {
await transactionsStore.updateSentTo( // set ln address to send to to the tx, could be elsewhere //
transaction.id as number,
lnurlPayParams.address as string
)
}
if (error) { // This handles timed out pending payments
setResultModalInfo({
status,
message: error.message,
message: message || error.message,
})
} else {
if(!isInvoiceDonation) { // Donation polling triggers own ResultModal on paid invoice
@@ -469,10 +477,8 @@ const satsColor = colors.palette.primary200
style={{color: 'white'}}
/>
<View style={$amountContainer}>
<Text
text='SATS'
size='xxs'
style={{color: satsColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
/>
<TextInput
ref={amountInputRef}
@@ -597,9 +603,9 @@ const satsColor = colors.palette.primary200
if(isInvoiceDonation) {
navigation.navigate('ContactsNavigator', {screen: 'Contacts', params: {}})
} else {
navigation.navigate('Wallet', {})}
navigation.navigate('Wallet', {})
}
}
}}
/>
</View>
</>
@@ -639,6 +645,26 @@ const satsColor = colors.palette.primary200
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.PENDING && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.iconYellow300}
title="Transfer pending"
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={() => {
navigation.navigate('Wallet', {})
}}
/>
</View>
</>
)}
</>
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Button, Icon, IconTypes, ListItem, Screen, Text } from "../../components"
import { colors, spacing, typography, useThemeColor } from "../../theme"
export enum CurrencyCode {
SATS = 'SATS',
}
export const CurrencySign = function(props: {
currencyCode: CurrencyCode,
containerStyle?: ViewStyle,
textStyle?: TextStyle
}
) {
const textColor = useThemeColor('header')
const bgColor = colors.palette.primary200
return (
<View
style={[{
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: bgColor,
}, props.containerStyle || {}]}
>
<Text
text='₿ sats'
style={[{
color: textColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
}, props.textStyle || {}]}
/>
</View>
)
}
+14 -10
View File
@@ -58,6 +58,7 @@ import { poller } from '../utils/poller'
import Clipboard from '@react-native-clipboard/clipboard'
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { CurrencyCode, CurrencySign } from './Wallet/CurrencySign'
@@ -414,9 +415,8 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
<TotalBalanceBlock
totalBalance={balances.totalBalance}
pendingBalance={balances.totalPendingBalance}
// gotoTranHistory={gotoTranHistory}
/>
<View style={$contentContainer}>
<View style={[$contentContainer, groupedMints.length > 1 && ({marginTop: -spacing.extraLarge * 2.5})]}>
{mintsStore.mintCount === 0 ? (
<PromoBlock addMint={addMint} />
) : (
@@ -428,7 +428,7 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
inActiveDotColor={colors.palette.primary300}
activeDotColor={colors.palette.primary100}
activeDotScale={1.2}
containerStyle={{bottom: undefined, position: undefined, marginTop: -spacing.small, paddingBottom: spacing.medium}}
containerStyle={{bottom: undefined, position: undefined, marginTop: -spacing.tiny, paddingBottom: spacing.small}}
//@ts-ignore
scrollX={scrollX}
dotSize={30}
@@ -559,10 +559,9 @@ const TotalBalanceBlock = observer(function (props: {
return (
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
text='SATS'
size='xxs'
style={{color: pendingBalanceColor, fontFamily: typography.primary?.light}}
<CurrencySign
currencyCode={CurrencyCode.SATS}
containerStyle={{marginTop: -5}}
/>
<Text
testID='total-balance'
@@ -640,8 +639,8 @@ const MintsByHostnameListItem = observer(function (props: {
RightComponent={
<View style={$balanceContainer}>
<Text style={[$balance, {color: balanceColor}]}>
{props.mintBalances.find(b => b.mint === mint.mintUrl)
?.balance || 0}
{(props.mintBalances.find(b => b.mint === mint.mintUrl)
?.balance || 0).toLocaleString()}
</Text>
</View>
}
@@ -667,7 +666,10 @@ const $headerContainer: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
paddingTop: 0,
marginTop: 0,
height: spacing.screenHeight * 0.18,
// borderWidth: 1,
// borderColor: 'red',
}
const $buttonContainer: ViewStyle = {
@@ -679,7 +681,9 @@ const $buttonContainer: ViewStyle = {
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 2,
flex: 0.9,
paddingTop: spacing.extraSmall,
paddingTop: spacing.extraSmall - 3,
// borderWidth: 1,
// borderColor: 'green',
}
const $card: ViewStyle = {
+2 -2
View File
@@ -1,6 +1,6 @@
import {Mint} from '../../models/Mint'
import {
getDecodedToken,
getDecodedToken,
} from '@cashu/cashu-ts'
import cloneDeep from 'lodash.clonedeep'
import AppError, {Err} from '../../utils/AppError'
@@ -262,7 +262,7 @@ const getMintFromProof = function (
export const CashuUtils = {
findEncodedCashuToken,
extractEncodedCashuToken,
decodeToken,
decodeToken,
getTokenAmounts,
getTokenEntryAmount,
getProofsAmount,
+22 -9
View File
@@ -167,7 +167,7 @@ const sendFromMint = async function (
}
}
const getSpentProofsFromMint = async function (
const getSpentOrPendingProofsFromMint = async function (
mintUrl: string,
proofs: Proof[],
) {
@@ -175,13 +175,20 @@ const getSpentProofsFromMint = async function (
const cashuWallet = getWallet(mintUrl)
const spentPendingProofs = await cashuWallet.checkProofsSpent(proofs)
return spentPendingProofs
log.trace('[getSpentOrPendingProofsFromMint]', spentPendingProofs)
return spentPendingProofs as {
spent: CashuProof[]
pending: CashuProof[]
}
} catch (e: any) {
throw new AppError(
Err.MINT_ERROR,
'The mint could not check if the proofs were spent.',
'The mint could not check if the proofs were spent or are pending.',
{
caller: 'getSpentProofsFromMint',
caller: 'getSpentOrPendingProofsFromMint',
mintUrl,
message: e.message
}
@@ -241,9 +248,15 @@ const payLightningInvoice = async function (
preimage,
newKeys
}
} catch (e: any) {
console.log(e)
throw new AppError(Err.MINT_ERROR, e.message)
} catch (e: any) {throw new AppError(
Err.MINT_ERROR,
'Lightning payment failed.',
{
caller: 'payLightningInvoice',
mintUrl,
message: e.message
}
)
}
}
@@ -291,7 +304,7 @@ const requestProofs = async function (
}
} catch (e: any) {
if(e.message.includes('Invoice not paid') === false) {
log.info(`[MINT_ERROR] ${e.message}`,[],'cashuMintClient.requestProofs')
log.warn('[requestProofs]', `${e.message}`)
}
return {proofs: []}
@@ -302,7 +315,7 @@ export const MintClient = {
getMintKeys,
receiveFromMint,
sendFromMint,
getSpentProofsFromMint,
getSpentOrPendingProofsFromMint,
getLightningFee,
payLightningInvoice,
requestLightningInvoice,
-11
View File
@@ -117,17 +117,6 @@ const getLnurlAddressParams = async (lnurlAddress: string) => {
return {
lnurlParams
} as LnurlParamsResult
/*const encodedInvoice = await getInvoice(lnurlParams)
if(encodedInvoice) {
return {
lnurlParams,
encodedInvoice
} as LnurlParamsResult
}
throw new AppError(Err.CONNECTION_ERROR, 'Could not get lightning invoice from the LNURL provider', {domain: lnurlParams.domain, caller: 'getLnurlAddressParams'})*/
}
+2 -2
View File
@@ -185,8 +185,8 @@ const publish = async function (
log.trace('Event to be published', event, 'publish')
const pool = getRelayPool()
await pool.publish(relays, event)
await delay(1000)
let pubs = pool.publish(relays, event)
await Promise.all(pubs)
const published: NostrEvent = await pool.get(relays, {
ids: [event.id]
+1 -1
View File
@@ -646,7 +646,7 @@ const updateSentToAsync = async function (id: number, sentTo: string) {
const db = getInstance()
await db.executeAsync(query, params)
log.debug('[updateSentToAsync]', 'Transaction sentFrom updated', {id, sentTo})
log.debug('[updateSentToAsync]', 'Transaction sentTo updated', {id, sentTo})
const updatedTx = getTransactionById(id as number)
+296 -103
View File
@@ -474,58 +474,140 @@ async function _checkSpentByMint(mintUrl: string, isPending: boolean = false) {
return
}
const spentProofs = await MintClient.getSpentProofsFromMint(
const {spent: spentProofs, pending: pendingProofs} = await MintClient.getSpentOrPendingProofsFromMint(
mintUrl,
proofsFromMint,
)
for (const proof of pendingProofs) {
proofsStore.addToPendingByMint(proof as Proof)
}
const spentCount = spentProofs.length
const spentAmount = CashuUtils.getProofsAmount(spentProofs)
const spentAmount = CashuUtils.getProofsAmount(spentProofs as Proof[])
const pendingAmount = CashuUtils.getProofsAmount(pendingProofs as Proof[])
log.trace('[_checkSpentByMint]', `spentProofs amount: ${spentAmount}`)
log.trace('[_checkSpentByMint]', `spentProofs and pendingProofs amounts`, {spentAmount, pendingAmount, isPending})
if (spentProofs.length < 1) {
log.trace('[_checkSpentByMint]', `No spent ${isPending ? 'pending' : ''} proofs returned from the mint`, mintUrl)
return
}
if (spentProofs.length > 0) {
// we need to identify txIds to update their statuses, there might be more then one tx to complete
let relatedTransactionIds: number[] = []
// we need to identify txIds to update their statuses, there might be more then one tx to complete
let relatedTransactionIds: number[] = []
for (const spentProof of spentProofs) {
const tId = proofsFromMint.find(
(proof: Proof) => proof.secret === spentProof.secret,
)?.tId
for (const spentProof of spentProofs) {
const tId = proofsFromMint.find(
(proof: Proof) => proof.secret === spentProof.secret,
)?.tId
if (tId && !relatedTransactionIds.includes(tId)) {
relatedTransactionIds.push(tId)
}
}
// remove spent proofs model instances from pending
proofsStore.removeProofs(spentProofs as Proof[], isPending)
// Update related transactions statuses
log.debug('[_checkSpentByMint]', 'Transaction id(s) to complete', relatedTransactionIds.toString())
// Complete related transactions in normal wallet operations
if (isPending) {
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
if (tId && !relatedTransactionIds.includes(tId)) {
relatedTransactionIds.push(tId)
}
}
await transactionsStore.updateStatuses(
relatedTransactionIds,
TransactionStatus.COMPLETED,
JSON.stringify(transactionDataUpdate),
)
// Clean pendingByMint secrets if in-flight payment completed and they came back as spent
if(proofsStore.pendingByMintSecrets.length > 0) {
EventEmitter.emit('sendCompleted', relatedTransactionIds)
stopPolling('checkSpentByMintPoller')
log.trace('[_checkSpentByMint]', 'Starting sweep of spent pendingByMint proofs')
for (const proof of spentProofs) {
const secret = proofsStore.pendingByMintSecrets.find((s => s === proof.secret))
if(secret) {
proofsStore.removeFromPendingByMint(proof as Proof)
}
}
}
// remove spent proofs model instances from pending
proofsStore.removeProofs(spentProofs as Proof[], isPending)
// Update related transactions statuses
log.debug('[_checkSpentByMint]', 'Transaction id(s) to complete', relatedTransactionIds.toString())
// Complete related transactions in normal wallet operations
if (isPending) {
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
relatedTransactionIds,
TransactionStatus.COMPLETED,
JSON.stringify(transactionDataUpdate),
)
EventEmitter.emit('sendCompleted', relatedTransactionIds)
stopPolling('checkSpentByMintPoller')
}
} else {
log.trace('[_checkSpentByMint]', `No spent ${isPending ? 'pending' : ''} proofs returned from the mint`, mintUrl)
}
// Check if remaining pendingByMint secrets are still pending with the mint.
// If not, move related wallet's pending proofs back to spendable as the payment failed and they were not spent.
const remainingSecrets = getSnapshot(proofsStore.pendingByMintSecrets)
log.trace('[_checkSpentByMint]', 'pendingByMintSecrets', remainingSecrets)
if(remainingSecrets.length > 0 && isPending) {
log.trace('[_checkSpentByMint]', 'Starting sweep of pendingByMintSecrets')
const movedProofs: Proof[] = []
for (const secret of remainingSecrets) {
const proofToMove = proofsStore.getBySecret(secret, true) // find the proof in wallet's pending
// only move if it is from current mint
if(proofToMove && proofToMove.mintUrl === mintUrl) {
const stillPendingByMint = pendingProofs.find((p => p.secret === secret))
if(!stillPendingByMint) {
// move it if it is not pending by mint anymore
proofsStore.removeFromPendingByMint(proofToMove as Proof)
movedProofs.push(proofToMove)
}
}
}
if(movedProofs.length > 0) {
log.trace('[_checkSpentByMint]', 'Moving proofs from pending to spendable', movedProofs.length)
// Update related transactions as reverted
let relatedTransactionIds: number[] = []
for (const movedProof of movedProofs) {
const tId = movedProof.tId
if (tId && !relatedTransactionIds.includes(tId)) {
relatedTransactionIds.push(tId)
}
}
// remove it from pending proofs in the wallet
proofsStore.removeProofs(movedProofs, true, true)
// add proofs back to the spendable wallet
proofsStore.addProofs(movedProofs)
if(relatedTransactionIds.length > 0) {
const transactionDataUpdate = {
status: TransactionStatus.REVERTED,
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
relatedTransactionIds,
TransactionStatus.REVERTED,
JSON.stringify(transactionDataUpdate),
)
}
} else {
log.trace('[_checkSpentByMint]', `No moved proofs from pending`, mintUrl)
}
}
// TODO what to do with tx error status after removing spent proofs
return {
mintUrl,
spentCount,
@@ -660,19 +742,14 @@ const receive = async function (
)
}
// attach transaction id and mintUrl to the proofs before storing them
const newProofs: Proof[] = []
for (const entry of updatedToken.token) {
for (const proof of entry.proofs) {
proof.tId = transactionId
proof.mintUrl = entry.mint
newProofs.push(proof)
}
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
entry.proofs,
entry.mint,
transactionId as number
)
}
// create ProofModel instances and store them into the proofsStore
proofsStore.addProofs(newProofs)
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
@@ -939,20 +1016,15 @@ const receiveOfflineComplete = async function (
)
}
// attach transaction id and mintUrl to the proofs before storing them
const newProofs: Proof[] = []
for (const entry of updatedToken.token) {
for (const proof of entry.proofs) {
proof.tId = transaction.id
proof.mintUrl = entry.mint
newProofs.push(proof)
}
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
entry.proofs,
entry.mint,
transaction.id as number
)
}
// create ProofModel instances and store them into the proofsStore
proofsStore.addProofs(newProofs)
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
log.debug('[receiveOfflineComplete]', 'Received amount', receivedAmount)
@@ -1390,36 +1462,60 @@ const transfer = async function (
if (newKeys) {_updateMintKeys(mintUrl, newKeys)}
// We've sent the proofsToPay to the mint, so we remove those pending proofs from model storage.
// Hopefully mint gets important shit done synchronously.
// We might not need to await for this and set it up as async poller with 1 poll?
// Hopefully mint gets important shit done synchronously.
await _checkSpentByMint(mintUrl, true)
// I have no idea yet if this can happen, return sent Proofs to the store an track tx as Reverted
if (!isPaid) {
_addCashuProofs(proofsToPay, mintUrl, transactionId, true)
const { amountPendingByMint } = await _moveProofsFromPending(proofsToPay, mintUrl, transactionId)
transactionData.push({
status: TransactionStatus.REVERTED,
createdAt: new Date(),
})
if(amountPendingByMint > 0) {
transactionData.push({
status: TransactionStatus.PENDING,
amountPendingByMint,
})
const revertedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.REVERTED,
JSON.stringify(transactionData),
)
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
// There is not clear way to get refund for saved fees later so we treat estimated fees as final
await transactionsStore.updateFee(transactionId, estimatedFee)
return {
transaction: revertedTransaction,
message: 'Payment of lightning invoice failed. Coins were returned to your wallet.',
} as TransactionResult
return {
transaction: pendingTransaction,
message: 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.',
} as TransactionResult
} else {
transactionData.push({
status: TransactionStatus.REVERTED,
createdAt: new Date(),
})
const revertedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.REVERTED,
JSON.stringify(transactionData),
)
return {
transaction: revertedTransaction,
message: 'Payment of lightning invoice failed. Coins were returned to your wallet.',
} as TransactionResult
}
}
// If real fees were less then estimated, cash the returned savings.
let finalFee = estimatedFee
if (feeSavedProofs.length) {
const feeSaved = _addCashuProofs(feeSavedProofs, mintUrl, transactionId)
const {addedAmount: feeSaved} = _addCashuProofs(
feeSavedProofs,
mintUrl,
transactionId
)
finalFee = estimatedFee - feeSaved
}
@@ -1445,7 +1541,7 @@ const transfer = async function (
return {
transaction: completedTransaction,
message: `Lightning invoice has been successfully paid and settled with your minibits coins. Final lightning network fee has been ${finalFee} sats.`,
message: `Lightning invoice has been successfully paid and settled with your minibits coins. Final network fee has been ${finalFee} sats.`,
finalFee,
} as TransactionResult
} catch (e: any) {
@@ -1453,6 +1549,42 @@ const transfer = async function (
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
// Return tokens intended for payment to the wallet if payment failed with an error
if (proofsToPay.length > 0) {
log.warn('[transfer]', 'Returning proofsToPay to the wallet likely after failed lightning payment.', proofsToPay.length)
const {
amountToMove,
amountPendingByMint
} = await _moveProofsFromPending(proofsToPay, mintUrl, transactionId)
// keep tx as pending if proofs were not added because of a mint that keeps them as pending for timed out in-flight payment
if(amountPendingByMint > 0) {
transactionData.push({
status: TransactionStatus.PENDING,
error: _formatError(e),
amountToMove,
amountPendingByMint,
})
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
// There is not clear way to get refund for saved fees later so we treat estimated fees as final
await transactionsStore.updateFee(transactionId, estimatedFee)
return {
transaction: pendingTransaction,
message: 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.',
error: _formatError(e),
} as TransactionResult
}
}
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
@@ -1463,12 +1595,6 @@ const transfer = async function (
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
// Return tokens intended for payment to the wallet if payment failed with an error
if (proofsToPay.length > 0) {
log.info('[transfer]', 'Returning proofsToPay to the wallet likely after failed lightning payment', proofsToPay.length)
const amount = _addCashuProofs(proofsToPay, mintUrl, transactionId, true)
}
}
log.error(e.name, e.message, {}, 'transfer')
@@ -1486,26 +1612,93 @@ const transfer = async function (
const _addCashuProofs = function (
proofsToAdd: CashuProof[],
mintUrl: string,
transactionId: number,
isRecoveredFromPending: boolean = false
): number {
transactionId: number
): {
amountToAdd: number,
addedAmount: number
} {
// Add internal references
for (const proof of proofsToAdd as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
}
// Creates proper model instances and adds them to storage
proofsStore.addProofs(proofsToAdd as Proof[])
const amount = CashuUtils.getProofsAmount(proofsToAdd as Proof[])
log.trace('[_addCashuProofs]', 'Added proofs with amount', { amount, isRecoveredFromPending })
if(isRecoveredFromPending) {
// Remove them from pending if they are returned to the wallet due to failed lightning payment
// Do not mark them as spent as they are recovered back to wallet
proofsStore.removeProofs(proofsToAdd as Proof[], true, isRecoveredFromPending)
}
const amountToAdd = CashuUtils.getProofsAmount(proofsToAdd as Proof[])
// Creates proper model instances and adds them to the wallet
const addedAmount = proofsStore.addProofs(proofsToAdd as Proof[])
log.trace('[_addCashuProofs]', 'Added proofs to the wallet with amount', { amountToAdd, addedAmount })
return amount
return {
amountToAdd,
addedAmount
}
}
const _moveProofsFromPending = async function (
proofsToMove: CashuProof[],
mintUrl: string,
transactionId: number,
): Promise<{
amountToMove: number,
amountPendingByMint: number,
movedAmount: number
}> {
// Add internal references
for (const proof of proofsToMove as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
}
const amountToMove = CashuUtils.getProofsAmount(proofsToMove as Proof[])
// Here we move proofs from pending back to spendable wallet in case of lightning payment failure
// Check with the mint if the proofs are not marked as pending. This happens when lightning payment fails
// due to the timeout but mint's node keeps the payment as in-flight (e.g. receiving node holds the invoice)
// In this case we need to keep such proofs as pending and not move them back to wallet as in other payment failures.
const {pending: pendingByMint} = await MintClient.getSpentOrPendingProofsFromMint(
mintUrl,
proofsToMove as Proof[],
)
let amountPendingByMint: number = 0
let movedAmount: number = 0
const movedProofs: Proof[] = []
for (const proof of proofsToMove) {
// if proof to return to the wallet is tracked by mint as pending we do not move it from wallet's pending
if(pendingByMint.some(p => p.secret === proof.secret)){
// add it to the list of secrets so we can later handle them based on eventual lightning payment result
if(proofsStore.addToPendingByMint(proof as Proof)) {
amountPendingByMint += proof.amount
}
} else {
movedAmount += proof.amount
movedProofs.push(proof as Proof)
}
}
if(movedProofs.length > 0) {
// remove it from pending proofs in the wallet
proofsStore.removeProofs(movedProofs, true, true)
// add proofs back to the spendable wallet
proofsStore.addProofs(movedProofs)
log.trace('[_moveProofsFromPending]', 'Moved proofs back from pending to spendable', {amountToMove, amountPendingByMint, movedAmount})
}
if(amountPendingByMint > 0 && amountPendingByMint !== amountToMove) {
// This should not happen, monitor
log.warn('[_moveProofsFromPending]', 'Not all proofs to be moved were pending by the mint')
}
return {
amountToMove,
amountPendingByMint,
movedAmount
}
}
@@ -1690,11 +1883,11 @@ const checkPendingTopups = async function () {
if(newKeys) {_updateMintKeys(pr.mint as string, newKeys)}
// accept whatever we've got
const receivedAmount = _addCashuProofs(
// accept to the wallet whatever we've got
const {addedAmount: receivedAmount} = _addCashuProofs(
proofs,
pr.mint as string,
pr.transactionId as number,
pr.transactionId as number
)
if (receivedAmount !== pr.amount) {
+10 -4
View File
@@ -1606,6 +1606,11 @@
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/semver-v6/-/semver-v6-6.3.3.tgz#ea6d23ade78a325f7a52750aab1526b02b628c29"
integrity sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==
"@noble/ciphers@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.2.0.tgz#a12cda60f3cf1ab5d7c77068c3711d2366649ed7"
integrity sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==
"@noble/curves@1.1.0", "@noble/curves@~1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
@@ -6257,11 +6262,12 @@ normalize-path@^3.0.0:
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
nostr-tools@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.14.0.tgz#5cb21cd9f7b1a471b9010e3feed6c74d126ab106"
integrity sha512-hwq2i1z5/DneXRE5Zu/TzQuKzVLcB+gOdfT9CeoiScvNw/2dWRGJvyTXIdF92d7NQ7nMcEwqVJPDytLpEpiiKw==
nostr-tools@^1.17.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.17.0.tgz#b6f62e32fedfd9e68ec0a7ce57f74c44fc768e8c"
integrity sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==
dependencies:
"@noble/ciphers" "0.2.0"
"@noble/curves" "1.1.0"
"@noble/hashes" "1.3.1"
"@scure/base" "1.1.1"