Skip to content

Add initial Bitrefill widget to staging #3343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: staging-bitrefill
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
101 changes: 101 additions & 0 deletions backend/exchanges/bitrefill.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 21 additions & 2 deletions backend/exchanges/exchanges.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
}
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
})
}

Expand All @@ -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
}
Expand All @@ -200,6 +212,7 @@ func GetExchangeDeals(account accounts.Interface, regionCode string, action Exch
IsMoonpayEnabled: true,
IsPocketEnabled: true,
IsBtcDirectEnabled: true,
IsBitrefillEnabled: true,
}
}

Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
Expand Down
23 changes: 20 additions & 3 deletions frontends/web/src/api/exchanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const getExchangeRegionCodes = (): Promise<string[]> => {
return apiGet('exchange/region-codes');
};

export type TPaymentMethod = 'card' | 'bank-transfer' | 'bancontact' | 'sofort';
export type TPaymentMethod = 'card' | 'bank-transfer' | 'bancontact' | 'sofort' | 'spend';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: 'spend' looks a bit wrong here, but I haven't yet checked why it is needed…

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's a bit ugly. I'm basically abusing the payment method field to pass a custom message (which depends on the provider). Strictly speaking "spend" is the payment method here, i.e. the wallet itself pays.


export type ExchangeDeal = {
fee: number;
Expand All @@ -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;
Expand All @@ -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<TExchangeDealsResponse> => {
return apiGet(`exchange/deals/${action}/${accountCode}?region=${region}`);
Expand Down Expand Up @@ -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<TBitrefillInfoResponse> => {
return apiGet(`exchange/bitrefill/info/${action}/${code}`);
};

export type SupportedExchanges= {
exchanges: string[];
};
Expand Down
Loading