Skip to content
Draft
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
96 changes: 96 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package handlers

import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -49,6 +50,7 @@ import (
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/devices/device"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/keystore"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/market"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/market/swapkit"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/rates"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/versioninfo"
utilConfig "github.com/BitBoxSwiss/bitbox-wallet-app/util/config"
Expand Down Expand Up @@ -247,6 +249,9 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/market/vendors/{code}", handlers.getMarketVendors).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/btcdirect-otc/supported/{code}", handlers.getMarketBtcDirectOTCSupported).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/btcdirect/info/{action}/{code}", handlers.getMarketBtcDirectInfo).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/quote", handlers.getSwapkitQuote).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/execute", handlers.swapkitSwap).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/track", handlers.swapkitTrack).Methods("GET")
getAPIRouter(apiRouter)("/market/moonpay/buy-info/{code}", handlers.getMarketMoonpayBuyInfo).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/pocket/api-url/{action}", handlers.getMarketPocketURL).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/pocket/verify-address", handlers.postPocketWidgetVerifyAddress).Methods("POST")
Expand Down Expand Up @@ -1664,3 +1669,94 @@ func (handlers *Handlers) postConnectKeystore(r *http.Request) interface{} {
_, err := handlers.backend.ConnectKeystore([]byte(request.RootFingerprint))
return response{Success: err == nil}
}

func (handlers *Handlers) getSwapkitQuote(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Quote *swapkit.QuoteResponse `json:"quote,omitempty"`
}

var request swapkit.QuoteRequest

if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
return result{Success: false, Error: err.Error()}
}

s := swapkit.NewClient("0722e09f-9d3f-4817-a870-069848d03ee9")

quoteResponse, err := s.Quote(context.Background(), &request)
if err != nil {
return result{
Success: false,
Error: err.Error(),
}
}

res := result{
Success: quoteResponse.Error != "",
Error: quoteResponse.Error, // Surface the response error to the top-level
}
if res.Success {
res.Quote = quoteResponse
}
return res
}

func (handlers *Handlers) swapkitSwap(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Swap *swapkit.SwapResponse `json:"swap,omitempty"`
}

var request swapkit.SwapRequest

if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
return result{Success: false, Error: err.Error()}
}

s := swapkit.NewClient("0722e09f-9d3f-4817-a870-069848d03ee9")
swapResponse, err := s.Swap(context.Background(), &request)
if err != nil {
return result{
Success: false,
Error: err.Error(),
}
}

return result{
Success: true,
Swap: swapResponse,
}

}

func (handlers *Handlers) swapkitTrack(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Track *swapkit.TrackResponse `json:"swap,omitempty"`
}

var request swapkit.TrackRequest

if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
return result{Success: false, Error: err.Error()}
}

s := swapkit.NewClient("0722e09f-9d3f-4817-a870-069848d03ee9")
trackResponse, err := s.Track(context.Background(), &request)
if err != nil {
return result{
Success: false,
Error: err.Error(),
}
}

return result{
Success: true,
Track: trackResponse,
}

}
77 changes: 77 additions & 0 deletions backend/market/swapkit/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package swapkit

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/BitBoxSwiss/bitbox-wallet-app/util/logging"
"github.com/sirupsen/logrus"
)

// Client is a SwapKit client that can be used to interact with
// SwapKit API.
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
log *logrus.Entry
}

// NewClient returns a new swapkit client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
baseURL: "https://api.swapkit.dev",
httpClient: &http.Client{
Timeout: 20 * time.Second,
},
log: logging.Get().WithGroup("swapkit"),
}
}

func (c *Client) post(ctx context.Context, path string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(b))
if err != nil {
return fmt.Errorf("create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("x-api-key", c.apiKey)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http error: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.log.WithError(err).Error("Error closing response body")
}
}()

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}

if resp.StatusCode >= 400 {
return fmt.Errorf("swapkit error %d: %s", resp.StatusCode, string(bodyBytes))
}

if err := json.Unmarshal(bodyBytes, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}

return nil
}
32 changes: 32 additions & 0 deletions backend/market/swapkit/methods.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package swapkit

import (
"context"
)

