UNPKG

@arcana/ca-sdk

Version:

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

353 lines (352 loc) 15.4 kB
import { ChaindataMap, OmniversalChainID, PermitVariant, signPermitForAddressAndValue, Universe, } from "@arcana/ca-common"; import Decimal from "decimal.js"; import { createPublicClient, hexToBytes, http, maxUint256, pad, parseSignature, } from "viem"; import { SOPHON_CHAIN_ID } from "../../chains"; import { getLogoFromSymbol } from "../../constants"; import { ErrorInsufficientBalance, ErrorUserDeniedAllowance, ErrorUserDeniedIntent, } from "../../errors"; import { getLogger } from "../../logger"; import BaseRequest from "../../requestHandlers/common/base"; import { ALLOWANCE_APPROVAL_MINED, ALLOWANCE_APPROVAL_REQ, ALLOWANCE_COMPLETE, INTENT_ACCEPTED, } from "../../steps"; import { convertGasToToken, convertIntent, convertTo32Bytes, divDecimals, equalFold, fetchBalances, fetchPriceOracle, getAllowances, getERC20Contract, getFeeStore, mulDecimals, vscCreateSponsoredApprovals, } from "../../utils"; const logger = getLogger(); class ERC20RequestBase extends BaseRequest { constructor(input) { super(input); this.input = input; this.isNative = false; this.buildIntent = async () => { console.time("process:preIntentSteps"); console.time("preIntentSteps:API"); const [simulation, [balances, oraclePrices, feeStore]] = await Promise.all([ this.simulateTx(), Promise.all([ fetchBalances(this.input.options.networkConfig.VSC_DOMAIN, this.input.evm.address, this.chainList, this.input.fuel?.address), fetchPriceOracle(this.input.options.networkConfig.GRPC_URL), getFeeStore(this.input.options.networkConfig.GRPC_URL), ]), ]); // if simulation is null, then the transaction is not a supported token transfer, so skip if (!simulation) { return; } const token = { ...simulation.token, logo: getLogoFromSymbol(simulation.token.symbol), }; console.timeEnd("preIntentSteps:API"); logger.debug("Step 1:", { balances, feeStore, oraclePrices, simulation, }); console.time("preIntentSteps: Parse"); const { assets } = balances; // Step 2: parse simulation results const { amount, gas, isIntentRequired } = await this.parseSimulation({ assets, simulation, }); logger.debug("NativeRequestBase:1", { amount: amount.toFixed(), gas: gas.toFixed(), isIntentRequired, }); console.timeEnd("preIntentSteps: Parse"); if (!isIntentRequired) { return; } console.time("preIntentSteps: CalculateGas"); const gasInToken = convertGasToToken(simulation.token, oraclePrices, this.input.chain.id, gas); console.timeEnd("preIntentSteps: CalculateGas"); logger.debug("preIntent:1", { gasInNative: gas.toFixed(), gasInToken: gasInToken.toFixed(), }); // Step 4: create intent console.time("preIntentSteps: CreateIntent"); const intent = this.createIntent({ amount, assets, feeStore, gas, gasInToken, token, }); console.timeEnd("preIntentSteps: CreateIntent"); console.timeEnd("process:preIntentSteps"); return { intent, token }; }; this.process = async () => { const i = await this.buildIntent(); if (!i) { return; } let intent = i.intent; const token = i.token; if (intent.isAvailableBalanceInsufficient) { throw ErrorInsufficientBalance; } const allowances = await getAllowances(intent.sources, this.input.evm.address, this.input.chainList); const unallowedSources = this.getUnallowedSources(intent, allowances); this.createExpectedSteps(intent, unallowedSources); console.time("process:AllowanceHook"); // Step 5: set allowance if not set const wait = await this.waitForOnAllowanceHook(unallowedSources); console.timeEnd("process:AllowanceHook"); let accepted = false; const refresh = async () => { if (accepted) { logger.warn("Intent refresh called after acceptance"); return convertIntent(intent, token, this.chainList); } const i = await this.buildIntent(); intent = i.intent; return convertIntent(intent, token, this.chainList); }; if (wait) { await refresh(); } // wait for intent acceptance hook await new Promise((resolve, reject) => { const allow = () => { accepted = true; return resolve("User allowed intent"); }; const deny = () => { return reject(ErrorUserDeniedIntent); }; this.input.hooks.onIntent({ allow, deny, intent: convertIntent(intent, token, this.chainList), refresh, }); }); this.markStepDone(INTENT_ACCEPTED); // Step 6: process intent await this.processIntent(intent); }; } getUnallowedSources(intent, allowances) { const sources = []; for (const s of intent.sources) { if (s.chainID === intent.destination.chainID || s.universe === Universe.FUEL) { continue; } const chain = this.chainList.getChainByID(s.chainID); if (!chain) { throw new Error("chain is not supported"); } const token = this.chainList.getTokenByAddress(s.chainID, s.tokenContract); if (!token) { throw new Error("token is not supported"); } const requiredAllowance = mulDecimals(s.amount, token.decimals); const currentAllowance = allowances[s.chainID] ?? 0n; logger.debug("getUnallowedSources:1", { chainID: s.chainID, currentAllowance: currentAllowance.toString(), requiredAllowance: requiredAllowance.toString(), }); if (requiredAllowance > currentAllowance) { const d = { allowance: { current: currentAllowance.toString(), minimum: requiredAllowance.toString(), }, chain: { id: chain.id, logo: chain.custom.icon, name: chain.name, }, token: { contractAddress: token.contractAddress, decimals: token.decimals, logo: token.logo || "", name: token.name, symbol: token.symbol, }, }; sources.push(d); } } return sources; } async setAllowances(input) { const sponsoredApprovalParams = []; try { for (const source of input) { const chain = this.chainList.getChainByID(source.chainID); if (!chain) { throw new Error("chain not supported"); } const vc = this.input.chainList.getVaultContractAddress(chain.id); const chainId = new OmniversalChainID(Universe.ETHEREUM, source.chainID); const chainDatum = ChaindataMap.get(chainId); if (!chainDatum) { throw new Error("Chain data not found"); } const currency = chainDatum.CurrencyMap.get(convertTo32Bytes(source.tokenContract)); if (!currency) { throw new Error("Currency not found"); } await this.input.evm.client.switchChain({ id: source.chainID, }); if (currency.permitVariant === PermitVariant.Unsupported) { const contract = getERC20Contract(source.tokenContract, this.input.evm.client); const h = await contract.write.approve([vc, BigInt(source.amount)], { account: this.input.evm.address, chain, }); this.markStepDone(ALLOWANCE_APPROVAL_REQ(source.chainID)); const publicClient = createPublicClient({ transport: http(chain.rpcUrls.default.http[0]), }); await publicClient.waitForTransactionReceipt({ hash: h, }); this.markStepDone(ALLOWANCE_APPROVAL_MINED(source.chainID)); } else { const account = { address: this.input.evm.address, type: "json-rpc", }; const signed = parseSignature(await signPermitForAddressAndValue(currency, this.input.evm.client, account, vc, source.amount)); this.markStepDone(ALLOWANCE_APPROVAL_REQ(source.chainID)); sponsoredApprovalParams.push({ address: hexToBytes(pad(account.address, { dir: "left", size: 32, })), chain_id: chainDatum.ChainID32, operations: [ { sig_r: hexToBytes(signed.r), sig_s: hexToBytes(signed.s), sig_v: signed.yParity < 27 ? signed.yParity + 27 : signed.yParity, token_address: currency.tokenAddress, value: convertTo32Bytes(source.amount), variant: currency.permitVariant === PermitVariant.PolygonEMT ? 2 : 1, }, ], universe: chainDatum.Universe, }); } } if (sponsoredApprovalParams.length) { await vscCreateSponsoredApprovals(this.input.options.networkConfig.VSC_DOMAIN, sponsoredApprovalParams, this.markStepDone); } this.markStepDone(ALLOWANCE_COMPLETE); } catch (e) { console.error("Error setting allowances", e); throw ErrorUserDeniedAllowance; } finally { if (this.input.chain.universe === Universe.ETHEREUM) { await this.input.evm.client.switchChain({ id: this.input.chain.id, }); } } } async waitForOnAllowanceHook(sources) { if (sources.length === 0) { return false; } await new Promise((resolve, reject) => { const allow = (allowances) => { if (sources.length !== allowances.length) { return reject(new Error(`invalid allowance values for allow().`)); } const val = []; for (let i = 0; i < sources.length; i++) { const source = sources[i]; const allowance = allowances[i]; let amount = 0n; if (typeof allowance === "string" && equalFold(allowance, "max")) { amount = maxUint256; } else if (typeof allowance === "string" && equalFold(allowance, "min")) { amount = mulDecimals(source.allowance.minimum, source.token.decimals); } else if (typeof allowance === "string") { amount = mulDecimals(allowance, source.token.decimals); } else { amount = allowance; } val.push({ amount, chainID: source.chain.id, tokenContract: source.token.contractAddress, }); } this.setAllowances(val).then(resolve).catch(reject); }; const deny = () => { return reject(ErrorUserDeniedAllowance); }; this.input.hooks.onAllowance({ allow, deny, sources, }); }); return true; } async parseSimulation({ assets, simulation, }) { const tokenContract = simulation.token.contractAddress; const amount = simulation.amount ?? new Decimal(0); const nativeToken = this.input.chain.nativeCurrency; logger.debug("ERC20RequestBase:ParseSimulation:1", { assets, tokenContract, }); const { asset, chainsWithBalance, destinationAssetBalance, destinationGasBalance, } = assets.getAssetDetails(this.input.chain, tokenContract); const gasMultiple = simulation.gasFee .mul(this.input.chain.id === SOPHON_CHAIN_ID ? 3 : 2) .add(divDecimals(this.input.options.gas, nativeToken.decimals)); logger.debug("ERC20RequestBase:ParseSimulation:0", { destinationGasBalance, expectedGas: gasMultiple.toFixed(), simGas: simulation.gasFee.toFixed(), }); const isGasRequiredToBeBorrowed = this.input.options.bridge ? gasMultiple.greaterThan(0) : gasMultiple.greaterThan(destinationGasBalance); let isIntentRequired = false; if (this.input.options.bridge) { isIntentRequired = true; } let gas = new Decimal(0); logger.debug("ERC20RequestBase:parseSimulation:1", { abstracted: asset?.abstracted, chainsWithBalance, destinationAssetBalance, isGasRequiredToBeBorrowed, }); if (chainsWithBalance && asset?.abstracted) { if (amount.greaterThan(destinationAssetBalance)) { isIntentRequired = true; } if (isGasRequiredToBeBorrowed) { isIntentRequired = true; gas = this.input.options.bridge ? gasMultiple : gasMultiple.minus(destinationGasBalance); } } return { amount, gas, isIntentRequired, }; } } export default ERC20RequestBase;