@arcana/ca-sdk
Version:
Arcana Network's chain abstraction SDK for unified balance in Web3 apps
662 lines (661 loc) • 30.5 kB
JavaScript
import { ArcanaVault, ChaindataMap, ERC20ABI, EVMVaultABI, MsgCreateRequestForFunds, OmniversalChainID, OmniversalRFF, PermitVariant, Universe, } from "@arcana/ca-common";
import Decimal from "decimal.js";
import { Account, BN, CHAIN_IDS, hexlify } from "fuels";
import Long from "long";
import { hexToBytes, maxUint256, parseSignature, toBytes, toHex, } from "viem";
import { INTENT_EXPIRY, isNativeAddress } from "../../constants";
import { ErrorInsufficientBalance, ErrorUserDeniedAllowance, ErrorUserDeniedIntent, } from "../../errors";
import { getLogger } from "../../logger";
import { ALLOWANCE_APPROVAL_MINED, ALLOWANCE_APPROVAL_REQ, ALLOWANCE_COMPLETE, createSteps, INTENT_ACCEPTED, INTENT_DEPOSIT_REQ, INTENT_DEPOSITS_CONFIRMED, INTENT_FULFILLED, INTENT_HASH_SIGNED, INTENT_SUBMITTED, } from "../../steps";
import { convertGasToToken, convertIntent, convertTo32Bytes, convertTo32BytesHex, cosmosCreateRFF, createDepositDoubleCheckTx, createPublicClientWithFallback, createRequestEVMSignature, createRequestFuelSignature, equalFold, fetchBalances, fetchPriceOracle, getAllowances, getExplorerURL, getFeeStore, getSourcesAndDestinationsForRFF, mulDecimals, removeIntentHashFromStore, signPermitForAddressAndValue, storeIntentHashToStore, switchChain, vscCreateRFF, vscCreateSponsoredApprovals, vscPublishRFF, waitForTxReceipt, } from "../../utils";
const logger = getLogger();
class BaseRequest {
constructor(input) {
this.input = input;
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),
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;
}
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 } = this.parseSimulation({
assets,
simulation,
});
console.timeEnd("preIntentSteps: Parse");
if (!isIntentRequired) {
return;
}
console.time("preIntentSteps: CalculateGas");
const gasInToken = convertGasToToken(simulation.token, oraclePrices, this.input.chain.id, this.input.chain.universe, 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: simulation.token,
});
console.timeEnd("preIntentSteps: CreateIntent");
console.timeEnd("process:preIntentSteps");
return { intent, token: simulation.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);
};
this.markStepDone = (step, data) => {
const s = this.steps.find((s) => s.typeID === step.typeID);
if (s) {
this.input.options.emit("step_complete", {
...s,
...(data ? { data } : {}),
});
}
};
this.chainList = this.input.chainList;
}
getUnallowedSources(intent, allowances) {
const sources = [];
for (const s of intent.sources) {
if (s.chainID === intent.destination.chainID ||
isNativeAddress(s.universe, s.tokenContract)) {
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", {
currentAllowance: currentAllowance.toString(),
requiredAllowance: requiredAllowance.toString(),
token,
});
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 processIntent(intent) {
logger.debug("intent", { intent });
const { id, requestHash, waitForDoubleCheckTx } = await this.processRFF(intent);
storeIntentHashToStore(this.input.evm.address, id.toNumber());
await this.waitForFill(requestHash, id, waitForDoubleCheckTx);
removeIntentHashFromStore(this.input.evm.address, id);
this.markStepDone(INTENT_FULFILLED);
}
async processRFF(intent) {
const { destinations, sources, universes } = getSourcesAndDestinationsForRFF(intent, this.input.chainList, this.destinationUniverse);
const parties = [];
for (const universe of universes) {
if (universe === Universe.ETHEREUM) {
parties.push({
address: convertTo32BytesHex(this.input.evm.address),
universe: universe,
});
}
if (universe === Universe.FUEL) {
parties.push({
address: convertTo32BytesHex(this.input.fuel.address),
universe,
});
}
}
logger.debug("processRFF:1", {
destinations,
parties,
sources,
universes,
});
const omniversalRff = new OmniversalRFF({
destinationChainID: convertTo32Bytes(intent.destination.chainID),
destinations: destinations.map((dest) => ({
tokenAddress: toBytes(dest.tokenAddress),
value: toBytes(dest.value),
})),
destinationUniverse: intent.destination.universe,
expiry: Long.fromString((BigInt(Date.now() + INTENT_EXPIRY) / 1000n).toString()),
nonce: window.crypto.getRandomValues(new Uint8Array(32)),
// @ts-ignore
signatureData: parties.map((p) => ({
address: toBytes(p.address),
universe: p.universe,
})),
// @ts-ignore
sources: sources.map((source) => ({
chainID: convertTo32Bytes(source.chainID),
tokenAddress: convertTo32Bytes(source.tokenAddress),
universe: source.universe,
value: toBytes(source.value),
})),
});
const signatureData = [];
for (const universe of universes) {
if (universe === Universe.ETHEREUM) {
const { requestHash, signature } = await createRequestEVMSignature(omniversalRff.asEVMRFF(), this.input.evm.address, this.input.evm.client);
signatureData.push({
address: convertTo32Bytes(this.input.evm.address),
requestHash,
signature,
universe: Universe.ETHEREUM,
});
}
if (universe === Universe.FUEL) {
if (!this.input.fuel?.address ||
!this.input.fuel?.provider ||
!this.input.fuel?.connector) {
logger.error("universe has fuel but not expected input", {
fuelInput: this.input.fuel,
});
throw new Error("universe has fuel but not expected input");
}
const { requestHash, signature } = await createRequestFuelSignature(this.input.chainList.getVaultContractAddress(CHAIN_IDS.fuel.mainnet), this.input.fuel.provider, this.input.fuel.connector, omniversalRff.asFuelRFF());
signatureData.push({
address: toBytes(this.input.fuel.address),
requestHash,
signature,
universe: Universe.FUEL,
});
}
}
logger.debug("processRFF:2", { omniversalRff, signatureData });
this.markStepDone(INTENT_HASH_SIGNED);
const cosmosWalletAddress = (await this.input.cosmosWallet.getAccounts())[0]
.address;
const msgBasicCosmos = MsgCreateRequestForFunds.create({
destinationChainID: omniversalRff.protobufRFF.destinationChainID,
destinations: omniversalRff.protobufRFF.destinations,
destinationUniverse: omniversalRff.protobufRFF.destinationUniverse,
expiry: omniversalRff.protobufRFF.expiry,
nonce: omniversalRff.protobufRFF.nonce,
signatureData: signatureData.map((s) => ({
address: s.address,
signature: s.signature,
universe: s.universe,
})),
sources: omniversalRff.protobufRFF.sources,
user: cosmosWalletAddress,
});
logger.debug("processRFF:3", { msgBasicCosmos });
const intentID = await cosmosCreateRFF({
address: cosmosWalletAddress,
cosmosURL: this.input.options.networkConfig.COSMOS_URL,
msg: msgBasicCosmos,
wallet: this.input.cosmosWallet,
});
this.markStepDone(INTENT_SUBMITTED, {
explorerURL: getExplorerURL(this.input.options.networkConfig.EXPLORER_URL, intentID),
intentID: intentID.toNumber(),
});
const tokenCollections = [];
for (const [i, s] of sources.entries()) {
if (!isNativeAddress(s.universe, s.tokenAddress)) {
tokenCollections.push(i);
}
}
const evmDeposits = [];
const fuelDeposits = [];
const evmSignatureData = signatureData.find((d) => d.universe === Universe.ETHEREUM);
if (!evmSignatureData && universes.has(Universe.ETHEREUM)) {
throw new Error("ethereum in universe list but no signature data present");
}
const fuelSignatureData = signatureData.find((d) => d.universe === Universe.FUEL);
if (!fuelSignatureData && universes.has(Universe.FUEL)) {
throw new Error("fuel in universe list but no signature data present");
}
const doubleCheckTxs = [];
for (const [i, s] of sources.entries()) {
const chain = this.input.chainList.getChainByID(Number(s.chainID));
if (!chain) {
throw new Error("chain not found");
}
if (s.universe === Universe.FUEL) {
if (!this.input.fuel) {
throw new Error("fuel is involved but no associated data");
}
const account = new Account(this.input.fuel.address, this.input.fuel.provider, this.input.fuel.connector);
const vault = new ArcanaVault(this.chainList.getVaultContractAddress(CHAIN_IDS.fuel.mainnet), account);
const tx = await vault.functions
.deposit(omniversalRff.asFuelRFF(), hexlify(fuelSignatureData.signature), i)
.callParams({
forward: {
amount: new BN(s.value.toString()),
assetId: s.tokenAddress,
},
})
.call();
this.markStepDone(INTENT_DEPOSIT_REQ(i + 1));
fuelDeposits.push((async function () {
const txResult = await tx.waitForResult();
logger.debug("PostIntentSubmission: Fuel deposit result", {
txResult,
});
if (txResult.transactionResult.isStatusFailure) {
throw new Error("fuel deposit failed");
}
})());
}
else if (s.universe === Universe.ETHEREUM &&
isNativeAddress(s.universe, s.tokenAddress)) {
const chain = this.input.chainList.getChainByID(Number(s.chainID));
if (!chain) {
throw new Error("chain not found");
}
await switchChain(this.input.evm.client, chain);
const publicClient = createPublicClientWithFallback(chain);
const { request } = await publicClient.simulateContract({
abi: EVMVaultABI,
account: this.input.evm.address,
address: this.input.chainList.getVaultContractAddress(chain.id),
args: [
omniversalRff.asEVMRFF(),
toHex(evmSignatureData.signature),
BigInt(i),
],
chain: chain,
functionName: "deposit",
value: s.value,
});
const hash = await this.input.evm.client.writeContract(request);
this.markStepDone(INTENT_DEPOSIT_REQ(i + 1));
evmDeposits.push(waitForTxReceipt(hash, publicClient));
}
doubleCheckTxs.push(createDepositDoubleCheckTx(convertTo32Bytes(chain.id), {
address: cosmosWalletAddress,
wallet: this.input.cosmosWallet,
}, intentID, this.input.options.networkConfig));
}
if (evmDeposits.length || fuelDeposits.length) {
await Promise.all([Promise.all(evmDeposits), Promise.all(fuelDeposits)]);
this.markStepDone(INTENT_DEPOSITS_CONFIRMED);
}
logger.debug("PostIntentSubmission: Intent ID", {
id: intentID.toNumber(),
});
if (tokenCollections.length > 0) {
logger.debug("processRFF", {
intentID: intentID.toString(),
message: "going to create RFF",
tokenCollections,
});
await vscCreateRFF(this.input.options.networkConfig.VSC_DOMAIN, intentID, this.markStepDone, tokenCollections);
}
else {
logger.debug("processRFF", {
message: "going to publish RFF",
});
await vscPublishRFF(this.input.options.networkConfig.VSC_DOMAIN, intentID);
}
const destinationSigData = signatureData.find((s) => s.universe === intent.destination.universe);
if (!destinationSigData) {
throw new Error("requestHash not found for destination");
}
return {
id: intentID,
requestHash: destinationSigData.requestHash,
waitForDoubleCheckTx: waitForDoubleCheckTx(doubleCheckTxs),
};
}
async setAllowances(input) {
const originalChain = this.input.chain.id;
logger.debug("setAllowances", { originalChain });
const sponsoredApprovalParams = [];
try {
for (const source of input) {
const chain = this.chainList.getChainByID(source.chainID);
if (!chain) {
throw new Error("chain not supported");
}
const publicClient = createPublicClientWithFallback(chain);
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 switchChain(this.input.evm.client, chain);
if (currency.permitVariant === PermitVariant.Unsupported) {
const h = await this.input.evm.client.writeContract({
abi: ERC20ABI,
account: this.input.evm.address,
address: source.tokenContract,
args: [vc, BigInt(source.amount)],
chain,
functionName: "approve",
});
this.markStepDone(ALLOWANCE_APPROVAL_REQ(source.chainID));
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, publicClient, account, vc, source.amount));
this.markStepDone(ALLOWANCE_APPROVAL_REQ(source.chainID));
sponsoredApprovalParams.push({
address: convertTo32Bytes(account.address),
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);
}
}
catch (e) {
console.error("Error setting allowances", e);
throw ErrorUserDeniedAllowance;
}
finally {
if (this.input.chain.universe === Universe.ETHEREUM) {
await switchChain(this.input.evm.client, this.input.chain);
}
this.markStepDone(ALLOWANCE_COMPLETE);
}
}
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 input length for allow(). expected: ${sources.length} got: ${allowances.length}`));
}
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;
}
createExpectedSteps(intent, unallowedSources) {
this.steps = createSteps(intent, this.chainList, unallowedSources);
this.input.options.emit("expected_steps", this.steps);
logger.debug("ExpectedSteps", this.steps);
}
createIntent(input) {
const { amount, assets, feeStore, gas, gasInToken, token } = input;
const intent = {
destination: {
amount: new Decimal("0"),
chainID: this.input.chain.id,
decimals: token.decimals,
gas: 0n,
tokenContract: token.contractAddress,
universe: this.destinationUniverse,
},
fees: {
caGas: "0",
collection: "0",
fulfilment: "0",
gasSupplied: input.gasInToken.toFixed(),
protocol: "0",
solver: "0",
},
isAvailableBalanceInsufficient: false,
sources: [],
};
const asset = assets.find(token.symbol);
if (!asset) {
throw new Error(`Asset ${token.symbol} not found in UserAssets`);
}
const destinationBalance = asset.getBalanceOnChain(this.input.chain.id, token.contractAddress);
let borrow = new Decimal(0);
if (this.input.options.bridge) {
borrow = amount;
}
else {
if (amount.greaterThan(destinationBalance)) {
borrow = amount.minus(destinationBalance);
}
if (destinationBalance !== "0") {
intent.sources.push({
amount: amount.greaterThan(destinationBalance)
? new Decimal(destinationBalance)
: amount,
chainID: this.input.chain.id,
tokenContract: token.contractAddress,
universe: this.destinationUniverse,
});
}
}
const protocolFee = feeStore.calculateProtocolFee(borrow);
intent.fees.protocol = protocolFee.toFixed();
let borrowWithFee = borrow.add(gasInToken).add(protocolFee);
logger.debug("createIntent:0", {
borrow: borrow.toFixed(),
borrowWithFee: borrowWithFee.toFixed(),
destinationBalance,
gasInToken: gasInToken.toFixed(),
protocolFee: protocolFee.toFixed(),
});
const fulfilmentFee = feeStore.calculateFulfilmentFee({
decimals: token.decimals,
destinationChainID: this.input.chain.id,
destinationTokenAddress: token.contractAddress,
});
logger.debug("createIntent:1", { fulfilmentFee });
intent.fees.fulfilment = fulfilmentFee.toFixed();
borrowWithFee = borrowWithFee.add(fulfilmentFee);
let accountedAmount = new Decimal(0);
for (const assetC of asset.iterate(feeStore)) {
if (accountedAmount.greaterThanOrEqualTo(borrowWithFee)) {
break;
}
if (assetC.chainID === this.input.chain.id) {
continue;
}
if (assetC.chainID === CHAIN_IDS.fuel.mainnet) {
const fuelChain = this.chainList.getChainByID(CHAIN_IDS.fuel.mainnet);
const baseAssetBalanceOnFuel = assets.getNativeBalance(fuelChain);
if (new Decimal(baseAssetBalanceOnFuel).lessThan("0.000_003")) {
logger.debug("fuel base asset balance is lesser than min expected deposit fee, so skip", {
current: baseAssetBalanceOnFuel,
minimum: "0.000_003",
});
continue;
}
}
if (!isNativeAddress(assetC.universe, assetC.tokenContract)) {
const collectionFee = feeStore.calculateCollectionFee({
decimals: assetC.decimals,
sourceChainID: assetC.chainID,
sourceTokenAddress: assetC.tokenContract,
});
intent.fees.collection = collectionFee
.add(intent.fees.collection)
.toFixed();
borrowWithFee = borrowWithFee.add(collectionFee);
logger.debug("createIntent:2", { collectionFee });
}
const unaccountedAmount = borrowWithFee.minus(accountedAmount);
let borrowFromThisChain = new Decimal(assetC.balance).lessThanOrEqualTo(unaccountedAmount)
? new Decimal(assetC.balance)
: unaccountedAmount;
const solverFee = feeStore.calculateSolverFee({
borrowAmount: borrowFromThisChain,
decimals: assetC.decimals,
destinationChainID: this.input.chain.id,
destinationTokenAddress: token.contractAddress,
sourceChainID: assetC.chainID,
sourceTokenAddress: assetC.tokenContract,
});
intent.fees.solver = solverFee.add(intent.fees.solver).toFixed();
logger.debug("createIntent:3", { solverFee });
borrowWithFee = borrowWithFee.add(solverFee);
const unaccountedBalance = borrowWithFee.minus(accountedAmount);
borrowFromThisChain = new Decimal(assetC.balance).lessThanOrEqualTo(unaccountedBalance)
? new Decimal(assetC.balance)
: unaccountedBalance;
intent.sources.push({
amount: borrowFromThisChain,
chainID: assetC.chainID,
tokenContract: assetC.tokenContract,
universe: assetC.universe,
});
accountedAmount = accountedAmount.add(borrowFromThisChain);
}
intent.destination.amount = borrow;
if (accountedAmount < borrowWithFee) {
intent.isAvailableBalanceInsufficient = true;
}
if (!gas.equals(0)) {
intent.destination.gas = mulDecimals(gas, this.input.chain.nativeCurrency.decimals);
}
logger.debug("createIntent:4", { intent });
return intent;
}
}
const waitForDoubleCheckTx = (input) => {
return async () => {
await Promise.allSettled(input.map((i) => i()));
};
};
export default BaseRequest;