diff --git a/packages/swap/.env.example b/packages/swap/.env.example new file mode 100644 index 000000000..71af26f54 --- /dev/null +++ b/packages/swap/.env.example @@ -0,0 +1 @@ +PROPELLER_HEADS_API_KEY= diff --git a/packages/swap/src/configs.ts b/packages/swap/src/configs.ts index 90405a66b..f665bb65c 100644 --- a/packages/swap/src/configs.ts +++ b/packages/swap/src/configs.ts @@ -2,7 +2,14 @@ import { NetworkNames } from "@enkryptcom/types"; import { numberToHex } from "web3-utils"; import { ProviderName, SupportedNetworkName, WalletIdentifier } from "./types"; -const FEE_CONFIGS = { +type ProviderFeeConfig = Record< + WalletIdentifier, + { referrer: string; fee: number } +>; + +type ProvidersFeeConfigs = Partial>; + +const FEE_CONFIGS: ProvidersFeeConfigs = { [ProviderName.oneInch]: { [WalletIdentifier.enkrypt]: { referrer: "0x551d9d8eb02e1c713009da8f7c194870d651054a", @@ -43,6 +50,17 @@ const FEE_CONFIGS = { fee: 0.025, }, }, + // TODO: update referrer addresses + [ProviderName.propellerHeads]: { + [WalletIdentifier.enkrypt]: { + referrer: "0xabe295bac4b5bce0edcf42d180a3a952ef718b9e", + fee: 0.00875, + }, + [WalletIdentifier.mew]: { + referrer: "0x48ae878bf9f752ee65679c017e32e4cafac51696", + fee: 0.025, + }, + }, }; const TOKEN_LISTS: { diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index 613b3200e..1aa365db9 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -7,6 +7,7 @@ import Paraswap from "./providers/paraswap"; import Changelly from "./providers/changelly"; import ZeroX from "./providers/zerox"; import Rango from "./providers/rango"; +import PropellerHeads from "./providers/propeller-heads"; import NetworkDetails, { isSupportedNetwork, getSupportedNetworks, @@ -57,9 +58,18 @@ class Swap extends EventEmitter { | typeof Paraswap | typeof ZeroX | typeof Rango + | typeof Rango + | typeof PropellerHeads )[]; - private providers: (OneInch | Changelly | Paraswap | ZeroX | Rango)[]; + private providers: ( + | OneInch + | Changelly + | Paraswap + | ZeroX + | Rango + | PropellerHeads + )[]; private tokenList: FromTokenType; @@ -81,7 +91,14 @@ class Swap extends EventEmitter { }; this.api = options.api; this.walletId = options.walletIdentifier; - this.providerClasses = [OneInch, Paraswap, Changelly, ZeroX, Rango]; + this.providerClasses = [ + OneInch, + Paraswap, + Changelly, + ZeroX, + Rango, + PropellerHeads, + ]; this.topTokenInfo = { contractsToId: {}, topTokens: {}, diff --git a/packages/swap/src/providers/propeller-heads/index.ts b/packages/swap/src/providers/propeller-heads/index.ts new file mode 100644 index 000000000..706e37edf --- /dev/null +++ b/packages/swap/src/providers/propeller-heads/index.ts @@ -0,0 +1,286 @@ +import dotenv from "dotenv"; +import Web3Eth from "web3-eth"; +import { numberToHex, stringToHex, toBN } from "web3-utils"; +import { + EVMTransaction, + getQuoteOptions, + MinMaxResponse, + ProviderClass, + ProviderFromTokenResponse, + ProviderName, + ProviderQuoteResponse, + ProviderSwapResponse, + ProviderToTokenResponse, + QuoteMetaOptions, + StatusOptions, + StatusOptionsResponse, + SupportedNetworkName, + SwapQuote, + SwapTransaction, + TokenType, + TransactionStatus, + TransactionType, +} from "../../types"; +import { + DEFAULT_SLIPPAGE, + FEE_CONFIGS, + GAS_LIMITS, + NATIVE_TOKEN_ADDRESS, +} from "../../configs"; +import { + PropellerHeadsResponseType, + PropellerHeadsSwapResponse, +} from "./types"; +import { + getAllowanceTransactions, + TOKEN_AMOUNT_INFINITY_AND_BEYOND, +} from "../../utils/approvals"; +import { isEVMAddress } from "../../utils/common"; + +dotenv.config(); + +const { PROPELLER_HEADS_API_KEY } = process.env; + +if (!PROPELLER_HEADS_API_KEY) { + throw new Error("PROPELLER_HEADS_API_KEY is not set"); +} + +const supportedNetworks: { + [key in SupportedNetworkName]?: { approvalAddress: string; chainId: string }; +} = { + [SupportedNetworkName.Ethereum]: { + approvalAddress: "0x14f2b6ca0324cd2B013aD02a7D85541d215e2906", + chainId: "1", + }, + [SupportedNetworkName.Zksync]: { + approvalAddress: "0xe832e655E4C3c36b2be5256915ECF8536a642f59", + chainId: "324", + }, + [SupportedNetworkName.Starknet]: { + approvalAddress: + "0x060b1a6a696cbd77df0b6be6a2a951cf0fc7b951304a9371eac2f5d05a77357f", + chainId: "0x534e5f4d41494e", + }, +}; + +const NetworkNamesToSupportedProppellerHeadsBlockchains: Partial< + Record +> = { + [SupportedNetworkName.Ethereum]: "ethereum", + [SupportedNetworkName.Zksync]: "zksync", + [SupportedNetworkName.Starknet]: "starknet", +}; + +const BASE_URL = "https://api.propellerheads.xyz/partner/v2"; + +class PropellerHeads extends ProviderClass { + tokenList: TokenType[]; + + network: SupportedNetworkName; + + web3eth: Web3Eth; + + name: ProviderName; + + fromTokens: ProviderFromTokenResponse; + + toTokens: ProviderToTokenResponse; + + constructor(web3eth: Web3Eth, network: SupportedNetworkName) { + super(web3eth, network); + this.name = ProviderName.propellerHeads; + this.network = network; + this.web3eth = web3eth; + this.tokenList = []; + this.fromTokens = {}; + this.toTokens = {}; + } + + init(tokenList: TokenType[]): Promise { + if (!PropellerHeads.isSupported(this.network)) return; + tokenList.forEach((t) => { + this.fromTokens[t.address] = t; + if (!this.toTokens[this.network]) this.toTokens[this.network] = {}; + this.toTokens[this.network][t.address] = { + ...t, + networkInfo: { + name: this.network, + isAddress: (address: string) => + Promise.resolve(isEVMAddress(address)), + }, + }; + }); + } + + static isSupported(network: SupportedNetworkName) { + return Object.keys(supportedNetworks).includes( + network as unknown as string + ); + } + + getFromTokens() { + return this.fromTokens; + } + + getToTokens() { + return this.toTokens; + } + + getMinMaxAmount(): Promise { + return Promise.resolve({ + minimumFrom: toBN("1"), + maximumFrom: toBN(TOKEN_AMOUNT_INFINITY_AND_BEYOND), + minimumTo: toBN("1"), + maximumTo: toBN(TOKEN_AMOUNT_INFINITY_AND_BEYOND), + }); + } + + private getPropellerHeadsSwap( + options: getQuoteOptions, + meta: QuoteMetaOptions + ): Promise { + if ( + !PropellerHeads.isSupported( + options.toToken.networkInfo.name as SupportedNetworkName + ) || + this.network !== options.toToken.networkInfo.name + ) + return Promise.resolve(null); + + if (options.fromAddress.toLowerCase() !== options.toAddress.toLowerCase()) + return Promise.resolve(null); + + const body = { + orders: [ + { + sell_token: options.fromToken.address, + buy_token: options.toToken.address, + sell_amount: options.amount.toString(), + origin_address: options.fromAddress, + }, + ], + }; + + const params = new URLSearchParams({ + blockchain: + NetworkNamesToSupportedProppellerHeadsBlockchains[this.network], + }); + + return fetch(`${BASE_URL}/solver/quote?${params.toString()}`, { + method: "POST", + body: JSON.stringify(body), + headers: { + "x-api-key": PROPELLER_HEADS_API_KEY, + accept: "application/json", + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then(async (response: PropellerHeadsResponseType) => { + const transactions: SwapTransaction[] = []; + const transactionType: TransactionType = + this.network === SupportedNetworkName.Starknet + ? TransactionType.generic + : TransactionType.evm; + + if (options.fromToken.address !== NATIVE_TOKEN_ADDRESS) { + const approvalTxs = await getAllowanceTransactions({ + infinityApproval: meta.infiniteApproval, + spender: supportedNetworks[this.network].approvalAddress, + web3eth: this.web3eth, + amount: options.amount, + fromAddress: options.fromAddress, + fromToken: options.fromToken, + }); + transactions.push(...approvalTxs); + } + transactions.push({ + from: options.fromAddress, + gasLimit: GAS_LIMITS.swap, + to: options.fromAddress, + value: numberToHex(options.amount), + data: stringToHex(JSON.stringify(response)), + type: transactionType, + }); + + return { + transactions, + toTokenAmount: toBN(response.quotes[0].buy_amount), + fromTokenAmount: toBN(response.quotes[0].sell_amount), + }; + }) + .catch((e) => { + console.error(e); + return null; + }); + } + + getQuote( + options: getQuoteOptions, + meta: QuoteMetaOptions + ): Promise { + return this.getPropellerHeadsSwap(options, meta).then(async (res) => { + if (!res) return null; + const response: ProviderQuoteResponse = { + fromTokenAmount: res.fromTokenAmount, + additionalNativeFees: toBN(0), + toTokenAmount: res.toTokenAmount, + provider: this.name, + quote: { + meta, + options, + provider: this.name, + }, + totalGaslimit: res.transactions.reduce( + (total: number, curVal: EVMTransaction) => + total + toBN(curVal.gasLimit).toNumber(), + 0 + ), + minMax: await this.getMinMaxAmount(), + }; + return response; + }); + } + + getSwap(quote: SwapQuote): Promise { + return this.getPropellerHeadsSwap(quote.options, quote.meta).then((res) => { + if (!res) return null; + const feeConfig = + FEE_CONFIGS[this.name][quote.meta.walletIdentifier].fee || 0; + const response: ProviderSwapResponse = { + fromTokenAmount: res.fromTokenAmount, + additionalNativeFees: toBN(0), + provider: this.name, + toTokenAmount: res.toTokenAmount, + transactions: res.transactions, + slippage: quote.meta.slippage || DEFAULT_SLIPPAGE, + fee: feeConfig * 100, + getStatusObject: async ( + options: StatusOptions + ): Promise => ({ + options, + provider: this.name, + }), + }; + return response; + }); + } + + getStatus(options: StatusOptions): Promise { + const promises = options.transactionHashes.map((hash) => + this.web3eth.getTransactionReceipt(hash) + ); + return Promise.all(promises).then((receipts) => { + // eslint-disable-next-line no-restricted-syntax + for (const receipt of receipts) { + if (!receipt || (receipt && !receipt.blockNumber)) { + return TransactionStatus.pending; + } + if (receipt && !receipt.status) return TransactionStatus.failed; + } + return TransactionStatus.success; + }); + } +} + +export default PropellerHeads; diff --git a/packages/swap/src/providers/propeller-heads/types.ts b/packages/swap/src/providers/propeller-heads/types.ts new file mode 100644 index 000000000..509fa731c --- /dev/null +++ b/packages/swap/src/providers/propeller-heads/types.ts @@ -0,0 +1,34 @@ +import { BN, EVMTransaction } from "../../types"; + +export interface PropellerHeadsResponseType { + request_id: string; + quotes: [ + { + sell_token: string; + buy_token: string; + sell_amount: string; + buy_amount: string; + external_id: string; + } + ]; + gas: number; + buy_tokens: [ + { + symbol: string; + decimals: string; + address: string; + } + ]; + sell_tokens: [ + { + symbol: string; + decimals: string; + address: string; + } + ]; +} +export interface PropellerHeadsSwapResponse { + transactions: EVMTransaction[]; + toTokenAmount: BN; + fromTokenAmount: BN; +} diff --git a/packages/swap/src/types/index.ts b/packages/swap/src/types/index.ts index ac1e38b84..c98fbb9eb 100644 --- a/packages/swap/src/types/index.ts +++ b/packages/swap/src/types/index.ts @@ -27,6 +27,7 @@ export enum SupportedNetworkName { Klaytn = NetworkNames.Klaytn, Aurora = NetworkNames.Aurora, Zksync = NetworkNames.ZkSync, + Starknet = NetworkNames.Starknet, } // eslint-disable-next-line no-shadow @@ -118,6 +119,7 @@ export enum ProviderName { zerox = "zerox", changelly = "changelly", rango = "rango", + propellerHeads = "propellerHeads", } // eslint-disable-next-line no-shadow diff --git a/packages/swap/tests/propeller-heads.test.ts b/packages/swap/tests/propeller-heads.test.ts new file mode 100644 index 000000000..6b70e055d --- /dev/null +++ b/packages/swap/tests/propeller-heads.test.ts @@ -0,0 +1,77 @@ +import { expect } from "chai"; +import { toBN } from "web3-utils"; +import Web3Eth from "web3-eth"; +import PropellerHeads from "../src/providers/propeller-heads"; +import { + ProviderName, + SupportedNetworkName, + WalletIdentifier, +} from "../src/types"; +import { fromTokenWBTC, toToken, nodeURL } from "./fixtures/mainnet/configs"; + +describe("Propeller Heads Provider", () => { + const web3eth = new Web3Eth(nodeURL); + const localAmount = toBN("100000000"); + + it("should return a quote with infinite approval", async () => { + const propellerHeads = new PropellerHeads( + web3eth, + SupportedNetworkName.Ethereum + ); + + const quote = await propellerHeads.getQuote( + { + amount: localAmount, + fromAddress: "0x9ff58f4ffb29fa2266ab25e75e2a8b3503311656", // aave + fromToken: fromTokenWBTC, + toToken, + toAddress: "0x9ff58f4ffb29fa2266ab25e75e2a8b3503311656", // aave + }, + { infiniteApproval: true, walletIdentifier: WalletIdentifier.enkrypt } + ); + + expect(quote?.provider).to.be.eq(ProviderName.propellerHeads); + expect(quote?.quote.meta.infiniteApproval).to.be.eq(true); + expect(quote?.quote.meta.walletIdentifier).to.be.eq( + WalletIdentifier.enkrypt + ); + expect(typeof quote?.fromTokenAmount).to.be.eq(typeof localAmount); + expect(quote?.toTokenAmount.gtn(0)).to.be.eq(true); + + const swap = await propellerHeads.getSwap(quote!.quote); + + expect(swap?.transactions.length).to.be.eq(2); + expect(swap?.transactions[0].to).to.be.eq(fromTokenWBTC.address); + }).timeout(25000); + + it("should return a quote with non-infinite approval", async () => { + const propellerHeads = new PropellerHeads( + web3eth, + SupportedNetworkName.Ethereum + ); + + const quote = await propellerHeads.getQuote( + { + amount: localAmount, + fromAddress: "0x9ff58f4ffb29fa2266ab25e75e2a8b3503311656", // aave + fromToken: fromTokenWBTC, + toToken, + toAddress: "0x9ff58f4ffb29fa2266ab25e75e2a8b3503311656", // aave + }, + { infiniteApproval: false, walletIdentifier: WalletIdentifier.enkrypt } + ); + + expect(quote?.provider).to.be.eq(ProviderName.propellerHeads); + expect(quote?.quote.meta.infiniteApproval).to.be.eq(false); + expect(quote?.quote.meta.walletIdentifier).to.be.eq( + WalletIdentifier.enkrypt + ); + expect(typeof quote?.fromTokenAmount).to.be.eq(typeof localAmount); + expect(quote?.toTokenAmount.gtn(0)).to.be.eq(true); + + const swap = await propellerHeads.getSwap(quote!.quote); + + expect(swap?.transactions.length).to.be.eq(2); + expect(swap?.transactions[0].to).to.be.eq(fromTokenWBTC.address); + }).timeout(25000); +}); diff --git a/packages/types/src/networks.ts b/packages/types/src/networks.ts index 08198e4bb..3ad2c058c 100644 --- a/packages/types/src/networks.ts +++ b/packages/types/src/networks.ts @@ -62,6 +62,7 @@ export enum NetworkNames { Dogecoin = "DOGE", ArtheraTest = "AATest", Arthera = "AA", + Starknet = "Starknet", FormTestnet = "FormTestnet", }