@arcana/ca-sdk
Version:
Arcana Network's chain abstraction SDK for unified balance in Web3 apps
351 lines (350 loc) • 13.7 kB
JavaScript
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");
};