// Quote performs a SwapKit V3 quote request.
func (c *Client) Quote(ctx context.Context, req *QuoteRequest) (*QuoteResponse, error) {
var resp QuoteResponse
if err := c.post(ctx, "/v3/quote", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// Swap performs a SwapKit V3 swap request.
func (c *Client) Swap(ctx context.Context, req *SwapRequest) (*SwapResponse, error) {
var resp SwapResponse
if err := c.post(ctx, "/v3/swap", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// Track performs a SwapKit track request.
func (c *Client) Track(ctx context.Context, req *TrackRequest) (*TrackResponse, error) {
var resp TrackResponse
if err := c.post(ctx, "/track", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
159 changes: 159 additions & 0 deletions backend/market/swapkit/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package swapkit

import "encoding/json"

// QuoteRequest represents a request to swapkit for a swap quote.
type QuoteRequest struct {
SellAsset string `json:"sellAsset"`
BuyAsset string `json:"buyAsset"`
SellAmount string `json:"sellAmount"`
Providers []string `json:"providers,omitempty"`
Slippage *string `json:"slippage,omitempty"`
AffiliateFee *int `json:"affiliateFee,omitempty"`
CfBoost *bool `json:"cfBoost,omitempty"`
MaxExecutionTime *int `json:"maxExecutionTime,omitempty"`
}

// SwapRequest represents a request to swakip to execute a swap.
type SwapRequest struct {
RouteID string `json:"routeId"`
SourceAddress string `json:"sourceAddress"`
DestinationAddress string `json:"destinationAddress"`
DisableBalanceCheck *bool `json:"disableBalanceCheck,omitempty"`
DisableEstimate *bool `json:"disableEstimate,omitempty"`
AllowSmartContractSender *bool `json:"allowSmartContractSender,omitempty"`
AllowSmartContractReceiver *bool `json:"allowSmartContractReceiver,omitempty"`
DisableSecurityChecks *bool `json:"disableSecurityChecks,omitempty"`
OverrideSlippage *bool `json:"overrideSlippage,omitempty"`
}

// QuoteResponse contains info about swaps' quotes.
type QuoteResponse struct {
QuoteID string `json:"quoteId"`
Routes []QuoteRoute `json:"routes"`
ProviderErrors []ProviderError `json:"providerErrors,omitempty"`
Error string `json:"error,omitempty"`
}

// SwapResponse is the answer provided by swapkit when asking to execute a swap.
type SwapResponse struct {
RouteID string `json:"routeId"`
Providers []string `json:"providers"`
SellAsset string `json:"sellAsset"`
BuyAsset string `json:"buyAsset"`
SellAmount string `json:"sellAmount"`
ExpectedBuyAmount string `json:"expectedBuyAmount"`
ExpectedBuyAmountMaxSlippage string `json:"expectedBuyAmountMaxSlippage"`
Tx json.RawMessage `json:"tx"`
ApprovalTx json.RawMessage `json:"approvalTx,omitempty"`
TargetAddress string `json:"targetAddress"`
Memo string `json:"memo,omitempty"`
Fees []Fee `json:"fees"`
EstimatedTime json.RawMessage `json:"estimatedTime,omitempty"`
TotalSlippageBps int `json:"totalSlippageBps"`
Legs json.RawMessage `json:"legs,omitempty"`
Warnings json.RawMessage `json:"warnings,omitempty"`
Meta json.RawMessage `json:"meta,omitempty"`
NextActions []NextAction `json:"nextActions,omitempty"`
}

// QuoteRoute represent a single route to swap coins from
// SellAsset to BuyAsset.
type QuoteRoute struct {
RouteID string `json:"routeId"`
Providers []string `json:"providers"`
SellAsset string `json:"sellAsset"`
BuyAsset string `json:"buyAsset"`
SellAmount string `json:"sellAmount"`
ExpectedBuyAmount string `json:"expectedBuyAmount"`
ExpectedBuyAmountMaxSlippage string `json:"expectedBuyAmountMaxSlippage"`

// tx object varies by chain:
// - EVM → Ethers v6 transaction
// - UTXO → base64 PSBT
Tx json.RawMessage `json:"tx"`

ApprovalTx json.RawMessage `json:"approvalTx,omitempty"`

TargetAddress string `json:"targetAddress"`
Memo string `json:"memo,omitempty"`
Fees []Fee `json:"fees"`
EstimatedTime json.RawMessage `json:"estimatedTime,omitempty"`
TotalSlippageBps float64 `json:"totalSlippageBps"`
Legs json.RawMessage `json:"legs,omitempty"`
Warnings json.RawMessage `json:"warnings,omitempty"`
Meta json.RawMessage `json:"meta,omitempty"`

NextActions []NextAction `json:"nextActions,omitempty"`
}

// Fee represents one of the possible fees for executing a swap.
type Fee struct {
Type string `json:"type"`
Amount string `json:"amount"`
Asset string `json:"asset"`
Chain string `json:"chain"`
Protocol string `json:"protocol"`
}

// NextAction is provided by swap as a convenience field to suggest what
// the next step in a swap workflow could be.
type NextAction struct {
Method string `json:"method"`
URL string `json:"url"`
Payload json.RawMessage `json:"payload,omitempty"`
}

// ProviderError contains errors specific to a Provider
// (e.g. some provided will only provide quotes for sell amounts
// higher than a certain treshold).
type ProviderError struct {
Provider string `json:"provider"`
ErrorCode string `json:"errorCode"`
Message string `json:"message"`
}

// TrackRequest is used to query swapkit fo track the status of a swap.
type TrackRequest struct {
Hash string `json:"hash"`
ChainID string `json:"chainId"`
}

// TrackResponse represents SwapKit's response for a tracked transaction
type TrackResponse struct {
ChainID string `json:"chainId"`
Hash string `json:"hash"`
Block int64 `json:"block"`
Type string `json:"type"` // swap, token_transfer, etc.
Status string `json:"status"` // not_started, pending, swapping, completed, refunded, failed, unknown
TrackingStatus string `json:"trackingStatus"` // deprecated, status is enough
FromAsset string `json:"fromAsset"`
FromAmount string `json:"fromAmount"`
FromAddress string `json:"fromAddress"`
ToAsset string `json:"toAsset"`
ToAmount string `json:"toAmount"`
ToAddress string `json:"toAddress"`
FinalisedAt int64 `json:"finalisedAt"` // UNIX timestamp
Meta json.RawMessage `json:"meta,omitempty"` // provider, images, etc.
Payload json.RawMessage `json:"payload,omitempty"` // transaction-specific info
Legs []TrackLeg `json:"legs,omitempty"` // individual steps in transaction
}

// TrackLeg represents a step of the transaction
type TrackLeg struct {
ChainID string `json:"chainId"`
Hash string `json:"hash"`
Block int64 `json:"block"`
Type string `json:"type"`
Status string `json:"status"`
TrackingStatus string `json:"trackingStatus"`
FromAsset string `json:"fromAsset"`
FromAmount string `json:"fromAmount"`
FromAddress string `json:"fromAddress"`
ToAsset string `json:"toAsset"`
ToAmount string `json:"toAmount"`
ToAddress string `json:"toAddress"`
FinalisedAt int64 `json:"finalisedAt"`
Meta json.RawMessage `json:"meta,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
Loading