@citizenwallet/sdk
Version:
An sdk to easily work with citizen wallet.
984 lines (827 loc) • 25.5 kB
text/typescript
import { type JsonRpcProvider, ethers } from "ethers";
import tokenEntryPointContractAbi from "../abi/TokenEntryPoint.abi.json";
import accountFactoryContractAbi from "../abi/AccountFactory.abi.json";
import safeAccountFactoryContractAbi from "../abi/SafeAccountFactory.abi.json";
import accountContractAbi from "../abi/Account.abi.json";
import safeContractAbi from "../abi/Safe.abi.json";
import tokenContractAbi from "../abi/ERC20.abi.json";
import profileContractAbi from "../abi/Profile.abi.json";
import { formatUsernameToBytes32 } from "../profiles";
import { MINTER_ROLE, hasRole } from "../utils/crypto";
import type { CommunityConfig } from "../config";
import { tokenTransferEventTopic } from "../calldata";
import { addressToId } from "../profiles/utils";
import accessControlABI from "../abi/IAccessControlUpgradeable.abi.json";
const accountFactoryInterface = new ethers.Interface(accountFactoryContractAbi);
const safeAccountFactoryInterface = new ethers.Interface(
safeAccountFactoryContractAbi
);
const accountInterface = new ethers.Interface(accountContractAbi);
const safeInterface = new ethers.Interface(safeContractAbi);
const erc20Token = new ethers.Interface(tokenContractAbi);
const profileInterface = new ethers.Interface(profileContractAbi);
const accessControlInterface = new ethers.Interface(accessControlABI);
export interface UserOpData {
[key: string]: string;
}
export interface UserOpExtraData {
description: string;
}
export interface UserOp {
sender: string;
nonce: bigint;
initCode: Uint8Array;
callData: Uint8Array;
callGasLimit: bigint;
verificationGasLimit: bigint;
preVerificationGas: bigint;
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
paymasterAndData: Uint8Array;
signature: Uint8Array;
}
interface JsonUserOp {
sender: string;
nonce: string;
initCode: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
paymasterAndData: string;
signature: string;
}
const executeCallData = (
contractAddress: string,
value: bigint,
calldata: Uint8Array
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
contractAddress,
value,
calldata,
])
);
const executeSafeCallData = (
contractAddress: string,
value: bigint,
calldata: Uint8Array
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
contractAddress,
value,
calldata,
BigInt(0),
])
);
const transferCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
value,
erc20Token.encodeFunctionData("transfer", [receiver, amount]),
])
);
const safeTransferCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
tokenAddress,
value,
erc20Token.encodeFunctionData("transfer", [receiver, amount]),
BigInt(0),
])
);
const mintCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
value,
erc20Token.encodeFunctionData("mint", [receiver, amount]),
])
);
const safeMintCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
tokenAddress,
value,
erc20Token.encodeFunctionData("mint", [receiver, amount]),
BigInt(0),
])
);
const burnFromCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
value,
erc20Token.encodeFunctionData("burnFrom", [receiver, amount]),
])
);
const safeBurnFromCallData = (
tokenAddress: string,
value: bigint,
receiver: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
tokenAddress,
value,
erc20Token.encodeFunctionData("burnFrom", [receiver, amount]),
BigInt(0),
])
);
const profileCallData = (
profileContractAddress: string,
profileAccountAddress: string,
username: string,
ipfsHash: string
): Uint8Array => {
return ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
profileContractAddress,
BigInt(0),
profileInterface.encodeFunctionData("set", [
profileAccountAddress,
formatUsernameToBytes32(username),
ipfsHash,
]),
])
);
};
const safeProfileCallData = (
profileContractAddress: string,
profileAccountAddress: string,
username: string,
ipfsHash: string
): Uint8Array => {
return ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
profileContractAddress,
BigInt(0),
profileInterface.encodeFunctionData("set", [
profileAccountAddress,
formatUsernameToBytes32(username),
ipfsHash,
]),
BigInt(0),
])
);
};
const profileBurnCallData = (
profileContractAddress: string,
tokenId: bigint
): Uint8Array => {
return ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
profileContractAddress,
BigInt(0),
profileInterface.encodeFunctionData("burn", [tokenId]),
])
);
};
const safeProfileBurnCallData = (
profileContractAddress: string,
tokenId: bigint
): Uint8Array => {
return ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
profileContractAddress,
BigInt(0),
profileInterface.encodeFunctionData("burn", [tokenId]),
BigInt(0),
])
);
};
const approveCallData = (
tokenAddress: string,
issuer: string,
amount: bigint
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
BigInt(0),
erc20Token.encodeFunctionData("approve", [issuer, amount]),
])
);
const getEmptyUserOp = (sender: string): UserOp => ({
sender,
nonce: BigInt(0),
initCode: ethers.getBytes("0x"),
callData: ethers.getBytes("0x"),
callGasLimit: BigInt(0),
verificationGasLimit: BigInt(0),
preVerificationGas: BigInt(0),
maxFeePerGas: BigInt(0),
maxPriorityFeePerGas: BigInt(0),
paymasterAndData: ethers.getBytes("0x"),
signature: ethers.getBytes("0x"),
});
const userOpToJson = (userop: UserOp): JsonUserOp => {
const newUserop: JsonUserOp = {
sender: userop.sender,
nonce: ethers.toBeHex(userop.nonce.toString()).replace("0x0", "0x"),
initCode: ethers.hexlify(userop.initCode),
callData: ethers.hexlify(userop.callData),
callGasLimit: ethers
.toBeHex(userop.callGasLimit.toString())
.replace("0x0", "0x"),
verificationGasLimit: ethers
.toBeHex(userop.verificationGasLimit.toString())
.replace("0x0", "0x"),
preVerificationGas: ethers
.toBeHex(userop.preVerificationGas.toString())
.replace("0x0", "0x"),
maxFeePerGas: ethers
.toBeHex(userop.maxFeePerGas.toString())
.replace("0x0", "0x"),
maxPriorityFeePerGas: ethers
.toBeHex(userop.maxPriorityFeePerGas.toString())
.replace("0x0", "0x"),
paymasterAndData: ethers.hexlify(userop.paymasterAndData),
signature: ethers.hexlify(userop.signature),
};
return newUserop;
};
const userOpFromJson = (userop: JsonUserOp): UserOp => {
const newUserop: UserOp = {
sender: userop.sender,
nonce: BigInt(userop.nonce),
initCode: ethers.getBytes(userop.initCode),
callData: ethers.getBytes(userop.callData),
callGasLimit: BigInt(userop.callGasLimit),
verificationGasLimit: BigInt(userop.verificationGasLimit),
preVerificationGas: BigInt(userop.preVerificationGas),
maxFeePerGas: BigInt(userop.maxFeePerGas),
maxPriorityFeePerGas: BigInt(userop.maxPriorityFeePerGas),
paymasterAndData: ethers.getBytes(userop.paymasterAndData),
signature: ethers.getBytes(userop.signature),
};
return newUserop;
};
const grantRoleCallData = (
tokenAddress: string,
role: string,
account: string
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
BigInt(0),
erc20Token.encodeFunctionData("grantRole", [role, account]),
])
);
const safeGrantRoleCallData = (
tokenAddress: string,
role: string,
account: string
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
tokenAddress,
BigInt(0),
erc20Token.encodeFunctionData("grantRole", [role, account]),
BigInt(0),
])
);
const revokeRoleCallData = (
tokenAddress: string,
role: string,
account: string
): Uint8Array =>
ethers.getBytes(
accountInterface.encodeFunctionData("execute", [
tokenAddress,
BigInt(0),
erc20Token.encodeFunctionData("revokeRole", [role, account]),
])
);
const safeRevokeRoleCallData = (
tokenAddress: string,
role: string,
account: string
): Uint8Array =>
ethers.getBytes(
safeInterface.encodeFunctionData("execTransactionFromModule", [
tokenAddress,
BigInt(0),
erc20Token.encodeFunctionData("revokeRole", [role, account]),
BigInt(0),
])
);
export interface BundlerOptions {
accountFactoryAddress?: string;
}
export class BundlerService {
private provider: JsonRpcProvider;
private accountType: "cw" | "cw-safe";
private options: BundlerOptions = {};
constructor(private config: CommunityConfig, options?: BundlerOptions) {
this.config = config;
const rpcUrl = this.config.getRPCUrl(options?.accountFactoryAddress);
this.provider = new ethers.JsonRpcProvider(rpcUrl);
this.accountType = this.config.getAccountConfig(
options?.accountFactoryAddress
).paymaster_type as "cw" | "cw-safe";
if (options) {
this.options = { ...this.options, ...options };
}
}
async senderAccountExists(sender: string): Promise<boolean> {
const url = `${this.config.primaryNetwork.node.url}/v1/accounts/${sender}/exists`;
const resp = await fetch(url);
return resp.status === 200;
}
private generateUserOp(
signerAddress: string,
sender: string,
senderAccountExists = false,
accountFactoryAddress: string,
callData: Uint8Array
): UserOp {
const userop = getEmptyUserOp(sender);
// initCode
if (!senderAccountExists) {
const accountCreationCode =
this.accountType === "cw-safe"
? safeAccountFactoryInterface.encodeFunctionData("createAccount", [
signerAddress,
BigInt(0),
])
: accountFactoryInterface.encodeFunctionData("createAccount", [
signerAddress,
BigInt(0),
]);
userop.initCode = ethers.getBytes(
ethers.concat([accountFactoryAddress, accountCreationCode])
);
}
// callData
userop.callData = callData;
return userop;
}
private async prepareUserOp(
owner: string,
sender: string,
callData: Uint8Array,
options?: { accountFactoryAddress?: string }
): Promise<UserOp> {
const { accountFactoryAddress } = options ?? {};
const accountsConfig = this.config.getAccountConfig(accountFactoryAddress);
const account_factory_address = accountsConfig.account_factory_address;
// check that the sender's account exists
const exists = await this.senderAccountExists(sender);
// generate a userop
const userop = this.generateUserOp(
owner,
sender,
exists,
account_factory_address,
callData
);
return userop;
}
private async paymasterSignUserOp(
userop: UserOp,
options?: { accountFactoryAddress?: string }
): Promise<UserOp> {
const { accountFactoryAddress } = options ?? {};
const method = "pm_ooSponsorUserOperation";
const accountsConfig = this.config.getAccountConfig(accountFactoryAddress);
const params = [
userOpToJson(userop),
accountsConfig.entrypoint_address,
{ type: accountsConfig.paymaster_type },
1,
];
const response = await this.provider.send(method, params);
if (!response?.length) {
throw new Error("Invalid response");
}
return userOpFromJson(response[0]);
}
private async signUserOp(
signer: ethers.Signer,
userop: UserOp,
options?: { accountFactoryAddress?: string }
): Promise<Uint8Array> {
const { accountFactoryAddress } = options ?? {};
const accountsConfig = this.config.getAccountConfig(accountFactoryAddress);
const tokenEntryPointContract = new ethers.Contract(
accountsConfig.entrypoint_address,
tokenEntryPointContractAbi,
this.provider
);
const userOpHash = ethers.getBytes(
await tokenEntryPointContract.getUserOpHash(userop)
);
const signature = ethers.getBytes(await signer.signMessage(userOpHash));
return signature;
}
private async submitUserOp(
userop: UserOp,
data?: UserOpData,
extraData?: UserOpExtraData,
options?: { accountFactoryAddress?: string }
) {
const { accountFactoryAddress } = options ?? {};
const method = "eth_sendUserOperation";
const accountConfig = this.config.getAccountConfig(accountFactoryAddress);
const params: (string | JsonUserOp | UserOpData | UserOpExtraData)[] = [
userOpToJson(userop),
accountConfig.entrypoint_address,
];
if (data) {
params.push(data);
}
if (extraData) {
params.push(extraData);
}
const response: string = await this.provider.send(method, params);
if (!response?.length) {
throw new Error("Invalid response");
}
return response;
}
async call(
signer: ethers.Signer,
contractAddress: string,
sender: string,
data: Uint8Array,
value?: bigint,
userOpData?: UserOpData,
extraData?: UserOpExtraData,
options?: { accountFactoryAddress?: string }
) {
const { accountFactoryAddress } = options ?? {};
const owner = await signer.getAddress();
const calldata =
this.accountType === "cw-safe"
? executeSafeCallData(contractAddress, value ?? BigInt(0), data)
: executeCallData(contractAddress, value ?? BigInt(0), data);
let userop = await this.prepareUserOp(owner, sender, calldata, {
accountFactoryAddress,
});
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
// submit the user op
const hash = await this.submitUserOp(userop, userOpData, extraData, {
accountFactoryAddress,
});
return hash;
}
async sendERC20Token(
signer: ethers.Signer,
tokenAddress: string,
from: string,
to: string,
amount: string,
description?: string,
options?: { accountFactoryAddress?: string }
): Promise<string> {
const { accountFactoryAddress } = options ?? {};
const token = this.config.getToken(tokenAddress);
const formattedAmount = ethers.parseUnits(amount, token.decimals);
const calldata =
this.accountType === "cw-safe"
? safeTransferCallData(token.address, BigInt(0), to, formattedAmount)
: transferCallData(token.address, BigInt(0), to, formattedAmount);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(owner, from, calldata, {
accountFactoryAddress,
});
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
const data: UserOpData = {
topic: tokenTransferEventTopic,
from,
to,
value: formattedAmount.toString(),
};
// submit the user op
const hash = await this.submitUserOp(
userop,
data,
description !== undefined ? { description } : undefined,
{ accountFactoryAddress }
);
return hash;
}
async mintERC20Token(
signer: ethers.Signer,
tokenAddress: string,
from: string,
to: string,
amount: string,
description?: string,
options?: { accountFactoryAddress?: string }
): Promise<string> {
const { accountFactoryAddress } = options ?? {};
const token = this.config.getToken(tokenAddress);
const formattedAmount = ethers.parseUnits(amount, token.decimals);
const calldata =
this.accountType === "cw-safe"
? safeMintCallData(token.address, BigInt(0), to, formattedAmount)
: mintCallData(token.address, BigInt(0), to, formattedAmount);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(owner, from, calldata, {
accountFactoryAddress,
});
try {
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
} catch (e) {
throw new Error(`Error preparing user op: ${e}`);
}
try {
const data: UserOpData = {
topic: tokenTransferEventTopic,
from: ethers.ZeroAddress,
to,
value: formattedAmount.toString(),
};
// submit the user op
const hash = await this.submitUserOp(
userop,
data,
description !== undefined ? { description } : undefined,
{ accountFactoryAddress }
);
return hash;
} catch (e) {
if (!(await hasRole(tokenAddress, MINTER_ROLE, from, this.provider))) {
throw new Error(
`Signer (${from}) does not have the MINTER_ROLE on token contract ${tokenAddress}`
);
}
throw new Error(`Error submitting user op: ${e}`);
}
}
async burnFromERC20Token(
signer: ethers.Signer,
tokenAddress: string,
sender: string,
from: string,
amount: string,
description?: string,
options?: { accountFactoryAddress?: string }
): Promise<string> {
const { accountFactoryAddress } = options ?? {};
const token = this.config.getToken(tokenAddress);
const formattedAmount = ethers.parseUnits(amount, token.decimals);
const calldata =
this.accountType === "cw-safe"
? safeBurnFromCallData(token.address, BigInt(0), from, formattedAmount)
: burnFromCallData(token.address, BigInt(0), from, formattedAmount);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(owner, sender, calldata, {
accountFactoryAddress,
});
try {
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
} catch (e) {
throw new Error(`Error preparing user op: ${e}`);
}
try {
const data: UserOpData = {
topic: tokenTransferEventTopic,
from,
to: ethers.ZeroAddress,
value: formattedAmount.toString(),
};
// submit the user op
const hash = await this.submitUserOp(
userop,
data,
description !== undefined ? { description } : undefined,
{ accountFactoryAddress }
);
return hash;
} catch (e) {
if (!(await hasRole(token.address, MINTER_ROLE, from, this.provider))) {
throw new Error(
`Signer (${from}) does not have the MINTER_ROLE on token contract ${token.address}`
);
}
throw new Error(`Error submitting user op: ${e}`);
}
}
async setProfile(
signer: ethers.Signer,
signerAccountAddress: string,
profileAccountAddress: string,
username: string,
ipfsHash: string,
options?: { accountFactoryAddress?: string }
): Promise<string> {
const { accountFactoryAddress } = options ?? {};
const profile = this.config.community.profile;
const calldata =
this.accountType === "cw-safe"
? safeProfileCallData(
profile.address,
profileAccountAddress,
username,
ipfsHash
)
: profileCallData(
profile.address,
profileAccountAddress,
username,
ipfsHash
);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(
owner,
signerAccountAddress,
calldata,
{ accountFactoryAddress }
);
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
// submit the user op
const hash = await this.submitUserOp(userop, undefined, undefined, {
accountFactoryAddress,
});
return hash;
}
async burnProfile(
signer: ethers.Signer,
signerAccountAddress: string,
profileAccountAddress: string,
options?: { accountFactoryAddress?: string }
): Promise<string> {
const { accountFactoryAddress } = options ?? {};
const profile = this.config.community.profile;
const tokenId = addressToId(profileAccountAddress);
const calldata =
this.accountType === "cw-safe"
? safeProfileBurnCallData(profile.address, tokenId)
: profileBurnCallData(profile.address, tokenId);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(
owner,
signerAccountAddress,
calldata,
{ accountFactoryAddress }
);
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
// submit the user op
const hash = await this.submitUserOp(userop, undefined, undefined, {
accountFactoryAddress,
});
return hash;
}
async awaitSuccess(
txHash: string,
timeout: number = 12000
): Promise<ethers.TransactionReceipt> {
const receipt = await this.provider.waitForTransaction(txHash, 1, timeout);
if (!receipt) {
throw new Error("Transaction failed");
}
if (receipt.status !== 1) {
throw new Error("Transaction failed");
}
return receipt;
}
async grantRole(
signer: ethers.Signer,
tokenAddress: string,
sender: string,
role: string,
account: string,
options?: { accountFactoryAddress?: string }
) {
const { accountFactoryAddress } = options ?? {};
const token = this.config.getToken(tokenAddress);
const calldata =
this.accountType === "cw-safe"
? safeGrantRoleCallData(token.address, role, account)
: grantRoleCallData(token.address, role, account);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(owner, sender, calldata, {
accountFactoryAddress,
});
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
// submit the user op
const hash = await this.submitUserOp(userop, undefined, undefined, {
accountFactoryAddress,
});
return hash;
}
async revokeRole(
signer: ethers.Signer,
tokenAddress: string,
sender: string,
role: string,
account: string,
options?: { accountFactoryAddress?: string }
) {
const { accountFactoryAddress } = options ?? {};
const token = this.config.getToken(tokenAddress);
const calldata =
this.accountType === "cw-safe"
? safeRevokeRoleCallData(token.address, role, account)
: revokeRoleCallData(token.address, role, account);
const owner = await signer.getAddress();
let userop = await this.prepareUserOp(owner, sender, calldata, {
accountFactoryAddress,
});
// get the paymaster to sign the userop
userop = await this.paymasterSignUserOp(userop, {
accountFactoryAddress,
});
// sign the userop
const signature = await this.signUserOp(signer, userop, {
accountFactoryAddress,
});
userop.signature = signature;
// submit the user op
const hash = await this.submitUserOp(userop, undefined, undefined, {
accountFactoryAddress,
});
return hash;
}
}