Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(protocol-parser): add keySchema/valueSchema helpers #1443

Merged
merged 6 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tricky-comics-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/store": minor
---

Moved `KeySchema`, `ValueSchema`, `SchemaToPrimitives` and `TableRecord` types into `@latticexyz/protocol-parser`
5 changes: 5 additions & 0 deletions .changeset/wicked-tigers-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/protocol-parser": minor
---

Adds `decodeKey`, `decodeValue`, `encodeKey`, and `encodeValue` helpers to decode/encode from key/value schemas. Deprecates previous methods that use a schema object with static/dynamic field arrays, originally attempting to model our on-chain behavior but ended up not very ergonomic when working with table configs.
17 changes: 16 additions & 1 deletion packages/protocol-parser/src/common.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { DynamicAbiType, StaticAbiType } from "@latticexyz/schema-type";
import { DynamicAbiType, SchemaAbiType, SchemaAbiTypeToPrimitiveType, StaticAbiType } from "@latticexyz/schema-type";

/** @deprecated use `KeySchema` or `ValueSchema` instead */
export type Schema = {
readonly staticFields: readonly StaticAbiType[];
readonly dynamicFields: readonly DynamicAbiType[];
};

/** @deprecated use `KeySchema` and `ValueSchema` instead */
export type TableSchema = {
readonly keySchema: Schema; // TODO: refine to set dynamicFields to []
readonly valueSchema: Schema;
};

export type KeySchema = Record<string, StaticAbiType>;
export type ValueSchema = Record<string, SchemaAbiType>;

/** Map a table schema like `{ value: "uint256" }` to its primitive types like `{ value: bigint }` */
export type SchemaToPrimitives<TSchema extends ValueSchema> = {
[key in keyof TSchema]: SchemaAbiTypeToPrimitiveType<TSchema[key]>;
};

export type TableRecord<TKeySchema extends KeySchema = KeySchema, TValueSchema extends ValueSchema = ValueSchema> = {
key: SchemaToPrimitives<TKeySchema>;
value: SchemaToPrimitives<TValueSchema>;
};
15 changes: 15 additions & 0 deletions packages/protocol-parser/src/decodeKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Hex } from "viem";
import { SchemaToPrimitives, KeySchema } from "./common";
import { decodeKeyTuple } from "./decodeKeyTuple";

export function decodeKey<TSchema extends KeySchema>(
keySchema: TSchema,
data: readonly Hex[]
): SchemaToPrimitives<TSchema> {
// TODO: refactor and move all decodeKeyTuple logic into this method so we can delete decodeKeyTuple
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is decodeKeyTuple still used somewhere else? If not, should we just move the logic in there in this PR?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's used by blockLogsToStorage which is getting refactored in the other PR

didn't wanna refactor everything quite yet to avoid a big rebase of the other PR, just wanted to add new methods and deprecate old ones for an easy first pass

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated the original issue to remind me to refactor this: #1296

const keyValues = decodeKeyTuple({ staticFields: Object.values(keySchema), dynamicFields: [] }, data);

return Object.fromEntries(
Object.keys(keySchema).map((name, i) => [name, keyValues[i]])
) as SchemaToPrimitives<TSchema>;
}
1 change: 1 addition & 0 deletions packages/protocol-parser/src/decodeKeyTuple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Schema } from "./common";

// key tuples are encoded in the same way as abi.encode, so we can decode them with viem

