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