diff --git a/README.md b/README.md index bd03103..8af6d4e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Orion’s SDK is free to use and does not require an API key or registration. Re - [Get assets](#get-assets) - [Get pairs](#get-pairs) - [Get Orion Bridge history](#get-orion-bridge-history) + - [Bridge swap](#bridge-swap) - [Withdraw](#withdraw) - [Deposit](#deposit) - [Get swap info](#get-swap-info) @@ -149,6 +150,26 @@ const bridgeHistory = await orion.bridge.getHistory( ); ``` +### Bridge swap + +```ts +const orion = new Orion("production"); +const wallet = new Wallet(privateKey); + +orion.bridge.swap( + "ORN", // Asset name + 0.12345678, // Amount + SupportedChainId.FANTOM_OPERA, + SupportedChainId.BSC, + wallet, + { + autoApprove: true, + logger: console.log, + withdrawToWallet: true, // Enable withdraw to wallet + } +); +``` + ### Withdraw ```ts diff --git a/package.json b/package.json index dced31e..a57dec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orionprotocol/sdk", - "version": "0.17.21", + "version": "0.17.22", "description": "Orion Protocol SDK", "main": "./lib/esm/index.js", "module": "./lib/esm/index.js", diff --git a/src/Orion/getBridgeHistory.ts b/src/Orion/bridge/getHistory.ts similarity index 95% rename from src/Orion/getBridgeHistory.ts rename to src/Orion/bridge/getHistory.ts index 18d588f..fffe0de 100644 --- a/src/Orion/getBridgeHistory.ts +++ b/src/Orion/bridge/getHistory.ts @@ -1,11 +1,11 @@ import { ethers } from 'ethers'; -import type OrionUnit from '../OrionUnit'; -import simpleFetch from '../simpleFetch'; -import type { SupportedChainId } from '../types'; -import { isValidChainId } from '../utils'; +import type OrionUnit from '../../OrionUnit'; +import simpleFetch from '../../simpleFetch'; +import type { SupportedChainId } from '../../types'; +import { isValidChainId } from '../../utils'; import ObjectID from 'bson-objectid'; -const getBridgeHistory = async (units: OrionUnit[], address: string, limit = 1000) => { +const getHistory = async (units: OrionUnit[], address: string, limit = 1000) => { if (!ethers.utils.isAddress(address)) throw new Error(`Invalid address: ${address}`); const data = await Promise.all(units.map(async ({ orionBlockchain, orionAggregator, chainId }) => { const sourceNetworkHistory = await simpleFetch(orionBlockchain.getSourceAtomicSwapHistory)({ @@ -209,4 +209,4 @@ const getBridgeHistory = async (units: OrionUnit[], address: string, limit = 100 return aggregatedData; } -export default getBridgeHistory; +export default getHistory; diff --git a/src/Orion/bridge/swap.ts b/src/Orion/bridge/swap.ts new file mode 100644 index 0000000..4c4a631 --- /dev/null +++ b/src/Orion/bridge/swap.ts @@ -0,0 +1,329 @@ +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; +import { Exchange__factory } from '@orionprotocol/contracts'; +import getBalances from '../../utils/getBalances'; +import BalanceGuard from '../../BalanceGuard'; +import getAvailableSources from '../../utils/getAvailableFundsSources'; +import { + INTERNAL_ORION_PRECISION, + NATIVE_CURRENCY_PRECISION, + LOCKATOMIC_GAS_LIMIT, + REDEEMATOMIC_GAS_LIMIT, + WITHDRAW_GAS_LIMIT +} from '../../constants'; +import getNativeCryptocurrency from '../../utils/getNativeCryptocurrency'; +import simpleFetch from '../../simpleFetch'; +import { denormalizeNumber, generateSecret, normalizeNumber, toUpperCase } from '../../utils'; +import type { SupportedChainId } from '../../types'; +import type Orion from '..'; +import type { z } from 'zod'; +import type { placeAtomicSwapSchema } from '../../services/OrionAggregator/schemas'; + +type Params = { + assetName: string + amount: BigNumber.Value + sourceChain: SupportedChainId + targetChain: SupportedChainId + signer: ethers.Signer + orion: Orion + options?: { + withdrawToWallet?: boolean // By default, the transfer amount remains in the exchange contract + autoApprove?: boolean + logger?: (message: string) => void + } +} + +export default async function swap({ + amount, + assetName, + sourceChain, + targetChain, + signer, + options, + orion +}: Params) { + const startProcessingTime = Date.now(); + if (amount === '') throw new Error('Amount can not be empty'); + if (assetName === '') throw new Error('AssetIn can not be empty'); + + const amountBN = new BigNumber(amount); + if (amountBN.isNaN()) throw new Error(`Amount '${amountBN.toString()}' is not a number`); + if (amountBN.lte(0)) throw new Error(`Amount '${amountBN.toString()}' should be greater than 0`); + + const sourceChainOrionUnit = orion.getUnit(sourceChain); + const targetChainOrionUnit = orion.getUnit(targetChain); + + const { + orionBlockchain: sourceOrionBlockchain, + orionAggregator: sourceOrionAggregator, + provider: sourceProvider, + chainId, + } = sourceChainOrionUnit; + + const { + orionAggregator: targetOrionAggregator, + orionBlockchain: targetOrionBlockchain, + provider: targetProvider, + } = targetChainOrionUnit; + + const sourceSupportedBridgeAssets = await simpleFetch(sourceOrionBlockchain.getAtomicSwapAssets)(); + const targetSupportedBridgeAssets = await simpleFetch(targetOrionBlockchain.getAtomicSwapAssets)(); + + const commonSupportedBridgeAssets = sourceSupportedBridgeAssets.filter((asset) => targetSupportedBridgeAssets.includes(asset)); + if (!sourceSupportedBridgeAssets.includes(assetName) || !targetSupportedBridgeAssets.includes(assetName)) { + throw new Error(`Asset '${assetName}' not available for swap between ${sourceChain} and ${targetChain} chains. Available assets: ${commonSupportedBridgeAssets.join(', ')}`); + } + + const brokersBalances = await new Promise>>((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Can't get brokers balances. Timeout")); + }, 10000); + const id = targetOrionAggregator.ws.subscribe('btasabus', { + callback: (data) => { + targetOrionAggregator.ws.unsubscribe(id); + targetOrionAggregator.ws.destroy(); + clearTimeout(timeout); + resolve(data); + } + }) + }) + + const walletAddress = await signer.getAddress(); + options?.logger?.(`Wallet address is ${walletAddress}`); + + const assetBrokersBalance = brokersBalances[assetName]; + if (assetBrokersBalance === undefined) throw new Error(`Asset '${assetName}' not found in brokers balances`); + if (assetBrokersBalance === 0) throw new Error(`Asset '${assetName}' is not available for swap`); + + const { + exchangeContractAddress: sourceExchangeContractAddress, + assetToAddress: sourceAssetToAddress, + } = await simpleFetch(sourceOrionBlockchain.getInfo)(); + + const sourceChainNativeCryptocurrency = getNativeCryptocurrency(sourceAssetToAddress); + const sourceExchangeContract = Exchange__factory.connect(sourceExchangeContractAddress, sourceProvider); + const sourceChainGasPriceWei = await simpleFetch(sourceOrionBlockchain.getGasPriceWei)(); + + const sourceChainAssetAddress = sourceAssetToAddress[assetName]; + if (sourceChainAssetAddress === undefined) throw new Error(`Asset '${assetName}' not found in source chain`); + + const { + exchangeContractAddress: targetExchangeContractAddress, + assetToAddress: targetAssetToAddress, + } = await simpleFetch(targetOrionBlockchain.getInfo)(); + + const targetChainAssetAddress = targetAssetToAddress[assetName]; + if (targetChainAssetAddress === undefined) throw new Error(`Asset '${assetName}' not found in target chain`); + const targetChainNativeCryptocurrency = getNativeCryptocurrency(targetAssetToAddress); + const targetExchangeContract = Exchange__factory.connect(targetExchangeContractAddress, targetProvider); + + const sourceChainBalances = await getBalances( + { + [assetName]: sourceChainAssetAddress, + [sourceChainNativeCryptocurrency]: ethers.constants.AddressZero, + }, + sourceOrionAggregator, + walletAddress, + sourceExchangeContract, + sourceProvider, + ); + + const targetChainBalances = await getBalances( + { + [assetName]: targetChainAssetAddress, + [targetChainNativeCryptocurrency]: ethers.constants.AddressZero, + }, + targetOrionAggregator, + walletAddress, + targetExchangeContract, + targetProvider, + ); + + const sourceChainBalanceGuard = new BalanceGuard( + sourceChainBalances, + { + name: sourceChainNativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + sourceProvider, + signer, + options?.logger, + ); + + const targetChainBalanceGuard = new BalanceGuard( + targetChainBalances, + { + name: targetChainNativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + targetProvider, + signer, + options?.logger, + ); + + sourceChainBalanceGuard.registerRequirement({ + reason: 'Amount', + asset: { + name: assetName, + address: sourceChainAssetAddress, + }, + amount: amountBN.toString(), + spenderAddress: sourceExchangeContractAddress, + sources: getAvailableSources('amount', sourceChainAssetAddress, 'orion_pool'), + }); + + const amountBlockchainParam = normalizeNumber( + amount, + INTERNAL_ORION_PRECISION, + BigNumber.ROUND_FLOOR, + ); + const secret = generateSecret(); + const secretHash = ethers.utils.keccak256(secret); + options?.logger?.(`Secret is ${secret}`); + options?.logger?.(`Secret hash is ${secretHash}`); + + const secondsInDay = 60 * 60 * 24; + const expirationDays = 4; + const expirationEtherBN = ethers.BigNumber.from( + Date.now() + (secondsInDay * expirationDays * 1000), + ); + + const unsignedLockAtomicTx = await sourceExchangeContract.populateTransaction.lockAtomic({ + amount: amountBlockchainParam, + asset: sourceChainAssetAddress, + expiration: expirationEtherBN, + secretHash, + sender: walletAddress, + targetChainId: ethers.BigNumber.from(targetChain), + }); + + unsignedLockAtomicTx.chainId = parseInt(chainId, 10); + unsignedLockAtomicTx.gasPrice = ethers.BigNumber.from(sourceChainGasPriceWei); + unsignedLockAtomicTx.from = walletAddress; + + let value = new BigNumber(0); + const denormalizedAssetInExchangeBalance = sourceChainBalances[assetName]?.exchange; + if (denormalizedAssetInExchangeBalance === undefined) throw new Error(`Asset '${assetName}' exchange balance is not found`); + if (assetName === sourceChainNativeCryptocurrency && amountBN.gt(denormalizedAssetInExchangeBalance)) { + value = amountBN.minus(denormalizedAssetInExchangeBalance); + } + unsignedLockAtomicTx.value = normalizeNumber( + value.dp(INTERNAL_ORION_PRECISION, BigNumber.ROUND_CEIL), + NATIVE_CURRENCY_PRECISION, + BigNumber.ROUND_CEIL, + ); + unsignedLockAtomicTx.gasLimit = ethers.BigNumber.from(LOCKATOMIC_GAS_LIMIT); + + const transactionCost = ethers.BigNumber.from(LOCKATOMIC_GAS_LIMIT).mul(sourceChainGasPriceWei); + const denormalizedTransactionCost = denormalizeNumber(transactionCost, NATIVE_CURRENCY_PRECISION); + + sourceChainBalanceGuard.registerRequirement({ + reason: 'Network fee', + asset: { + name: sourceChainNativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + amount: denormalizedTransactionCost.toString(), + sources: ['wallet'] + }); + + await sourceChainBalanceGuard.check(options?.autoApprove); + + const nonce = await sourceProvider.getTransactionCount(walletAddress, 'pending'); + unsignedLockAtomicTx.nonce = nonce; + + options?.logger?.('Signing lock tx transaction...'); + const signedTransaction = await signer.signTransaction(unsignedLockAtomicTx); + const lockAtomicTxResponse = await sourceChainOrionUnit.provider.sendTransaction(signedTransaction); + options?.logger?.(`Lock tx sent. Tx hash: ${lockAtomicTxResponse.hash}. Waiting for tx to be mined...`); + await lockAtomicTxResponse.wait(); + options?.logger?.('Lock tx mined.'); + options?.logger?.('Placing atomic swap...'); + + const atomicSwap = await new Promise>((resolve, reject) => { + const placeAtomicSwap = () => simpleFetch(targetOrionAggregator.placeAtomicSwap)( + secretHash, + toUpperCase(sourceChainOrionUnit.networkCode) + ).then((data) => { + clearInterval(interval); + clearTimeout(timeout); + resolve(data); + }).catch(console.error); + const interval = setInterval(() => { + placeAtomicSwap().catch(console.error); + }, 2000); + + const timeout = setTimeout(() => { + clearInterval(interval); + reject(new Error('Atomic swap placing timeout')); + }, 1000 * 60 * 5); + }); + + options?.logger?.('Atomic swap placed.'); + + const targetChainGasPriceWei = await simpleFetch(targetOrionBlockchain.getGasPriceWei)(); + const unsignedRedeemAtomicTx = await targetExchangeContract.populateTransaction.redeemAtomic( + { + amount: amountBlockchainParam, + asset: targetChainAssetAddress, + claimReceiver: atomicSwap.redeemOrder.claimReceiver, + expiration: atomicSwap.redeemOrder.expiration, + receiver: atomicSwap.redeemOrder.receiver, + secretHash: atomicSwap.redeemOrder.secretHash, + sender: atomicSwap.redeemOrder.sender, + signature: atomicSwap.redeemOrder.signature, + }, + secret + ) + + unsignedRedeemAtomicTx.chainId = parseInt(targetChain, 10); + unsignedRedeemAtomicTx.gasPrice = ethers.BigNumber.from(targetChainGasPriceWei); + unsignedRedeemAtomicTx.from = walletAddress; + unsignedRedeemAtomicTx.gasLimit = ethers.BigNumber.from(REDEEMATOMIC_GAS_LIMIT); + + const targetTransactionCost = ethers.BigNumber.from(REDEEMATOMIC_GAS_LIMIT).mul(targetChainGasPriceWei); + const targetDenormalizedTransactionCost = denormalizeNumber(targetTransactionCost, NATIVE_CURRENCY_PRECISION); + + targetChainBalanceGuard.registerRequirement({ + reason: 'Network fee', + asset: { + name: targetChainNativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + amount: targetDenormalizedTransactionCost.toString(), + sources: ['wallet'] + }); + + await targetChainBalanceGuard.check(options?.autoApprove); + + unsignedRedeemAtomicTx.nonce = await targetProvider.getTransactionCount(walletAddress, 'pending'); + + options?.logger?.('Signing redeem tx transaction...'); + + const targetSignedTransaction = await signer.signTransaction(unsignedRedeemAtomicTx); + const targetLockAtomicTxResponse = await targetChainOrionUnit.provider.sendTransaction(targetSignedTransaction); + options?.logger?.(`Redeem tx sent. Tx hash: ${targetLockAtomicTxResponse.hash}. Waiting for tx to be mined...`); + + await targetLockAtomicTxResponse.wait(); + options?.logger?.('Redeem tx mined.'); + options?.logger?.('Atomic swap completed.'); + + if (options?.withdrawToWallet) { + options.logger?.('Withdrawing to wallet...'); + const unsignedWithdrawTx = await targetExchangeContract.populateTransaction.withdraw( + targetChainAssetAddress, + amountBlockchainParam, + ); + unsignedWithdrawTx.gasLimit = ethers.BigNumber.from(WITHDRAW_GAS_LIMIT); + unsignedWithdrawTx.gasPrice = ethers.BigNumber.from(targetChainGasPriceWei); + unsignedWithdrawTx.from = walletAddress; + unsignedWithdrawTx.nonce = await targetProvider.getTransactionCount(walletAddress, 'pending'); + const signedTx = await signer.signTransaction(unsignedWithdrawTx); + const withdrawTx = await targetProvider.sendTransaction(signedTx); + options.logger?.(`Withdraw tx sent. Tx hash: ${withdrawTx.hash}. Waiting for tx to be mined...`); + await withdrawTx.wait(); + options.logger?.('Withdraw tx mined.'); + } + + options?.logger?.(`Total processing time: ${Date.now() - startProcessingTime} ms`); +} diff --git a/src/Orion/index.ts b/src/Orion/index.ts index be6704b..72439ea 100644 --- a/src/Orion/index.ts +++ b/src/Orion/index.ts @@ -1,3 +1,5 @@ +import type BigNumber from 'bignumber.js'; +import type { ethers } from 'ethers'; import { merge } from 'merge-anything'; import { chains, envs } from '../config'; import type { networkCodes } from '../constants'; @@ -6,7 +8,8 @@ import { ReferralSystem } from '../services/ReferralSystem'; import simpleFetch from '../simpleFetch'; import type { SupportedChainId, DeepPartial, VerboseOrionUnitConfig, KnownEnv } from '../types'; import { isValidChainId } from '../utils'; -import getBridgeHistory from './getBridgeHistory'; +import swap from './bridge/swap'; +import getHistory from './bridge/getHistory'; type EnvConfig = { analyticsAPI: string @@ -212,6 +215,26 @@ export default class Orion { } bridge = { - getHistory: (address: string, limit = 1000) => getBridgeHistory(this.unitsArray, address, limit), + getHistory: (address: string, limit = 1000) => getHistory(this.unitsArray, address, limit), + swap: ( + assetName: string, + amount: BigNumber.Value, + sourceChain: SupportedChainId, + targetChain: SupportedChainId, + signer: ethers.Signer, + options: { + autoApprove?: boolean + logger?: (message: string) => void + withdrawToWallet?: boolean + } + ) => swap({ + amount, + assetName, + sourceChain, + targetChain, + signer, + orion: this, + options, + }) } } diff --git a/src/OrionUnit/index.ts b/src/OrionUnit/index.ts index dafa1c7..b2c480d 100644 --- a/src/OrionUnit/index.ts +++ b/src/OrionUnit/index.ts @@ -79,6 +79,7 @@ export default class OrionUnit { const intNetwork = parseInt(this.chainId, 10); if (Number.isNaN(intNetwork)) throw new Error('Invalid chainId (not a number)' + this.chainId); this.provider = new ethers.providers.StaticJsonRpcProvider(this.config.nodeJsonRpc, intNetwork); + this.provider.pollingInterval = 1000; this.orionBlockchain = new OrionBlockchain(this.config.services.orionBlockchain.http); this.orionAggregator = new OrionAggregator(