/** @deprecated use `decodeKey` instead */
export function decodeKeyTuple(keySchema: Schema, keyTuple: readonly Hex[]): StaticPrimitiveType[] {
if (keySchema.staticFields.length !== keyTuple.length) {
throw new Error(
Expand Down
12 changes: 12 additions & 0 deletions packages/protocol-parser/src/decodeRecord.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,16 @@ describe("decodeRecord", () => {
]
`);
});

it("can decode an out of bounds array", () => {
const schema = { staticFields: [], dynamicFields: ["uint32[]"] } as const;
const values = decodeRecord(schema, "0x0000000000000000000000000000000000000000000000000400000000000004");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hex represents an encoded dynamic length field with total length 4 and length of the first array of 4, correct? Can you add more context for why the decoded array has one element with value 0? Somehow I would have expected either an empty because there is no data after the encoded data length, or an array with 4 elements that are all 0?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I had added this test for proof that padSliceHex was doing the right thing internally when used within decodeRecord. I can't recall where exactly I was getting a the hex value but I copied it from some tests or logs that were failing without padSliceHex.

To explain what's going on:

The total length and first array length is the byte length (4 bytes in the case of uint32). Meaning that there's one item/value for the uint32[] field.

The record hex is "trimmed" to the right-most byte (end of the counter). On chain, any bytes after the right-most byte would be treated as zeros, so we want to have the same behavior in the client.

So if we were to read the same value on chain, we should get a value of [0] for this record (one uint32 value with unset bytes, i.e. 0). This test ensures that the same behavior happens in the client.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh makes sense, had forgotten that the length corresponds to bytes and assumed elements. Thanks for the explanation!

expect(values).toMatchInlineSnapshot(`
[
[
0,
],
]
`);
});
});
10 changes: 6 additions & 4 deletions packages/protocol-parser/src/decodeRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@ import {
staticAbiTypeToByteLength,
dynamicAbiTypeToDefaultValue,
} from "@latticexyz/schema-type";
import { Hex, sliceHex } from "viem";
import { Hex } from "viem";
import { Schema } from "./common";
import { decodeDynamicField } from "./decodeDynamicField";
import { decodeStaticField } from "./decodeStaticField";
import { hexToPackedCounter } from "./hexToPackedCounter";
import { staticDataLength } from "./staticDataLength";
import { readHex } from "./readHex";

/** @deprecated use `decodeValue` instead */
export function decodeRecord(schema: Schema, data: Hex): readonly (StaticPrimitiveType | DynamicPrimitiveType)[] {
const values: (StaticPrimitiveType | DynamicPrimitiveType)[] = [];

let bytesOffset = 0;
schema.staticFields.forEach((fieldType) => {
const fieldByteLength = staticAbiTypeToByteLength[fieldType];
const value = decodeStaticField(fieldType, sliceHex(data, bytesOffset, bytesOffset + fieldByteLength));
const value = decodeStaticField(fieldType, readHex(data, bytesOffset, bytesOffset + fieldByteLength));
bytesOffset += fieldByteLength;
values.push(value);
});
Expand All @@ -37,13 +39,13 @@ export function decodeRecord(schema: Schema, data: Hex): readonly (StaticPrimiti
}

if (schema.dynamicFields.length > 0) {
const dataLayout = hexToPackedCounter(sliceHex(data, bytesOffset, bytesOffset + 32));
const dataLayout = hexToPackedCounter(readHex(data, bytesOffset, bytesOffset + 32));
bytesOffset += 32;

schema.dynamicFields.forEach((fieldType, i) => {
const dataLength = dataLayout.fieldByteLengths[i];
if (dataLength > 0) {
const value = decodeDynamicField(fieldType, sliceHex(data, bytesOffset, bytesOffset + dataLength));
const value = decodeDynamicField(fieldType, readHex(data, bytesOffset, bytesOffset + dataLength));
bytesOffset += dataLength;
values.push(value);
} else {
Expand Down
16 changes: 16 additions & 0 deletions packages/protocol-parser/src/decodeValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isStaticAbiType, isDynamicAbiType } from "@latticexyz/schema-type";
import { Hex } from "viem";
import { SchemaToPrimitives, ValueSchema } from "./common";
import { decodeRecord } from "./decodeRecord";

export function decodeValue<TSchema extends ValueSchema>(valueSchema: TSchema, data: Hex): SchemaToPrimitives<TSchema> {
const staticFields = Object.values(valueSchema).filter(isStaticAbiType);
const dynamicFields = Object.values(valueSchema).filter(isDynamicAbiType);

// TODO: refactor and move all decodeRecord logic into this method so we can delete decodeRecord
const valueTuple = decodeRecord({ staticFields, dynamicFields }, data);

return Object.fromEntries(
Object.keys(valueSchema).map((name, i) => [name, valueTuple[i]])
) as SchemaToPrimitives<TSchema>;
}
11 changes: 7 additions & 4 deletions packages/protocol-parser/src/encodeField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ export function encodeField<TSchemaAbiType extends SchemaAbiType>(
): Hex {
if (isArrayAbiType(fieldType) && Array.isArray(value)) {
const staticFieldType = arrayAbiTypeToStaticAbiType(fieldType);
return encodePacked(
value.map(() => staticFieldType),
value
);
// TODO: we can remove conditional once this is fixed: https://github.com/wagmi-dev/viem/pull/1147
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like it got merged already!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep! we'll need to bump viem to get this change so gonna leave this here for now

return value.length === 0
? "0x"
: encodePacked(
value.map(() => staticFieldType),
value
);
}
return encodePacked([fieldType], [value]);
}
10 changes: 10 additions & 0 deletions packages/protocol-parser/src/encodeKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isStaticAbiType } from "@latticexyz/schema-type";
import { Hex } from "viem";
import { SchemaToPrimitives, KeySchema } from "./common";
import { encodeKeyTuple } from "./encodeKeyTuple";

export function encodeKey<TSchema extends KeySchema>(keySchema: TSchema, key: SchemaToPrimitives<TSchema>): Hex[] {
const staticFields = Object.values(keySchema).filter(isStaticAbiType);
// TODO: refactor and move all encodeKeyTuple logic into this method so we can delete encodeKeyTuple
return encodeKeyTuple({ staticFields, dynamicFields: [] }, Object.values(key));
}
1 change: 1 addition & 0 deletions packages/protocol-parser/src/encodeKeyTuple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StaticPrimitiveType } from "@latticexyz/schema-type";
import { Hex, encodeAbiParameters } from "viem";
import { Schema } from "./common";

/** @deprecated use `encodeKey` instead */
export function encodeKeyTuple(keySchema: Schema, keyTuple: StaticPrimitiveType[]): Hex[] {
return keyTuple.map((key, index) => encodeAbiParameters([{ type: keySchema.staticFields[index] }], [key]));
}
8 changes: 7 additions & 1 deletion packages/protocol-parser/src/encodeRecord.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe("encodeRecord", () => {
const schema = { staticFields: ["uint32", "uint128"], dynamicFields: ["uint32[]", "string"] } as const;
const hex = encodeRecord(schema, [1, 2n, [3, 4], "some string"]);
expect(hex).toBe(
"0x0000000100000000000000000000000000000002000000000000130000000008000000000b0000000000000000000000000000000000000300000004736f6d6520737472696e67"
"0x0000000100000000000000000000000000000002000000000000000000000000000000000000000b0000000008000000000000130000000300000004736f6d6520737472696e67"
);
});

Expand All @@ -15,4 +15,10 @@ describe("encodeRecord", () => {
const hex = encodeRecord(schema, [1, 2n]);
expect(hex).toBe("0x0000000100000000000000000000000000000002");
});

it("can encode an array to hex", () => {
const schema = { staticFields: [], dynamicFields: ["uint32[]"] } as const;
const hex = encodeRecord(schema, [[42]]);
expect(hex).toBe("0x00000000000000000000000000000000000000000000000004000000000000040000002a");
});
});
7 changes: 4 additions & 3 deletions packages/protocol-parser/src/encodeRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Hex } from "viem";
import { encodeField } from "./encodeField";
import { Schema } from "./common";

/** @deprecated use `encodeValue` instead */
export function encodeRecord(schema: Schema, values: readonly (StaticPrimitiveType | DynamicPrimitiveType)[]): Hex {
const staticValues = values.slice(0, schema.staticFields.length) as readonly StaticPrimitiveType[];
const dynamicValues = values.slice(schema.staticFields.length) as readonly DynamicPrimitiveType[];
Expand All @@ -17,14 +18,14 @@ export function encodeRecord(schema: Schema, values: readonly (StaticPrimitiveTy
encodeField(schema.dynamicFields[i], value).replace(/^0x/, "")
);

const dynamicFieldByteLengths = dynamicDataItems.map((value) => value.length / 2);
const dynamicFieldByteLengths = dynamicDataItems.map((value) => value.length / 2).reverse();
const dynamicTotalByteLength = dynamicFieldByteLengths.reduce((total, length) => total + BigInt(length), 0n);

const dynamicData = dynamicDataItems.join("");

const packedCounter = `${encodeField("uint56", dynamicTotalByteLength).replace(/^0x/, "")}${dynamicFieldByteLengths
const packedCounter = `${dynamicFieldByteLengths
.map((length) => encodeField("uint40", length).replace(/^0x/, ""))
.join("")}`.padEnd(64, "0");
.join("")}${encodeField("uint56", dynamicTotalByteLength).replace(/^0x/, "")}`.padStart(64, "0");

return `0x${staticData}${packedCounter}${dynamicData}`;
}
18 changes: 18 additions & 0 deletions packages/protocol-parser/src/encodeValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { isStaticAbiType, isDynamicAbiType } from "@latticexyz/schema-type";
import { Hex } from "viem";
import { SchemaToPrimitives, ValueSchema } from "./common";
import { encodeRecord } from "./encodeRecord";

export function encodeValue<TSchema extends ValueSchema>(
valueSchema: TSchema,
value: SchemaToPrimitives<TSchema>
): Hex {
const staticFields = Object.values(valueSchema).filter(isStaticAbiType);
const dynamicFields = Object.values(valueSchema).filter(isDynamicAbiType);

// TODO: refactor and move all encodeRecord logic into this method so we can delete encodeRecord

// This currently assumes fields/values are ordered by static, dynamic
// TODO: make sure we preserve ordering based on schema definition
return encodeRecord({ staticFields, dynamicFields }, Object.values(value));
}
7 changes: 4 additions & 3 deletions packages/protocol-parser/src/hexToPackedCounter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Hex, sliceHex } from "viem";
import { Hex } from "viem";
import { decodeStaticField } from "./decodeStaticField";
import { decodeDynamicField } from "./decodeDynamicField";
import { InvalidHexLengthForPackedCounterError, PackedCounterLengthMismatchError } from "./errors";
import { readHex } from "./readHex";

// Keep this logic in sync with PackedCounter.sol

Expand All @@ -18,9 +19,9 @@ export function hexToPackedCounter(data: Hex): {
throw new InvalidHexLengthForPackedCounterError(data);
}

const totalByteLength = decodeStaticField("uint56", sliceHex(data, 32 - 7, 32));
const totalByteLength = decodeStaticField("uint56", readHex(data, 32 - 7, 32));
// TODO: use schema to make sure we only parse as many as we need (rather than zeroes at the end)?
const reversedFieldByteLengths = decodeDynamicField("uint40[]", sliceHex(data, 0, 32 - 7));
const reversedFieldByteLengths = decodeDynamicField("uint40[]", readHex(data, 0, 32 - 7));
// Reverse the lengths
const fieldByteLengths = Object.freeze([...reversedFieldByteLengths].reverse());

Expand Down
7 changes: 7 additions & 0 deletions packages/protocol-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ export * from "./abiTypesToSchema";
export * from "./common";
export * from "./decodeDynamicField";
export * from "./decodeField";
export * from "./decodeKey";
export * from "./decodeKeyTuple";
export * from "./decodeRecord";
export * from "./decodeStaticField";
export * from "./decodeValue";
export * from "./encodeField";
export * from "./encodeKey";
export * from "./encodeKeyTuple";
export * from "./encodeRecord";
export * from "./encodeValue";
export * from "./errors";
export * from "./hexToPackedCounter";
export * from "./hexToSchema";
export * from "./hexToTableSchema";
export * from "./keySchemaToHex";
export * from "./readHex";
export * from "./schemaIndexToAbiType";
export * from "./schemaToHex";
export * from "./staticDataLength";
export * from "./valueSchemaToHex";
8 changes: 8 additions & 0 deletions packages/protocol-parser/src/keySchemaToHex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { isStaticAbiType } from "@latticexyz/schema-type";
import { Hex } from "viem";
import { KeySchema } from "./common";
import { schemaToHex } from "./schemaToHex";

export function keySchemaToHex(schema: KeySchema): Hex {
return schemaToHex({ staticFields: Object.values(schema).filter(isStaticAbiType), dynamicFields: [] });
}
15 changes: 15 additions & 0 deletions packages/protocol-parser/src/readHex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { readHex } from "./readHex";

describe("readHex", () => {
it("can slice empty hex", () => {
expect(readHex("0x", 6)).toBe("0x");
expect(readHex("0x", 6, 10)).toBe("0x00000000");
});
it("can slice hex out of bounds", () => {
expect(readHex("0x000100", 1)).toBe("0x0100");
expect(readHex("0x000100", 1, 4)).toBe("0x010000");
expect(readHex("0x000100", 3)).toBe("0x");
expect(readHex("0x000100", 3, 4)).toBe("0x00");
});
});
15 changes: 15 additions & 0 deletions packages/protocol-parser/src/readHex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Hex } from "viem";

/**
* Get the hex value at start/end positions. This will always return a valid hex string.
*
* If `start` is out of range, this returns `"0x"`.
*
* If `end` is specified and out of range, the result is right zero-padded to the desired length (`end - start`).
*/
export function readHex(data: Hex, start: number, end?: number): Hex {
return `0x${data
.replace(/^0x/, "")
.slice(start * 2, end != null ? end * 2 : undefined)
.padEnd(((end ?? start) - start) * 2, "0")}`;
}
1 change: 1 addition & 0 deletions packages/protocol-parser/src/schemaToHex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Hex } from "viem";
import { Schema } from "./common";
import { staticDataLength } from "./staticDataLength";

/** @deprecated use `keySchemaToHex` or `valueSchemaToHex` instead */
export function schemaToHex(schema: Schema): Hex {
const staticSchemaTypes = schema.staticFields.map((abiType) => schemaAbiTypes.indexOf(abiType));
const dynamicSchemaTypes = schema.dynamicFields.map((abiType) => schemaAbiTypes.indexOf(abiType));
Expand Down
11 changes: 11 additions & 0 deletions packages/protocol-parser/src/valueSchemaToHex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isDynamicAbiType, isStaticAbiType } from "@latticexyz/schema-type";
import { Hex } from "viem";
import { ValueSchema } from "./common";
import { schemaToHex } from "./schemaToHex";

export function valueSchemaToHex(schema: ValueSchema): Hex {
return schemaToHex({
staticFields: Object.values(schema).filter(isStaticAbiType),
dynamicFields: Object.values(schema).filter(isDynamicAbiType),
});
}
6 changes: 2 additions & 4 deletions packages/store-sync/src/common.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { Address, Block, Hex, Log, PublicClient, TransactionReceipt } from "viem";
import { Address, Block, Hex, Log, PublicClient } from "viem";
import { GroupLogsByBlockNumberResult } from "@latticexyz/block-logs-stream";
import {
StoreConfig,
KeySchema,
ValueSchema,
ConfigToKeyPrimitives as Key,
ConfigToValuePrimitives as Value,
TableRecord,
StoreEventsAbiItem,
StoreEventsAbi,
} from "@latticexyz/store";
import { Observable } from "rxjs";
import { BlockStorageOperations } from "./blockLogsToStorage";
import { KeySchema, ValueSchema, TableRecord } from "@latticexyz/protocol-parser";

export type ChainId = number;
export type WorldId = `${ChainId}:${Address}`;
Expand Down
5 changes: 3 additions & 2 deletions packages/store-sync/src/postgres/buildInternalTables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { integer, pgSchema, text } from "drizzle-orm/pg-core";
import { DynamicAbiType, StaticAbiType } from "@latticexyz/schema-type";
import { transformSchemaName } from "./transformSchemaName";
import { asAddress, asBigInt, asJson, asNumber } from "./columnTypes";
import { KeySchema, ValueSchema } from "@latticexyz/protocol-parser";

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function buildInternalTables() {
Expand All @@ -22,8 +23,8 @@ export function buildInternalTables() {
tableId: text("table_id").notNull(),
namespace: text("namespace").notNull(),
name: text("name").notNull(),
keySchema: asJson<Record<string, StaticAbiType>>("key_schema").notNull(),
valueSchema: asJson<Record<string, StaticAbiType | DynamicAbiType>>("value_schema").notNull(),
keySchema: asJson<KeySchema>("key_schema").notNull(),
valueSchema: asJson<ValueSchema>("value_schema").notNull(),
lastUpdatedBlockNumber: asBigInt("last_updated_block_number", "numeric"),
// TODO: last block hash?
lastError: text("last_error"),
Expand Down
Loading