@minatokens/storage
Version:
MinaTokens Storage Library
342 lines (316 loc) • 10.4 kB
text/typescript
import { Experimental, Struct, Field, Option, Provable, Bool } from "o1js";
import {
serializeIndexedMap,
loadIndexedMerkleMap,
IndexedMapSerialized,
IndexedMapSerializedJson,
} from "../indexed-map/indexed-map.js";
import { sleep } from "../util/sleep.js";
import { Storage, IpfsHash } from "../storage/storage.js";
import { createIpfsURL } from "../storage/ipfs.js";
import { pinJSON } from "../storage/pinata.js";
const { IndexedMerkleMap } = Experimental;
type IndexedMerkleMap = Experimental.IndexedMerkleMap;
const OFFCHAIN_MAP_HEIGHT = 20;
export interface OffchainMapSerialized extends IndexedMapSerializedJson {
[key: string]: {
map: IndexedMapSerialized;
list: { key: string; value?: string }[];
data?: object;
};
}
/** Represents the Offchain Map using an Indexed Merkle Map. */
export class OffchainMap extends IndexedMerkleMap(OFFCHAIN_MAP_HEIGHT) {}
export class OffchainMapOption extends Option(OffchainMap) {}
export class FieldOption extends Option(Field) {}
export interface OffChainMapEntry {
key: Field | bigint | number | string;
value?: Field | bigint | number | string; // default is 0
}
/**
* IpfsHash is used when the map is already pinned to IPFS, it should be string with IPFS hash
*/
export type OffChainMapEntries = OffChainMapEntry[] | IpfsHash;
/**
* Base class for offchain lists and maps that does not have storage
*/
export class OffChainListBase extends Struct({
/** The root hash of the Merkle tree representing the whitelist. */
root: Field,
}) {
isNone(): Bool {
return this.root.equals(Field(0));
}
isSome(): Bool {
return this.isNone().not();
}
async load(
storage: Storage,
name: string = "offchain-map"
): Promise<OffchainMapOption> {
const isNone = this.isNone();
const map = await Provable.witnessAsync(OffchainMapOption, async () => {
if (isNone.toBoolean()) return OffchainMapOption.none();
else
return OffchainMapOption.fromValue(
await loadIndexedMerkleMap({
url: createIpfsURL({ hash: storage.toString() }),
type: OffchainMap,
name,
})
);
});
isNone.assertEquals(map.isSome.not());
const root = Provable.if(
map.isSome,
map.orElse(new OffchainMap()).root,
Field(0)
);
root.assertEquals(this.root);
return map;
}
/**
* The function fetches a whitelisted amount associated with a given key using a map and returns it
* as a FieldOption.
* @param {Field} key - The `key` parameter is of type `Field`,
* which represents a field element in the context of a cryptographic system.
* @returns The `getValue` function returns a `Promise` that resolves to a `FieldOption`
* object. This object contains a `value` property representing the amount retrieved from a map based
* on the provided key. The `isSome` property indicates whether the value is present or not.
* The value is not present if the list is NOT empty and the key is NOT in the map.
* The value is present if the list IS empty or the key IS in the map.
* The value is present and equals to Field(0) if the list IS empty.
*/
async getValue(
key: Field,
storage: Storage,
name: string = "offchain-map"
): Promise<FieldOption> {
const map = await this.load(storage, name);
const value = map.orElse(new OffchainMap()).getOption(key);
const valueField = value.orElse(Field(0));
return new FieldOption({
value: valueField,
isSome: value.isSome.or(this.isNone()),
});
}
static empty(): OffChainListBase {
return new OffChainListBase({
root: Field(0),
});
}
/**
* Creates a new OffchainListBase
* @param params - The parameters for creating the list.
* @param params.list - The list of entries to be added to the map.
* @param params.data - The JSON data that should be added to the IPFS storage that represent the initial data
* @returns A new `OffChainList` instance.
*/
static async create(params: {
list: OffChainMapEntry[] | { key: string; value?: number }[];
name?: string;
data?: object;
}): Promise<{
listBase: OffChainListBase;
json: OffchainMapSerialized;
}> {
function toField(
value: bigint | string | number | Field | undefined
): Field {
if (!value) return Field(0);
if (typeof value === "string") return Field.fromJSON(value);
if (typeof value === "bigint" || typeof value === "number")
return Field(value);
return value;
}
const list = params.list.map((item) => ({
key: toField(item.key),
value: toField(item.value),
}));
const map = new OffchainMap();
for (const item of list) {
map.insert(item.key, item.value);
}
const serializedMap = serializeIndexedMap(map);
const json: OffchainMapSerialized = {
[params.name ?? "offchain-map"]: {
map: serializedMap,
list: list.map((item) => ({
key: item.key.toJSON(),
value: item.value?.toJSON(),
})),
data: params.data,
},
};
return {
listBase: new OffChainListBase({
root: map.root,
}),
json,
};
}
toString(): string {
return JSON.stringify({ root: this.root.toJSON() }, null, 2);
}
static fromString(str: string): OffChainListBase {
const json = JSON.parse(str);
return new OffChainListBase({
root: Field.fromJSON(json.root),
});
}
}
export class OffChainList extends Struct({
/** The root hash of the Merkle tree representing the whitelist. */
root: Field,
/** Off-chain storage information, typically an IPFS hash pointing to the whitelist data. */
storage: Storage,
}) {
isNone(): Bool {
return this.root.equals(Field(0)).or(this.storage.isEmpty());
}
isSome(): Bool {
return this.isNone().not();
}
async load(
name: string | undefined = "offchain-map"
): Promise<OffchainMapOption> {
const isNone = this.isNone();
const map = await Provable.witnessAsync(OffchainMapOption, async () => {
if (isNone.toBoolean()) return OffchainMapOption.none();
else
return OffchainMapOption.fromValue(
await loadIndexedMerkleMap({
url: createIpfsURL({ hash: this.storage.toString() }),
type: OffchainMap,
name,
})
);
});
isNone.assertEquals(map.isSome.not());
const root = Provable.if(
map.isSome,
map.orElse(new OffchainMap()).root,
Field(0)
);
root.assertEquals(this.root);
return map;
}
/**
* The function fetches a whitelisted amount associated with a given key using a map and returns it
* as a FieldOption.
* @param {Field} key - The `key` parameter is of type `Field`,
* which represents a field element in the context of a cryptographic system.
* @returns The `getValue` function returns a `Promise` that resolves to a `FieldOption`
* object. This object contains a `value` property representing the amount retrieved from a map based
* on the provided key. The `isSome` property indicates whether the value is present or not.
* The value is not present if the list is NOT empty and the key is NOT in the map.
* The value is present if the list IS empty or the key IS in the map.
* The value is present and equals to Field(0) if the list IS empty.
*/
async getValue(
key: Field,
name: string | undefined = "offchain-map"
): Promise<FieldOption> {
const map = await this.load(name);
const value = map.orElse(new OffchainMap()).getOption(key);
const valueField = value.orElse(Field(0));
return new FieldOption({
value: valueField,
isSome: value.isSome.or(this.isNone()),
});
}
static empty(): OffChainList {
return new OffChainList({
root: Field(0),
storage: Storage.empty(),
});
}
/**
* Creates a new OffchainList
* and pins it to IPFS.
* @param params - The parameters for creating the list.
* @param params.list - The list of entries to be added to the map.
* @param params.data - The JSON data that should be added to the IPFS storage that represent the initial data
* @returns A new `OffChainList` instance.
*/
static async create(params: {
list: OffChainMapEntry[] | { key: string; value?: number }[];
data?: object;
name?: string;
filename?: string;
keyvalues?: object;
timeout?: number;
attempts?: number;
auth?: string;
pin?: boolean;
json?: OffchainMapSerialized;
}): Promise<{ list: OffChainList; json: OffchainMapSerialized }> {
const {
name = "offchain-map",
filename = "offchain-list.json",
keyvalues,
timeout = 60 * 1000,
attempts = 5,
auth,
pin = true,
json: initialJson = {},
} = params;
const { listBase, json: newJson } = await OffChainListBase.create({
list: params.list,
data: params.data,
name,
});
const json = { ...initialJson, ...newJson };
if (process.env.DEBUG === "true")
console.log("OffChainList.create:", { json, name, keyvalues });
if (pin) {
let attempt = 0;
const start = Date.now();
let hash = await pinJSON({
data: json,
name: filename,
keyvalues,
auth,
});
while (!hash && attempt < attempts && Date.now() - start < timeout) {
attempt++;
await sleep(5000 * attempt); // handle rate-limits
hash = await pinJSON({
data: json,
name,
keyvalues,
auth,
});
}
if (!hash) throw new Error("Failed to pin OffchainMap");
return {
list: new OffChainList({
root: listBase.root,
storage: Storage.fromString(hash),
}),
json,
};
}
return {
list: new OffChainList({
root: listBase.root,
storage: Storage.empty(),
}),
json,
};
}
toString(): string {
return JSON.stringify(
{ root: this.root.toJSON(), storage: this.storage.toString() },
null,
2
);
}
static fromString(str: string): OffChainList {
const json = JSON.parse(str);
return new OffChainList({
root: Field.fromJSON(json.root),
storage: Storage.fromString(json.storage),
});
}
}