@citizenwallet/sdk
Version: 
An sdk to easily work with citizen wallet.
497 lines (446 loc) • 16.2 kB
text/typescript
import {
  id,
  keccak256,
  AbiCoder,
  verifyMessage,
  getBytes,
  Wallet,
  Interface,
  JsonRpcProvider,
  Contract,
} from 'ethers';
import { CommunityConfig } from '../config';
import { BundlerService } from '../bundler';
import sessionManagerModuleJson from '../abi/SessionManagerModule.json';
import twoFAFactoryJson from '../abi/TwoFAFactory.json';
const sessionManagerInterface = new Interface(sessionManagerModuleJson.abi);
const twoFAFactoryInterface = new Interface(twoFAFactoryJson.abi);
/**
 * Generates a unique salt for a session based on a source and type.
 * The salt is created by hashing the concatenated source and type with a colon separator.
 *
 * @param {string} params.source - The source identifier for the session
 * @param {string} params.type - The type identifier for the session
 * @returns {string} A bytes32 hash of the source and type combination
 */
export const generateSessionSalt = ({
  source,
  type,
}: {
  source: string;
  type: string;
}): string => {
  return id(`${source}:${type}`);
};
/**
 * Generates a hash for a session request by encoding and hashing the session parameters.
 * Uses ABI encoding to ensure compatibility with the Dart implementation.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig
 * @param {string} params.sessionOwner - The address of the session owner (address of private key)
 * @param {string} params.salt - The unique salt generated for this session
 * @param {number} params.expiry - UTC timestamp in seconds when the session expires
 * @returns {string} A bytes32 hash of the encoded session request parameters
 */
export const generateSessionRequestHash = ({
  community,
  sessionOwner,
  salt,
  expiry,
}: {
  community: CommunityConfig;
  sessionOwner: string;
  salt: string;
  expiry: number;
}): string => {
  const sessionProvider = community.primarySessionConfig.provider_address;
  // Use ABI encoding to match the Dart implementation
  const abiCoder = new AbiCoder();
  const packedData = abiCoder.encode(
    ['address', 'address', 'bytes32', 'uint48'],
    [sessionProvider, sessionOwner, salt, BigInt(expiry)]
  );
  const result = keccak256(packedData);
  return result;
};
/**
 * Generates the final session hash by combining the session request hash with a challenge number.
 * Uses ABI encoding to ensure compatibility with the Dart implementation.
 *
 * @param {string} params.sessionRequestHash - The hash generated from generateSessionRequestHash
 * @param {number} params.challenge - The challenge number to combine with the session request hash
 * @returns {string} A bytes32 hash of the encoded session parameters and challenge
 */
export const generateSessionHash = ({
  sessionRequestHash,
  challenge,
}: {
  sessionRequestHash: string;
  challenge: number | string;
}): string => {
  // Use ABI encoding to match the Dart implementation
  const abiCoder = new AbiCoder();
  const packedData = abiCoder.encode(
    ['bytes32', 'uint256'],
    [sessionRequestHash, BigInt(challenge)]
  );
  return keccak256(packedData);
};
/**
 * Verifies a session request by validating the signature against the session owner's address.
 * This function combines the session salt generation and request hash generation to verify
 * that the signature was created by the session owner.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig
 * @param {string} params.sessionOwner - The address of the session owner to verify against
 * @param {string} params.source - The source identifier used in generating the session salt
 * @param {string} params.type - The type identifier used in generating the session salt
 * @param {number} params.expiry - UTC timestamp in seconds when the session expires
 * @param {string} params.signature - The signature to verify, created by the session owner
 * @returns {boolean} True if the signature is valid and matches the session owner's address
 */
export const verifySessionRequest = ({
  community,
  sessionOwner,
  source,
  type,
  expiry,
  signature,
}: {
  community: CommunityConfig;
  sessionOwner: string;
  source: string;
  type: string;
  expiry: number;
  signature: string;
}): boolean => {
  const salt = generateSessionSalt({ source, type });
  const sessionRequestHash = generateSessionRequestHash({
    community,
    sessionOwner,
    salt,
    expiry,
  });
  const recoveredAddress = verifyMessage(
    getBytes(sessionRequestHash),
    signature
  );
  return recoveredAddress === sessionOwner;
};
/**
 * Verifies a session confirmation by validating the signed session hash against the session owner's address.
 * This function checks if the signature of the session hash was created by the session owner.
 *
 * @param {string} params.sessionOwner - The address of the session owner to verify against
 * @param {string} params.sessionHash - The session hash generated from generateSessionHash
 * @param {string} params.signedSessionHash - The signature of the session hash to verify
 * @returns {boolean} True if the signature is valid and matches the session owner's address
 */
