@fort-major/msq
Version:
Privacy-focused MetaMask snap for the Internet Computer (ICP)
544 lines (476 loc) • 23.9 kB
text/typescript
import {
type IOriginData,
type IState,
type IStatistics,
type TOrigin,
unreacheable,
zodParse,
ZState,
fromCBOR,
toCBOR,
TIdentityId,
IMask,
err,
ErrorCode,
IAssetData,
TAccountId,
IStatisticsData,
} from "@fort-major/msq-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 {
/**
* Asynchronously retrieves origin data for a given origin from the state.
* If the data for the specified origin does not exist in the state, it generates
* a new mask for the origin using the `makeMask` function with a default parameter
* of 0, and then creates default origin data using the `makeDefaultOriginData` function.
* This ensures that every origin, whether previously stored or not, will have associated
* data that can be retrieved.
*
* @param {TOrigin} origin - The origin for which to retrieve or create data.
* @returns {Promise<IOriginData>} A promise that resolves to the data associated with the given origin.
*/
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;
}
/**
* Sets or updates the origin data in the state for a given origin.
* This method directly assigns the provided data to the specified origin
* within the `originData` property of the state. It is used to store or update
* information related to a specific origin, ensuring that the state reflects
* the most current data available.
*
* @param {TOrigin} origin - The origin for which to set the data.
* @param {IOriginData} data - The data to set for the specified origin.
*/
public setOriginData(origin: TOrigin, data: IOriginData): void {
this.state.originData[origin] = data;
}
/**
* Retrieves all origin data stored in the state.
*
* @returns {Record<TOrigin, IOriginData | undefined>} An object containing all origin data, with origin as the key and data as the value.
*/
public getAllOriginData(): Record<TOrigin, IOriginData | undefined> {
return this.state.originData;
}
/**
* Retrieves all asset data stored in the state.
*
* @returns {Record<string, IAssetData | undefined>} An object containing all asset data, with asset ID as the key and data as the value.
*/
public getAllAssetData(): Record<string, IAssetData | undefined> {
return this.state.assetData;
}
/**
* Edits the pseudonym for a given identity within the origin data stored in the state.
* This method first checks if there is existing data for the specified origin. If not,
* it throws an error indicating that no origin data exists. It then checks if a mask
* exists for the given identity ID within the origin data. If a mask does not exist,
* it throws an error indicating the absence of the mask. If both checks pass, it updates
* the pseudonym for the specified identity ID within the origin data's masks to the
* new pseudonym provided.
*
* Note: This method assumes that `err` is a function available in the context that
* throws an error or otherwise handles error reporting based on an error code and message.
*
* @param {TOrigin} origin - The origin associated with the pseudonym to be edited.
* @param {TIdentityId} identityId - The identity ID within the origin for which to edit the pseudonym.
* @param {string} newPseudonym - The new pseudonym to set for the specified identity.
* @throws Will throw an error if no origin data exists for the specified origin or if no mask exists for the given identity ID.
*/
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;
}
/**
* Checks if a bidirectional link exists between two origins in the state.
* This method verifies the existence of a link from 'from' origin to 'to' origin and vice versa.
* It asserts that for a valid link, both sides (from -> to and to -> from) must acknowledge the link's existence.
* If it finds an inconsistency, where one side recognizes the link while the other does not, it calls an
* `unreacheable` function to indicate a critical logic error, as this scenario should never occur under correct operation.
*
* Note: The `unreacheable` function is assumed to be a mechanism for handling logic errors that should not happen.
*
* @param {TOrigin} from - The origin from which the link originates.
* @param {TOrigin} to - The origin to which the link is directed.
* @returns {boolean} True if a consistent, bidirectional link exists between the two origins; otherwise, it triggers an error.
* @throws Triggers an error if a link exists in one direction without a corresponding link in the opposite direction, indicating a logic flaw.
*/
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;
}
/**
* Creates a bidirectional link between two origins in the state.
* This asynchronous method first retrieves the origin data for both the 'from' and 'to' origins.
* It then checks if a link already exists from the 'from' origin to the 'to' origin or from the 'to'
* origin back to the 'from' origin. If either link already exists, it calls an `unreacheable` function
* to indicate a critical error, as attempting to add an existing link should not occur. If no such links
* exist, it establishes a new link by setting the appropriate flags in the origin data for both origins
* and then updates the state with the modified origin data.
*
* Note: The `unreacheable` function is assumed to be a mechanism for handling errors that are not expected
* to occur, indicating a severe logic flaw if triggered.
*
* @param {TOrigin} from - The origin initiating the link.
* @param {TOrigin} to - The target origin of the link.
* @returns {Promise<void>} A promise that resolves once the link has been successfully established.
* @throws Triggers an error if an attempt is made to add a link that already exists, indicating a logic error.
*/
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);
}
/**
* Removes a bidirectional link between two origins in the state.
* This asynchronous method retrieves the origin data for both the 'from' and 'to' origins. It then checks
* if the link from the 'from' origin to the 'to' origin and the link from the 'to' origin to the 'from' origin
* actually exist. If either link does not exist, it invokes an `unreacheable` function to signal a critical
* error, as attempting to delete a non-existing link indicates a logic flaw. If both links exist, it proceeds
* to remove them by deleting the respective entries in the origin data and then updates the state with the
* modified origin data to reflect the unlinking.
*
* Note: The `unreacheable` function is used to handle situations that are logically not supposed to occur,
* indicating a severe logic error if triggered.
*
* @param {TOrigin} from - The origin from which the link is initiated.
* @param {TOrigin} to - The target origin of the link to be removed.
* @returns {Promise<void>} A promise that resolves once the link has been successfully removed.
* @throws Triggers an error if an attempt is made to delete a link that does not exist, indicating a logic error.
*/
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);
}
/**
* Removes all outgoing links from a specified origin, effectively unlinking it from all connected origins.
* This asynchronous method first retrieves the origin data for the 'from' origin. It then iterates over
* all origins that the 'from' origin has links to, retrieves the origin data for each linked origin ('to' origin),
* and deletes the corresponding link back to the 'from' origin. After removing all such links, it resets the
* 'linksTo' object of the 'from' origin data, effectively removing all outgoing links. The state is updated
* to reflect these changes for both the 'from' origin and all affected 'to' origins.
*
* This method also returns a list of all origins that were linked from the 'from' origin, providing a record
* of the connections that were removed.
*
* @param {TOrigin} from - The origin from which to remove all outgoing links.
* @returns {Promise<TOrigin[]>} A promise that resolves to an array of origins that were previously linked from the 'from' origin.
*/
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;
}
/**
* Adds a new identity (mask) to the specified origin within the state and returns the created mask.
* If the origin data does not exist for the specified origin, it first creates default origin data
* with an initial mask. Then, it calculates a new identity ID based on the number of existing identities
* (masks) for that origin. It generates a new mask using this identity ID and the origin, and adds this
* new mask to the origin data. The state is updated to include this new mask under the specified origin.
* This method facilitates dynamic identity creation within origins, allowing for the expansion of identities
* associated with each origin.
*
* @param {TOrigin} origin - The origin for which to add a new identity (mask).
* @returns {Promise<IMask>} A promise that resolves to the newly created mask for the added identity.
*/
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;
}
/**
* Adds an asset to the state, or returns the existing asset data if it already exists.
* This method checks if asset data for a given asset ID already exists in the state.
* If it does, it immediately returns the existing asset data. If not, it creates default
* asset data using the `makeDefaultAssetData` function, adds this new asset data to the
* state under the specified asset ID, and then returns the newly created asset data.
* This ensures that each asset is uniquely represented in the state and facilitates easy
* retrieval and management of asset data.
*
* @param {string} assetId - The ID of the asset to add or retrieve.
* @param {string} name - The name of the asset, e.g. "Internet Computer".
* @param {string} symbol - The symbol (ticker) of the asset, e.g. "ICP".
* @param {bigint} fee - The system fee of this token
* @param {number} decimals - The position of decimal point
* @returns {IAssetData} The asset data associated with the given asset ID.
*/
public addAsset(assetId: string, name: string, symbol: string, fee: bigint, decimals: number): IAssetData {
let assetData = this.state.assetData[assetId];
if (assetData !== undefined) return assetData;
assetData = makeDefaultAssetData(name, symbol, fee, decimals);
this.state.assetData[assetId] = assetData;
return assetData;
}
/**
* Adds a new account to the specified asset in the state and returns the name of the created account.
* If the asset data does not already exist for the specified asset ID, it throws an error indicating
* that no such asset exists. Otherwise, it generates a new account ID based on the number of existing
* accounts for that asset. It then creates a new account with a name following the pattern "Account #X",
* where X is the new account ID, and adds this account to the asset's data. The state is updated to
* reflect this new account under the specified asset.
*
* Note: The `unreacheable` function is assumed to be a method for handling unexpected or logically
* impossible conditions, indicating a severe logic error if triggered.
*
* @param {string} assetId - The ID of the asset for which to add a new account.
* @returns {string} The name of the newly created account for the asset.
* @throws Throws an error if no asset data exists for the specified asset ID, indicating a logic error.
*/
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;
}
/**
* Edits the name of an existing account for a specified asset within the state.
* This method first checks if asset data exists for the given asset ID. If not, it triggers an error
* indicating that no such asset exists. It then checks if an account with the specified account ID exists
* within the asset's data. If the account does not exist, it triggers another error indicating the absence
* of the account. If both the asset and the account exist, it updates the name of the account to the new
* name provided. This allows for the dynamic renaming of accounts associated with assets in the state.
*
* Note: The `unreacheable` function is used to handle conditions that are expected to never occur,
* serving as a mechanism for flagging severe logic errors when they are triggered.
*
* @param {string} assetId - The ID of the asset whose account is to be edited.
* @param {TAccountId} accountId - The ID of the account within the asset to edit.
* @param {string} newName - The new name to assign to the account.
* @throws Throws an error if no asset data exists for the specified asset ID or if no account exists with the specified account ID.
*/
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(data: Partial<IStatisticsData>): void {
if (data.login) this.state.statistics.data.login += data.login;
if (data.transfer) this.state.statistics.data.transfer += data.transfer;
if (data.origin_link) this.state.statistics.data.origin_link += data.origin_link;
if (data.origin_unlink) this.state.statistics.data.origin_unlink += data.origin_unlink;
}
public getStats(): IStatistics {
return this.state.statistics;
}
public resetStats(): void {
this.state.statistics = makeDefaultStatistics();
}
}
/**
* Creates the default state object.
* @returns The default state object.
*/
function makeDefaultState(): IState {
return {
version: 1,
originData: {},
assetData: {},
statistics: makeDefaultStatistics(),
};
}
/**
* Creates a default statistics object.
* @returns The default statistics object.
*/
function makeDefaultStatistics(): IStatistics {
return {
lastResetTimestamp: Date.now(),
data: {
login: 0,
transfer: 0,
origin_link: 0,
origin_unlink: 0,
},
};
}
/**
* Creates the default origin data object.
*
* @param mask - The mask object.
* @returns The default origin data object.
*/
function makeDefaultOriginData(mask: IMask): IOriginData {
return {
masks: { 0: mask },
currentSession: undefined,
linksFrom: {},
linksTo: {},
};
}
/**
* Creates the default asset data.
* @returns The default asset data.
*/
function makeDefaultAssetData(name: string, symbol: string, fee: bigint, decimals: number): IAssetData {
return {
name,
symbol,
fee,
decimals,
accounts: { 0: "Main" },
};
}
let STATE: IState | null = null;
let LAST_STATE_PERSIST_TIMESTAMP = 0;
let STATE_UPDATE_TIMESTAMP = 0;
/**
* Retrieves the current state from persistent storage or initializes it with a default state if not present.
* This function first checks if the global state (`STATE`) is null, indicating that it has not been initialized.
* If so, it attempts to retrieve the state using the `snap.request` method with a "get" operation. If the state
* is not found in persistent storage (i.e., returned state is null), it initializes the global state with a default
* state using `makeDefaultState`. If a state is found, it is parsed and validated using `zodParse` after decoding
* it from CBOR format to ensure it conforms to the expected state schema (`ZState`). The retrieved or initialized
* state is then wrapped in a proxy that monitors for any changes to the state or its nested properties, updating a
* global timestamp (`STATE_UPDATE_TIMESTAMP`) to track the latest update. This mechanism facilitates state persistence
* and synchronization by allowing for efficient state retrieval and automatic tracking of modifications for subsequent
* persistence operations.
*
* @returns {Promise<IState>} A promise that resolves to the global state object, wrapped in a change-detection proxy.
*/
async function retrieveStateWrapped(): Promise<IState> {
if (STATE === null) {
const state = await snap.request({
method: "snap_manageState",
params: {
operation: "get",
},
});
STATE = state == null ? makeDefaultState() : zodParse(ZState, fromCBOR(state.data as string));
LAST_STATE_PERSIST_TIMESTAMP = Date.now();
STATE_UPDATE_TIMESTAMP = LAST_STATE_PERSIST_TIMESTAMP;
}
return createDeepOnChangeProxy(STATE, () => {
STATE_UPDATE_TIMESTAMP = Date.now();
}) as IState;
}
let proxyCache = new WeakMap();
/**
* Creates a proxy for an object that recursively applies itself to all nested objects, enabling deep change detection.
* Whenever a set operation occurs on any level of the object or its nested objects, a provided callback function is invoked.
* This is achieved using JavaScript's Proxy object to intercept get and set operations. The function maintains a cache of
* already proxied objects to prevent creating multiple proxies for the same object, which could lead to performance issues
* and infinite recursion. This method is particularly useful for implementing reactive state management or deep observation
* patterns where changes to any part of an object or its sub-objects need to trigger a global or specific action.
*
* @param {any} target - The target object to wrap with the proxy.
* @param {() => void} onChange - A callback function to be invoked whenever a set operation occurs.
* @returns {unknown} A proxy wrapped version of the target object that deeply monitors changes.
*/
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;
},
});
}
/**
* Asynchronously persists the current state to local storage using the MetaMask Snaps `snap_manageState` method.
* This function first checks if the state has been updated since the last persistence by comparing timestamps.
* If the state is up-to-date (meaning no updates have occurred since the last persistence), the function returns early.
* Otherwise, it validates the current state against a Zod schema (`ZState`) to ensure it meets the expected structure.
* After validation, it updates the timestamp to the current time, indicating the state is being persisted.
* Finally, it serializes the state using CBOR (Concise Binary Object Representation) for efficient storage and
* uses the `snap.request` method to request the MetaMask Snaps environment to persist the new state.
*
* This method ensures that only valid and recently modified states are persisted, minimizing unnecessary operations
* and ensuring data integrity through schema validation.
*
* @returns {Promise<void>} A promise that resolves once the state has been successfully persisted.
*/
async function persistStateLocal(): Promise<void> {
if (LAST_STATE_PERSIST_TIMESTAMP >= STATE_UPDATE_TIMESTAMP) return;
zodParse(ZState, STATE);
await snap.request({
method: "snap_manageState",
params: {
operation: "update",
newState: { data: toCBOR(STATE) },
},
});
LAST_STATE_PERSIST_TIMESTAMP = Date.now();
}