From 6ba787f9d2dab90d3f79e92233419c6b943a5013 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Wed, 21 Aug 2024 16:00:18 +0100 Subject: [PATCH] feat: update gateway for v3 Signed-off-by: Gregory Hill --- sdk/package.json | 3 +- sdk/src/gateway.ts | 88 ----------------- sdk/src/gateway/client.ts | 194 ++++++++++++++++++++++++++++++++++++++ sdk/src/gateway/index.ts | 2 + sdk/src/gateway/tokens.ts | 38 ++++++++ sdk/src/gateway/types.ts | 32 +++++++ sdk/src/index.ts | 2 +- 7 files changed, 269 insertions(+), 90 deletions(-) delete mode 100644 sdk/src/gateway.ts create mode 100644 sdk/src/gateway/client.ts create mode 100644 sdk/src/gateway/index.ts create mode 100644 sdk/src/gateway/tokens.ts create mode 100644 sdk/src/gateway/types.ts diff --git a/sdk/package.json b/sdk/package.json index b0d7270c..501a3783 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -32,6 +32,7 @@ "@scure/base": "^1.1.7", "@scure/btc-signer": "^1.3.2", "bitcoin-address-validation": "^2.2.3", - "bitcoinjs-lib": "^6.1.6" + "bitcoinjs-lib": "^6.1.6", + "ethers": "^6.13.2" } } \ No newline at end of file diff --git a/sdk/src/gateway.ts b/sdk/src/gateway.ts deleted file mode 100644 index d07fdcd6..00000000 --- a/sdk/src/gateway.ts +++ /dev/null @@ -1,88 +0,0 @@ -export type EvmAddress = string; - -type GatewayQuote = { - onramp_address: EvmAddress; - dust_threshold: number; - satoshis: number; - fee: number; - gratuity: string; - bitcoin_address: string; - tx_proof_difficulty_factor: number; -}; - -type GatewayOrderResponse = { - onramp_address: EvmAddress; - token_address: EvmAddress; - txid: string; - status: boolean; - timestamp: number; - tokens: string; - satoshis: number; - fee: number; - tx_proof_difficulty_factor: number; -}; - -export class GatewayApiClient { - private baseUrl: string; - - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } - - async getQuote(address: string, atomicAmount?: number | string): Promise { - const response = await fetch(`${this.baseUrl}/quote/${address}/${atomicAmount || ''}`, { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - } - }); - - return await response.json(); - } - - // TODO: add error handling - async createOrder(contractAddress: string, userAddress: EvmAddress, atomicAmount: number | string): Promise { - const response = await fetch(`${this.baseUrl}/order`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify({ onramp_address: contractAddress, user_address: userAddress, satoshis: atomicAmount }) - }); - - if (!response.ok) { - throw new Error('Failed to create order'); - } - - return await response.json(); - } - - async updateOrder(id: string, tx: string) { - const response = await fetch(`${this.baseUrl}/order/${id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify({ bitcoin_tx: tx }) - }); - - if (!response.ok) { - throw new Error('Failed to update order'); - } - } - - async getOrders(userAddress: EvmAddress): Promise { - const response = await fetch(`${this.baseUrl}/orders/${userAddress}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - } - }); - - return response.json(); - } -} - diff --git a/sdk/src/gateway/client.ts b/sdk/src/gateway/client.ts new file mode 100644 index 00000000..7633c7c1 --- /dev/null +++ b/sdk/src/gateway/client.ts @@ -0,0 +1,194 @@ +import { ethers, AbiCoder } from "ethers"; +import { GatewayQuoteParams } from "./types"; +import { TOKENS } from "./tokens"; + +type EvmAddress = string; + +type GatewayQuote = { + gatewayAddress: EvmAddress; + dustThreshold: number; + satoshis: number; + fee: number; + gratuity: string; + bitcoinAddress: string; + txProofDifficultyFactor: number; + strategyAddress: EvmAddress | null, +}; + +type GatewayCreateOrderRequest = { + gatewayAddress: EvmAddress, + strategyAddress: EvmAddress | null, + satsToConvertToEth: number, + userAddress: EvmAddress, + gatewayExtraData: string | null, + strategyExtraData: string | null, + satoshis: number, +}; + +type GatewayOrderResponse = { + gatewayAddress: EvmAddress; + tokenAddress: EvmAddress; + txid: string; + status: boolean; + timestamp: number; + tokens: string; + satoshis: number; + fee: number; + txProofDifficultyFactor: number; +}; + +type GatewayCreateOrderResponse = { + uuid: string, + opReturnHash: string, +}; + +type GatewayStartOrderResult = GatewayCreateOrderResponse & { + bitcoinAddress: string, + satoshis: number; +}; + +/** + * Base url for the mainnet Gateway API. + * @default "https://gateway-api-mainnet.gobob.xyz" + */ +export const MAINNET_GATEWAY_BASE_URL = "https://gateway-api-mainnet.gobob.xyz"; + +/** + * Base url for the testnet Gateway API. + * @default "https://gateway-api-testnet.gobob.xyz" + */ +export const TESTNET_GATEWAY_BASE_URL = "https://gateway-api-testnet.gobob.xyz"; + +export class GatewayApiClient { + private baseUrl: string; + + constructor(networkOrUrl: string = "mainnet") { + switch (networkOrUrl) { + case "mainnet" || "bob": + this.baseUrl = MAINNET_GATEWAY_BASE_URL; + break; + case "testnet" || "bob-sepolia": + this.baseUrl = TESTNET_GATEWAY_BASE_URL; + break; + default: + this.baseUrl = networkOrUrl; + } + } + + async getQuote(params: GatewayQuoteParams): Promise { + const isMainnet = params.toChain == "bob" || params.toChain == 60808; + + let outputToken = ""; + if (params.toToken.startsWith("0x")) { + outputToken = params.toToken; + } else if (params.toToken in TOKENS) { + outputToken = isMainnet ? TOKENS[params.toToken].bob : TOKENS[params.toToken].bob_sepolia; + } else { + throw new Error('Unknown output token'); + } + + const atomicAmount = params.amount; + const response = await fetch(`${this.baseUrl}/quote/${outputToken}/${atomicAmount || ''}`, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + }); + + return await response.json(); + } + + // TODO: add error handling + async startOrder(gatewayQuote: GatewayQuote, params: GatewayQuoteParams): Promise { + const request: GatewayCreateOrderRequest = { + gatewayAddress: gatewayQuote.gatewayAddress, + strategyAddress: gatewayQuote.strategyAddress, + satsToConvertToEth: params.gasRefill, + userAddress: params.toUserAddress, + // TODO: figure out how to get extra data + gatewayExtraData: null, + strategyExtraData: null, + satoshis: gatewayQuote.satoshis, + }; + + const response = await fetch(`${this.baseUrl}/order`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + throw new Error('Failed to create order'); + } + + const data: GatewayCreateOrderResponse = await response.json(); + // NOTE: could remove this check but good for sanity + if (data.opReturnHash != calculateOpReturnHash(request)) { + throw new Error('Invalid OP_RETURN hash'); + } + + return { + uuid: data.uuid, + opReturnHash: data.opReturnHash, + bitcoinAddress: gatewayQuote.bitcoinAddress, + satoshis: gatewayQuote.satoshis, + } + } + + async finalizeOrder(orderUuid: string, bitcoinTx: string) { + const response = await fetch(`${this.baseUrl}/order/${orderUuid}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ bitcoinTx: bitcoinTx }) + }); + + if (!response.ok) { + throw new Error('Failed to update order'); + } + } + + async getOrders(userAddress: EvmAddress): Promise { + const response = await fetch(`${this.baseUrl}/orders/${userAddress}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + }); + + return response.json(); + } + + async getTokens(): Promise { + const response = await fetch(`${this.baseUrl}/tokens`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + }); + + return response.json(); + } +} + +function calculateOpReturnHash(req: GatewayCreateOrderRequest) { + const abiCoder = new AbiCoder(); + return ethers.keccak256(abiCoder.encode( + ["address", "address", "uint256", "address", "bytes", "bytes"], + [ + req.gatewayAddress, + req.strategyAddress || ethers.ZeroAddress, + req.satsToConvertToEth, + req.userAddress, + req.gatewayExtraData, + req.strategyExtraData + ] + )) +} \ No newline at end of file diff --git a/sdk/src/gateway/index.ts b/sdk/src/gateway/index.ts new file mode 100644 index 00000000..62ebe962 --- /dev/null +++ b/sdk/src/gateway/index.ts @@ -0,0 +1,2 @@ +export { GatewayApiClient } from "./client"; +export { GatewayQuoteParams } from "./types"; \ No newline at end of file diff --git a/sdk/src/gateway/tokens.ts b/sdk/src/gateway/tokens.ts new file mode 100644 index 00000000..e69588d1 --- /dev/null +++ b/sdk/src/gateway/tokens.ts @@ -0,0 +1,38 @@ +type Token = { + name: string, + bob: string, + bob_sepolia: string +} + +export const TOKENS: { [key: string]: Token } = { + "tBTC": { + name: "tBTC v2", + bob: "0xBBa2eF945D523C4e2608C9E1214C2Cc64D4fc2e2", + bob_sepolia: "0x6744bAbDf02DCF578EA173A9F0637771A9e1c4d0", + }, + "WBTC": { + name: "Wrapped BTC", + bob: "0x03C7054BCB39f7b2e5B2c7AcB37583e32D70Cfa3", + bob_sepolia: "0xe51e40e15e6e1496a0981f90Ca1D632545bdB519", + }, + "sbtBTC": { + name: "sb tBTC v2", + bob: "0x2925dF9Eb2092B53B06A06353A7249aF3a8B139e", + bob_sepolia: "", + }, + "sbWBTC": { + name: "sb Wrapped BTC", + bob: "0x5c46D274ed8AbCAe2964B63c0360ad3Ccc384dAa", + bob_sepolia: "", + }, + "seTBTC": { + name: "Segment TBTC", + bob: "0xD30288EA9873f376016A0250433b7eA375676077", + bob_sepolia: "", + }, + "seWBTC": { + name: "Segment WBTC", + bob: "0x6265C05158f672016B771D6Fb7422823ed2CbcDd", + bob_sepolia: "", + } +} \ No newline at end of file diff --git a/sdk/src/gateway/types.ts b/sdk/src/gateway/types.ts new file mode 100644 index 00000000..671a2795 --- /dev/null +++ b/sdk/src/gateway/types.ts @@ -0,0 +1,32 @@ +type ChainSlug = string | number; +type TokenSymbol = string; + +export interface GatewayQuoteParams { + /** @description Source chain slug or ID */ + fromChain: ChainSlug; + /** @description Destination chain slug or ID */ + toChain: ChainSlug; + /** @description Token symbol or address on source chain */ + fromToken: TokenSymbol; + /** @description Token symbol or address on destination chain */ + toToken: TokenSymbol; + /** @description Wallet address on source chain */ + fromUserAddress: string; + /** @description Wallet address on destination chain */ + toUserAddress: string; + /** @description Amount of tokens to send from the source chain */ + amount: number | string; + + /** @description Maximum slippage percentage between 0.01 and 0.03 (Default: 0.03) */ + maxSlippage?: number; + + /** @description Unique affiliate ID for tracking */ + affiliateId?: string; + /** @description Optionally filter the type of routes returned */ + type?: 'swap' | 'deposit' | 'withdraw' | 'claim'; + /** @description The percentage of fee charged by partners in Basis Points (BPS) units. This will override the default fee rate configured via platform. 1 BPS = 0.01%. The maximum value is 1000 (which equals 10%). The minimum value is 1 (which equals 0.01%). */ + fee?: number; + + /** @description Amount of satoshis to swap for ETH */ + gasRefill?: number, +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a7720e77..3fdbd8f6 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -4,4 +4,4 @@ export * from "./utils"; export * from "./ordinals"; export * from "./helpers"; export * from "./wallet"; -export * from "./gateway"; \ No newline at end of file +export * as GatewaySDK from "./gateway"; \ No newline at end of file