@fort-major/masquerade
Version:
Privacy-focused MetaMask snap for the Internet Computer (ICP)
368 lines (293 loc) • 9.86 kB
text/typescript
import {
type IOriginData,
type IState,
type IStatistics,
type TOrigin,
unreacheable,
zodParse,
ZState,
fromCBOR,
toCBOR,
TIdentityId,
IMask,
err,
ErrorCode,
IAssetData,
TAccountId,
EStatisticsKind,
PRE_LISTED_TOKENS,
} from "@fort-major/masquerade-shared";
import { generateRandomPseudonym, getSignIdentity } from "./utils";
/**
* Provides a higher-level interface for interacting with the snap's state.
*
* @keywords state, memory, data, persistence
*/
export class StateManager {
/**
* @param origin - origin to get data about
* @returns - existing links, current session and a total number of user identities
*/
public async getOriginData(origin: TOrigin): Promise<IOriginData> {
let originData = this.state.originData[origin];
if (originData === undefined) {
const mask = await this.makeMask(origin, 0);
originData = makeDefaultOriginData(mask);
}
return originData;
}
public setOriginData(origin: TOrigin, data: IOriginData): void {
this.state.originData[origin] = data;
}
public getAllOriginData(): { [x: TOrigin]: IOriginData | undefined } {
return this.state.originData;
}
public getAllAssetData(): { [x: string]: IAssetData | undefined } {
return this.state.assetData;
}
public editPseudonym(origin: TOrigin, identityId: TIdentityId, newPseudonym: string) {
const originData = this.state.originData[origin];
if (originData === undefined) {
err(ErrorCode.INVALID_INPUT, `No origin data exists ${origin}`);
}
if (Object.keys(originData.masks).length <= identityId) {
err(ErrorCode.INVALID_INPUT, `No mask exists ${identityId}`);
}
originData.masks[identityId]!.pseudonym = newPseudonym;
}
public linkExists(from: TOrigin, to: TOrigin): boolean {
const fromHasToLink = this.state.originData[from]?.linksTo[to] ?? false;
const toHasFromLink = this.state.originData[to]?.linksFrom[from] ?? false;
if ((fromHasToLink && !toHasFromLink) || (!fromHasToLink && toHasFromLink)) {
unreacheable("There should always be two sides of a link");
}
return fromHasToLink;
}
public async link(from: TOrigin, to: TOrigin): Promise<void> {
const fromOriginData = await this.getOriginData(from);
const toOriginData = await this.getOriginData(to);
if (fromOriginData.linksTo[to]) {
unreacheable(`Unable to add an existing TO link: ${from} -> ${to}`);
}
if (toOriginData.linksFrom[from]) {
unreacheable(`Unable to add an existing FROM link: ${from} -> ${to}`);
}
fromOriginData.linksTo[to] = true;
toOriginData.linksFrom[from] = true;
this.setOriginData(from, fromOriginData);
this.setOriginData(to, toOriginData);
}
public async unlink(from: TOrigin, to: TOrigin): Promise<void> {
const fromOriginData = await this.getOriginData(from);
const toOriginData = await this.getOriginData(to);
if (!fromOriginData.linksTo[to]) {
unreacheable(`Unable to delete a non-existing TO link: ${from} -> ${to}`);
}
if (!toOriginData.linksFrom[from]) {
unreacheable(`Unable to delete a non-existing FROM link: ${from} -> ${to}`);
}
delete fromOriginData.linksTo[to];
delete toOriginData.linksFrom[from];
this.setOriginData(from, fromOriginData);
this.setOriginData(to, toOriginData);
}
public async unlinkAll(from: TOrigin): Promise<TOrigin[]> {
const fromOriginData = await this.getOriginData(from);
const oldLinks = Object.keys(fromOriginData.linksTo);
for (let to of oldLinks) {
const toOriginData = await this.getOriginData(to);
delete toOriginData.linksFrom[from];
this.setOriginData(to, toOriginData);
}
fromOriginData.linksTo = {};
this.setOriginData(from, fromOriginData);
return oldLinks;
}
public async addIdentity(origin: TOrigin): Promise<IMask> {
let originData = this.state.originData[origin];
if (originData === undefined) {
const mask = await this.makeMask(origin, 0);
originData = makeDefaultOriginData(mask);
}
const identityId = Object.keys(originData.masks).length;
const mask = await this.makeMask(origin, identityId);
originData.masks[identityId] = mask;
this.state.originData[origin] = originData;
return mask;
}
public addAsset(assetId: string): IAssetData {
let assetData = this.state.assetData[assetId];
if (assetData !== undefined) return assetData;
assetData = makeDefaultAssetData();
this.state.assetData[assetId] = assetData;
return assetData;
}
public addAssetAccount(assetId: string): string {
const assetData = this.state.assetData[assetId];
if (assetData === undefined) unreacheable(`No asset exists ${assetId}`);
const accountId = Object.keys(assetData.accounts).length;
const name = `Account #${accountId}`;
assetData.accounts[accountId] = name;
return name;
}
public editAssetAccount(assetId: string, accountId: TAccountId, newName: string) {
const assetData = this.state.assetData[assetId];
if (assetData === undefined) unreacheable(`No asset exists ${assetId}`);
if (Object.keys(assetData.accounts).length <= accountId) unreacheable(`No account exists ${assetId} ${accountId}`);
assetData.accounts[accountId] = newName;
}
private async makeMask(origin: TOrigin, identityId: TIdentityId): Promise<IMask> {
const identity = await getSignIdentity(origin, identityId, new Uint8Array());
const principal = identity.getPrincipal();
const prinBytes = principal.toUint8Array();
const seed1 = prinBytes[3];
const seed2 = prinBytes[4];
return { pseudonym: generateRandomPseudonym(seed1, seed2), principal: principal.toText() };
}
constructor(private readonly state: IState) {}
public static async make(): Promise<StateManager> {
const state = await retrieveStateWrapped();
return new StateManager(state);
}
public static async persist(): Promise<void> {
return persistStateLocal();
}
public incrementStats(
kind: EStatisticsKind,
icrc1_sent?: { ticker: keyof typeof PRE_LISTED_TOKENS; qty: bigint },
): void {
switch (kind) {
case EStatisticsKind.MasksCreated:
this.state.statistics.prod.masks_created += 1;
break;
case EStatisticsKind.SignaturesProduced:
this.state.statistics.prod.signatures_produced += 1;
break;
case EStatisticsKind.OriginsLinked:
this.state.statistics.prod.origins_linked += 1;
break;
case EStatisticsKind.OriginsUnlinked:
this.state.statistics.prod.origins_unlinked += 1;
break;
case EStatisticsKind.Icrc1AccountsCreated:
this.state.statistics.prod.icrc1_accounts_created += 1;
break;
case EStatisticsKind.Icrc1Sent: {
if (icrc1_sent == undefined) {
unreacheable("No icrc1 stat body provided");
}
// @ts-expect-error - types are consistent
this.state.statistics.prod.icrc1_sent[icrc1_sent.ticker] += icrc1_sent.qty;
break;
}
}
}
public getStats(): IStatistics {
return this.state.statistics;
}
public resetStats(): void {
this.state.statistics = makeDefaultStatistics();
}
}
function makeDefaultState(): IState {
return {
version: 1,
originData: {},
assetData: {},
statistics: makeDefaultStatistics(),
};
}
function makeDefaultStatistics(): IStatistics {
return {
lastResetTimestamp: Date.now(),
prod: {
masks_created: 0,
signatures_produced: 0,
origins_linked: 0,
origins_unlinked: 0,
icrc1_accounts_created: 0,
icrc1_sent: {
ICP: BigInt(0),
ckBTC: BigInt(0),
ckETH: BigInt(0),
CHAT: BigInt(0),
SONIC: BigInt(0),
SNS1: BigInt(0),
OGY: BigInt(0),
MOD: BigInt(0),
GHOST: BigInt(0),
KINIC: BigInt(0),
HOT: BigInt(0),
CAT: BigInt(0),
},
},
};
}
function makeDefaultOriginData(mask: IMask): IOriginData {
return {
masks: { 0: mask },
currentSession: undefined,
linksFrom: {},
linksTo: {},
};
}
function makeDefaultAssetData(): IAssetData {
return {
accounts: { 0: "Main" },
};
}
let STATE: IState | null = null;
let LAST_STATE_PERSIST_TIMESTAMP = 0;
let STATE_UPDATE_TIMESTAMP = 0;
async function retrieveStateWrapped(): Promise<IState> {
if (STATE === null) {
const state = await snap.request({
method: "snap_manageState",
params: {
operation: "get",
},
});
if (state == null) {
const s = makeDefaultState();
STATE = s;
STATE_UPDATE_TIMESTAMP = Date.now();
} else {
STATE = zodParse(ZState, fromCBOR(state.data as string));
}
}
return createDeepOnChangeProxy(STATE, () => {
STATE_UPDATE_TIMESTAMP = Date.now();
}) as IState;
}
let proxyCache = new WeakMap();
function createDeepOnChangeProxy(target: any, onChange: () => void): unknown {
return new Proxy(target, {
get(target, property) {
const item = target[property];
if (item && typeof item === "object") {
if (proxyCache.has(item)) return proxyCache.get(item);
const proxy = createDeepOnChangeProxy(item, onChange);
proxyCache.set(item, proxy);
return proxy;
}
return item;
},
set(target, property, newValue) {
target[property] = newValue;
onChange();
return true;
},
});
}
async function persistStateLocal(): Promise<void> {
if (LAST_STATE_PERSIST_TIMESTAMP >= STATE_UPDATE_TIMESTAMP) return;
zodParse(ZState, STATE);
LAST_STATE_PERSIST_TIMESTAMP = Date.now();
await snap.request({
method: "snap_manageState",
params: {
operation: "update",
newState: { data: toCBOR(STATE) },
},
});
}