UNPKG

@arcana/ca-sdk

Version:

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

351 lines (350 loc) 13.7 kB
import { GrpcWebImpl, QueryClientImpl, Universe, } from "@arcana/ca-common"; import axios from "axios"; import Decimal from "decimal.js"; import { connect } from "it-ws"; import { pack, unpack } from "msgpackr"; import { bytesToBigInt, bytesToNumber, toHex } from "viem"; import { getLogger } from "../logger"; import { ALLOWANCE_APPROVAL_MINED, INTENT_COLLECTION, INTENT_COLLECTION_COMPLETE, } from "../steps"; import { balancesToAssets, convertAddressByUniverse, convertToHexAddressByUniverse, divDecimals, equalFold, minutesToMs, } from "./common.utils"; const logger = getLogger(); let cosmosQueryClient = null; const getCosmosQueryClient = (grpcURL) => { if (!cosmosQueryClient) { const rpc = new GrpcWebImpl(grpcURL, {}); cosmosQueryClient = new QueryClientImpl(rpc); } return cosmosQueryClient; }; const PAGE_LIMIT = 100; export async function fetchMyIntents(address, grpcURL, page = 1) { try { const response = await getCosmosQueryClient(grpcURL).RequestForFundsByAddress({ account: address, pagination: { limit: PAGE_LIMIT, offset: (page - 1) * PAGE_LIMIT, reverse: true, }, }); return intentTransform(response.requestForFunds); } catch (error) { logger.error("Failed to fetch intents", error); throw new Error("Failed to fetch intents"); } } const intentTransform = (input) => { return input.map((rff) => ({ deposited: rff.deposited, destinationChainID: bytesToNumber(rff.destinationChainID), destinations: rff.destinations.map((d) => ({ tokenAddress: convertToHexAddressByUniverse(d.tokenAddress, rff.destinationUniverse), value: bytesToBigInt(d.value), })), destinationUniverse: Universe[rff.destinationUniverse], expiry: rff.expiry.toNumber(), fulfilled: rff.fulfilled, id: rff.id.toNumber(), refunded: rff.refunded, sources: rff.sources.map((s) => ({ chainID: bytesToNumber(s.chainID), tokenAddress: convertToHexAddressByUniverse(s.tokenAddress, s.universe), universe: Universe[s.universe], value: bytesToBigInt(s.value), })), })); }; export async function fetchProtocolFees(grpcURL) { try { const response = await getCosmosQueryClient(grpcURL).ProtocolFees({ Universe: Universe.FUEL, }); return response; } catch (error) { logger.error("Failed to fetch protocol fees", error); throw new Error("Failed to fetch protocol fees"); } } export async function fetchSolverData(grpcURL) { try { const response = await getCosmosQueryClient(grpcURL).SolverDataAll({}); return response; } catch (error) { logger.error("Failed to fetch solver data", error); throw new Error("Failed to fetch solver data"); } } export const fetchPriceOracle = async (grpcURL) => { const data = await getCosmosQueryClient(grpcURL).PriceOracleData({}); if (data.PriceOracleData?.priceData?.length) { const oracleRates = data.PriceOracleData?.priceData.map((data) => ({ chainId: bytesToNumber(data.chainID), priceUsd: new Decimal(bytesToNumber(data.price)).div(Decimal.pow(10, data.decimals)), tokenAddress: convertAddressByUniverse(toHex(data.tokenAddress), data.universe), tokensPerUsd: new Decimal(1).div(new Decimal(bytesToNumber(data.price)).div(Decimal.pow(10, data.decimals))), })); return oracleRates; } throw new Error("InternalError: No price data found."); }; const coinbasePrices = { lastUpdatedAt: 0, rates: {}, }; const COINBASE_UPDATE_INTERVAL = minutesToMs(1); export const getCoinbasePrices = async () => { if (coinbasePrices.lastUpdatedAt + COINBASE_UPDATE_INTERVAL < Date.now()) { try { const exchange = await axios.get("https://api.coinbase.com/v2/exchange-rates?currency=USD"); coinbasePrices.rates = exchange.data.data.rates; coinbasePrices.lastUpdatedAt = Date.now(); } catch (error) { logger.error("Failed to fetch Coinbase prices", error); // Return cached rates if available, otherwise throw if (Object.keys(coinbasePrices.rates).length === 0) { throw new Error("Failed to fetch exchange rates and no cache available"); } } } return coinbasePrices.rates; }; export const fetchBalances = async (vscDomain, evmAddress, chainList, fuelAddress) => { const [evmBalances, fuelBalances, rates] = await Promise.allSettled([ getEVMBalancesForAddress(vscDomain, evmAddress), fuelAddress ? getFuelBalancesForAddress(vscDomain, fuelAddress) : Promise.resolve([]), getCoinbasePrices(), ]); logger.debug("unified balances", { evmBalances, fuelBalances }); let balances = []; if (evmBalances.status === "fulfilled") { balances = evmBalances.value.filter((b) => b.universe === Universe.ETHEREUM); } if (fuelBalances.status === "fulfilled") { balances = [ ...balances, ...fuelBalances.value.filter((b) => b.universe === Universe.FUEL), ]; } return { ...balancesToAssets(balances, chainList), rates: rates.status === "fulfilled" ? rates.value : {}, }; }; export class FeeStore { constructor(data) { this.data = data; } calculateCollectionFee({ decimals, sourceChainID, sourceTokenAddress, }) { const collectionFee = this.data.fee.collection.find((f) => { return (Number(f.chainID) === sourceChainID && equalFold(f.tokenAddress, sourceTokenAddress)); }); if (!collectionFee) { return new Decimal(0); } return divDecimals(collectionFee.fee ?? 0, decimals); } calculateFulfilmentFee({ decimals, destinationChainID, destinationTokenAddress, }) { const fulfilmentFeeBasis = this.data.fee.fulfilment.find((f) => { return (Number(f.chainID) === destinationChainID && equalFold(f.tokenAddress, destinationTokenAddress)); }); if (!fulfilmentFeeBasis) { return new Decimal(0); } return new Decimal(fulfilmentFeeBasis.fee ?? 0).div(Decimal.pow(10, decimals)); } calculateProtocolFee(borrow) { const protocolFeeBasis = new Decimal(this.data.fee.protocol.feeBP ?? 0).div(Decimal.pow(10, 4)); return borrow.mul(protocolFeeBasis); } calculateSolverFee({ borrowAmount, decimals, destinationChainID, destinationTokenAddress, sourceChainID, sourceTokenAddress, }) { const solverFeeBP = this.data.solverRoutes.find((f) => { return (Number(f.sourceChainID) === sourceChainID && Number(f.destinationChainID) === destinationChainID && equalFold(f.sourceTokenAddress, sourceTokenAddress) && equalFold(f.destinationTokenAddress, destinationTokenAddress)); })?.feeBP ?? 0; return new Decimal(solverFeeBP ?? 0) .div(Decimal.pow(10, 4)) .mul(borrowAmount) .toDP(decimals, Decimal.ROUND_CEIL); } } export const getFeeStore = async (grpcURL) => { const feeData = { fee: { collection: [], fulfilment: [], protocol: { feeBP: "0", }, }, solverRoutes: [], }; const [p, s] = await Promise.allSettled([ fetchProtocolFees(grpcURL), fetchSolverData(grpcURL), ]); if (p.status === "fulfilled") { logger.debug("getFeeStore", { collection: p.value.ProtocolFees?.collectionFees, fulfilment: p.value.ProtocolFees?.fulfilmentFees, protocol: p.value.ProtocolFees?.feeBP, }); feeData.fee.protocol.feeBP = p.value.ProtocolFees?.feeBP.toString(10) ?? "0"; feeData.fee.collection = p.value.ProtocolFees?.collectionFees.map((fee) => { return { chainID: bytesToNumber(fee.chainID), fee: bytesToNumber(fee.fee), tokenAddress: convertAddressByUniverse(toHex(fee.tokenAddress), fee.universe), universe: fee.universe, }; }) ?? []; feeData.fee.fulfilment = p.value.ProtocolFees?.fulfilmentFees.map((fee) => { return { chainID: bytesToNumber(fee.chainID), fee: bytesToNumber(fee.fee), tokenAddress: convertAddressByUniverse(toHex(fee.tokenAddress), fee.universe), universe: fee.universe, }; }) ?? []; } if (s.status === "fulfilled") { feeData.solverRoutes = s.value.solverData[0]?.advertisedFees.map((s) => { return { destinationChainID: bytesToNumber(s.destinationChainID), destinationTokenAddress: convertAddressByUniverse(toHex(s.destinationTokenAddress), s.destinationUniverse), destinationUniverse: s.destinationUniverse, feeBP: s.feeBP, sourceChainID: bytesToNumber(s.sourceChainID), sourceTokenAddress: convertAddressByUniverse(toHex(s.sourceTokenAddress), s.sourceUniverse), sourceUniverse: s.sourceUniverse, }; }) || []; } return new FeeStore(feeData); }; const getVSCURL = (vscDomain, protocol) => { return `${protocol}://${vscDomain}`; }; let vscReq = null; const getVscReq = (vscDomain) => { if (!vscReq) { vscReq = axios.create({ baseURL: new URL("/api/v1", getVSCURL(vscDomain, "https")).toString(), headers: { Accept: "application/msgpack", }, responseType: "arraybuffer", transformRequest: [ function (data, headers) { if (["get", "head"].includes(this.method.toLowerCase())) return; headers["Content-Type"] = "application/msgpack"; return pack(data); }, ], transformResponse: [(data) => unpack(data)], }); } return vscReq; }; const getEVMBalancesForAddress = async (vscDomain, address) => { const response = await getVscReq(vscDomain).get(`/get-balance/ETHEREUM/${address}`); logger.debug("getEVMBalancesForAddress", { response }); return response.data.balances; }; const getFuelBalancesForAddress = async (vscDomain, address) => { const response = await getVscReq(vscDomain).get(`/get-balance/FUEL/${address}`); return response.data.balances; }; export const vscCreateFeeGrant = async (vscDomain, address) => { const response = await getVscReq(vscDomain).post(`/create-feegrant`, { cosmos_address: address, }); return response; }; export const vscPublishRFF = async (vscDomain, id) => { const response = await getVscReq(vscDomain).post("/publish-rff", { id: id.toNumber(), }); logger.debug("publishRFF", { response }); return { id }; }; export const vscCreateSponsoredApprovals = async (vscDomain, input, msd) => { const connection = connect(new URL("/api/v1/create-sponsored-approvals", getVSCURL(vscDomain, "wss")).toString()); await connection.connected(); try { connection.socket.send(pack(input)); let count = 0; for await (const resp of connection.source) { const data = unpack(resp); logger.debug("vscCreateSponsoredApprovals", { data }); if (data.errored) { throw new Error("Errored out"); } if (msd) { msd(ALLOWANCE_APPROVAL_MINED(bytesToNumber(input[data.part_idx].chain_id))); } count += 1; if (count == input.length) { break; } } return "ok"; } finally { connection.close(); } }; export const vscCreateRFF = async (vscDomain, id, msd, totalCollections) => { const connection = connect(new URL("/api/v1/create-rff", getVSCURL(vscDomain, "wss")).toString()); await connection.connected(); try { connection.socket.send(pack({ id: id.toNumber() })); let count = 0; for await (const resp of connection.source) { const data = unpack(resp); logger.debug("vscCreateRFF", { data }); if (data.status === 255) { msd(INTENT_COLLECTION_COMPLETE); break; } else if (data.status === 16) { count += 1; msd(INTENT_COLLECTION(count), { confirmed: count, total: totalCollections, }); logger.debug("vscCreateRFF", { data }); } else { logger.debug("vscCreateRFF", { data }); throw new Error("vsc create-rff errored out"); } } } finally { connection.close(); } }; export const checkIntentFilled = async (intentID, grpcURL) => { const response = await getCosmosQueryClient(grpcURL).RequestForFunds({ id: intentID, }); if (response.requestForFunds?.fulfilled) { return "ok"; } throw new Error("not filled yet"); };