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