Files
app.zeusln.zeus/stores/LightningAddressStore.ts
Evan KaloudisandGitHub 70ee0fe767 Merge pull request #4102 from kaloudis/clink
feat: pay Nostr Offer Strings (CLINK)
2026-06-05 12:47:59 -04:00

1487 lines
48 KiB
TypeScript

import { Platform } from 'react-native';
import { action, observable, runInAction } from 'mobx';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { Notifications } from 'react-native-notifications';
import BigNumber from 'bignumber.js';
import Bolt11Utils from '../utils/Bolt11Utils';
import { io } from 'socket.io-client';
import { schnorr } from '@noble/curves/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
import hashjs from 'hash.js';
// @ts-ignore:next-line
import { getPublicKey, relayInit } from 'nostr-tools';
import { mnemonicToEntropy, generateMnemonic } from '@scure/bip39';
import { sha256 } from 'js-sha256';
import CashuStore from './CashuStore';
import NodeInfoStore from './NodeInfoStore';
import SettingsStore from './SettingsStore';
import BackendUtils from '../utils/BackendUtils';
import Base64Utils from '../utils/Base64Utils';
import { BIP39_WORD_LIST } from '../utils/Bip39Utils';
import { sleep } from '../utils/SleepUtils';
import { localeString } from '../utils/LocaleUtils';
import Storage from '../storage';
const LNURL_HOST = 'https://zeuspay.com';
const LNURL_SOCKET_PATH = '/stream';
export const LEGACY_ADDRESS_ACTIVATED_STRING = 'olympus-lightning-address';
export const LEGACY_HASHES_STORAGE_STRING = 'olympus-lightning-address-hashes';
export const ADDRESS_ACTIVATED_STRING = 'zeuspay-lightning-address';
export const HASHES_STORAGE_STRING = 'zeuspay-lightning-address-hashes';
export const ZEUS_PAY_DOMAIN_KEYS = [
{
key: 'zeuspay.com',
value: 'zeuspay.com'
},
{
key: 'zeusnuts.com',
value: 'zeusnuts.com'
}
];
interface Auth {
verification: string;
signature: string;
}
interface Perk {
title: string;
note?: string;
value?: string;
links?: PerkLink[];
comingSoon?: boolean;
}
interface PerkLink {
title: string;
url: string;
}
export default class LightningAddressStore {
@observable public lightningAddress: string;
@observable public lightningAddressHandle: string;
@observable public lightningAddressDomain: string;
@observable public lightningAddressType: string;
@observable public lightningAddressActivated: boolean = false;
@observable public noffer: string | null = null;
@observable public zeusPlusExpiresAt: any;
@observable public zeusPlusAnnualFeeSats: any;
@observable public zeusPlusDiscount: any;
@observable public legacyAccount: boolean = false;
@observable public perks: Perk[] = [];
@observable public image: string | null = null;
@observable public bio: string | null = null;
@observable public loading: boolean = false;
@observable public redeeming: boolean = false;
@observable public redeemingAll: boolean = false;
@observable public error: boolean = false;
@observable public error_msg: string | undefined;
@observable public availableHashes: number = 0; // on server
@observable public localHashes: number = 0; // on device
@observable public paid: any = [];
@observable public preimageMap: any = {};
@observable public fees: any = {};
@observable public minimumSats: number;
@observable public socket: any;
// Push
@observable public currentDeviceToken: string;
@observable public serviceDeviceToken: string;
private pendingPushUpdate: boolean = false;
@observable public readyToAutomaticallyAccept: boolean = false;
@observable public prepareToAutomaticallyAcceptStart: boolean = false;
// Auth
auth: Auth;
authDate?: Date;
cashuStore: CashuStore;
nodeInfoStore: NodeInfoStore;
settingsStore: SettingsStore;
constructor(
cashuStore: CashuStore,
nodeInfoStore: NodeInfoStore,
settingsStore: SettingsStore
) {
this.cashuStore = cashuStore;
this.nodeInfoStore = nodeInfoStore;
this.settingsStore = settingsStore;
}
private getAuthData = async () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
if (this.authDate && this.authDate > tenMinutesAgo) {
return this.auth;
} else {
const authResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/auth`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey
})
);
const authData = authResponse.json();
if (authResponse.info().status !== 200) throw authData.error;
const { verification } = authData;
const signData = await BackendUtils.signMessage(verification);
const signature = signData.zbase || signData.signature;
this.auth = { verification, signature };
this.authDate = new Date(Date.now());
return this.auth;
}
};
public deleteAndGenerateNewPreimages = async () => {
this.loading = true;
await Storage.setItem(HASHES_STORAGE_STRING, '');
this.generatePreimages(true);
};
@action
public deleteLocalHashes = async () => {
this.loading = true;
await Storage.setItem(HASHES_STORAGE_STRING, '');
this.preimageMap = {};
this.localHashes = 0;
await this.status();
this.loading = false;
};
private getPreimageMap = async () => {
this.loading = true;
const map = await Storage.getItem(HASHES_STORAGE_STRING);
runInAction(() => {
if (map) {
this.preimageMap = JSON.parse(map);
this.localHashes = Object.keys(this.preimageMap).length;
}
this.loading = false;
});
return this.preimageMap;
};
private setLightningAddress = async (handle: string, domain: string) => {
await Storage.setItem(ADDRESS_ACTIVATED_STRING, true);
runInAction(() => {
this.lightningAddressActivated = true;
this.lightningAddressHandle = handle;
this.lightningAddressDomain = domain;
this.lightningAddress = `${handle}@${domain}`;
});
};
private deleteHash = async (hash: string) => {
const hashesString =
(await Storage.getItem(HASHES_STORAGE_STRING)) || '{}';
const oldHashes = JSON.parse(hashesString);
delete oldHashes[hash];
const newHashes = oldHashes;
return await Storage.setItem(HASHES_STORAGE_STRING, newHashes);
};
@action
private generatePreimages = async (newDevice?: boolean) => {
this.error = false;
this.error_msg = '';
this.loading = true;
const preimageHashMap: any = {};
const preimages = [];
for (let i = 0; i < 250; i++) {
preimages.push(
Buffer.from(
mnemonicToEntropy(
generateMnemonic(BIP39_WORD_LIST, 256),
BIP39_WORD_LIST
)
).toString('hex')
);
}
const hashes: any = [];
const nostrSignatures: any = [];
if (preimages) {
const nostrPrivateKey =
this.settingsStore?.settings?.lightningAddress?.nostrPrivateKey;
for (let i = 0; i < preimages.length; i++) {
const preimage = preimages[i];
const hash = sha256
.create()
.update(Base64Utils.hexToBytes(preimage))
.hex();
if (nostrPrivateKey) {
const pmthash_sig = bytesToHex(
schnorr.sign(hash, nostrPrivateKey)
);
nostrSignatures.push(pmthash_sig);
}
preimageHashMap[hash] = preimage;
hashes.push(hash);
}
}
const hashesString = await Storage.getItem(HASHES_STORAGE_STRING);
let newHashes;
if (hashesString) {
const oldHashes = JSON.parse(hashesString);
newHashes = {
...oldHashes,
...preimageHashMap
};
} else {
newHashes = {
...preimageHashMap
};
}
await Storage.setItem(HASHES_STORAGE_STRING, newHashes);
try {
const { verification, signature } = await this.getAuthData();
const payload = {
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
hashes,
newDevice,
...(nostrSignatures.length > 0 && { nostrSignatures })
};
const submitResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/submitHashes`,
{
'Content-Type': 'application/json'
},
JSON.stringify(payload)
);
const submitData = submitResponse.json();
if (!submitData.success) throw submitData.error;
this.loading = false;
await this.status();
return { created_at: submitData.created_at };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.loading = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
};
@action
public createZaplocker = async (
nostr_pk: string,
nostrPrivateKey: string,
relays: Array<string>
) => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const relays_sig = bytesToHex(
schnorr.sign(
hashjs
.sha256()
.update(JSON.stringify(relays))
.digest('hex'),
nostrPrivateKey
)
);
const createResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/create`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
nostr_pk,
relays,
relays_sig,
address_type: 'zaplocker'
})
);
const createData = createResponse.json();
if (createResponse.info().status !== 200 || !createData.success) {
throw createData.error;
}
const { handle: responseHandle, domain, success } = createData;
if (responseHandle) {
this.setLightningAddress(responseHandle, domain);
}
await this.settingsStore.updateSettings({
lightningAddress: {
enabled: true,
automaticallyAccept: true,
automaticallyRequestOlympusChannels: false, // deprecated
allowComments: true,
nostrPrivateKey,
nostrRelays: relays,
notifications: 1
}
});
runInAction(() => {
// ensure push credentials are in place
// right after creation
this.updatePushCredentials();
this.loading = false;
});
return { success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.loading = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
};
@action
public createCashu = async (mint_url: string) => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
if (!this.cashuStore.cashuWallets[mint_url]) {
await this.cashuStore.initializeWallet(mint_url);
}
const createResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/create`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
cashu_pubkey: this.cashuStore.cashuWallets[mint_url].pubkey,
message: verification,
signature,
mint_url,
address_type: 'cashu'
})
);
const createData = createResponse.json();
if (createResponse.info().status !== 200 || !createData.success) {
throw createData.error;
}
const { handle: responseHandle, domain, success } = createData;
if (responseHandle) {
this.setLightningAddress(responseHandle, domain);
}
await this.settingsStore.updateSettings({
lightningAddress: {
enabled: true,
automaticallyAccept: true,
allowComments: true,
mintUrl: mint_url
}
});
runInAction(() => {
// ensure push credentials are in place
// right after creation
this.updatePushCredentials();
this.loading = false;
});
return { success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.error_msg = error_msg;
this.error = true;
this.loading = false;
});
throw error;
}
};
@action
public createNWC = async (nwc_string: string) => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const createResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/create`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
nwc_string,
address_type: 'nwc'
})
);
const createData = createResponse.json();
if (createResponse.info().status !== 200 || !createData.success) {
throw createData.error;
}
const { handle: responseHandle, domain, success } = createData;
if (responseHandle) {
this.setLightningAddress(responseHandle, domain);
}
await this.settingsStore.updateSettings({
lightningAddress: {
enabled: true,
automaticallyAccept: true,
allowComments: true
}
});
runInAction(() => {
// ensure push credentials are in place
// right after creation
this.updatePushCredentials();
this.loading = false;
});
return { success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.error_msg = error_msg;
this.error = true;
this.loading = false;
});
throw error;
}
};
@action
public testNWCConnectionString = async (nwc_string: string) => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const createResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/nwc/test`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
nwc_string
})
);
const createData = createResponse.json();
if (createResponse.info().status !== 200 || !createData.success) {
throw createData.error;
}
const { success, error } = createData;
runInAction(() => {
if (error) {
this.error = true;
this.error_msg = error;
}
this.loading = false;
});
return { success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.error_msg = error_msg;
this.error = true;
this.loading = false;
});
throw error;
}
};
@action
public update = async (updates: any) => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const updateResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/update`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
updates
})
);
const updateData = updateResponse.json();
if (updateResponse.info().status !== 200 || !updateData.success) {
throw updateData.error;
}
const { handle, domain, success } = updateData;
if (handle) {
this.setLightningAddress(handle, domain || 'zeuspay.com');
}
this.loading = false;
return { success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.loading = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
};
private enhanceWithFee = (paymentArray: Array<any>) =>
paymentArray.map((item: any) => {
let fee;
try {
const decoded = Bolt11Utils.decode(item.hodl);
if (decoded.millisatoshis) {
fee = new BigNumber(decoded.millisatoshis)
.minus(item.amount_msat)
.div(1000);
}
} catch (e) {}
item.fee = fee;
return item;
});
@action
public status = async (isRedeem?: boolean) => {
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const statusResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/status`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature
})
);
const statusData = statusResponse.json();
if (statusResponse.info().status !== 200 || !statusData.success) {
throw statusData.error;
}
const {
results,
paid,
fees,
minimumSats,
handle,
domain,
addressType,
plusExpiresAt,
plusAnnualFeeSats,
plusDiscount,
legacyAccount,
perks,
deviceToken,
image,
bio,
noffer
} = statusData;
runInAction(() => {
if (!isRedeem) {
this.error = false;
this.error_msg = '';
}
this.loading = false;
this.availableHashes = results || 0;
});
await this.getPreimageMap();
runInAction(() => {
this.paid = this.enhanceWithFee(paid);
this.fees = fees;
this.minimumSats = minimumSats;
this.lightningAddressHandle = handle;
this.lightningAddressDomain = domain;
this.lightningAddressType = addressType;
this.zeusPlusExpiresAt = plusExpiresAt;
this.zeusPlusAnnualFeeSats = plusAnnualFeeSats;
this.zeusPlusDiscount = plusDiscount;
this.legacyAccount = legacyAccount;
this.perks = perks;
this.image = image || null;
this.bio = bio || null;
this.noffer = noffer || null;
this.serviceDeviceToken = deviceToken;
if (handle && domain) {
this.lightningAddress = `${handle}@${domain}`;
}
if (
this.lightningAddress &&
this.localHashes === 0 &&
this.lightningAddressType === 'zaplocker'
) {
this.generatePreimages(true);
} else if (
this.lightningAddress &&
new BigNumber(this.availableHashes).lt(50) &&
this.lightningAddressType === 'zaplocker'
) {
this.generatePreimages();
}
});
return { results };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.loading = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
};
@action
private redeemZaplocker = async (
hash: string,
payReq?: string,
preimageNotFound?: boolean
) => {
if (preimageNotFound) {
this.error = true;
this.error_msg = localeString(
'stores.LightningAddressStore.preimageNotFound'
);
return;
}
this.error = false;
this.error_msg = '';
this.redeeming = true;
try {
const { verification, signature } = await this.getAuthData();
const redeemResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/redeem`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
hash,
payReq
})
);
const redeemData = redeemResponse.json();
if (redeemResponse.info().status !== 200 || !redeemData.success) {
throw redeemData.error;
}
this.redeeming = false;
await this.deleteHash(hash);
return { success: redeemData.success };
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.redeeming = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
};
private callRedeemEndpoint = async (quote_id: string) => {
const { verification, signature } = await this.getAuthData();
const redeemResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/nuts/redeem`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature,
quoteId: quote_id
})
);
const redeemData = redeemResponse.json();
if (redeemResponse.info().status !== 200 || !redeemData.success) {
throw redeemData.error;
}
return redeemData;
};
@action
public deletePayment = async (quote_id: string) => {
this.error = false;
this.error_msg = '';
this.redeeming = true;
try {
await this.callRedeemEndpoint(quote_id);
runInAction(() => {
this.redeeming = false;
});
this.status(true);
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.redeeming = false;
this.error = true;
this.error_msg = error_msg;
});
}
};
@action
public redeemCashu = async (
quote_id: string,
mint_url: string,
amount_msat: number,
skipStatus?: boolean,
localNotification?: boolean,
skipMintCheck?: boolean
) => {
this.error = false;
this.error_msg = '';
this.redeeming = true;
const fireLocalNotification = () => {
const value = (amount_msat / 1000).toString();
const value_commas = value.replace(
/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g,
','
);
const title = localeString('zeuspay.paymentReceived.title');
const body = localeString('zeuspay.paymentReceived.body', {
value: value_commas,
unit:
value_commas === '1' ? 'sat' : localeString('general.sats')
});
if (Platform.OS === 'android') {
// @ts-ignore:next-line
Notifications.postLocalNotification({
title,
body
});
}
if (Platform.OS === 'ios') {
// @ts-ignore:next-line
Notifications.postLocalNotification({
title,
body,
sound: 'chime.aiff'
});
}
};
try {
const response = await this.cashuStore.checkInvoicePaid(
quote_id,
mint_url,
true, // lockedQuote
skipMintCheck
);
if (
response?.isPaid ||
response?.updatedInvoice?.state === 'ISSUED'
) {
try {
const redeemData = await this.callRedeemEndpoint(quote_id);
// If server returns a token, receive it
if (redeemData.token) {
console.log(
'Receiving token from server:',
redeemData.token
);
// Provide our Cashu secret key; CDK will only use it if the token is P2PK-locked.
const signingKey =
this.cashuStore.deriveCashuSecretKey();
await this.cashuStore.receiveTokenCDK(
redeemData.token,
signingKey || undefined
);
}
this.redeeming = false;
if (localNotification && response.isPaid) {
fireLocalNotification();
}
if (!skipStatus) {
this.status(true);
}
return true;
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.redeeming = false;
this.error = true;
this.error_msg = error_msg;
});
throw error;
}
} else {
runInAction(() => {
this.redeeming = false;
this.error = true;
this.error_msg = localeString(
'stores.LightningAddressStore.Cashu.quoteNotPaid'
);
});
return true;
}
} catch (e) {
runInAction(() => {
this.redeeming = false;
this.error = true;
this.error_msg = localeString(
'stores.LightningAddressStore.Cashu.quotePaymentErr'
);
});
return true;
}
};
@action
public lookupAttestations = async (hash: string, amountMsat: number) => {
const attestations: any = [];
let status = 'warning';
try {
const attestationEvents: any = {};
const hashpk = getPublicKey(hash);
await Promise.all(
this.settingsStore.settings.lightningAddress.nostrRelays.map(
async (relayItem) => {
const relay = relayInit(relayItem);
relay.on('connect', () => {
console.log(`connected to ${relay.url}`);
});
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`);
});
await relay.connect();
const events = await relay.list([
{
kinds: [55869],
'#p': [hashpk]
}
]);
events.map((event: any) => {
attestationEvents[event.id] = event;
});
relay.close();
return;
}
)
);
Object.keys(attestationEvents).map((key) => {
const attestation = this.analyzeAttestation(
attestationEvents[key],
hash,
amountMsat
);
attestations.push(attestation);
});
if (attestations.length === 1) {
const attestation = attestations[0];
if (attestation.isValid) {
status = 'success';
} else {
status = 'error';
}
}
if (attestations.length > 1) status = 'error';
} catch (e) {
console.log('attestation lookup error', e);
}
return {
attestations,
status
};
};
private calculateFeeMsat = (amountMsat: string | number) => {
let feeMsat;
for (let i = this.fees.length - 1; i >= 0; i--) {
const feeItem = this.fees[i];
const { limitAmount, limitQualifier, fee, feeQualifier } = feeItem;
let match;
if (limitQualifier === 'lt') {
match = new BigNumber(amountMsat).div(1000).lt(limitAmount);
} else if (limitQualifier === 'lte') {
match = new BigNumber(amountMsat).div(1000).lte(limitAmount);
} else if (limitQualifier === 'gt') {
match = new BigNumber(amountMsat).div(1000).gt(limitAmount);
} else if (limitQualifier === 'gte') {
match = new BigNumber(amountMsat).div(1000).gte(limitAmount);
}
if (match) {
if (feeQualifier === 'fixedSats') {
feeMsat = fee * 1000;
} else if (feeQualifier === 'percentage') {
feeMsat = Number(
new BigNumber(amountMsat).times(fee).div(100)
);
}
}
}
if (feeMsat) {
return feeMsat;
} else {
// return 250 sat fee in case of error
return 250000;
}
};
private analyzeAttestation = (
attestation: any,
hash: string,
amountMsat: string | number
) => {
const { content } = attestation;
// defaults
attestation.isValid = false;
attestation.isValidLightningInvoice = false;
attestation.isHashValid = false;
attestation.isAmountValid = false;
try {
const decoded = Bolt11Utils.decode(content);
attestation.isValidLightningInvoice = true;
if (decoded.payment_hash === hash) {
attestation.isHashValid = true;
}
if (decoded.millisatoshis) {
attestation.millisatoshis = decoded.millisatoshis;
attestation.feeMsat = this.calculateFeeMsat(
decoded.millisatoshis
);
if (
new BigNumber(amountMsat)
.plus(attestation.feeMsat)
.isEqualTo(decoded.millisatoshis)
) {
attestation.isAmountValid = true;
}
}
if (
attestation.isValidLightningInvoice &&
attestation.isHashValid &&
attestation.isAmountValid
) {
attestation.isValid = true;
}
} catch (e) {
console.log('analyzeAttestation decode error', e);
}
return attestation;
};
@action
public setDeviceToken = (token: string) => {
this.currentDeviceToken = token;
if (this.pendingPushUpdate) {
this.pendingPushUpdate = false;
this.updatePushCredentials().catch((e) =>
console.log('Failed to update push credentials', e)
);
}
};
public updatePushCredentials = async () => {
if (!this.currentDeviceToken) {
// Device token hasn't arrived yet — defer until
// setDeviceToken is called by the push notification callback
this.pendingPushUpdate = true;
return;
}
// only push update if the device token has changed
if (
!this.serviceDeviceToken ||
this.currentDeviceToken !== this.serviceDeviceToken
) {
await this.update({
device_token: this.currentDeviceToken,
device_platform: Platform.OS
});
}
};
@action
public lookupPreimageAndRedeemZaplocker = async (
hash: string,
amount_msat: number,
comment?: string,
skipStatus?: boolean,
localNotification?: boolean
) => {
return await this.getPreimageMap().then(async (map) => {
const preimage = map[hash];
const preimageNotFound = !preimage;
const value = (amount_msat / 1000).toString();
const value_commas = value.replace(
/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g,
','
);
const fireLocalNotification = () => {
const title = localeString('zeuspay.paymentReceived.title');
const body = localeString('zeuspay.paymentReceived.body', {
value: value_commas,
unit:
value_commas === '1'
? 'sat'
: localeString('general.sats')
});
if (Platform.OS === 'android') {
// @ts-ignore:next-line
Notifications.postLocalNotification({
title,
body
});
}
if (Platform.OS === 'ios') {
// @ts-ignore:next-line
Notifications.postLocalNotification({
title,
body,
sound: 'chime.aiff'
});
}
};
return await BackendUtils.createInvoice({
// 24 hrs
expiry: '86400',
value_msat: amount_msat,
memo: comment ? `ZEUS Pay: ${comment}` : 'ZEUS Pay',
preimage,
private:
this.settingsStore?.settings?.lightningAddress
?.routeHints || false
})
.then((result: any) => {
if (result.payment_request) {
return this.redeemZaplocker(
hash,
result.payment_request,
preimageNotFound
).then((success: any) => {
if (success?.success === true && localNotification)
fireLocalNotification();
if (!skipStatus) this.status(true);
return;
});
}
})
.catch(() => {
// first, try looking up invoice for redeem
try {
return BackendUtils.lookupInvoice({
r_hash: hash
}).then((result: any) => {
if (result.payment_request) {
return this.redeemZaplocker(
hash,
result.payment_request,
preimageNotFound
).then((success: any) => {
if (
success?.success === true &&
localNotification
)
fireLocalNotification();
if (!skipStatus) this.status(true);
return;
});
}
});
} catch (e) {
// then, try to redeem without new pay req
return this.redeemZaplocker(
hash,
undefined,
preimageNotFound
).then((success) => {
if (success?.success === true && localNotification)
fireLocalNotification();
if (!skipStatus) this.status(true);
return;
});
}
});
});
};
@action
public redeemAllOpenPaymentsZaplocker = async (
localNotification?: boolean
) => {
this.redeemingAll = true;
const attestationLevel = this.settingsStore?.settings?.lightningAddress
?.automaticallyAcceptAttestationLevel
? this.settingsStore.settings.lightningAddress
.automaticallyAcceptAttestationLevel
: 2;
// disabled
if (attestationLevel === 0) {
for (const item of this.paid) {
await this.lookupPreimageAndRedeemZaplocker(
item.hash,
item.amount_msat,
item.comment,
true,
localNotification
);
return;
}
} else {
for (const item of this.paid) {
await this.lookupAttestations(item.hash, item.amount_msat)
.then(async ({ status }: { status?: string }) => {
if (status === 'error') return;
// success only
if (status === 'warning' && attestationLevel === 1)
return;
return await this.lookupPreimageAndRedeemZaplocker(
item.hash,
item.amount_msat,
item.comment,
true,
localNotification
);
})
.catch((e) => {
console.log('Error looking up attestation', e);
});
}
}
runInAction(() => {
this.status(true);
this.redeemingAll = false;
});
};
@action
public redeemAllOpenPaymentsCashu = async (localNotification?: boolean) => {
this.redeemingAll = true;
const openPaymentsToProcess = this.paid.slice().reverse();
const totalPayments = openPaymentsToProcess.length;
for (let i = 0; i < totalPayments; i++) {
const item = openPaymentsToProcess[i];
const isLast = i === totalPayments - 1; // True if this is the last item
await this.redeemCashu(
item.quote_id,
item.mint_url,
item.amount_msat,
true, // skipStatus
localNotification,
!isLast // skipMintCheck
);
}
runInAction(() => {
this.status(true);
this.redeemingAll = false;
});
};
private subscribeUpdatesZaplocker = async () => {
const { verification, signature } = await this.getAuthData();
this.socket = io(LNURL_HOST, {
path: LNURL_SOCKET_PATH
}).connect();
this.socket.emit('auth', {
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature
});
this.socket.on('paid', (data: any) => {
const { hash, amount_msat, comment } = data;
const attestationLevel = this.settingsStore?.settings
?.lightningAddress?.automaticallyAcceptAttestationLevel
? this.settingsStore.settings.lightningAddress
.automaticallyAcceptAttestationLevel
: 2;
if (attestationLevel === 0) {
this.lookupPreimageAndRedeemZaplocker(
hash,
amount_msat,
comment,
false,
true
);
} else {
this.lookupAttestations(hash, amount_msat)
.then(({ status }: { status?: string }) => {
if (status === 'error') return;
// success only
if (status === 'warning' && attestationLevel === 1)
return;
this.lookupPreimageAndRedeemZaplocker(
hash,
amount_msat,
comment,
false,
true
);
})
.catch((e) =>
console.log('Error looking up attestation', e)
);
}
});
};
private subscribeUpdatesCashu = async () => {
const { verification, signature } = await this.getAuthData();
this.socket = io(LNURL_HOST, {
path: LNURL_SOCKET_PATH
}).connect();
this.socket.emit('auth', {
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature
});
this.socket.on('paid', (data: any) => {
const { quote_id, mint_url, amount_msat } = data;
this.redeemCashu(quote_id, mint_url, amount_msat, false, true);
});
};
public prepareToAutomaticallyAcceptZaplocker = async () => {
this.readyToAutomaticallyAccept = false;
this.prepareToAutomaticallyAcceptStart = true;
while (!this.readyToAutomaticallyAccept) {
const isReady =
await this.nodeInfoStore.isLightningReadyToReceive();
if (isReady) {
runInAction(() => {
this.readyToAutomaticallyAccept = true;
if (this.socket && this.socket.connected) return;
this.redeemAllOpenPaymentsZaplocker(true);
this.subscribeUpdatesZaplocker();
});
}
await sleep(3000);
}
};
public prepareToAutomaticallyAcceptCashu = () => {
if (this.socket && this.socket.connected) return;
this.redeemAllOpenPaymentsCashu(true);
this.subscribeUpdatesCashu();
};
@action
public reset = () => {
this.loading = false;
this.error = false;
this.error_msg = '';
this.availableHashes = 0;
this.localHashes = 0;
this.paid = [];
this.preimageMap = {};
this.socket = undefined;
this.lightningAddress = '';
this.lightningAddressHandle = '';
this.lightningAddressDomain = '';
this.lightningAddressType = '';
this.zeusPlusExpiresAt = undefined;
this.zeusPlusAnnualFeeSats = undefined;
this.zeusPlusDiscount = undefined;
this.legacyAccount = false;
this.perks = [];
};
@action
public deleteAddress = async () => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const deleteResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/lnurl/delete`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature
})
);
const deleteData = deleteResponse.json();
if (deleteResponse.info().status !== 200) throw deleteData.error;
// Clear local storage and reset store state
await Storage.setItem(ADDRESS_ACTIVATED_STRING, false);
await Storage.setItem(HASHES_STORAGE_STRING, '');
this.reset();
runInAction(() => {
this.loading = false;
});
return true;
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.error = true;
this.error_msg = error_msg || 'Failed to delete account';
this.loading = false;
});
throw error;
}
};
@action
public createZeusPayPlusOrder = async () => {
this.error = false;
this.error_msg = '';
this.loading = true;
try {
const { verification, signature } = await this.getAuthData();
const orderResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${LNURL_HOST}/api/plus/order`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey: this.nodeInfoStore.nodeInfo.identity_pubkey,
message: verification,
signature
})
);
const orderData = orderResponse.json();
if (orderResponse.info().status !== 200) throw orderData.error;
runInAction(() => {
this.loading = false;
});
return orderData;
} catch (error) {
const error_msg = error?.toString();
runInAction(() => {
this.error = true;
this.error_msg = error_msg || 'Failed to create order';
this.loading = false;
});
throw error;
}
};
}