@broxus/locklift-deploy
Version:
Locklift plugin for replicable deployments and easy testing
445 lines (418 loc) • 14.7 kB
text/typescript
import { FactoryType } from "locklift/internal/factory";
import { Address, Contract, Locklift } from "locklift";
import {
AccountWithSigner,
AddExistingAccountParams,
CreateAccountParams,
CreateAccountParamsWithoutPk,
DeployContractParams,
DeployContractResponse,
DeployType,
SaveAccount,
TagFile,
WriteDeployInfo,
} from "./types";
import path from "path";
import fs from "fs-extra";
import { concatMap, defer, from, lastValueFrom, mergeMap, of, tap, toArray } from "rxjs";
import { calculateDependenciesCount, isT } from "./utils";
import { Logger } from "./logger";
import { Transaction } from "locklift/everscale-provider";
import _ from "lodash";
export class Deployments<T extends FactoryType = FactoryType> {
deploymentsStore: Record<string, Contract<any>> = {};
accountsStore: Record<string, AccountWithSigner> = {};
private deployTypeSettings: { type: DeployType; forceDeploy: boolean } = {
type: DeployType.DEPLOY,
forceDeploy: false,
};
// private readonly pathToLogFile: string;
private readonly pathToNetworkFolder: string;
private readonly logger = new Logger();
constructor(
private readonly locklift: Locklift<T>,
// private readonly deployFolderPath: string,
private readonly tags: Array<TagFile>,
private readonly network: string,
private readonly networkId: number,
) {
this.pathToNetworkFolder = path.join("deployments", this.network);
fs.ensureDirSync(this.pathToNetworkFolder);
fs.writeFileSync(
`${this.pathToNetworkFolder}/.networkInfo.json`,
JSON.stringify({ chainId: this.networkId }, null, 4),
);
}
private getLogContent = (): Array<WriteDeployInfo> => {
const files = fs.readdirSync(this.pathToNetworkFolder);
return files
.filter((file) => file.endsWith("json"))
.map((fileName) => {
try {
const deployInfo = JSON.parse(
fs.readFileSync(path.join(this.pathToNetworkFolder, fileName), "utf-8"),
) as WriteDeployInfo;
if (deployInfo.type === "Contract" || deployInfo.type === "Account") {
return deployInfo;
}
} catch {}
})
.filter(isT);
};
private getAccountOrContractFilePath = (deploymentName: string, type: "Account" | "Contract") =>
path.join(this.pathToNetworkFolder, `${type}__${deploymentName}.json`);
private writeDeployInfo = <T extends AddExistingAccountParams>(deployInfo: WriteDeployInfo, enableLogs: boolean) => {
if (this.deployTypeSettings.type === DeployType.DEPLOY) {
const fileName = this.getAccountOrContractFilePath(deployInfo.deploymentName, deployInfo.type);
fs.writeFileSync(fileName, JSON.stringify(deployInfo, null, 4));
}
if (enableLogs) {
this.logger.printDeployLog(deployInfo);
}
};
needToRedeploy = (deploymentsName: string, type: "Contract" | "Account"): boolean => {
if (this.deployTypeSettings.forceDeploy) {
return true;
}
if (type === "Contract") {
return !this.deploymentsStore[deploymentsName];
}
return !this.accountsStore[deploymentsName];
// return (
// this.deployTypeSettings.forceDeploy &&
// this.deployTypeSettings.type === DeployType.DEPLOY &&
// (!!this.deploymentsStore[deploymentsName] || !!this.accountsStore[deploymentsName])
// );
};
//region contract
deploy = async ({
deployConfig,
deploymentName,
enableLogs = false,
}: {
deployConfig: DeployContractParams<T>;
deploymentName: string;
enableLogs?: boolean;
}): Promise<DeployContractResponse<T>> => {
if (!this.needToRedeploy(deploymentName, "Contract")) {
const contract = this.deploymentsStore[deploymentName];
if (enableLogs) {
this.logger.printRetrievedLog({
type: "Contract",
address: this.deploymentsStore[deploymentName].address.toString(),
deploymentName,
});
}
return {
contract: contract as unknown as Contract<T[keyof T]>,
newlyDeployed: false,
tx: undefined,
};
}
return this.locklift.factory.deployContract(deployConfig).then(
async (
res,
): Promise<{
contract: Contract<T[keyof T]>;
tx: { transaction: Transaction; output?: Record<string, unknown> | undefined };
newlyDeployed: true;
}> => {
this.setContractToStore({ deploymentName: deploymentName, contract: res.contract });
this.writeDeployInfo(
{
type: "Contract",
deploymentName,
abi: JSON.parse(res.contract.abi),
address: res.contract.address.toString(),
transaction: res.tx,
codeHash: await res.contract.getFullState().then((res) => res.state?.codeHash),
contractName: deployConfig.contract as string,
// @ts-ignore
deployContractParams: deployConfig,
},
enableLogs,
);
return {
contract: res.contract,
tx: res.tx,
newlyDeployed: true,
};
},
);
};
saveContract = async (
{
deploymentName,
address,
contractName,
}: {
deploymentName: string;
address: string | Address;
contractName: keyof T;
},
enableLogs = false,
) => {
const contract = this.locklift.factory.getDeployedContract(
contractName,
typeof address === "string" ? new Address(address) : address,
);
this.writeDeployInfo(
{
type: "Contract",
deploymentName,
address: contract.address.toString(),
// @ts-ignore
contractName: contractName,
abi: JSON.parse(contract.abi),
codeHash: await contract.getFullState().then((res) => res.state?.codeHash),
},
enableLogs,
);
this.setContractToStore({ contract, deploymentName });
};
private setContractToStore = ({ deploymentName, contract }: { contract: Contract<any>; deploymentName: string }) => {
this.deploymentsStore[deploymentName] = contract;
};
getContract = <T>(contractName: string): Contract<T> => {
const contract = this.deploymentsStore[contractName];
if (!contract) {
throw new Error(
`Contract ${contractName} not found in deployments store\nList of deployed contracts: \n${Object.keys(
this.deploymentsStore,
).join("\n")}`,
);
}
return contract;
};
//endregion
//region account
deployAccounts = async (
accounts: Array<{
deploymentName: string;
accountSettings: CreateAccountParamsWithoutPk<T>;
signerId: string;
}>,
enableLogs = false,
): Promise<Array<AccountWithSigner>> => {
return lastValueFrom(
from(accounts).pipe(
concatMap((accountSetup) =>
defer(async () => {
const { accountSettings, deploymentName, signerId } = accountSetup;
if (!this.needToRedeploy(deploymentName, "Account")) {
const { account, signer } = this.accountsStore[deploymentName];
if (enableLogs) {
this.logger.printRetrievedLog({
type: "Account",
address: account.address.toString(),
deploymentName,
});
return {
account: account,
signer,
deploymentName,
newlyDeployed: false,
};
}
}
const signer = await this.locklift.keystore.getSigner(signerId).then((mayBeSigner) => {
if (!mayBeSigner) {
throw new Error(`Signer with signerId ${signerId} not found`);
}
return mayBeSigner;
});
// @ts-ignore
const { account } = await this.locklift.factory.accounts.addNewAccount({
...accountSettings,
publicKey: signer.publicKey,
} as CreateAccountParams<T>);
this.writeDeployInfo(
{
type: "Account",
address: account.address.toString(),
deploymentName,
createAccountParams: {
...accountSettings,
publicKey: signer.publicKey,
} as CreateAccountParams<FactoryType>,
publicKey: signer.publicKey,
signerId,
},
enableLogs,
);
return {
account,
signer,
deploymentName,
newlyDeployed: true,
};
}),
),
tap(({ account, deploymentName, signer }) => {
this.setAccountToStore({ accountName: deploymentName, account, signer });
}),
toArray(),
),
);
};
getAccount = (accountName: string): AccountWithSigner => {
const accountWithSigner = this.accountsStore[accountName];
if (!accountWithSigner) {
throw new Error(`Account ${accountName} not found in deployments store`);
}
return accountWithSigner;
};
saveAccount = async <T extends AddExistingAccountParams>(
{
deploymentName,
signerId,
address,
accountSettings,
}: {
accountSettings: SaveAccount<T>;
signerId: string;
deploymentName: string;
address: string;
},
enableLogs = false,
) => {
const signer = await this.locklift.keystore.getSigner(signerId);
if (!signer) {
throw new Error(`Signer not found`);
}
this.writeDeployInfo(
{
type: "Account",
address: address,
signerId,
deploymentName,
publicKey: signer.publicKey,
saveAccountParams: accountSettings,
},
enableLogs,
);
const account = await this.locklift.factory.accounts.addExistingAccount({
// @ts-ignore
address: new Address(address),
publicKey: signer.publicKey,
...accountSettings,
});
this.setAccountToStore({ account, accountName: deploymentName, signer });
};
private setAccountToStore = ({ account, accountName, signer }: AccountWithSigner & { accountName: string }) => {
this.accountsStore[accountName] = { account, signer };
};
//endregion
reset = () => {
try {
const files = fs.readdirSync(this.pathToNetworkFolder);
files.forEach((file) => fs.rmSync(path.join(this.pathToNetworkFolder, file)));
// reset stores
this.deploymentsStore = {};
this.accountsStore = {};
} catch {}
};
load = async () => {
const accountsAndContracts = this.getLogContent();
const { contracts, accounts } = {
accounts: accountsAndContracts.filter(({ type }) => type === "Account") as Array<
Extract<WriteDeployInfo, { type: "Account" }>
>,
contracts: accountsAndContracts.filter(({ type }) => type === "Contract") as Array<
Extract<WriteDeployInfo, { type: "Contract" }>
>,
};
if (accounts.length > 0) {
await lastValueFrom(
from(accounts).pipe(
mergeMap((deployedAccount) =>
defer(async () => {
const accountSigner =
deployedAccount.signerId && (await this.locklift.keystore.getSigner(deployedAccount.signerId));
if (!accountSigner) {
throw new Error(`Signer id ${deployedAccount.signerId} not found`);
}
if (accountSigner.publicKey !== deployedAccount.publicKey) {
throw new Error(
`Signer ${deployedAccount.signerId} publicKey doesn't match with account ${deployedAccount.deploymentName} publicKey, did you forgot to set seed phrase in locklift.config.ts ?`,
);
}
const account = await this.locklift.factory.accounts.addExistingAccount({
...(deployedAccount.createAccountParams || deployedAccount.saveAccountParams),
address: new Address(deployedAccount.address),
publicKey: accountSigner.publicKey,
} as AddExistingAccountParams);
this.setAccountToStore({
accountName: deployedAccount.deploymentName,
account,
signer: accountSigner,
});
}),
),
),
);
}
contracts.forEach(({ deploymentName, contractName, address }) => {
this.setContractToStore({
deploymentName: deploymentName,
contract: this.locklift.factory.getDeployedContract(contractName, new Address(address)),
});
});
};
fixture = (fixtureConfig?: { include?: Array<string>; exclude?: Array<string> }) => {
this.deployTypeSettings = {
forceDeploy: true,
type: DeployType.FIXTURE,
};
return this._deployTags(fixtureConfig);
};
private deployTags = (fixtureConfig?: {
include?: Array<string>;
exclude?: Array<string>;
isForceDeploy: boolean;
}) => {
this.deployTypeSettings = {
forceDeploy: fixtureConfig?.isForceDeploy || false,
type: DeployType.DEPLOY,
};
return this._deployTags(fixtureConfig);
};
private _deployTags = async (fixtureConfig?: { include?: Array<string>; exclude?: Array<string> }) => {
const { include, exclude } = fixtureConfig || {};
if (include && exclude) {
throw new Error("includes and excludes can't be defined together");
}
const deploymentsConfig = this.tags
.filter(isT)
.map((el, idx, arr) => ({ ...el, dependenciesCount: calculateDependenciesCount(arr, el.tag, el.tag) }));
const includedTags = include
? deploymentsConfig.filter(({ tag }) => include.some((include) => tag === include))
: exclude
? deploymentsConfig.filter(({ tag }) => !exclude.some((exclude) => exclude === tag))
: deploymentsConfig;
const includedTagsWithDeps = _(
includedTags.reduce((acc, deployment) => {
acc.push(deployment);
if ((deployment.dependencies || []).length > 0) {
acc.push(...deploymentsConfig.filter(({ tag }) => deployment.dependencies?.includes(tag)));
}
return acc;
}, [] as typeof includedTags),
)
.unionBy("tag")
.sort((prev, next) => prev.dependenciesCount.deep - next.dependenciesCount.deep)
.value();
if (includedTagsWithDeps.length > 0) {
await lastValueFrom(
from(includedTagsWithDeps).pipe(
concatMap(({ default: deployFunction, tag }) => {
if (deployFunction && "name" in deployFunction) {
return deployFunction();
}
return of(undefined);
}),
),
);
}
};
}