diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 809dbc15ff..2b72a950f7 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -17,6 +17,7 @@ package handlers import ( "bytes" + "context" "encoding/base64" "encoding/hex" "encoding/json" @@ -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" @@ -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") @@ -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, + } + +} diff --git a/backend/market/swapkit/client.go b/backend/market/swapkit/client.go new file mode 100644 index 0000000000..791c613b2c --- /dev/null +++ b/backend/market/swapkit/client.go @@ -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 +} diff --git a/backend/market/swapkit/methods.go b/backend/market/swapkit/methods.go new file mode 100644 index 0000000000..b85063741e --- /dev/null +++ b/backend/market/swapkit/methods.go @@ -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 +} diff --git a/backend/market/swapkit/types.go b/backend/market/swapkit/types.go new file mode 100644 index 0000000000..205c392b05 --- /dev/null +++ b/backend/market/swapkit/types.go @@ -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"` +} diff --git a/frontends/web/src/api/swap.ts b/frontends/web/src/api/swap.ts new file mode 100644 index 0000000000..6621676cf7 --- /dev/null +++ b/frontends/web/src/api/swap.ts @@ -0,0 +1,66 @@ +/** + * 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 type { FailResponse, SuccessResponse } from './response'; +import type { AccountCode } from './account'; +import { apiGet, apiPost } from '@/utils/request'; +import { subscribeEndpoint, TUnsubscribe } from './subscribe'; + +export type TSwapQuotes = { + quoteId: string; + buyAsset: 'ETH.ETH'; + sellAsset: 'BTC.BTC'; + sellAmount: '0.001'; + // expectedBuyAmount; + // expectedBuyAmountMaxSlippage; + // fees: []; + // routeId: string; + // ... + // expiration + // estimatedTime + // warnings: []; + // targetAddress // so we can show the address in the app so the user can confirm with the one on the device + // memo? +}; + +export const getSwapState = (): Promise => { + return apiGet('swap/state'); +}; + +export const syncSwapState = ( + cb: (state: TSwapQuotes) => void +): TUnsubscribe => { + return subscribeEndpoint('swap/state', cb); +}; + +export type TProposeSwap = { + buyAsset: AccountCode; + sellAmount: string; + sellAsset: AccountCode; +}; + +export const proposeSwap = ( + data: TProposeSwap, +): Promise => { + return apiPost('swap/quote', data); +}; + +type TSwapFailed = FailResponse & { aborted: boolean }; +type TSwapExecutionResult = SuccessResponse | TSwapFailed; + +export const executeSwap = (): Promise => { + return apiPost('swap/execute'); +};