diff --git a/src/app/components/TransactionsTable/TransactionModal.tsx b/src/app/components/TransactionsTable/TransactionModal.tsx index 220cb4fe45..be441fe310 100644 --- a/src/app/components/TransactionsTable/TransactionModal.tsx +++ b/src/app/components/TransactionsTable/TransactionModal.tsx @@ -29,7 +29,7 @@ export default function TransactionModal({ keyPrefix: "transactions_table", }); const [showMoreFields, setShowMoreFields] = useState(false); - const { getFormattedSats } = useSettings(); + const { getFormattedSats, getFormattedInCurrency } = useSettings(); function toggleShowMoreFields() { setShowMoreFields(!showMoreFields); @@ -80,7 +80,12 @@ export default function TransactionModal({ )} > {transaction.type == "sent" ? "-" : "+"}{" "} - {getFormattedSats(transaction.totalAmount)} + {!transaction.displayAmount + ? getFormattedSats(transaction.totalAmount) + : getFormattedInCurrency( + transaction.displayAmount[0], + transaction.displayAmount[1] + )}

{!!transaction.totalAmountFiat && ( diff --git a/src/app/components/TransactionsTable/index.tsx b/src/app/components/TransactionsTable/index.tsx index b90d036bbd..5c2616c697 100644 --- a/src/app/components/TransactionsTable/index.tsx +++ b/src/app/components/TransactionsTable/index.tsx @@ -20,7 +20,7 @@ export default function TransactionsTable({ noResultMsg, loading = false, }: Props) { - const { getFormattedSats } = useSettings(); + const { getFormattedSats, getFormattedInCurrency } = useSettings(); const [modalOpen, setModalOpen] = useState(false); const [transaction, setTransaction] = useState(); const { t } = useTranslation("components", { @@ -90,7 +90,12 @@ export default function TransactionsTable({ )} > {type == "outgoing" ? "-" : "+"}{" "} - {getFormattedSats(tx.totalAmount)} + {!tx.displayAmount + ? getFormattedSats(tx.totalAmount) + : getFormattedInCurrency( + tx.displayAmount[0], + tx.displayAmount[1] + )}

{!!tx.totalAmountFiat && ( diff --git a/src/app/hooks/useTransactions.ts b/src/app/hooks/useTransactions.ts index 1d1f5cd001..f7b24d7377 100644 --- a/src/app/hooks/useTransactions.ts +++ b/src/app/hooks/useTransactions.ts @@ -27,6 +27,11 @@ export const useTransactions = () => { ); for (const transaction of transactions) { + if ( + transaction.displayAmount && + transaction.displayAmount[1] === settings.currency + ) + continue; transaction.totalAmountFiat = settings.showFiat ? await getFormattedFiat(transaction.totalAmount) : ""; diff --git a/src/app/screens/Keysend/index.tsx b/src/app/screens/Keysend/index.tsx index d35e9775e1..d581e0359a 100644 --- a/src/app/screens/Keysend/index.tsx +++ b/src/app/screens/Keysend/index.tsx @@ -39,7 +39,11 @@ function Keysend() { const { t: tCommon } = useTranslation("common"); const amountMin = 1; - const amountExceeded = +amountSat > (auth?.account?.balance || 0); + + const amountExceeded = + (auth?.account?.currency || "BTC") !== "BTC" + ? false + : +amountSat > (auth?.account?.balance || 0); const rangeExceeded = +amountSat < amountMin; useEffect(() => { diff --git a/src/app/screens/LNURLPay/index.tsx b/src/app/screens/LNURLPay/index.tsx index 43132b705a..27b212a53a 100644 --- a/src/app/screens/LNURLPay/index.tsx +++ b/src/app/screens/LNURLPay/index.tsx @@ -71,7 +71,10 @@ function LNURLPay() { const amountMin = Math.floor(+details.minSendable / 1000); const amountMax = Math.floor(+details.maxSendable / 1000); - const amountExceeded = +valueSat > (auth?.account?.balance || 0); + const amountExceeded = + (auth?.account?.currency || "BTC") !== "BTC" + ? false + : +valueSat > (auth?.account?.balance || 0); const rangeExceeded = +valueSat > amountMax || +valueSat < amountMin; const [showMoreFields, setShowMoreFields] = useState(false); diff --git a/src/app/screens/Publishers/Detail/index.tsx b/src/app/screens/Publishers/Detail/index.tsx index c0909df09e..25d235b277 100644 --- a/src/app/screens/Publishers/Detail/index.tsx +++ b/src/app/screens/Publishers/Detail/index.tsx @@ -43,6 +43,11 @@ function PublisherDetail() { ); for (const payment of _transactions) { + if ( + payment.displayAmount && + payment.displayAmount[1] === settings.currency + ) + continue; payment.totalAmountFiat = settings.showFiat ? await getFormattedFiat(payment.totalAmount) : ""; @@ -53,7 +58,7 @@ function PublisherDetail() { console.error(e); if (e instanceof Error) toast.error(`Error: ${e.message}`); } - }, [id, settings.showFiat, getFormattedFiat]); + }, [id, settings.showFiat, getFormattedFiat, settings.currency]); useEffect(() => { // Run once. diff --git a/src/app/screens/SendToBitcoinAddress/index.tsx b/src/app/screens/SendToBitcoinAddress/index.tsx index bc27586c85..b363d39bf3 100644 --- a/src/app/screens/SendToBitcoinAddress/index.tsx +++ b/src/app/screens/SendToBitcoinAddress/index.tsx @@ -206,7 +206,10 @@ function SendToBitcoinAddress() { const amountMin = 100_000; const amountMax = 10_000_000; - const amountExceeded = +amountSat > (auth?.account?.balance || 0); + const amountExceeded = + (auth?.account?.currency || "BTC") !== "BTC" + ? false + : +amountSat > (auth?.account?.balance || 0); const rangeExceeded = +amountSat > amountMax || +amountSat < amountMin; const timeEstimateAlert = {t("time_estimate")}; diff --git a/src/app/screens/connectors/ConnectGaloy/index.tsx b/src/app/screens/connectors/ConnectGaloy/index.tsx index 1d5ede6e19..4b701475a8 100644 --- a/src/app/screens/connectors/ConnectGaloy/index.tsx +++ b/src/app/screens/connectors/ConnectGaloy/index.tsx @@ -1,5 +1,6 @@ import ConnectorForm from "@components/ConnectorForm"; import Input from "@components/form/Input"; +import Select from "@components/form/Select"; import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; import fetchAdapter from "@vespaiach/axios-fetch-adapter"; import axios from "axios"; @@ -54,11 +55,16 @@ export default function ConnectGaloy(props: Props) { }); const [loading, setLoading] = useState(false); const [authToken, setAuthToken] = useState(); + const [currency, setCurrency] = useState("BTC"); function handleAuthTokenChange(event: React.ChangeEvent) { setAuthToken(event.target.value.trim()); } + function handleCurrencyChange(event: React.ChangeEvent) { + setCurrency(event.target.value); + } + async function loginWithAuthToken(event: React.FormEvent) { event.preventDefault(); setLoading(true); @@ -100,10 +106,10 @@ export default function ConnectGaloy(props: Props) { } else { // Find the BTC wallet and get its ID const btcWallet = meData.data.me.defaultAccount.wallets.find( - (w: Wallet) => w.walletCurrency === "BTC" + (w: Wallet) => w.walletCurrency === currency ); const walletId = btcWallet.id; - saveAccount({ headers, walletId }); + saveAccount({ headers, walletId, currency }); } } catch (e: unknown) { console.error(e); @@ -122,7 +128,11 @@ export default function ConnectGaloy(props: Props) { } } - async function saveAccount(config: { headers: Headers; walletId: string }) { + async function saveAccount(config: { + headers: Headers; + walletId: string; + currency: string; + }) { setLoading(true); const account = { @@ -132,6 +142,7 @@ export default function ConnectGaloy(props: Props) { headers: config.headers, walletId: config.walletId, apiCompatibilityMode, + currency: config.currency, }, connector: "galoy", }; @@ -215,6 +226,26 @@ export default function ConnectGaloy(props: Props) { } + +
+ +
+ +
+
); } diff --git a/src/common/utils/paymentRequest.ts b/src/common/utils/paymentRequest.ts new file mode 100644 index 0000000000..acc5d0c3d3 --- /dev/null +++ b/src/common/utils/paymentRequest.ts @@ -0,0 +1,9 @@ +import lightningPayReq from "bolt11"; + +export function getPaymentRequestDescription(paymentRequest: string): string { + const decodedPaymentRequest = lightningPayReq.decode(paymentRequest); + const descriptionTag = decodedPaymentRequest.tags.find( + (tag) => tag.tagName === "description" + ); + return descriptionTag ? descriptionTag.data.toString() : ""; +} diff --git a/src/extension/background-script/connectors/connector.interface.ts b/src/extension/background-script/connectors/connector.interface.ts index 412722d27b..7531d30688 100644 --- a/src/extension/background-script/connectors/connector.interface.ts +++ b/src/extension/background-script/connectors/connector.interface.ts @@ -33,6 +33,7 @@ export interface ConnectorTransaction { */ settleDate: number; totalAmount: number; + displayAmount?: [number, ACCOUNT_CURRENCIES]; type: "received" | "sent"; } diff --git a/src/extension/background-script/connectors/galoy.ts b/src/extension/background-script/connectors/galoy.ts index 72d5da616d..0f7ff6a79e 100644 --- a/src/extension/background-script/connectors/galoy.ts +++ b/src/extension/background-script/connectors/galoy.ts @@ -1,16 +1,18 @@ import fetchAdapter from "@vespaiach/axios-fetch-adapter"; import axios, { AxiosRequestConfig } from "axios"; import lightningPayReq from "bolt11"; +import { ACCOUNT_CURRENCIES, CURRENCIES } from "~/common/constants"; +import { getPaymentRequestDescription } from "~/common/utils/paymentRequest"; +import { getCurrencyRateWithCache } from "~/extension/background-script/actions/cache/getCurrencyRate"; import { Account } from "~/types"; - import Connector, { CheckPaymentArgs, CheckPaymentResponse, ConnectPeerResponse, + ConnectorTransaction, GetBalanceResponse, GetInfoResponse, GetTransactionsResponse, - ConnectorTransaction, KeysendArgs, MakeInvoiceArgs, MakeInvoiceResponse, @@ -20,12 +22,15 @@ import Connector, { SignMessageResponse, } from "./connector.interface"; +type GaloyCurrencies = Extract; + interface Config { walletId: string; url: string; headers?: Headers; // optional for backward compatibility apiCompatibilityMode?: boolean; // optional for backward compatibility accessToken?: string; // only present in old connectors + currency: GaloyCurrencies; // default is BTC } class Galoy implements Connector { @@ -43,6 +48,7 @@ class Galoy implements Connector { config.apiCompatibilityMode !== undefined ? config.apiCompatibilityMode : true, + currency: config.currency || "BTC", }; } @@ -62,6 +68,14 @@ class Galoy implements Connector { return Promise.resolve(); } + toFiatCents(amount: number): number { + return Math.round(amount * 100); + } + + fromFiatCents(amount: number): number { + return amount / 100; + } + get supportedMethods() { return [ "getInfo", @@ -147,6 +161,7 @@ class Galoy implements Connector { direction initiationVia { ... on InitiationViaLn { + paymentRequest paymentHash } } @@ -178,6 +193,7 @@ class Galoy implements Connector { }; const response = await this.request(query); + const errs = response.errors || response.data.me.errors; if (errs && errs.length) { throw new Error(errs[0].message || JSON.stringify(errs)); @@ -187,36 +203,54 @@ class Galoy implements Connector { const targetWallet = wallets.find((w) => w.id === this.config.walletId); if (targetWallet) { - if (targetWallet.walletCurrency === "USD") { - throw new Error("USD currency support is not yet implemented."); + if (targetWallet.walletCurrency !== this.config.currency) { + throw new Error( + "Wallet currency does not match the account currency. " + + targetWallet.walletCurrency + + " != " + + this.config.currency + ); } - targetWallet.transactions.edges.forEach( - (edge: { cursor: string; node: TransactionNode }) => { - const tx = edge.node; - // Determine transaction type based on the direction field - const transactionType: "received" | "sent" = - tx.direction === "RECEIVE" ? "received" : "sent"; - // Do not display a double negative if sent - const absSettlementAmount = Math.abs(tx.settlementAmount); - // Convert createdAt from UNIX timestamp to Date - const createdAtDate = new Date(tx.createdAt * 1000); - - transactions.push({ - id: edge.cursor, - memo: tx.memo, - preimage: - tx.settlementVia.preImage || - tx.settlementVia.paymentSecret || - "", - payment_hash: tx.initiationVia.paymentHash || "", - settled: tx.status === "SUCCESS", - settleDate: createdAtDate.getTime(), - totalAmount: absSettlementAmount, // Assuming this is in the correct unit - type: transactionType, - }); + for (const edge of targetWallet.transactions.edges) { + const tx = edge.node; + // Determine transaction type based on the direction field + const transactionType: "received" | "sent" = + tx.direction === "RECEIVE" ? "received" : "sent"; + const currency = targetWallet.walletCurrency; + // Do not display a double negative if sent + let absSettlementAmount = Math.abs(tx.settlementAmount); + let displayAmount: [number, ACCOUNT_CURRENCIES] | undefined = + undefined; + if (currency !== "BTC") { + const rate = await getCurrencyRateWithCache(CURRENCIES[currency]); + absSettlementAmount = this.fromFiatCents(absSettlementAmount); + displayAmount = [absSettlementAmount, CURRENCIES[currency]]; + absSettlementAmount = Math.floor(absSettlementAmount / rate); } - ); + + const createdAtDate = new Date(tx.createdAt * 1000); + + let paymentRequestDescription = ""; + if (!tx.memo && tx.initiationVia.paymentRequest) { + paymentRequestDescription = getPaymentRequestDescription( + tx.initiationVia.paymentRequest + ); + } + + transactions.push({ + id: edge.cursor, + memo: tx.memo || paymentRequestDescription, + preimage: + tx.settlementVia.preImage || tx.settlementVia.paymentSecret || "", + payment_hash: tx.initiationVia.paymentHash || "", + settled: tx.status === "SUCCESS", + settleDate: createdAtDate.getTime(), + totalAmount: absSettlementAmount, + type: transactionType, + displayAmount, + }); + } } hasNextPage = targetWallet?.transactions.pageInfo.hasNextPage || false; @@ -255,12 +289,25 @@ class Galoy implements Connector { (w: GaloyWallet) => w.id === this.config.walletId ); if (targetWallet) { - if (targetWallet.walletCurrency === "USD") { - throw new Error("USD currency support is not yet implemented."); + if (targetWallet.walletCurrency !== this.config.currency) { + throw new Error( + "Wallet currency does not match the account currency. " + + targetWallet.walletCurrency + + " != " + + this.config.currency + ); } + + const currency = targetWallet.walletCurrency; + const balance = + currency !== "BTC" + ? this.fromFiatCents(targetWallet.balance) + : targetWallet.balance; + return { data: { - balance: targetWallet.balance, + balance, + currency, }, }; } else { @@ -419,8 +466,13 @@ class Galoy implements Connector { if (wallet === undefined) { throw new Error("Bad data received."); } - if (wallet.walletCurrency === "USD") { - throw new Error("USD currency support is not yet implemented."); + if (wallet.walletCurrency !== this.config.currency) { + throw new Error( + "Wallet currency does not match the account currency. " + + wallet.walletCurrency + + " != " + + this.config.currency + ); } const txEdges = wallet.transactions.edges; @@ -453,10 +505,22 @@ class Galoy implements Connector { } async makeInvoice(args: MakeInvoiceArgs): Promise { + const isUSD = this.config.currency == "USD"; + const mutationName = isUSD + ? "LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient" + : "LnInvoiceCreate"; + const inputTypeName = isUSD + ? "LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput" + : "LnInvoiceCreateInput"; + const invoiceCreateFunction = isUSD + ? "lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient" + : "lnInvoiceCreate"; + const amountSats = Number(args.amount); + const query = { query: ` - mutation lnInvoiceCreate($input: LnInvoiceCreateInput!) { - lnInvoiceCreate(input: $input) { + mutation ${mutationName}($input: ${inputTypeName}!) { + ${invoiceCreateFunction}(input: $input) { invoice { paymentRequest paymentHash @@ -471,22 +535,23 @@ class Galoy implements Connector { `, variables: { input: { - walletId: this.config.walletId, - amount: args.amount, + walletId: !isUSD ? this.config.walletId : undefined, + recipientWalletId: isUSD ? this.config.walletId : undefined, + amount: amountSats, memo: args.memo, }, }, }; return this.request(query).then(({ data, errors }) => { - const errs = errors || data.lnInvoiceCreate.errors; + const errs = errors || data[invoiceCreateFunction].errors; if (errs && errs.length) { throw new Error(errs[0].message || JSON.stringify(errs)); } return { data: { - paymentRequest: data.lnInvoiceCreate.invoice.paymentRequest, - rHash: data.lnInvoiceCreate.invoice.paymentHash, + paymentRequest: data[invoiceCreateFunction].invoice.paymentRequest, + rHash: data[invoiceCreateFunction].invoice.paymentHash, }, }; }); @@ -535,6 +600,7 @@ type TransactionNode = { direction: string; initiationVia: { paymentHash?: string; + paymentRequest?: string; }; settlementVia: { preImage?: string; diff --git a/src/extension/background-script/connectors/lnc.ts b/src/extension/background-script/connectors/lnc.ts index c744dfacd7..fb93faee18 100644 --- a/src/extension/background-script/connectors/lnc.ts +++ b/src/extension/background-script/connectors/lnc.ts @@ -1,6 +1,5 @@ import { Invoice } from "@lightninglabs/lnc-core/dist/types/proto/lnd/lightning"; import LNC from "@lightninglabs/lnc-web"; -import lightningPayReq from "bolt11"; import Base64 from "crypto-js/enc-base64"; import Hex from "crypto-js/enc-hex"; import UTF8 from "crypto-js/enc-utf8"; @@ -10,6 +9,7 @@ import snakeCase from "lodash.snakecase"; import { encryptData } from "~/common/lib/crypto"; import utils from "~/common/lib/utils"; import { mergeTransactions } from "~/common/utils/helpers"; +import { getPaymentRequestDescription } from "~/common/utils/paymentRequest"; import { Account } from "~/types"; import state from "../state"; import Connector, { @@ -301,14 +301,9 @@ class Lnc implements Connector { const outgoingInvoices: ConnectorTransaction[] = outgoingInvoicesResponse.payments.map( (payment, index): ConnectorTransaction => { - let memo = "Sent"; + let memo = ""; if (payment.paymentRequest) { - memo = ( - lightningPayReq - .decode(payment.paymentRequest) - .tags.find((tag) => tag.tagName === "description")?.data || - "Sent" - ).toString(); + memo = getPaymentRequestDescription(payment.paymentRequest); } return { diff --git a/src/extension/background-script/connectors/lnd.ts b/src/extension/background-script/connectors/lnd.ts index d6ab7e9824..75f376b3a8 100644 --- a/src/extension/background-script/connectors/lnd.ts +++ b/src/extension/background-script/connectors/lnd.ts @@ -1,4 +1,3 @@ -import lightningPayReq from "bolt11"; import Base64 from "crypto-js/enc-base64"; import Hex from "crypto-js/enc-hex"; import UTF8 from "crypto-js/enc-utf8"; @@ -8,6 +7,7 @@ import utils from "~/common/lib/utils"; import { Account } from "~/types"; import { mergeTransactions } from "~/common/utils/helpers"; +import { getPaymentRequestDescription } from "~/common/utils/paymentRequest"; import Connector, { CheckPaymentArgs, CheckPaymentResponse, @@ -522,10 +522,7 @@ class Lnd implements Connector { (payment, index): ConnectorTransaction => { let description: string | undefined; if (payment.payment_request) { - description = lightningPayReq - .decode(payment.payment_request) - .tags.find((tag) => tag.tagName === "description") - ?.data.toString(); + description = getPaymentRequestDescription(payment.payment_request); } return { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 5954948459..a5ad97db95 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -219,7 +219,10 @@ }, "token": { "label": "Enter your API key", - "info": "To connect your wallet generate an API key in the <0>Blink Dashboard (dashboard.blink.sv):
- log in with email or phone number if you are using Blink already
- if you have no account yet can create a new one by logging in with a phone number
- create a new key on the API Keys tab
- give it a Name and choose the Read and Write Scope
- leave the default no expiry or choose a long timeframe to avoid needing to reconnect your wallet periodically
- copy the key (starting with blink_ ) and paste it to the textbox below.

The integration currently only supports using the BTC wallet.
" + "info": "To connect your wallet generate an API key in the <0>Blink Dashboard (dashboard.blink.sv):
- log in with email or phone number if you are using Blink already
- if you have no account yet can create a new one by logging in with a phone number
- create a new key on the API Keys tab
- give it a Name and choose the Read and Write Scope
- leave the default no expiry or choose a long timeframe to avoid needing to reconnect your wallet periodically
- copy the key (starting with blink_ ) and paste it to the textbox below.
" + }, + "currency": { + "label": "Select wallet" } }, "bitcoin_jungle": { @@ -230,6 +233,9 @@ "token": { "label": "Enter your Access token", "info": "The {{label}} integration with Alby is in alpha and only recommended for advanced users.

You can grab your Access token by going into the mobile app, clicking on the settings icon, and then tapping 3 times on the build number to open the developer menu.

The access token can be copied from here.

" + }, + "currency": { + "label": "Select wallet" } }, "galoy": { diff --git a/src/types.ts b/src/types.ts index 7af1da56c4..8e2737ebe5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -750,6 +750,7 @@ export type Transaction = { preimage: string; title: string | React.ReactNode; totalAmount: Allowance["payments"][number]["totalAmount"]; + displayAmount?: [number, ACCOUNT_CURRENCIES]; totalAmountFiat?: string; totalFees?: Allowance["payments"][number]["totalFees"]; type?: "sent" | "received"; @@ -921,6 +922,7 @@ export interface Invoice { settleDate: number; totalAmount: number; totalAmountFiat?: string; + displayAmount?: [number, ACCOUNT_CURRENCIES]; preimage: string; paymentHash?: string; custom_records?: ConnectorTransaction["custom_records"];