@arcana/ca-sdk
Version:
Arcana Network's chain abstraction SDK for unified balance in Web3 apps
378 lines (377 loc) • 17.7 kB
JavaScript
import { ArcanaVault, EVMVaultABI, MsgCreateRequestForFunds, OmniversalRFF, Universe, } from "@arcana/ca-common";
import Decimal from "decimal.js";
import { Account, BN, CHAIN_IDS, hexlify } from "fuels";
import Long from "long";
import { createPublicClient, http, toBytes, toHex } from "viem";
import { INTENT_EXPIRY, isNativeAddress } from "../../constants";
import { getLogger } from "../../logger";
import { createSteps, INTENT_DEPOSIT_REQ, INTENT_DEPOSITS_CONFIRMED, INTENT_FULFILLED, INTENT_HASH_SIGNED, INTENT_SUBMITTED, } from "../../steps";
import { convertTo32Bytes, convertTo32BytesHex, cosmosCreateRFF, createDepositDoubleCheckTx, createRequestEVMSignature, createRequestFuelSignature, getExplorerURL, getSourcesAndDestinationsForRFF, mulDecimals, removeIntentHashFromStore, storeIntentHashToStore, switchChain, vscCreateRFF, vscPublishRFF, waitForTxReceipt, } from "../../utils";
const logger = getLogger();
class BaseRequest {
constructor(input) {
this.input = input;
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;
}
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, sourceCounts, 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,
sourceCounts,
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(),
});
if (!this.isNative) {
await vscCreateRFF(this.input.options.networkConfig.VSC_DOMAIN, intentID, this.markStepDone, sourceCounts[Universe.ETHEREUM]);
}
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 = createPublicClient({
transport: http(chain.rpcUrls.default.http[0]),
});
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 (this.isNative) {
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),
};
}
createExpectedSteps(intent, unallowedSources) {
this.steps = createSteps(intent, this.isNative ? "native" : "erc20", 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 (!this.isNative) {
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;