From d983f7a68b022b31613252b69336364e131a312b Mon Sep 17 00:00:00 2001 From: Sebastian Sutter Date: Tue, 18 Mar 2025 12:05:00 +0100 Subject: [PATCH 1/4] backend: integrate bitrefill widget Integrates Bitrefill widget into the backend and adds a new "spend" action, as Bitrefill is a new type of exchange next to buy and sell. --- backend/backend.go | 3 + backend/exchanges/bitrefill.go | 101 +++++++++++++++++++++++++++++++++ backend/exchanges/exchanges.go | 23 +++++++- backend/handlers/handlers.go | 28 +++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 backend/exchanges/bitrefill.go diff --git a/backend/backend.go b/backend/backend.go index 03c23c8aec..bdf332807b 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -91,6 +91,9 @@ var fixedURLWhitelist = []string{ // BTCDirect "https://btcdirect.eu/", "https://start.btcdirect.eu/", + // Bitrefill + "https://www.bitrefill.com/", + "https://embed.bitrefill.com/", // Bitsurance "https://www.bitsurance.eu/", "https://get.bitsurance.eu/", diff --git a/backend/exchanges/bitrefill.go b/backend/exchanges/bitrefill.go new file mode 100644 index 0000000000..d8b5e3f909 --- /dev/null +++ b/backend/exchanges/bitrefill.go @@ -0,0 +1,101 @@ +// Copyright 2025 Shift Crypto AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package exchanges + +import ( + "slices" + + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin" +) + +const ( + // BitrefillName is the name of the exchange, it is unique among all the supported exchanges. + BitrefillName = "bitrefill" + + bitrefillRef = "SHU5bB6y" + + bitrefillProdUrl = "https://bitboxapp.shiftcrypto.io/widgets/bitrefill/v1/bitrefill.html" +) + +type bitrefillInfo struct { + Url string + Ref string + Address *string +} + +var bitrefillRegions = []string{ + "AE", "AN", "BL", "CH", "DE", "ES", "GD", "GB", "GI", "GS", "GR", "GL", "KN", + "KR", "LC", "LI", "LK", "LR", "LY", "LT", "LU", "MA", "MC", "MD", "ME", "MG", + "MH", "ML", "MK", "MN", "MO", "MQ", "MR", "MS", "MW", "MX", "MY", "MV", "MZ", + "NA", "NE", "NF", "NG", "NI", "NL", "NP", "NR", "NU", "NC", "MP", "NZ", "OM", + "PA", "PG", "PE", "PH", "PK", "PN", "PL", "PR", "PT", "QA", "RE", "RO", "RS", + "RW", "SA", "SB", "SC", "SH", "SI", "SK", "SL", "SM", "ST", "SG", "SS", "TG", + "TH", "TJ", "TK", "TL", "TO", "TR", "TT", "TV", "UG", "UA", "US", "UY", "UZ", + "VA", "VE", "VN", "VU", "WF", "WS", "YT", "YE", "ZM", "ZW"} + +// GetBitrefillSupportedRegions returns a string slice of the regions where Bitrefill services +// are available. +func GetBitrefillSupportedRegions() []string { + return bitrefillRegions +} + +// IsRegionSupportedBitrefill returns whether a specific region (or an empty one +// for "unspecified") is supported by Bitrefill. +func IsRegionSupportedBitrefill(region string) bool { + return len(region) == 0 || slices.Contains(bitrefillRegions, region) +} + +// IsBitrefillSupported is true if coin.Code is supported by Bitrefill. +func IsBitrefillSupported(coinCode coin.Code) bool { + supportedCoins := []coin.Code{ + coin.CodeBTC, coin.CodeLTC, coin.CodeETH, coin.CodeSEPETH, + "eth-erc20-usdc", "eth-erc20-usdt"} + + coinSupported := slices.Contains(supportedCoins, coinCode) + + return coinSupported +} + +// IsBitrefillSupportedForCoinInRegion returns whether Bitrefill is supported for the specific +// combination of coin and region. +func IsBitrefillSupportedForCoinInRegion(coinCode coin.Code, region string) bool { + return IsBitrefillSupported(coinCode) && IsRegionSupportedBitrefill(region) +} + +// BitrefillDeals returns the purchase conditions (fee and payment methods) offered by Bitrefill. +func BitrefillDeals() *ExchangeDealsList { + return &ExchangeDealsList{ + ExchangeName: BitrefillName, + Deals: []*ExchangeDeal{ + { + Fee: 0, // There is no fee on buying gift cards + Payment: SpendPayment, + }, + }, + } +} + +// BitrefillInfo returns the information needed to interact with Bitrefill, +// including the widget URL, referral code and an unused address for refunds. +func BitrefillInfo(action ExchangeAction, acct accounts.Interface) bitrefillInfo { + addr := acct.GetUnusedReceiveAddresses()[0].Addresses[0].EncodeForHumans() + res := bitrefillInfo{ + Url: bitrefillProdUrl, + Ref: bitrefillRef, + Address: &addr} + + return res +} diff --git a/backend/exchanges/exchanges.go b/backend/exchanges/exchanges.go index 270df92d7e..c9e05a11ba 100644 --- a/backend/exchanges/exchanges.go +++ b/backend/exchanges/exchanges.go @@ -65,6 +65,8 @@ const ( BuyAction ExchangeAction = "buy" // SellAction identifies a sell exchange action. SellAction ExchangeAction = "sell" + // SpendAction identifies a spend exchange action. + SpendAction ExchangeAction = "spend" ) // ParseAction parses an action string and returns an ExchangeAction. @@ -74,6 +76,8 @@ func ParseAction(action string) (ExchangeAction, error) { return BuyAction, nil case "sell": return SellAction, nil + case "spend": + return SpendAction, nil default: return "", errp.New("Invalid Exchange action") } @@ -91,6 +95,7 @@ type ExchangeRegion struct { IsMoonpayEnabled bool `json:"isMoonpayEnabled"` IsPocketEnabled bool `json:"isPocketEnabled"` IsBtcDirectEnabled bool `json:"isBtcDirectEnabled"` + IsBitrefillEnabled bool `json:"IsBitrefillEnabled"` } // PaymentMethod type is used for payment options in exchange deals. @@ -105,6 +110,8 @@ const ( SOFORTPayment PaymentMethod = "sofort" // BancontactPayment is a payment method in the SEPA region. BancontactPayment PaymentMethod = "bancontact" + // SpendPayment is a payment method using the BitBox wallet. + SpendPayment PaymentMethod = "spend" ) // ExchangeDeal represents a specific purchase option of an exchange. @@ -145,10 +152,12 @@ func ListExchangesByRegion(account accounts.Interface, httpClient *http.Client) } btcDirectRegions := GetBtcDirectSupportedRegions() + bitrefillRegions := GetBitrefillSupportedRegions() isMoonpaySupported := IsMoonpaySupported(account.Coin().Code()) isPocketSupported := IsPocketSupported(account.Coin().Code()) isBtcDirectSupported := IsBtcDirectSupported(account.Coin().Code()) + isBitrefillSupported := IsBitrefillSupported(account.Coin().Code()) exchangeRegions := ExchangeRegionList{} for _, code := range RegionCodes { @@ -161,12 +170,14 @@ func ListExchangesByRegion(account accounts.Interface, httpClient *http.Client) _, pocketEnabled = pocketRegions[code] } btcDirectEnabled := slices.Contains(btcDirectRegions, code) + bitrefillEnabled := slices.Contains(bitrefillRegions, code) exchangeRegions.Regions = append(exchangeRegions.Regions, ExchangeRegion{ Code: code, IsMoonpayEnabled: moonpayEnabled && isMoonpaySupported, IsPocketEnabled: pocketEnabled && isPocketSupported, IsBtcDirectEnabled: btcDirectEnabled && isBtcDirectSupported, + IsBitrefillEnabled: bitrefillEnabled && isBitrefillSupported, }) } @@ -176,9 +187,10 @@ func ListExchangesByRegion(account accounts.Interface, httpClient *http.Client) // GetExchangeDeals returns the exchange deals available for the specified account, region and action. func GetExchangeDeals(account accounts.Interface, regionCode string, action ExchangeAction, httpClient *http.Client) ([]*ExchangeDealsList, error) { moonpaySupportsCoin := IsMoonpaySupported(account.Coin().Code()) && action == BuyAction - pocketSupportsCoin := IsPocketSupported(account.Coin().Code()) + pocketSupportsCoin := IsPocketSupported(account.Coin().Code()) && (action == BuyAction || action == SellAction) btcDirectSupportsCoin := IsBtcDirectSupported(account.Coin().Code()) && action == BuyAction - coinSupported := moonpaySupportsCoin || pocketSupportsCoin || btcDirectSupportsCoin + bitrefillSupportsCoin := IsBitrefillSupported(account.Coin().Code()) && action == SpendAction + coinSupported := moonpaySupportsCoin || pocketSupportsCoin || btcDirectSupportsCoin || bitrefillSupportsCoin if !coinSupported { return nil, ErrCoinNotSupported } @@ -200,6 +212,7 @@ func GetExchangeDeals(account accounts.Interface, regionCode string, action Exch IsMoonpayEnabled: true, IsPocketEnabled: true, IsBtcDirectEnabled: true, + IsBitrefillEnabled: true, } } @@ -223,6 +236,12 @@ func GetExchangeDeals(account accounts.Interface, regionCode string, action Exch exchangeDealsLists = append(exchangeDealsLists, deals) } } + if bitrefillSupportsCoin && userRegion.IsBitrefillEnabled { + deals := BitrefillDeals() + if deals != nil { + exchangeDealsLists = append(exchangeDealsLists, deals) + } + } if len(exchangeDealsLists) == 0 { return nil, ErrRegionNotSupported diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 1f4066baf6..d024f0ffe9 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -246,6 +246,7 @@ func NewHandlers( getAPIRouter(apiRouter)("/exchange/moonpay/buy-info/{code}", handlers.getExchangeMoonpayBuyInfo).Methods("GET") getAPIRouterNoError(apiRouter)("/exchange/pocket/api-url/{action}", handlers.getExchangePocketURL).Methods("GET") getAPIRouterNoError(apiRouter)("/exchange/pocket/verify-address", handlers.postPocketWidgetVerifyAddress).Methods("POST") + getAPIRouterNoError(apiRouter)("/exchange/bitrefill/info/{action}/{code}", handlers.getExchangeBitrefillInfo).Methods("GET") getAPIRouterNoError(apiRouter)("/bitsurance/lookup", handlers.postBitsuranceLookup).Methods("POST") getAPIRouterNoError(apiRouter)("/bitsurance/url", handlers.getBitsuranceURL).Methods("GET") getAPIRouterNoError(apiRouter)("/aopp", handlers.getAOPP).Methods("GET") @@ -1511,6 +1512,33 @@ func (handlers *Handlers) postPocketWidgetVerifyAddress(r *http.Request) interfa } +func (handlers *Handlers) getExchangeBitrefillInfo(r *http.Request) interface{} { + type result struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage"` + Url string `json:"url"` + Ref string `json:"ref"` + Address *string `json:"address"` + } + + code := accountsTypes.Code(mux.Vars(r)["code"]) + acct, err := handlers.backend.GetAccountFromCode(code) + accountValid := acct != nil && acct.Offline() == nil && !acct.FatalError() + if err != nil || !accountValid { + return result{Success: false, ErrorMessage: "Account is not valid."} + } + + action := exchanges.ExchangeAction(mux.Vars(r)["action"]) + bitrefillInfo := exchanges.BitrefillInfo(action, acct) + + return result{ + Success: true, + Url: bitrefillInfo.Url, + Ref: bitrefillInfo.Ref, + Address: bitrefillInfo.Address, + } +} + func (handlers *Handlers) getAOPP(r *http.Request) interface{} { return handlers.backend.AOPP() } From 3e190f4087f6d1b8f807ba23876891d0824817b2 Mon Sep 17 00:00:00 2001 From: Sebastian Sutter Date: Thu, 13 Mar 2025 17:57:27 +0100 Subject: [PATCH 2/4] frontend: integrate bitrefill widget Integrates Bitrefill in the frontend. On the previous "Buy & sell" menu, there is now a third selectable pill "Spend", opening the available exchange options (Bitrefill in this case). The widget is requested from bitboxapp.shiftcrypto.io using config parameters from the user/app, handled in the "request-configuration" event. Note that the iframe will still have its source set to "https://embed.bitrefill.com". When the user clicks "Pay" inside the Bitrefill widget to purchase a gift card, the returned address and amount is used to propose a transaction on the connected BitBox. The PaymentMethod component is adjusted to allow custom messages to be shown in the exchange overview, as these will differ in the "Spend" section between providers. Disclaimer and info texts are added technically, but the actual text is still missing in this commit. Bitrefill developer documentation: https://www.bitrefill.com/playground/documentation/url-params --- CHANGELOG.md | 1 + frontends/web/src/api/exchanges.ts | 23 +- .../src/components/terms/bitrefill-terms.tsx | 113 +++++++++ frontends/web/src/locales/en/app.json | 16 +- .../web/src/routes/exchange/bitrefill.tsx | 239 ++++++++++++++++++ .../routes/exchange/components/buysell.tsx | 2 +- .../components/exchange-providers.tsx | 30 ++- .../exchange/components/exchangetab.tsx | 6 + .../exchange/components/infocontent.tsx | 40 +++ .../web/src/routes/exchange/exchange.tsx | 6 +- frontends/web/src/routes/exchange/utils.ts | 2 + frontends/web/src/routes/router.tsx | 8 + 12 files changed, 473 insertions(+), 13 deletions(-) create mode 100644 frontends/web/src/components/terms/bitrefill-terms.tsx create mode 100644 frontends/web/src/routes/exchange/bitrefill.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bf75af49c9..9128fcbac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Fix wrong estimated confirmation time for ERC20 tokens. - Enable unlock test wallet in testnet - Added support to show on the BitBox when a transaction's recipient is an address of a different account on the device. +- Integrate Bitrefill and add spending section # v4.47.2 - Linux: fix compatiblity with some versions of Mesa that are incompatible with the bundled wayland libraries diff --git a/frontends/web/src/api/exchanges.ts b/frontends/web/src/api/exchanges.ts index e23f7ddd13..188a1ed811 100644 --- a/frontends/web/src/api/exchanges.ts +++ b/frontends/web/src/api/exchanges.ts @@ -21,7 +21,7 @@ export const getExchangeRegionCodes = (): Promise => { return apiGet('exchange/region-codes'); }; -export type TPaymentMethod = 'card' | 'bank-transfer' | 'bancontact' | 'sofort'; +export type TPaymentMethod = 'card' | 'bank-transfer' | 'bancontact' | 'sofort' | 'spend'; export type ExchangeDeal = { fee: number; @@ -31,7 +31,7 @@ export type ExchangeDeal = { isHidden: boolean; } -export type TExchangeName = 'moonpay' | 'pocket' | 'btcdirect' | 'btcdirect-otc'; +export type TExchangeName = 'moonpay' | 'pocket' | 'btcdirect' | 'btcdirect-otc' | 'bitrefill'; export type ExchangeDeals = { exchangeName: TExchangeName; @@ -51,7 +51,7 @@ export type ExchangeError = { export type TExchangeDealsResponse = ExchangeDealsList | ExchangeError -export type TExchangeAction = 'buy' | 'sell'; +export type TExchangeAction = 'buy' | 'sell' | 'spend'; export const getExchangeDeals = (action: TExchangeAction, accountCode: AccountCode, region: string): Promise => { return apiGet(`exchange/deals/${action}/${accountCode}?region=${region}`); @@ -107,6 +107,23 @@ export const getBTCDirectInfo = ( return apiGet(`exchange/btcdirect/info/${action}/${code}`); }; +export type TBitrefillInfoResponse = { + success: true; + url: string; + ref: string; + address?: string; +} | { + success: false; + errorMessage: string; +}; + +export const getBitrefillInfo = ( + action: TExchangeAction, + code: string, +): Promise => { + return apiGet(`exchange/bitrefill/info/${action}/${code}`); +}; + export type SupportedExchanges= { exchanges: string[]; }; diff --git a/frontends/web/src/components/terms/bitrefill-terms.tsx b/frontends/web/src/components/terms/bitrefill-terms.tsx new file mode 100644 index 0000000000..faa0cc4422 --- /dev/null +++ b/frontends/web/src/components/terms/bitrefill-terms.tsx @@ -0,0 +1,113 @@ +/** + * Copyright 2025 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useTranslation } from 'react-i18next'; +import { ChangeEvent } from 'react'; +import { Button, Checkbox } from '@/components/forms'; +import { setConfig } from '@/utils/config'; +import { i18n } from '@/i18n/i18n'; +import { A } from '../anchor/anchor'; +import style from './terms.module.css'; +import { isBitcoinOnly } from '@/routes/account/utils'; +import { IAccount } from '@/api/account'; + +type TProps = { + account: IAccount; + onAgreedTerms: () => void; +} + +// Map languages supported by Bitrefill +export const localeMapping: Readonly> = { + en: 'en', + de: 'de', + fr: 'fr', + es: 'es', + it: 'it', + pt: 'pt', + ja: 'ja', + zh: 'zh-Hans' +}; + +export const getBitrefillPrivacyLink = () => { + const hl = i18n.resolvedLanguage ? localeMapping[i18n.resolvedLanguage] : 'en'; + return 'https://www.bitrefill.com/privacy/?hl=' + hl; +}; + +const handleSkipDisclaimer = (e: ChangeEvent) => { + setConfig({ frontend: { skipBitrefillWidgetDisclaimer: e.target.checked } }); +}; + +export const BitrefillTerms = ({ account, onAgreedTerms }: TProps) => { + const { t } = useTranslation(); + + const isBitcoin = isBitcoinOnly(account.coinCode); + // TODO: update with Bitrefill text + return ( +
+
+

+ {t('buy.exchange.infoContent.btcdirectWidget.disclaimer.title', { + context: isBitcoin ? 'bitcoin' : 'crypto' + })} +

+

{t('buy.exchange.infoContent.btcdirectWidget.disclaimer.description')}

+

+ {t('buy.exchange.infoContent.btcdirectWidget.disclaimer.paymentMethods.title')} +

+
    +
  • +

    {t('buy.exchange.infoContent.btcdirectWidget.disclaimer.paymentMethods.buy')}

    +
  • +
+

{t('buy.exchange.infoContent.btcdirectWidget.disclaimer.paymentMethods.note')}

+

+ + {t('buy.exchange.infoContent.btcdirectWidget.learnmore')} + +

+

+ {t('buy.exchange.infoContent.btcdirectWidget.disclaimer.security.title')} +

+

{t('buy.exchange.infoContent.btcdirectWidget.disclaimer.security.description')}

+

+ + {t('buy.exchange.infoContent.btcdirectWidget.disclaimer.security.link')} + +

+

{t('buy.exchange.infoContent.btcdirect.disclaimer.dataProtection.title')}

+

{t('buy.exchange.infoContent.btcdirect.disclaimer.dataProtection.text')}

+

+ + {t('buy.exchange.infoContent.btcdirect.disclaimer.dataProtection.link')} + +

+
+
+ +
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 6730685ba4..d7588ea7b2 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -399,6 +399,12 @@ "upgrade": "Upgrade" }, "buy": { + "bitrefill": { + "error": { + "insufficientFunds": "Insufficient funds to buy gift card." + }, + "transactionNote": "Sent to Bitrefill" + }, "exchange": { "bankTransfer": "Bank transfer", "bestDeal": "Best deal", @@ -553,7 +559,9 @@ "noExchanges": "Sorry, there are no available exchanges in this region.", "region": "Region", "selectRegion": "Not specified", - "sell": "Sell" + "sell": "Sell", + "spend": "Spend", + "spend_bitrefill": "Shop gift cards, phone refills & more" }, "info": { "continue": "Agree and continue", @@ -806,7 +814,7 @@ }, "generic": { "buy": "Buy {{coinCode}}", - "buySell": "Buy & sell", + "buySell": "Marketplace", "buy_bitcoin": "Buy Bitcoin", "buy_crypto": "Buy crypto", "enabled_false": "Disabled", @@ -815,7 +823,9 @@ "receive": "Receive {{coinCode}}", "receive_bitcoin": "Receive Bitcoin", "receive_crypto": "Receive crypto", - "search": "Search…" + "search": "Search…", + "spend_bitcoin": "Spend bitcoin", + "spend_crypto": "Spend crypto" }, "genericError": "An error occurred. If you notice any issues, please restart the application.", "goal": { diff --git a/frontends/web/src/routes/exchange/bitrefill.tsx b/frontends/web/src/routes/exchange/bitrefill.tsx new file mode 100644 index 0000000000..6c6ed61c7d --- /dev/null +++ b/frontends/web/src/routes/exchange/bitrefill.tsx @@ -0,0 +1,239 @@ +/** + * Copyright 2025 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Header } from '@/components/layout'; +import { Spinner } from '@/components/spinner/Spinner'; +import { AccountCode, IAccount, proposeTx, sendTx, TTxInput } from '@/api/account'; +import { findAccount, isBitcoinOnly } from '@/routes/account/utils'; +import { useDarkmode } from '@/hooks/darkmode'; +import { getConfig } from '@/utils/config'; +import style from './iframe.module.css'; +import { i18n } from '@/i18n/i18n'; +import { alertUser } from '@/components/alert/Alert'; +import { parseExternalBtcAmount } from '@/api/coins'; +import { useLoad } from '@/hooks/api'; +import { BitrefillTerms, localeMapping } from '@/components/terms/bitrefill-terms'; +import { getBitrefillInfo } from '@/api/exchanges'; + +// Map coins supported by Bitrefill +const coinMapping: Readonly> = { + btc: 'bitcoin', + tbtc: 'bitcoin', + ltc: 'litecoin', + eth: 'ethereum', + usdt: 'usdt_erc20', + usdc: 'usdc_erc20', +}; + +type TProps = { + accounts: IAccount[]; + code: AccountCode; +}; + +const getURLOrigin = (uri: string): string | null => { + try { + return new URL(uri).origin; + } catch (e) { + return null; + } +}; + +export const Bitrefill = ({ accounts, code }: TProps) => { + const { t } = useTranslation(); + const { isDarkMode } = useDarkmode(); + const account = findAccount(accounts, code); + + const containerRef = useRef(null); + const iframeRef = useRef(null); + const [iframeLoaded, setIframeLoaded] = useState(false); + const [height, setHeight] = useState(0); + const resizeTimerID = useRef | null>(null); + const bitrefillInfo = useLoad(() => getBitrefillInfo('spend', code)); + + const config = useLoad(getConfig); + const [agreedTerms, setAgreedTerms] = useState(false); + + const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); + + useEffect(() => { + if (config) { + setAgreedTerms(config.frontend.skipBitrefillWidgetDisclaimer); + } + }, [config]); + + const onResize = useCallback(() => { + if (resizeTimerID.current) { + clearTimeout(resizeTimerID.current); + } + resizeTimerID.current = setTimeout(() => { + if (containerRef.current) { + setHeight(containerRef.current.offsetHeight); + } + }, 200); + }, []); + + useEffect(() => { + onResize(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + if (resizeTimerID.current) { + clearTimeout(resizeTimerID.current); + } + }; + }, [onResize]); + + const handleMessage = useCallback(async (event: MessageEvent) => { + if ( + !account + || !bitrefillInfo?.success + || ![getURLOrigin(bitrefillInfo.url), 'https://embed.bitrefill.com'].includes(event.origin) + ) { + return; + } + + const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + switch (data.event) { + case 'request-configuration': { + event.source?.postMessage({ + event: 'configuration', + ref: bitrefillInfo.ref, + utm_source: 'BITBOX', + theme: isDarkMode ? 'dark' : 'light', + hl: i18n.resolvedLanguage ? localeMapping[i18n.resolvedLanguage] : 'en', + paymentMethods: account.coinCode ? coinMapping[account.coinCode] : 'bitcoin', + refundAddress: bitrefillInfo.address, + // Option to keep pending payment information longer in session, defaults to 'false' + paymentPending: 'true', + // Option to show payment information in the widget, defaults to 'true' + showPaymentInfo: 'true' + }, { + targetOrigin: event.origin + }); + break; + } + case 'payment_intent': { + // User clicked "Pay" in checkout + const { + invoiceId, + paymentMethod, + paymentAmount, + paymentAddress, + } = data; + + const parsedAmount = await parseExternalBtcAmount(paymentAmount.toString()); + if (!parsedAmount.success) { + alertUser(t('unknownError', { errorMessage: 'Invalid amount' })); + return; + } + // Ensure expected payment method matches account + if (coinMapping[account.coinCode] !== paymentMethod) { + alertUser(t('unknownError', { errorMessage: 'Payment method mismatch' })); + } + + const txInput: TTxInput = { + address: paymentAddress, + amount: parsedAmount.amount, + // Always use highest fee rate for Bitrefill spend + useHighestFee: true, + sendAll: 'no', + selectedUTXOs: [], + paymentRequest: null + }; + + let result = await proposeTx(code, txInput); + if (result.success) { + const txNote = `${t('buy.bitrefill.transactionNote')} (${String(invoiceId)})`; + const sendResult = await sendTx(code, txNote); + if (!sendResult.success && !('aborted' in sendResult)) { + alertUser(t('unknownError', { errorMessage: sendResult.errorMessage })); + } + } else { + if (result.errorCode === 'insufficientFunds') { + alertUser(t('buy.bitrefill.error.' + result.errorCode)); + } else if (result.errorCode) { + alertUser(t('send.error.' + result.errorCode)); + } else { + alertUser(t('genericError')); + } + } + break; + } + default: { + break; + } + } + }, [bitrefillInfo, isDarkMode, account, code, t]); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [handleMessage]); + + if ( + !account + || !config + || !bitrefillInfo?.success + || !bitrefillInfo.address + ) { + return null; + } + + return ( +
+
+
+
+
{t('generic.spend', { context: hasOnlyBTCAccounts ? 'bitcoin' : 'crypto' })}} /> +
+
+ { !agreedTerms ? ( + setAgreedTerms(true)} + /> + ) : ( +
+ {!iframeLoaded && } + { bitrefillInfo?.success && ( +