Skip to content

Commit

Permalink
feat: add session keys service
Browse files Browse the repository at this point in the history
  • Loading branch information
ctrlc03 committed Aug 22, 2024
1 parent e72cfbb commit 3efd294
Show file tree
Hide file tree
Showing 16 changed files with 648 additions and 4 deletions.
3 changes: 2 additions & 1 deletion packages/coordinator/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ SUBGRAPH_DEPLOY_KEY=
# Subgraph project folder
SUBGRAPH_FOLDER=./node_modules/maci-subgraph


# API Key for Pimlico RPC Bundler
PIMLICO_API_KEY=""
2 changes: 1 addition & 1 deletion packages/coordinator/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
zkeys/
proofs/
tally.json

session-keys.json
13 changes: 12 additions & 1 deletion packages/coordinator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,38 @@
"@nestjs/websockets": "^10.3.10",
"@nomicfoundation/hardhat-ethers": "^3.0.6",
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
"@zerodev/ecdsa-validator": "^5.3.1",
"@zerodev/permissions": "^5.4.3",
"@zerodev/sdk": "^5.3.8",
"@zerodev/session-key": "^5.4.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.5",
"ethers": "^6.13.1",
"hardhat": "^2.22.6",
"helmet": "^7.1.0",
"lowdb": "^1.0.0",
"maci-circuits": "^2.1.0",
"maci-cli": "^2.1.0",
"maci-contracts": "^2.1.0",
"maci-domainobjs": "^2.0.0",
"maci-subgraph": "^2.1.0",
"mustache": "^4.2.0",
"permissionless": "^0.1.44",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"socket.io": "^4.7.5",
"tar": "^7.4.1",
"ts-node": "^10.9.1"
"ts-node": "^10.9.1",
"viem": "^2.7.15"
},
"devDependencies": {
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.10",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/lowdb": "^1.0.15",
"@types/node": "^20.14.11",
"@types/supertest": "^6.0.2",
"fast-check": "^3.20.0",
Expand Down Expand Up @@ -103,6 +111,9 @@
"!<rootDir>/ts/jest/*.js",
"!<rootDir>/hardhat.config.js"
],
"coveragePathIgnorePatterns": [
"<rootDir>/ts/sessionKeys/__tests__/utils.ts"
],
"coverageDirectory": "<rootDir>/coverage",
"testEnvironment": "node"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/coordinator/ts/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ThrottlerModule } from "@nestjs/throttler";
import { CryptoModule } from "./crypto/crypto.module";
import { FileModule } from "./file/file.module";
import { ProofModule } from "./proof/proof.module";
import { SessionKeysModule } from "./sessionKeys/sessionKeys.module";
import { SubgraphModule } from "./subgraph/subgraph.module";

@Module({
Expand All @@ -18,6 +19,7 @@ import { SubgraphModule } from "./subgraph/subgraph.module";
CryptoModule,
SubgraphModule,
ProofModule,
SessionKeysModule,
],
})
export class AppModule {}
14 changes: 14 additions & 0 deletions packages/coordinator/ts/common/accountAbstraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Generate the RPCUrl for Pimlico based on the chain we need to interact with
* @param network - the network we want to interact with
* @returns the RPCUrl for the network
*/
export const genPimlicoRPCUrl = (network: string): string => {
const pimlicoAPIKey = process.env.PIMLICO_API_KEY;

if (!pimlicoAPIKey) {
throw new Error("PIMLICO_API_KEY is not set");
}

return `https://api.pimlico.io/v2/${network}/rpc?apikey=${pimlicoAPIKey}`;
};
1 change: 1 addition & 0 deletions packages/coordinator/ts/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum ErrorCodes {
ENCRYPTION = "5",
FILE_NOT_FOUND = "6",
SUBGRAPH_DEPLOY = "7",
SESSION_KEY_NOT_FOUND = "8",
}
28 changes: 28 additions & 0 deletions packages/coordinator/ts/file/__tests__/file.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@ describe("FileService", () => {
jest.clearAllMocks();
});

