UNPKG

@arcana/ca-sdk

Version:

Arcana Network's chain abstraction SDK for unified balance in Web3 apps

465 lines (464 loc) 17.3 kB
import { ArcanaVault, DepositVEPacket, Environment, EVMVaultABI, MsgDoubleCheckTx, Universe, } from "@arcana/ca-common"; import Decimal from "decimal.js"; import { arrayify, hexlify } from "fuels"; import { bytesToHex, bytesToNumber, encodeAbiParameters, getAbiItem, hashMessage, keccak256, pad, toBytes, toHex, } from "viem"; import { ChainList } from "../chains"; import { FUEL_BASE_ASSET_ID, getLogoFromSymbol, isNativeAddress, ZERO_ADDRESS, } from "../constants"; import { getLogger } from "../logger"; import { UserAssets, } from "../typings"; import { requestTimeout, waitForIntentFulfilment } from "./contract.utils"; import { cosmosCreateDoubleCheckTx, cosmosFillCheck, cosmosRefundIntent, } from "./cosmos.utils"; const logger = getLogger(); function convertAddressByUniverse(input, universe) { const inputIsString = typeof input === "string"; const bytes = inputIsString ? toBytes(input) : input; if (universe === Universe.ETHEREUM) { if (bytes.length === 20) { return inputIsString ? input : bytes; } if (bytes.length === 32) { return inputIsString ? toHex(bytes.subarray(12)) : bytes.subarray(12); } throw new Error("invalid length of input"); } if (universe === Universe.FUEL) { if (bytes.length === 32) { return inputIsString ? input : bytes; } if (bytes.length === 20) { const padded = pad(bytes, { dir: "left", size: 32, }); return inputIsString ? toHex(padded) : padded; } throw new Error("invalid length of input"); } throw new Error("universe is not supported"); } const minutesToMs = (min) => min * 60 * 1000; const balancesToAssets = (balances, chainList) => { const assets = new UserAssets([]); for (const balance of balances) { for (const currency of balance.currencies) { const chain = chainList.getChainByID(bytesToNumber(balance.chain_id)); if (!chain) { continue; } const tokenAddress = convertAddressByUniverse(toHex(currency.token_address), balance.universe); const token = chainList.getTokenByAddress(chain.id, tokenAddress); const decimals = token ? token.decimals : chain.nativeCurrency.decimals; if (token) { const asset = assets.data.find((s) => s.symbol === token.symbol); if (asset) { asset.balance = new Decimal(asset.balance) .add(currency.balance) .toFixed(); asset.balanceInFiat = new Decimal(asset.balanceInFiat) .add(currency.value) .toDecimalPlaces(2) .toNumber(); asset.breakdown.push({ balance: currency.balance, balanceInFiat: new Decimal(currency.value) .toDecimalPlaces(2) .toNumber(), chain: { id: bytesToNumber(balance.chain_id), logo: chain.custom.icon, name: chain.name, }, contractAddress: tokenAddress, decimals, universe: balance.universe, }); } else { assets.add({ abstracted: true, balance: currency.balance, balanceInFiat: new Decimal(currency.value) .toDecimalPlaces(2) .toNumber(), breakdown: [ { balance: currency.balance, balanceInFiat: new Decimal(currency.value) .toDecimalPlaces(2) .toNumber(), chain: { id: bytesToNumber(balance.chain_id), logo: chain.custom.icon, name: chain.name, }, contractAddress: tokenAddress, decimals, universe: balance.universe, }, ], decimals: token.decimals, icon: getLogoFromSymbol(token.symbol), symbol: token.symbol, }); } } } } assets.sort(); return { assets, balanceInFiat: assets.getBalanceInFiat(), }; }; const INTENT_KEY = "xar-sdk-intents"; const getIntentKey = (address) => { return `${INTENT_KEY}-${address}`; }; const storeIntentHashToStore = (address, id, createdAt = Date.now()) => { let intents = []; const fetchedIntents = localStorage.getItem(getIntentKey(address)); if (fetchedIntents) { intents = JSON.parse(fetchedIntents) ?? []; } intents.push({ createdAt, id }); localStorage.setItem(getIntentKey(address), JSON.stringify(intents)); }; const removeIntentHashFromStore = (address, id) => { let intents = []; const fetchedIntents = localStorage.getItem(getIntentKey(address)); if (fetchedIntents) { intents = JSON.parse(fetchedIntents) ?? []; } const oLen = intents.length; intents = intents.filter((h) => h.id !== id.toNumber()); if (oLen !== intents.length) { localStorage.setItem(getIntentKey(address), JSON.stringify(intents)); } }; const getExpiredIntents = (address) => { let intents = []; const fetchedIntents = localStorage.getItem(getIntentKey(address)); if (fetchedIntents) { intents = JSON.parse(fetchedIntents) ?? []; } logger.debug("getExpiredIntents", { intents }); const expiredIntents = []; const nonExpiredIntents = []; const TEN_MINUTES_BEFORE = Date.now() - 600000; for (const intent of intents) { if (intent.createdAt < TEN_MINUTES_BEFORE) { expiredIntents.push(intent); } else { nonExpiredIntents.push(intent); } } localStorage.setItem(getIntentKey(address), JSON.stringify(nonExpiredIntents)); return expiredIntents; }; const refundExpiredIntents = async (address, cosmosURL, wallet) => { logger.debug("Starting check for expired intents at ", new Date()); const expIntents = getExpiredIntents(address); const failedRefunds = []; for (const intent of expIntents) { logger.debug(`Starting refund for: ${intent.id}`); try { await cosmosRefundIntent(cosmosURL, intent.id, wallet); } catch (e) { logger.debug("Refund failed", e); failedRefunds.push({ createdAt: intent.createdAt, id: intent.id, }); } } if (failedRefunds.length > 0) { for (const failed of failedRefunds) { storeIntentHashToStore(address, failed.id, failed.createdAt); } } }; const equalFold = (a, b) => { if (!a || !b) { return false; } return a.toLowerCase() === b.toLowerCase(); }; const createRequestFuelSignature = async (fuelVaultAddress, provider, connector, fuelRFF) => { const account = await connector.currentAccount(); if (!account) { throw new Error("Fuel connector is not connected."); } const vault = new ArcanaVault(hexlify(fuelVaultAddress), provider); const { value: hash } = await vault.functions.hash_request(fuelRFF).get(); const signature = await connector.signMessage(account, { personalSign: arrayify(hash), }); return { requestHash: hash, signature: arrayify(signature) }; }; const getExplorerURL = (baseURL, id) => { return new URL(`/intent/${id.toNumber()}`, baseURL).toString(); }; /** * @param input * @param decimals * @returns input / (10**decimals) */ const divDecimals = (input, decimals) => { return new Decimal(input.toString()).div(Decimal.pow(10, decimals)); }; /** * @param input * @param decimals * @returns BigInt(input * (10**decimals)) */ const mulDecimals = (input, decimals) => { return BigInt(new Decimal(input) .mul(Decimal.pow(10, decimals)) .toFixed(0, Decimal.ROUND_CEIL)); }; const convertIntent = (intent, token, chainList) => { console.time("convertIntent"); const sources = []; let sourcesTotal = new Decimal(0); for (const s of intent.sources) { const chainInfo = chainList.getChainByID(s.chainID); if (!chainInfo) { throw new Error("chain not supported"); } sources.push({ amount: s.amount.toFixed(), chainID: chainInfo.id, chainLogo: chainInfo.custom.icon, chainName: chainInfo.name, contractAddress: s.tokenContract, }); sourcesTotal = sourcesTotal.plus(s.amount); } const destinationChainInfo = chainList.getChainByID(intent.destination.chainID); if (!destinationChainInfo) { throw new Error("chain not supported"); } const destination = { amount: intent.destination.amount.toFixed(), chainID: intent.destination.chainID, chainLogo: destinationChainInfo?.custom.icon, chainName: destinationChainInfo?.name, }; console.timeEnd("convertIntent"); return { destination, fees: { caGas: Decimal.sum(intent.fees.collection, intent.fees.fulfilment).toFixed(token.decimals), gasSupplied: new Decimal(intent.fees.gasSupplied).toFixed(), protocol: new Decimal(intent.fees.protocol).toFixed(), solver: new Decimal(intent.fees.solver).toFixed(), total: Decimal.sum(intent.fees.collection, intent.fees.solver, intent.fees.protocol, intent.fees.fulfilment, intent.fees.gasSupplied).toFixed(token.decimals), }, sources, sourcesTotal: sourcesTotal.toFixed(token.decimals), token: { decimals: token.decimals, logo: token.logo, name: token.name, symbol: token.symbol.toUpperCase(), }, }; }; const hexTo0xString = (hex) => { if (hex.startsWith("0x")) { return hex; } return `0x${hex}`; }; const getSupportedChains = (env = Environment.CORAL) => { const chainList = new ChainList(env); return chainList.chains.map((chain) => { return { id: chain.id, logo: chain.custom.icon, name: chain.name, tokens: [...chain.custom.knownTokens], }; }); }; const isArcanaWallet = (p) => { if ("isArcana" in p && p.isArcana) { return true; } return false; }; const createRequestEVMSignature = async (evmRFF, evmAddress, client) => { logger.debug("createReqEVMSignature", { evmRFF }); const abi = getAbiItem({ abi: EVMVaultABI, name: "deposit" }); const msg = encodeAbiParameters(abi.inputs[0].components, [ evmRFF.sources, evmRFF.destinationUniverse, evmRFF.destinationChainID, evmRFF.destinations, evmRFF.nonce, evmRFF.expiry, evmRFF.parties, ]); const hash = keccak256(msg, "bytes"); const signature = toBytes(await client.signMessage({ account: evmAddress, message: { raw: hash }, })); return { requestHash: hashMessage({ raw: hash }), signature }; }; const convertGasToToken = (token, oraclePrices, destinationChainID, destinationUniverse, gas) => { if (isNativeAddress(destinationUniverse, token.contractAddress)) { return gas; } const gasTokenInUSD = oraclePrices .find((rate) => rate.chainId === destinationChainID && (equalFold(rate.tokenAddress, ZERO_ADDRESS) || equalFold(rate.tokenAddress, FUEL_BASE_ASSET_ID))) ?.priceUsd.toFixed() ?? "0"; const transferTokenInUSD = oraclePrices .find((rate) => rate.chainId === destinationChainID && equalFold(rate.tokenAddress, token.contractAddress)) ?.priceUsd.toFixed(); if (!transferTokenInUSD) { throw new Error("could not find token in price oracle"); } const usdValue = gas.mul(gasTokenInUSD); const tokenEquivalent = usdValue.div(transferTokenInUSD); return tokenEquivalent.toDP(token.decimals, Decimal.ROUND_CEIL); }; const evmWaitForFill = async (vaultContractAddress, publicClient, requestHash, intentID, grpcURL, cosmosURL) => { const ac = new AbortController(); await Promise.race([ waitForIntentFulfilment(publicClient, vaultContractAddress, requestHash, ac), requestTimeout(3, ac), cosmosFillCheck(intentID, grpcURL, cosmosURL, ac), ]); }; const convertBalance = (balances) => { const parsedBreakdown = balances.assets.data.map((asset) => { return { ...asset, breakdown: Array.from({ ...asset.breakdown, length: Object.keys(asset.breakdown).length, }), }; }); return parsedBreakdown.map((asset) => { return { abstracted: asset.abstracted, balance: asset.balance, balanceInFiat: asset.balanceInFiat, breakdown: asset.breakdown.map((breakdown) => { return { balance: breakdown.balance, balanceInFiat: breakdown.balanceInFiat, chain: { id: breakdown.chain.id, logo: breakdown.chain.logo, name: breakdown.chain.name, }, contractAddress: breakdown.contractAddress, decimals: breakdown.decimals, isNative: breakdown.isNative, }; }), icon: asset.icon, symbol: asset.symbol, }; }); }; const convertTo32Bytes = (value) => { if (typeof value == "bigint" || typeof value === "number") { return toBytes(value, { size: 32, }); } if (typeof value === "string") { return pad(toBytes(value), { dir: "left", size: 32, }); } throw new Error("invalid type"); }; const convertTo32BytesHex = (value) => { const bytes = convertTo32Bytes(value); return toHex(bytes); }; const convertToHexAddressByUniverse = (address, universe) => { if (universe === Universe.FUEL) { if (address.length === 32) { return bytesToHex(address); } else { throw new Error("fuel: invalid address length"); } } else if (universe === Universe.ETHEREUM) { if (address.length === 20) { return bytesToHex(address); } else if (address.length === 32) { if (!address.subarray(0, 12).every((b) => b === 0)) { throw new Error("evm: non-zero-padded 32-byte address"); } return bytesToHex(address.subarray(12)); } else { throw new Error("evm: invalid address length"); } } else { throw new Error("unsupported universe"); } }; const createDepositDoubleCheckTx = (chainID, cosmos, intentID, network) => { const msg = MsgDoubleCheckTx.create({ creator: cosmos.address, packet: { $case: "depositPacket", value: DepositVEPacket.create({ gasRefunded: false, id: intentID, }), }, txChainID: chainID, txUniverse: Universe.ETHEREUM, }); return () => { return cosmosCreateDoubleCheckTx({ address: cosmos.address, cosmosURL: network.COSMOS_URL, msg, wallet: cosmos.wallet, }); }; }; const getSDKConfig = (c) => { return { debug: c.debug ?? false, network: c.network ?? Environment.CORAL, siweStatement: c.siweStatement ?? "Sign in to enable Arcana chain abstraction", }; }; const getTxOptions = (options) => { const defaultOptions = { bridge: false, gas: 0n, skipTx: false, }; if (options?.bridge !== undefined) { defaultOptions.bridge = options.bridge; } if (options?.gas !== undefined) { defaultOptions.gas = options.gas; } if (options?.skipTx !== undefined) { defaultOptions.skipTx = options.skipTx; } return defaultOptions; }; export { balancesToAssets, convertAddressByUniverse, convertBalance, convertGasToToken, convertIntent, convertTo32Bytes, convertTo32BytesHex, convertToHexAddressByUniverse, createDepositDoubleCheckTx, createRequestEVMSignature, createRequestFuelSignature, divDecimals, equalFold, evmWaitForFill, getExpiredIntents, getExplorerURL, getSDKConfig, getSupportedChains, getTxOptions, hexTo0xString, isArcanaWallet, minutesToMs, mulDecimals, refundExpiredIntents, removeIntentHashFromStore, storeIntentHashToStore, };