export const verifySessionConfirm = ({
  sessionOwner,
  sessionHash,
  signedSessionHash,
}: {
  sessionOwner: string;
  sessionHash: string;
  signedSessionHash: string;
}): boolean => {
  const recoveredAddress = verifyMessage(
    getBytes(sessionHash),
    signedSessionHash
  );
  return recoveredAddress === sessionOwner;
};
/**
 * Submits a session request to the session manager contract through a bundler service.
 * This function encodes the session parameters and sends a transaction to create a new session.
 * The challenge expiry is automatically set to 120 seconds from the current time.
 *
 * @param {CommunityConfig} params.community - An instance of CommunityConfig
 * @param {Wallet} params.signer - The wallet instance used to sign the transaction
 * @param {string} params.sessionSalt - The unique salt generated for this session
 * @param {string} params.sessionRequestHash - The hash of the session request parameters
 * @param {string} params.signedSessionRequestHash - The signature of the session request hash
 * @param {string} params.signedSessionHash - The signature of the session hash
 * @param {number} params.sessionExpiry - UTC timestamp in seconds when the session expires
 * @returns {Promise<string>} The transaction hash of the session request
 */
export const requestSession = async ({
  community,
  signer,
  sessionSalt,
  sessionRequestHash,
  signedSessionRequestHash,
  signedSessionHash,
  sessionExpiry,
}: {
  community: CommunityConfig;
  signer: Wallet;
  sessionSalt: string;
  sessionRequestHash: string;
  signedSessionRequestHash: string;
  signedSessionHash: string;
  sessionExpiry: number;
}): Promise<string> => {
  const sessionModuleAddress = community.primarySessionConfig.module_address;
  const sessionProvider = community.primarySessionConfig.provider_address;
  const bundler = new BundlerService(community);
  const challengeExpiry = Math.floor(Date.now() / 1000) + 120;
  const data = getBytes(
    sessionManagerInterface.encodeFunctionData('request', [
      sessionSalt,
      sessionRequestHash,
      signedSessionRequestHash,
      signedSessionHash,
      sessionExpiry,
      challengeExpiry,
    ])
  );
  const tx = await bundler.call(
    signer,
    sessionModuleAddress,
    sessionProvider,
    data
  );
  return tx;
};
/**
 * Verifies an incoming session request by checking its validity against the session manager contract.
 * Performs multiple validations:
 * 1. Checks if the session request exists in the contract
 * 2. Validates that the session has not expired
 * 3. Validates that the challenge period has not expired
 * 4. Verifies the signature matches the stored signature
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig
 * @param {Wallet} params.signer - The wallet instance used to sign and verify the session
 * @param {string} params.sessionRequestHash - The hash of the session request to verify
 * @param {string} params.sessionHash - The session hash to sign and compare
 * @returns {Promise<boolean>} True if the session request is valid and signatures match, false otherwise
 * @throws {Error} If session request is not found, expired, or challenge period has ended
 */
export const verifyIncomingSessionRequest = async ({
  community,
  signer,
  sessionRequestHash,
  sessionHash,
  options,
}: {
  community: CommunityConfig;
  signer: Wallet;
  sessionRequestHash: string;
  sessionHash: string;
  options?: { accountFactoryAddress?: string };
}): Promise<boolean> => {
  const { accountFactoryAddress } = options ?? {};
  try {
    // Get the session manager contract address
    const sessionModuleAddress = community.primarySessionConfig.module_address;
    const sessionProvider = community.primarySessionConfig.provider_address;
    const rpcProvider = new JsonRpcProvider(
      community.getRPCUrl(accountFactoryAddress)
    );
    const contract = new Contract(
      sessionModuleAddress,
      sessionManagerInterface,
      rpcProvider
    );
    const result = await contract.sessionRequests(
      sessionProvider,
      sessionRequestHash
    );
    if (result.length < 5) {
      throw new Error('Session request not found');
    }
    // check the expiry
    const expiry = Number(result[0]);
    const now = Math.floor(Date.now() / 1000);
    if (expiry < now) {
      throw new Error('Session request expired');
    }
    // check the challenge expiry
    const challengeExpiry = Number(result[1]);
    if (challengeExpiry < now) {
      throw new Error('Challenge expired');
    }
    // Extract the stored signedSessionHash from the result
    const storedSignedSessionHash = result[2];
    // Sign the provided sessionHash with the signer
    const calculatedSignedSessionHash = await signer.signMessage(
      getBytes(sessionHash)
    );
    // Compare the stored signedSessionHash with the provided one
    return storedSignedSessionHash === calculatedSignedSessionHash;
  } catch (error) {
    console.error('Error verifying incoming session request:', error);
    return false;
  }
};
/**
 * Confirms a session request by submitting the confirmation transaction through a bundler service.
 * This function encodes the session confirmation parameters and sends them to the session manager contract.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig containing contract addresses
 * @param {Wallet} params.signer - The wallet instance used to sign the transaction
 * @param {string} params.sessionRequestHash - The hash of the original session request
 * @param {string} params.sessionHash - The final session hash to confirm
 * @param {string} params.signedSessionHash - The signature of the session hash
 * @returns {Promise<string>} The transaction hash of the confirmation transaction
 */