test("should save session key properly", () => {
const service = new FileService();

const sessionKeyAddress = "0x123";
const sessionKey = "0x456";

service.storeSessionKey(sessionKey, sessionKeyAddress);

const storedSessionKey = service.getSessionKey(sessionKeyAddress);

expect(storedSessionKey).toEqual(sessionKey);
});

test("should delete session key properly", () => {
const service = new FileService();

const sessionKeyAddress = "0x123";
const sessionKey = "0x456";

service.storeSessionKey(sessionKey, sessionKeyAddress);

service.deleteSessionKey(sessionKeyAddress);

const deletedSessionKey = service.getSessionKey(sessionKeyAddress);

expect(deletedSessionKey).toBeUndefined();
});

test("should return public key properly", async () => {
const service = new FileService();

Expand Down
44 changes: 44 additions & 0 deletions packages/coordinator/ts/file/file.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { Injectable, Logger } from "@nestjs/common";
import low from "lowdb";
import FileSync from "lowdb/adapters/FileSync";

import fs from "fs";
import path from "path";

import type { IGetPrivateKeyData, IGetPublicKeyData, IGetZkeyFilePathsData } from "./types";
import type { Hex } from "viem";

import { ErrorCodes } from "../common";

/**
* Internal storage structure type.
* named: keys can be queried by name
*/
type TStorage = Record<string, Hex>;

/**
* FileService is responsible for working with local files like:
* 1. RSA public/private keys
Expand All @@ -19,11 +28,46 @@ export class FileService {
*/
private readonly logger: Logger;

/**
* Json file database instance
*/
private db: low.LowdbSync<TStorage>;

/**
* Initialize service
*/
constructor() {
this.logger = new Logger(FileService.name);
this.db = low(new FileSync<TStorage>(path.resolve(__dirname, "..", "..", "./session-keys.json")));
}

/**
* Store session key
*
* @param sessionKey - session key
* @param address - key address
*/
storeSessionKey(sessionKey: Hex, address: string): void {
this.db.set(address, sessionKey).write();
}

/**
* Delete session key
*
* @param address - key address
*/
deleteSessionKey(address: string): void {
this.db.unset(address).write();
}

/**
* Get session key
*
* @param address - key name
* @returns session key
*/
getSessionKey(address: string): Hex | undefined {
return this.db.get(address).value() as Hex | undefined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Test } from "@nestjs/testing";
import { zeroAddress } from "viem";

import type { IGenerateSessionKeyReturn } from "../types";

import { SessionKeysController } from "../sessionKeys.controller";
import { SessionKeysService } from "../sessionKeys.service";

describe("SessionKeysController", () => {
let sessionKeysController: SessionKeysController;

const mockSessionKeysService = {
generateSessionKey: jest.fn(),
deactivateSessionKey: jest.fn(),
};

const defaultGenerateSessionKeyReturn: IGenerateSessionKeyReturn = {
sessionKeyAddress: zeroAddress,
};

beforeEach(async () => {
const app = await Test.createTestingModule({
controllers: [SessionKeysController],
})
.useMocker((token) => {
if (token === SessionKeysService) {
mockSessionKeysService.generateSessionKey.mockResolvedValue(defaultGenerateSessionKeyReturn);
return mockSessionKeysService;
}

return jest.fn();
})
.compile();

sessionKeysController = app.get<SessionKeysController>(SessionKeysController);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("v1/session-keys/generate", () => {
test("should return a session key address", async () => {
// have to use await otherwise the mock will return a Promise
// eslint-disable-next-line @typescript-eslint/await-thenable
const data = await sessionKeysController.generateSessionKey();
expect(data).toStrictEqual(defaultGenerateSessionKeyReturn);
});
});

describe("v1/session-keys/:sessionKeyAddress", () => {
test("should delete a session key", () => {
sessionKeysController.deactivateSessionKey(zeroAddress);
expect(mockSessionKeysService.deactivateSessionKey).toHaveBeenCalledWith(zeroAddress);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import dotenv from "dotenv";
import { ZeroAddress } from "ethers";

import { CryptoService } from "../../crypto/crypto.service";
import { FileService } from "../../file/file.service";
import { SessionKeysService } from "../sessionKeys.service";

dotenv.config();

describe("SessionKeysService", () => {
afterEach(() => {
jest.clearAllMocks();
});

const cryptoService = new CryptoService();
const fileService = new FileService();
const sessionKeysService = new SessionKeysService(cryptoService, fileService);

test("should generate and store a session key", () => {
const sessionKeyAddress = sessionKeysService.generateSessionKey();
expect(sessionKeyAddress).toBeDefined();
expect(sessionKeyAddress).not.toEqual(ZeroAddress);

const sessionKey = fileService.getSessionKey(sessionKeyAddress.sessionKeyAddress);
expect(sessionKey).toBeDefined();
});

test("should delete a session key", () => {
const sessionKeyAddress = sessionKeysService.generateSessionKey();
expect(sessionKeyAddress).toBeDefined();
expect(sessionKeyAddress).not.toEqual(ZeroAddress);

const sessionKey = fileService.getSessionKey(sessionKeyAddress.sessionKeyAddress);
expect(sessionKey).toBeDefined();

sessionKeysService.deactivateSessionKey(sessionKeyAddress.sessionKeyAddress);
const sessionKeyDeleted = fileService.getSessionKey(sessionKeyAddress.sessionKeyAddress);
expect(sessionKeyDeleted).toBeUndefined();
});
});
82 changes: 82 additions & 0 deletions packages/coordinator/ts/sessionKeys/__tests__/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator";
import { type Policy, serializePermissionAccount, toPermissionValidator } from "@zerodev/permissions";
import { toTimestampPolicy } from "@zerodev/permissions/policies";
import { toECDSASigner } from "@zerodev/permissions/signers";
import { addressToEmptyAccount, createKernelAccount } from "@zerodev/sdk";
import { KERNEL_V3_1 } from "@zerodev/sdk/constants";
import { ENTRYPOINT_ADDRESS_V07 } from "permissionless";
import { createPublicClient, type Hex, http } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { localhost } from "viem/chains";

// use the latest kernel version
const kernelVersion = KERNEL_V3_1;
// we use the most recent entrypoint
const entryPoint = ENTRYPOINT_ADDRESS_V07;

/**
* Generate a timestamp policy
* @param endTime - The end time of the policy
* @param start - The start time of the policy
* @returns The timestamp policy
*/
export const generateTimestampPolicy = (endTime: number, start?: number): Policy =>
toTimestampPolicy({
validAfter: start,
validUntil: endTime,
});

/**
* Mock a session key approval
* @dev This will fail in hardhat with:
* "InvalidEntryPointError: The entry point address
* (`entryPoint` = 0x0000000071727De22E5E9d8BAf0edAc6f37da032)
* is not a valid entry point. getSenderAddress did not revert with
* a SenderAddressResult error."
*
* @param sessionKeyAddress - The address of the session key
* @returns The approval string
*/
export const mockSessionKeyApproval = async (sessionKeyAddress: Hex): Promise<string> => {
const policies = [generateTimestampPolicy(Math.floor(Date.now() / 1000) + 1000)];

const publicClient = createPublicClient({
chain: localhost,
transport: http(),
});

const sessionPrivateKey = generatePrivateKey();

const signer = privateKeyToAccount(sessionPrivateKey);

const ecdsaValidator = await signerToEcdsaValidator(publicClient, {
signer,
entryPoint,
kernelVersion,
});

// Create an "empty account" as the signer -- you only need the public
// key (address) to do this.
// disabling rule even though Hex is 0x${string}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const emptyAccount = addressToEmptyAccount(sessionKeyAddress);
const emptySessionKeySigner = toECDSASigner({ signer: emptyAccount });

const permissionPlugin = await toPermissionValidator(publicClient, {
entryPoint,
kernelVersion,
signer: emptySessionKeySigner,
policies,
});

const sessionKeyAccount = await createKernelAccount(publicClient, {
entryPoint,
kernelVersion,
plugins: {
sudo: ecdsaValidator,
regular: permissionPlugin,
},
});

return serializePermissionAccount(sessionKeyAccount);
};
Loading

0 comments on commit 3efd294

Please sign in to comment.