export const confirmSession = async ({
  community,
  signer,
  sessionRequestHash,
  sessionHash,
  signedSessionHash,
}: {
  community: CommunityConfig;
  signer: Wallet;
  sessionRequestHash: string;
  sessionHash: string;
  signedSessionHash: string;
}): Promise<string> => {
  const sessionModuleAddress = community.primarySessionConfig.module_address;
  const sessionProvider = community.primarySessionConfig.provider_address;
  const bundler = new BundlerService(community);
  const data = getBytes(
    sessionManagerInterface.encodeFunctionData('confirm', [
      sessionRequestHash,
      sessionHash,
      signedSessionHash,
    ])
  );
  const tx = await bundler.call(
    signer,
    sessionModuleAddress,
    sessionProvider,
    data
  );
  return tx;
};
/**
 * Checks if a session between an account and owner has expired by querying the session manager contract.
 * This function makes a direct call to the contract's isExpired method.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig containing contract addresses and RPC URL
 * @param {string} params.account - The account address to check the session for
 * @param {string} params.owner - The owner address to check the session against
 * @returns {Promise<boolean>} True if the session has expired or doesn't exist, false if the session is still valid
 * @throws {Error} Logs error and returns false if the contract call fails
 */
export const isSessionExpired = async ({
  community,
  account,
  owner,
  options,
}: {
  community: CommunityConfig;
  account: string;
  owner: string;
  options?: { accountFactoryAddress?: string };
}): Promise<boolean> => {
  const { accountFactoryAddress } = options ?? {};
  // Get the session manager contract address
  const sessionModuleAddress = community.primarySessionConfig.module_address;
  const rpcProvider = new JsonRpcProvider(
    community.getRPCUrl(accountFactoryAddress)
  );
  const contract = new Contract(
    sessionModuleAddress,
    sessionManagerInterface,
    rpcProvider
  );
  try {
    const result = await contract.isExpired(account, owner);
    return result;
  } catch (error) {
    console.error('Error checking if session is expired:', error);
    return true;
  }
};
/**
 * Retrieves the deterministic address for a 2FA account based on the community configuration, source, and type.
 * This function queries the 2FA factory contract to compute the address that would be created with these parameters.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig containing factory and provider addresses
 * @param {string} params.source - The source identifier used to generate the salt
 * @param {string} params.type - The type identifier used to generate the salt
 * @returns {Promise<string | null>} The computed 2FA address if successful, null if the computation fails
 */
export const getTwoFAAddress = async ({
  community,
  source,
  type,
  options,
}: {
  community: CommunityConfig;
  source: string;
  type: string;
  options?: {
    accountFactoryAddress?: string;
    sessionFactoryAddress?: string;
    sessionProviderAddress?: string;
    rpcUrl?: string;
  };
}): Promise<string | null> => {
  const {
    accountFactoryAddress,
    sessionFactoryAddress,
    sessionProviderAddress,
    rpcUrl,
  } = options ?? {};
  const factoryAddress =
    sessionFactoryAddress ?? community.primarySessionConfig.factory_address;
  const providerAddress =
    sessionProviderAddress ?? community.primarySessionConfig.provider_address;
  const salt = generateSessionSalt({ source, type });
  const saltBigInt = BigInt(salt);
  const resolvedRpcUrl = rpcUrl ?? community.getRPCUrl(accountFactoryAddress);
  const rpcProvider = new JsonRpcProvider(resolvedRpcUrl);
  const contract = new Contract(
    factoryAddress,
    twoFAFactoryInterface,
    rpcProvider
  );
  try {
    const result = await contract.getFunction('getAddress')(
      providerAddress,
      saltBigInt
    );
    return result;
  } catch (error) {
    console.error('Error getting twoFA address:', error);
    return null;
  }
};
/**
 * Revokes an active session between a signer and an account through the session manager contract.
 * This function sends a revocation transaction using the bundler service.
 *
 * @param {CommunityConfig} params.community - Instance of CommunityConfig containing contract addresses
 * @param {Wallet} params.signer - The wallet instance used to sign and revoke the session
 * @param {string} params.account - The account address from which to revoke the session
 * @returns {Promise<string|null>} The transaction hash if successful, null if the revocation fails
 */
export const revokeSession = async ({
  community,
  signer,
  account,
}: {
  community: CommunityConfig;
  signer: Wallet;
  account: string;
}): Promise<string | null> => {
  const sessionModuleAddress = community.primarySessionConfig.module_address;
  const bundler = new BundlerService(community);
  const data = getBytes(
    sessionManagerInterface.encodeFunctionData('revoke', [signer.address])
  );
  try {
    const tx = await bundler.call(signer, sessionModuleAddress, account, data);
    return tx;
  } catch (error) {
    console.error('Error revoking session:', error);
    return null;
  }
};