From 1b9772c08df3ebcd2267722341ccb1c413773cae Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 9 May 2024 09:57:26 +1200 Subject: [PATCH 01/47] refactor: Close menu rather than toggling after clicking menu link. --- apps/web/src/components/layout/shell.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/layout/shell.tsx b/apps/web/src/components/layout/shell.tsx index ca84d674b..ce071ea3b 100644 --- a/apps/web/src/components/layout/shell.tsx +++ b/apps/web/src/components/layout/shell.tsx @@ -33,7 +33,8 @@ import SendTransaction from "../../components/sendTransaction"; import { CartesiScanChains } from "../networks/cartesiScanNetworks"; const Shell: FC<{ children: ReactNode }> = ({ children }) => { - const [opened, { toggle: toggleMobileMenu }] = useDisclosure(); + const [opened, { toggle: toggleMobileMenu, close: closeMobileMenu }] = + useDisclosure(); const [ transaction, { @@ -146,14 +147,14 @@ const Shell: FC<{ children: ReactNode }> = ({ children }) => { label="Home" href="/" leftSection={} - onClick={toggleMobileMenu} + onClick={closeMobileMenu} data-testid="home-link" /> } data-testid="applications-link" @@ -162,7 +163,7 @@ const Shell: FC<{ children: ReactNode }> = ({ children }) => { } data-testid="inputs-link" @@ -176,7 +177,7 @@ const Shell: FC<{ children: ReactNode }> = ({ children }) => { > } href="/connections" From d5c462426a9c83f80630313c7fdbb21a022d461b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Sun, 19 May 2024 19:35:24 +1200 Subject: [PATCH 02/47] feat: Add decoder for abi_params and json_abi modes, custom errors, types and test cases. --- .../src/components/specification/decoder.ts | 192 ++++++++++++++++++ .../errors/SpecificationModeNotSupported.ts | 9 + .../web/src/components/specification/types.ts | 59 ++++++ apps/web/test/lib/decoder.test.ts | 190 +++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 apps/web/src/components/specification/decoder.ts create mode 100644 apps/web/src/components/specification/errors/SpecificationModeNotSupported.ts create mode 100644 apps/web/src/components/specification/types.ts create mode 100644 apps/web/test/lib/decoder.test.ts diff --git a/apps/web/src/components/specification/decoder.ts b/apps/web/src/components/specification/decoder.ts new file mode 100644 index 000000000..12130dac0 --- /dev/null +++ b/apps/web/src/components/specification/decoder.ts @@ -0,0 +1,192 @@ +import { T, cond, head, includes, pathEq, pathOr, pipe } from "ramda"; +import { + AbiFunction, + AbiFunctionSignatureNotFoundError, + Hex, + decodeAbiParameters, + decodeFunctionData, + parseAbiParameters, + slice, +} from "viem"; +import SpecificationModeNotSupportedError from "./errors/SpecificationModeNotSupported"; +import { ABI_PARAMS, JSON_ABI, Specification, specModes } from "./types"; + +interface Piece { + name: string; + part: Hex | Uint8Array; + decodedPart?: any; +} + +interface Envelope { + spec: Specification; + input: Hex | Uint8Array; + pieces: Piece[]; + result: Record; + error?: Error; +} + +const getPieces = ( + abiParams: readonly string[], + encodedData: Hex | Uint8Array, +): Piece[] => { + const abiParameters = parseAbiParameters(abiParams); + + const resultList = decodeAbiParameters( + // @ts-ignore dynamic type complains + abiParameters, + encodedData, + ); + + return resultList.map((decodedVal, index) => { + // @ts-ignore + const { name } = abiParameters[index] as { + name: string; + }; + + return { + name, + part: encodedData, + decodedPart: decodedVal, + } as Piece; + }); +}; + +const addPiecesToEnvelope = (e: Envelope): Envelope => { + if (e.spec.mode === "abi_params") { + try { + if (e.spec.sliceInstructions?.length) { + e.spec.sliceInstructions.forEach((instruction, index) => { + const { from, to, name, type } = instruction; + const part = slice(e.input, from, to); + const decodedPart = + type !== undefined + ? head(decodeAbiParameters([{ type, name }], part)) + : part; + + e.pieces.push({ + name: name ?? `param${index}`, + part, + decodedPart, + }); + }); + } else { + const pieces = getPieces(e.spec.abiParams, e.input); + e.pieces.push(...pieces); + } + } catch (error: any) { + const message = pathOr(error.message, ["shortMessage"], error); + const errorMeta = pathOr([], ["metaMessages"], error).join("\n"); + e.error = new Error(`${message}\n\n${errorMeta}`); + } + } + + return e; +}; + +const decodeTargetSliceAndAddToPieces = (e: Envelope): Envelope => { + if (e.spec.mode === "abi_params") { + const targetName = e.spec.sliceTarget; + const piece = e.pieces.find((piece) => piece.name === targetName); + + if (piece && piece.part) { + const pieces = getPieces(e.spec.abiParams, piece.part); + e.pieces.push(...pieces); + } + } + return e; +}; + +const prepareResultFromPieces = (e: Envelope): Envelope => { + if (e.spec.mode === "abi_params") { + const sliceTarget = e.spec.sliceTarget; + e.result = e.pieces.reduce((prev, { name, decodedPart }, index) => { + /** + * Adding a unwrap effect + * decoded target is not included in the result + */ + if (sliceTarget === name) return prev; + + const key = name ?? `params${index}`; + return { ...prev, [key]: decodedPart }; + }, {}); + } + + return e; +}; + +const prepareResultForJSONABI = (e: Envelope): Envelope => { + if (e.spec.mode === "json_abi") { + try { + const { functionName, args } = decodeFunctionData({ + abi: e.spec.abi, + data: e.input as Hex, + }); + + const orderedNamedArgs: [string, any][] = []; + + if (args && args?.length > 0) { + const abiItem = e.spec.abi.find( + (item) => + item.type === "function" && item.name === functionName, + ) as AbiFunction; + + // respecting order of arguments but including abi names + abiItem.inputs.forEach((param, index) => { + const name = param.name ?? `param${0}`; + orderedNamedArgs.push([name, args[index]]); + }); + } + + e.result = { + functionName, + args, + orderedNamedArgs, + }; + } catch (err: any) { + const message = + err instanceof AbiFunctionSignatureNotFoundError + ? err.shortMessage + : err.message; + e.error = new Error(message); + } + } + + return e; +}; + +const transform: (e: Envelope) => Envelope = cond([ + [ + pathEq(ABI_PARAMS, ["spec", "mode"]), + pipe( + addPiecesToEnvelope, + decodeTargetSliceAndAddToPieces, + prepareResultFromPieces, + ), + ], + [pathEq(JSON_ABI, ["spec", "mode"]), prepareResultForJSONABI], + [ + T, + (e: Envelope) => { + e.error = new SpecificationModeNotSupportedError(e.spec); + return e; + }, + ], +]); + +export function decodeInputPayload( + spec: Specification, + input: Hex | Uint8Array, +): Envelope { + if (!includes(spec.mode, specModes)) { + throw new SpecificationModeNotSupportedError(spec); + } + + const envelope: Envelope = { + spec, + input, + pieces: [], + result: {}, + }; + + return transform(envelope); +} diff --git a/apps/web/src/components/specification/errors/SpecificationModeNotSupported.ts b/apps/web/src/components/specification/errors/SpecificationModeNotSupported.ts new file mode 100644 index 000000000..5dfa5a44b --- /dev/null +++ b/apps/web/src/components/specification/errors/SpecificationModeNotSupported.ts @@ -0,0 +1,9 @@ +import { Specification, specModes } from "../types"; + +export default class SpecificationModeNotSupportedError extends Error { + constructor(specification: Specification) { + const supported = specModes.join(", "); + const message = `Supported Specification modes: [${supported}] - but found ${specification.mode}`; + super(message); + } +} diff --git a/apps/web/src/components/specification/types.ts b/apps/web/src/components/specification/types.ts new file mode 100644 index 000000000..649b060c2 --- /dev/null +++ b/apps/web/src/components/specification/types.ts @@ -0,0 +1,59 @@ +import { AbiType } from "abitype"; +import { Abi } from "viem"; + +export const JSON_ABI = "json_abi" as const; +export const ABI_PARAMS = "abi_params" as const; +export const specModes = [JSON_ABI, ABI_PARAMS] as const; +export const equalityOperators = ["equals"] as const; +export const logicalOperators = ["and", "or"] as const; +export const inputProperties = ["application.id", "msgSender"] as const; + +export type Modes = (typeof specModes)[number]; +export type Operator = (typeof equalityOperators)[number]; +export type ConditionalOperator = (typeof logicalOperators)[number]; +export type FieldPath = (typeof inputProperties)[number]; + +export interface Condition { + operator: Operator; + field: FieldPath; + value: string; +} + +export interface Predicate { + logicalOperator: ConditionalOperator; + conditions: Condition[]; +} + +interface SliceInstruction { + /** Start index of the hex or byte-array*/ + from: number; + /** End index of the hex or byte-array. Undefined means getting from start onwards e.g. arbitrary data size.*/ + to?: number; + /** A name for the final decoded object or for reference when the slice is a target for abi-params decoding*/ + name?: string; + /** The type to decode e.g. uint. Leaving empty will default to the raw sliced return; useful for Address for example.*/ + type?: AbiType; +} + +type Commons = { + name: string; + timestamp?: number; + conditionals?: Predicate[]; +}; + +export type AbiParamsSpec = Commons & { + mode: "abi_params"; + abiParams: readonly string[]; + /** Optional: instructions to treat the encoded data before applying the abiParams definition to a specific named part.*/ + sliceInstructions?: SliceInstruction[]; + /** Optional: find a named sliced data and decode it applying the defined values set on abiParams property. */ + sliceTarget?: string; +}; + +export type JSONAbiSpec = Commons & { + mode: "json_abi"; + /** Full fledge-json-abi to decode encoded data with 4 bytes selector + any arguments it has. */ + abi: Abi; +}; + +export type Specification = AbiParamsSpec | JSONAbiSpec; diff --git a/apps/web/test/lib/decoder.test.ts b/apps/web/test/lib/decoder.test.ts new file mode 100644 index 000000000..00d22416d --- /dev/null +++ b/apps/web/test/lib/decoder.test.ts @@ -0,0 +1,190 @@ +import { erc1155BatchPortalAbi, erc20Abi } from "@cartesi/rollups-wagmi"; +import { describe, it } from "vitest"; +import { decodeInputPayload } from "../../src/components/specification/decoder"; +import { Specification } from "../../src/components/specification/types"; + +const inputData = + "0x24d15c67000000000000000000000000f08b9b4044441e43337c1ab6e941c4e59d5f73c80000000000000000000000004ca2f6935200b9a782a78f408f640f17b29809d800000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; +const singleERC1155DepositPayload = + "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905ea074683b5be015f053b5dceb064c41fc9d11b6e5000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + +const batchPayload = + "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905ea074683b5be015f053b5dceb064c41fc9d11b6e5000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000c8000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + +describe("Decoding Specification", () => { + it("should throw an error for non-supported specification mode", () => { + try { + // @ts-ignore + decodeInputPayload({ mode: "random" }, batchPayload); + expect("Should never enter here").toEqual(""); + } catch (error: any) { + expect(error.message).toEqual( + "Supported Specification modes: [json_abi, abi_params] - but found random", + ); + } + }); + + describe("For JSON ABI", () => { + it("should decode data based on full JSON ABI", () => { + const spec: Specification = { + mode: "json_abi", + name: "erc1155BatchPortalAbi", + abi: erc1155BatchPortalAbi, + }; + + const envelope = decodeInputPayload(spec, inputData); + + expect(envelope.error).not.toBeDefined(); + expect(envelope.result).toEqual({ + functionName: "depositBatchERC1155Token", + args: [ + "0xf08B9B4044441e43337c1AB6e941c4e59d5F73c8", + "0x4cA2f6935200b9a782A78f408F640F17B29809d8", + [0n, 1n, 2n], + [50n, 40n, 30n], + "0x", + "0x", + ], + orderedNamedArgs: [ + ["_token", "0xf08B9B4044441e43337c1AB6e941c4e59d5F73c8"], + ["_dapp", "0x4cA2f6935200b9a782A78f408F640F17B29809d8"], + ["_tokenIds", [0n, 1n, 2n]], + ["_values", [50n, 40n, 30n]], + ["_baseLayerData", "0x"], + ["_execLayerData", "0x"], + ], + }); + }); + + it("should return error when 4-byte signature is not found in the ABI", () => { + const spec: Specification = { + mode: "json_abi", + name: "erc1155BatchPortalAbi", + abi: erc20Abi, + }; + + const envelope = decodeInputPayload(spec, inputData); + + expect(envelope.error).toBeDefined(); + expect(envelope.error?.message).toEqual( + `Encoded function signature "0x24d15c67" not found on ABI. +Make sure you are using the correct ABI and that the function exists on it. +You can look up the signature here: https://openchain.xyz/signatures?query=0x24d15c67.`, + ); + expect(envelope.result).toEqual({}); + }); + }); + + describe("For ABI Params", () => { + it("should parse encoded data with human-readable ABI format", () => { + const sample = + "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000"; + + const spec: Specification = { + mode: "abi_params", + abiParams: ["string name, uint amount, bool success"], + name: "Wagmi Encoded Data", + }; + + const envelope = decodeInputPayload(spec, sample); + + expect(envelope.error).not.toBeDefined(); + expect(envelope.result).toEqual({ + name: "wagmi", + amount: 420n, + success: true, + }); + }); + + it("should decode non-standard encoded data based on slice instruction", () => { + const spec: Specification = { + mode: "abi_params", + name: "Single ERC-1155 Deposit", + sliceInstructions: [ + { from: 0, to: 20, name: "tokenAddress" }, + { from: 20, to: 40, name: "from" }, + { from: 40, to: 72, name: "tokenId", type: "uint" }, + { from: 72, to: 104, name: "amount", type: "uint" }, + ], + abiParams: [], + }; + + const envelope = decodeInputPayload( + spec, + singleERC1155DepositPayload, + ); + + console.log(envelope.error); + + expect(envelope.error).not.toBeDefined(); + expect(envelope.result).toEqual({ + tokenAddress: "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905e", + from: "0xa074683b5be015f053b5dceb064c41fc9d11b6e5", + tokenId: 2n, + amount: 100n, + }); + }); + + it("should return type error when parsing non-standard encoded data given insufficient size", () => { + const spec: Specification = { + mode: "abi_params", + name: "Single ERC-1155 Deposit", + sliceInstructions: [ + { from: 0, to: 20, name: "willThrow", type: "address" }, + { from: 20, to: 40, name: "from" }, + { from: 40, to: 72, name: "tokenId", type: "uint" }, + { from: 72, to: 104, name: "amount", type: "uint" }, + ], + abiParams: [], + }; + + const envelope = decodeInputPayload( + spec, + singleERC1155DepositPayload, + ); + + expect(envelope.error).toBeDefined(); + expect(envelope.error?.message).toEqual( + "Data size of 20 bytes is too small for given parameters.\n\nParams: (address willThrow)\nData: 0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905e (20 bytes)", + ); + }); + + it("should decode based on data slice instructions targeting a more complex sliced data.", () => { + const spec: Specification = { + mode: "abi_params", + name: "1155 Batch Portal Deposit", + abiParams: [ + "uint[] tokenIds, uint[] amount, bytes baseLayer, bytes execLayer", + ], + sliceInstructions: [ + { + from: 0, + to: 20, + name: "tokenAddress", + }, + { + from: 20, + to: 40, + name: "from", + }, + { + from: 40, + name: "data", + }, + ], + sliceTarget: "data", + }; + + const envelope = decodeInputPayload(spec, batchPayload); + + expect(envelope.result).toEqual({ + amount: [200n, 50n], + baseLayer: "0x", + execLayer: "0x", + from: "0xa074683b5be015f053b5dceb064c41fc9d11b6e5", + tokenAddress: "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905e", + tokenIds: [2n, 1n], + }); + }); + }); +}); From 96e040b4df11edbb8a48dc7f1ba206527e769137 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 20 May 2024 22:44:50 +1200 Subject: [PATCH 03/47] refactor: Decode function name and test. Include jsdoc info. --- .../src/components/specification/decoder.ts | 11 ++++++++- .../specification}/decoder.test.ts | 24 +++++++------------ 2 files changed, 19 insertions(+), 16 deletions(-) rename apps/web/test/{lib => components/specification}/decoder.test.ts (92%) diff --git a/apps/web/src/components/specification/decoder.ts b/apps/web/src/components/specification/decoder.ts index 12130dac0..81e7b9cb6 100644 --- a/apps/web/src/components/specification/decoder.ts +++ b/apps/web/src/components/specification/decoder.ts @@ -173,7 +173,16 @@ const transform: (e: Envelope) => Envelope = cond([ ], ]); -export function decodeInputPayload( +/** + * Decode the payload data based on the specification passed. The return + * contains the result but also could contain an error as a value, therefore + * the callee should decide what to do with it. + * @param spec {Specification} + * @param payload {Hex | Uint8Array} + * @throws {SpecificationModeNotSupportedError} + * @returns {Envelope} + */ +export function decodePayload( spec: Specification, input: Hex | Uint8Array, ): Envelope { diff --git a/apps/web/test/lib/decoder.test.ts b/apps/web/test/components/specification/decoder.test.ts similarity index 92% rename from apps/web/test/lib/decoder.test.ts rename to apps/web/test/components/specification/decoder.test.ts index 00d22416d..90ff54834 100644 --- a/apps/web/test/lib/decoder.test.ts +++ b/apps/web/test/components/specification/decoder.test.ts @@ -1,7 +1,7 @@ import { erc1155BatchPortalAbi, erc20Abi } from "@cartesi/rollups-wagmi"; import { describe, it } from "vitest"; -import { decodeInputPayload } from "../../src/components/specification/decoder"; -import { Specification } from "../../src/components/specification/types"; +import { decodePayload } from "../../../src/components/specification/decoder"; +import { Specification } from "../../../src/components/specification/types"; const inputData = "0x24d15c67000000000000000000000000f08b9b4044441e43337c1ab6e941c4e59d5f73c80000000000000000000000004ca2f6935200b9a782a78f408f640f17b29809d800000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; @@ -15,7 +15,7 @@ describe("Decoding Specification", () => { it("should throw an error for non-supported specification mode", () => { try { // @ts-ignore - decodeInputPayload({ mode: "random" }, batchPayload); + decodePayload({ mode: "random" }, batchPayload); expect("Should never enter here").toEqual(""); } catch (error: any) { expect(error.message).toEqual( @@ -32,7 +32,7 @@ describe("Decoding Specification", () => { abi: erc1155BatchPortalAbi, }; - const envelope = decodeInputPayload(spec, inputData); + const envelope = decodePayload(spec, inputData); expect(envelope.error).not.toBeDefined(); expect(envelope.result).toEqual({ @@ -63,7 +63,7 @@ describe("Decoding Specification", () => { abi: erc20Abi, }; - const envelope = decodeInputPayload(spec, inputData); + const envelope = decodePayload(spec, inputData); expect(envelope.error).toBeDefined(); expect(envelope.error?.message).toEqual( @@ -86,7 +86,7 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d name: "Wagmi Encoded Data", }; - const envelope = decodeInputPayload(spec, sample); + const envelope = decodePayload(spec, sample); expect(envelope.error).not.toBeDefined(); expect(envelope.result).toEqual({ @@ -109,10 +109,7 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d abiParams: [], }; - const envelope = decodeInputPayload( - spec, - singleERC1155DepositPayload, - ); + const envelope = decodePayload(spec, singleERC1155DepositPayload); console.log(envelope.error); @@ -138,10 +135,7 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d abiParams: [], }; - const envelope = decodeInputPayload( - spec, - singleERC1155DepositPayload, - ); + const envelope = decodePayload(spec, singleERC1155DepositPayload); expect(envelope.error).toBeDefined(); expect(envelope.error?.message).toEqual( @@ -175,7 +169,7 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d sliceTarget: "data", }; - const envelope = decodeInputPayload(spec, batchPayload); + const envelope = decodePayload(spec, batchPayload); expect(envelope.result).toEqual({ amount: [200n, 50n], From df3f30167532bed5e7c26aceedaf064574af5808 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 20 May 2024 22:58:40 +1200 Subject: [PATCH 04/47] feat: Add conditionals to match against a input and return a Specification. --- .../components/specification/conditionals.ts | 113 ++++++++++++++++++ .../web/src/components/specification/types.ts | 18 +-- 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/specification/conditionals.ts diff --git a/apps/web/src/components/specification/conditionals.ts b/apps/web/src/components/specification/conditionals.ts new file mode 100644 index 000000000..8f89c6422 --- /dev/null +++ b/apps/web/src/components/specification/conditionals.ts @@ -0,0 +1,113 @@ +import { + allPass, + anyPass, + complement, + equals, + isEmpty, + isNil, + isNotNil, + path, + pipe, + thunkify, +} from "ramda"; +import { Input } from "../../graphql/explorer/types"; +import { Condition, Predicate, Specification } from "./types"; + +/** + * Conditional typing to create a duck typing situation + * with generated graphql types. For flexibility sake but still with autocompletion. + */ +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +type MaybeInput = DeepPartial; + +interface MatchEnvelope { + input: MaybeInput; + specs: Specification[]; +} + +type MatchResult = Specification | undefined; + +type Matcher = ( + input: MaybeInput, + specifications: Specification[], +) => Specification | null; + +const isNotNilOrEmpty = allPass([isNotNil, complement(isEmpty)]); + +const filterSpecsWithoutConditions = (me: MatchEnvelope): MatchEnvelope => { + const newList = me.specs.filter((spec) => + isNotNilOrEmpty(spec.conditionals), + ); + return { ...me, specs: newList }; +}; + +const operators = { + equals, +}; + +const logicalOperators = { + and: allPass, + or: anyPass, +}; + +/** + * Return a list of lazy functions to be evaluated later as parameters for Logical Operators e.g. or(fn1, fn2)() + * @param conditions + * @param input + * @returns + */ +const buildConditions = (conditions: Condition[], input: MaybeInput) => { + return conditions.map((condition) => { + const inputValue = path(condition.field?.split("."), input); + const operatorFn = operators[condition.operator]; + return thunkify(operatorFn)(inputValue, condition.value); + }); +}; + +const buildExpression = (predicates: Predicate[], input: MaybeInput) => { + const expressions = predicates.map((predicate) => { + const logicalOperator = logicalOperators[predicate.logicalOperator]; + const conditions = buildConditions(predicate.conditions, input); + return logicalOperator(conditions); + }); + + return anyPass(expressions); +}; + +const match = (me: MatchEnvelope): Specification | null => { + for (let i = 0; i < me.specs.length; i++) { + const spec = me.specs[i]; + const executableExpression = buildExpression( + spec.conditionals!, + me.input, + ); + + if (executableExpression() === true) { + return spec; + } + } + + return null; +}; + +const isNilOrEmpty = anyPass([isNil, isEmpty]); + +/** + * Return the first specification which the conditional defined evaluate to true. + * Note: that this is short-circuited, meaning it will stop iterating over the specification list after a match. + * @param input + * @param specifications + * @returns + */ +export const findSpecificationFor: Matcher = (input, specifications) => { + if (isNilOrEmpty(input) || isNilOrEmpty(specifications)) return null; + + const envelope: MatchEnvelope = { input, specs: specifications }; + + return pipe(filterSpecsWithoutConditions, match)(envelope); +}; diff --git a/apps/web/src/components/specification/types.ts b/apps/web/src/components/specification/types.ts index 649b060c2..c68e1d2c0 100644 --- a/apps/web/src/components/specification/types.ts +++ b/apps/web/src/components/specification/types.ts @@ -4,12 +4,12 @@ import { Abi } from "viem"; export const JSON_ABI = "json_abi" as const; export const ABI_PARAMS = "abi_params" as const; export const specModes = [JSON_ABI, ABI_PARAMS] as const; -export const equalityOperators = ["equals"] as const; +export const operators = ["equals"] as const; export const logicalOperators = ["and", "or"] as const; export const inputProperties = ["application.id", "msgSender"] as const; export type Modes = (typeof specModes)[number]; -export type Operator = (typeof equalityOperators)[number]; +export type Operator = (typeof operators)[number]; export type ConditionalOperator = (typeof logicalOperators)[number]; export type FieldPath = (typeof inputProperties)[number]; @@ -39,21 +39,23 @@ type Commons = { name: string; timestamp?: number; conditionals?: Predicate[]; + /** Reference to the implementation version. Not controlled by user.*/ + version?: number; }; - -export type AbiParamsSpec = Commons & { +export interface AbiParamsSpecification extends Commons { mode: "abi_params"; + /** List of human readable ABI format entries*/ abiParams: readonly string[]; /** Optional: instructions to treat the encoded data before applying the abiParams definition to a specific named part.*/ sliceInstructions?: SliceInstruction[]; /** Optional: find a named sliced data and decode it applying the defined values set on abiParams property. */ sliceTarget?: string; -}; +} -export type JSONAbiSpec = Commons & { +export interface JSONAbiSpecification extends Commons { mode: "json_abi"; /** Full fledge-json-abi to decode encoded data with 4 bytes selector + any arguments it has. */ abi: Abi; -}; +} -export type Specification = AbiParamsSpec | JSONAbiSpec; +export type Specification = AbiParamsSpecification | JSONAbiSpecification; From 9f5d6c9937045e8a3e865d6b93c8b7c11ec5d792 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 20 May 2024 22:59:30 +1200 Subject: [PATCH 05/47] feat: Add system specifications (Portals). conditional test cases and stubs. --- .../components/specification/systemSpecs.ts | 131 ++++++++++++++++++ .../specification/conditionals.test.ts | 87 ++++++++++++ .../test/components/specification/stubs.ts | 83 +++++++++++ 3 files changed, 301 insertions(+) create mode 100644 apps/web/src/components/specification/systemSpecs.ts create mode 100644 apps/web/test/components/specification/conditionals.test.ts create mode 100644 apps/web/test/components/specification/stubs.ts diff --git a/apps/web/src/components/specification/systemSpecs.ts b/apps/web/src/components/specification/systemSpecs.ts new file mode 100644 index 000000000..b23687550 --- /dev/null +++ b/apps/web/src/components/specification/systemSpecs.ts @@ -0,0 +1,131 @@ +/** + * A group of specifications defined by the system. Mostly related to Cartesi Portals. + * They can't be deleted or edited by the user. + */ +import { + erc1155BatchPortalConfig, + erc1155SinglePortalConfig, + erc20PortalConfig, + erc721PortalConfig, +} from "@cartesi/rollups-wagmi"; +import { Specification } from "./types"; + +const ERC1155SinglePortalSpec: Specification = { + mode: "abi_params", + name: `ERC-1155 Single Portal @cartesi/rollups@1.x`, + sliceInstructions: [ + { from: 0, to: 20, name: "tokenAddress" }, + { from: 20, to: 40, name: "from" }, + { from: 40, to: 72, name: "tokenId", type: "uint" }, + { from: 72, to: 104, name: "amount", type: "uint" }, + ], + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "msgSender", + operator: "equals", + value: erc1155SinglePortalConfig.address.toLowerCase(), + }, + ], + }, + ], + abiParams: [], + version: 1, +} as const; + +const ERC1155BatchPortalSpec: Specification = { + mode: "abi_params", + name: "ERC-1155 Batch Portal @cartesi/rollups@1.x", + abiParams: [ + "uint[] tokenIds, uint[] amounts, bytes baseLayer, bytes execLayer", + ], + sliceInstructions: [ + { + from: 0, + to: 20, + name: "tokenAddress", + }, + { + from: 20, + to: 40, + name: "from", + }, + { + from: 40, + name: "data", + }, + ], + sliceTarget: "data", + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "msgSender", + operator: "equals", + value: erc1155BatchPortalConfig.address.toLowerCase(), + }, + ], + }, + ], + version: 1, +} as const; + +const ERC20PortalSpec: Specification = { + mode: "abi_params", + name: "ERC-20 Portal @cartesi/rollups@1.x", + sliceInstructions: [ + { from: 1, to: 21, name: "tokenAddress" }, + { from: 21, to: 41, name: "from" }, + { from: 41, to: 73, name: "amount", type: "uint" }, + ], + abiParams: [], + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "msgSender", + operator: "equals", + value: erc20PortalConfig.address.toLowerCase(), + }, + ], + }, + ], + version: 1, +} as const; + +const ERC721PortalSpec: Specification = { + version: 1, + mode: "abi_params", + name: "ERC-721 Portal @cartesi/rollups@1.x", + sliceInstructions: [ + { from: 0, to: 20, name: "tokenAddress" }, + { from: 20, to: 40, name: "from" }, + { from: 40, to: 72, name: "tokenIndex", type: "uint" }, + ], + abiParams: [], + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "msgSender", + operator: "equals", + value: erc721PortalConfig.address.toLowerCase(), + }, + ], + }, + ], +} as const; + +export const systemSpecification = { + ERC1155SinglePortalSpec, + ERC1155BatchPortalSpec, + ERC20PortalSpec, + ERC721PortalSpec, +} as const; + +export const systemSpecificationAsList = Object.values(systemSpecification); diff --git a/apps/web/test/components/specification/conditionals.test.ts b/apps/web/test/components/specification/conditionals.test.ts new file mode 100644 index 000000000..4631fed17 --- /dev/null +++ b/apps/web/test/components/specification/conditionals.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "vitest"; +import { findSpecificationFor } from "../../../src/components/specification/conditionals"; +import { decodePayload } from "../../../src/components/specification/decoder"; +import { systemSpecificationAsList } from "../../../src/components/specification/systemSpecs"; +import { AbiParamsSpecification } from "../../../src/components/specification/types"; +import { inputResponses } from "./stubs"; + +describe("Specification Conditionals", () => { + describe("Matching by msgSender for Portals (System Specifications)", () => { + it("should match msgSender and return decoding specification for ERC-20 Portal", () => { + const { erc20DepositInput } = inputResponses; + const specification = findSpecificationFor( + erc20DepositInput, + systemSpecificationAsList, + ); + + expect(specification).toBeDefined(); + expect(specification?.name).toEqual( + "ERC-20 Portal @cartesi/rollups@1.x", + ); + }); + + it("should match msgSender and return decoding specification for ERC-1155 Single Portal", () => { + const { singleERC1155DepositInput } = inputResponses; + const specification = findSpecificationFor( + singleERC1155DepositInput, + systemSpecificationAsList, + ); + + expect(specification).toBeDefined(); + expect(specification?.name).toEqual( + "ERC-1155 Single Portal @cartesi/rollups@1.x", + ); + }); + + it("should match msgSender and return decoding specification for ERC-1155 Batch Portal", () => { + const { batchERC1155DepositInput } = inputResponses; + const specification = findSpecificationFor( + batchERC1155DepositInput, + systemSpecificationAsList, + ) as AbiParamsSpecification; + + expect(specification).toBeDefined(); + expect(specification?.name).toEqual( + "ERC-1155 Batch Portal @cartesi/rollups@1.x", + ); + expect(specification?.mode).toEqual("abi_params"); + expect(specification.sliceTarget).toEqual("data"); + expect(specification.sliceInstructions).toHaveLength(3); + + expect( + decodePayload(specification, batchERC1155DepositInput.payload) + .result, + ).toEqual({ + tokenAddress: "0xf08b9b4044441e43337c1ab6e941c4e59d5f73c8", + from: "0xa074683b5be015f053b5dceb064c41fc9d11b6e5", + amounts: [50n, 40n, 30n], + tokenIds: [0n, 1n, 2n], + baseLayer: "0x", + execLayer: "0x", + }); + }); + + it("should match msgSender and return decoding specification for ERC-721 Portal", () => { + const { erc721DepositInput } = inputResponses; + const specification = findSpecificationFor( + erc721DepositInput, + systemSpecificationAsList, + ) as AbiParamsSpecification; + + expect(specification).toBeDefined(); + expect(specification.name).toEqual( + "ERC-721 Portal @cartesi/rollups@1.x", + ); + expect(specification.mode).toEqual("abi_params"); + expect(specification.sliceInstructions).toHaveLength(3); + + expect( + decodePayload(specification, erc721DepositInput.payload).result, + ).toEqual({ + from: "0xa074683b5be015f053b5dceb064c41fc9d11b6e5", + tokenAddress: "0x7a3cc9c0408887a030a0354330c36a9cd681aa7e", + tokenIndex: 3n, + }); + }); + }); +}); diff --git a/apps/web/test/components/specification/stubs.ts b/apps/web/test/components/specification/stubs.ts new file mode 100644 index 000000000..a5f74a6ee --- /dev/null +++ b/apps/web/test/components/specification/stubs.ts @@ -0,0 +1,83 @@ +/** + * @file Contains real samples from our graphQL API. This file should only hold this kind of data. + */ + +const singleERC1155DepositInput = { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-802", + application: { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8", + }, + index: 802, + payload: + "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905ea074683b5be015f053b5dceb064c41fc9d11b6e5000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + msgSender: "0x7cfb0193ca87eb6e48056885e026552c3a941fc4", + timestamp: "1713235704", + transactionHash: + "0x8f0a0db51c8bcd6edd9e9d44adb778339fd39b76388c93bdc170fe944e8caf39", + erc20Deposit: null, +} as const; + +const batchERC1155DepositInput = { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-805", + application: { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8", + }, + index: 805, + payload: + "0xf08b9b4044441e43337c1ab6e941c4e59d5f73c8a074683b5be015f053b5dceb064c41fc9d11b6e500000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + msgSender: "0xedb53860a6b52bbb7561ad596416ee9965b055aa", + timestamp: "1713420924", + transactionHash: + "0x9b7e68052231cbfae202b1902d2efc2b04ea0feac6dcba1397d4ad4f9b4af292", + erc20Deposit: null, +} as const; + +const erc20DepositInput = { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-788", + application: { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8", + }, + index: 788, + payload: + "0x01dfc2e94264261a1f9d2a754cf20055bc6287ae5ba074683b5be015f053b5dceb064c41fc9d11b6e5000000000000000000000000000000000000000000000000000000174876e8003078", + msgSender: "0x9c21aeb2093c32ddbc53eef24b873bdcd1ada1db", + timestamp: "1708562892", + transactionHash: + "0x7f42dd070f9e431afbc82beab206a2df541606ca4179d5dd145952b2b507eadc", + erc20Deposit: { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-788", + from: "0xa074683b5be015f053b5dceb064c41fc9d11b6e5", + token: { + id: "0xdfc2e94264261a1f9d2a754cf20055bc6287ae5b", + name: "Cartesi Token", + symbol: "CTSI", + decimals: 18, + }, + amount: "100000000000", + }, +} as const; + +const erc721DepositInput = { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-782", + application: { + id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8", + }, + index: 782, + payload: + "0x7a3cc9c0408887a030a0354330c36a9cd681aa7ea074683b5be015f053b5dceb064c41fc9d11b6e50000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002307800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000023078000000000000000000000000000000000000000000000000000000000000", + msgSender: "0x237f8dd094c0e47f4236f12b4fa01d6dae89fb87", + timestamp: "1707710448", + transactionHash: + "0xcca56c45f486886a5d3669392440ef9127007226d9d456dd6da4ac9020322c34", + erc20Deposit: null, +} as const; + +/** + * Input response structure based on current graphQL queries + */ +export const inputResponses = { + singleERC1155DepositInput, + batchERC1155DepositInput, + erc20DepositInput, + erc721DepositInput, +} as const; From a7c6af768748142ec872344042db4d35c4cfd1f4 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 21 May 2024 14:29:25 +1200 Subject: [PATCH 06/47] test: Add cases for logical operators with multi-conditions defined for allowed fields. --- .../specification/conditionals.test.ts | 233 +++++++++++++++++- .../specification/encodedData.stubs.ts | 27 ++ .../test/components/specification/stubs.ts | 16 ++ 3 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 apps/web/test/components/specification/encodedData.stubs.ts diff --git a/apps/web/test/components/specification/conditionals.test.ts b/apps/web/test/components/specification/conditionals.test.ts index 4631fed17..1ef16f12a 100644 --- a/apps/web/test/components/specification/conditionals.test.ts +++ b/apps/web/test/components/specification/conditionals.test.ts @@ -1,11 +1,26 @@ +import { Hex } from "viem"; import { describe, it } from "vitest"; import { findSpecificationFor } from "../../../src/components/specification/conditionals"; import { decodePayload } from "../../../src/components/specification/decoder"; import { systemSpecificationAsList } from "../../../src/components/specification/systemSpecs"; -import { AbiParamsSpecification } from "../../../src/components/specification/types"; +import { + AbiParamsSpecification, + Specification, +} from "../../../src/components/specification/types"; +import { encodedDataSamples } from "./encodedData.stubs"; import { inputResponses } from "./stubs"; describe("Specification Conditionals", () => { + it("should return null when inputs does not match conditional criteria on existing specifications", () => { + const { nonPortalRelatedInput } = inputResponses; + const specification = findSpecificationFor( + nonPortalRelatedInput, + systemSpecificationAsList, + ); + + expect(specification).toBeNull(); + }); + describe("Matching by msgSender for Portals (System Specifications)", () => { it("should match msgSender and return decoding specification for ERC-20 Portal", () => { const { erc20DepositInput } = inputResponses; @@ -14,7 +29,7 @@ describe("Specification Conditionals", () => { systemSpecificationAsList, ); - expect(specification).toBeDefined(); + expect(specification).not.toBeNull(); expect(specification?.name).toEqual( "ERC-20 Portal @cartesi/rollups@1.x", ); @@ -27,7 +42,7 @@ describe("Specification Conditionals", () => { systemSpecificationAsList, ); - expect(specification).toBeDefined(); + expect(specification).not.toBeNull(); expect(specification?.name).toEqual( "ERC-1155 Single Portal @cartesi/rollups@1.x", ); @@ -40,7 +55,7 @@ describe("Specification Conditionals", () => { systemSpecificationAsList, ) as AbiParamsSpecification; - expect(specification).toBeDefined(); + expect(specification).not.toBeNull(); expect(specification?.name).toEqual( "ERC-1155 Batch Portal @cartesi/rollups@1.x", ); @@ -68,7 +83,7 @@ describe("Specification Conditionals", () => { systemSpecificationAsList, ) as AbiParamsSpecification; - expect(specification).toBeDefined(); + expect(specification).not.toBeNull(); expect(specification.name).toEqual( "ERC-721 Portal @cartesi/rollups@1.x", ); @@ -84,4 +99,212 @@ describe("Specification Conditionals", () => { }); }); }); + + describe("Matching by application.id", () => { + it("should return specification when application.id match input information", () => { + const dummyInput = { + application: { id: "my-app-hex-address" }, + payload: encodedDataSamples.wagmiSample, + }; + const spec: Specification = { + mode: "abi_params", + abiParams: ["string name, uint amount, bool success"], + name: "Conditional Test by application.id", + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "application.id", + operator: "equals", + value: "my-app-hex-address", + }, + ], + }, + ], + }; + // lets add all the system-specs first and our custom last. + const specifications = [...systemSpecificationAsList, spec]; + const specification = findSpecificationFor( + dummyInput, + specifications, + ); + + expect(specification).not.toBeNull(); + expect(specification?.name).toEqual( + "Conditional Test by application.id", + ); + + expect( + decodePayload(specification!, dummyInput.payload as Hex).result, + ).toEqual({ + amount: 420n, + name: "wagmi", + success: true, + }); + }); + }); + + describe("Matching multiple conditions with 'and' operator", () => { + it("should return specification when both conditions match", () => { + const input = { + msgSender: "an-address-to-match-here", + application: { id: "my-app-address" }, + }; + + const spec: Specification = { + mode: "abi_params", + abiParams: ["string name, uint amount, bool success"], + name: "Conditional Test match both conditions", + conditionals: [ + { + logicalOperator: "and", + conditions: [ + { + field: "application.id", + operator: "equals", + value: "my-app-address", + }, + { + field: "msgSender", + operator: "equals", + value: "an-address-to-match-here", + }, + ], + }, + ], + }; + + const specification = findSpecificationFor(input, [ + ...systemSpecificationAsList, + spec, + ]); + + expect(specification).not.toBeNull(); + expect(specification?.name).toEqual( + "Conditional Test match both conditions", + ); + }); + + it("should not return specification when one condition does not match", () => { + const input = { + msgSender: "the-not-matching-address", + application: { id: "my-app-address" }, + payload: encodedDataSamples.wagmiSample, + }; + + const spec: Specification = { + mode: "abi_params", + abiParams: ["string name, uint amount, bool success"], + name: "Conditional Test match both conditions", + conditionals: [ + { + logicalOperator: "and", + conditions: [ + { + field: "application.id", + operator: "equals", + value: "my-app-address", + }, + { + field: "msgSender", + operator: "equals", + value: "an-address-to-match-here", + }, + ], + }, + ], + }; + + const specification = findSpecificationFor(input, [ + ...systemSpecificationAsList, + spec, + ]); + + expect(specification).toBeNull(); + }); + }); + + describe("Matching multiple conditions with 'or' operator", () => { + it("should return specification when at least one condition match", () => { + const input = { + msgSender: "that-will-not-match", + application: { id: "but-the-app-address-will" }, + payload: encodedDataSamples.wagmiSample, + }; + + const spec: Specification = { + mode: "abi_params", + abiParams: ["string name, uint amount, bool success"], + name: "Conditional Test at least one match", + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "msgSender", + operator: "equals", + value: "expected-msg-sender", + }, + { + field: "application.id", + operator: "equals", + value: "but-the-app-address-will", + }, + ], + }, + ], + }; + + const specification = findSpecificationFor(input, [ + ...systemSpecificationAsList, + spec, + ]); + + expect(specification).not.toBeNull(); + expect(specification?.name).toEqual( + "Conditional Test at least one match", + ); + }); + + it("should return specification when both conditions match", () => { + const input = { + msgSender: "msg-sender-address", + application: { id: "cartesi-app-address" }, + }; + + const spec: Specification = { + mode: "abi_params", + abiParams: [], + name: "Conditional Test match both conditions", + conditionals: [ + { + logicalOperator: "or", + conditions: [ + { + field: "application.id", + operator: "equals", + value: "cartesi-app-address", + }, + { + field: "msgSender", + operator: "equals", + value: "msg-sender-address", + }, + ], + }, + ], + }; + + const specification = findSpecificationFor(input, [ + ...systemSpecificationAsList, + spec, + ]); + + expect(specification).not.toBeNull(); + expect(specification?.name).toEqual( + "Conditional Test match both conditions", + ); + }); + }); }); diff --git a/apps/web/test/components/specification/encodedData.stubs.ts b/apps/web/test/components/specification/encodedData.stubs.ts new file mode 100644 index 000000000..91b0af6d1 --- /dev/null +++ b/apps/web/test/components/specification/encodedData.stubs.ts @@ -0,0 +1,27 @@ +import { Hex } from "viem"; + +/** + * @description The standard abi encoded data has the following format [string, uint, bool] + */ +const wagmiSample: Hex = + "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000"; + +/** + * @description This standard abi encoded data has the following format [struct, uint, uint] + * @example + * // A inline human-readable abi format is as follow + * ['(uint256 allowance, bool success, address operator) foo, uint amount, uint id'] + * @example + * // Or a more organized way in case the struct is big + * [ + * 'Foo foo, uint amount, uint id', + * 'struct Foo {uint256 allowance; bool success; address operator;}' + * ] + */ +const encodedDataSampleWithStruct: Hex = + "0x000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000028367fe226cd9e5699f4288d512fe3a4a4a001200000000000000000000000000000000000000000000000000000000000003e80000000000000000000000000000000000000000000000000000000000000002"; + +export const encodedDataSamples = { + wagmiSample, + encodedDataSampleWithStruct, +}; diff --git a/apps/web/test/components/specification/stubs.ts b/apps/web/test/components/specification/stubs.ts index a5f74a6ee..bf5cee667 100644 --- a/apps/web/test/components/specification/stubs.ts +++ b/apps/web/test/components/specification/stubs.ts @@ -2,6 +2,21 @@ * @file Contains real samples from our graphQL API. This file should only hold this kind of data. */ +const nonPortalRelatedInput = { + id: "0xc65bf4b414cdb9e7625a33be0fc993d44030e89a-43", + application: { + id: "0xc65bf4b414cdb9e7625a33be0fc993d44030e89a", + }, + index: 43, + payload: + "0x227b5c226d6574686f645c223a5c22726f6c6c446963655c222c5c22646174615c223a7b5c2267616d6549645c223a5c2264386330346137622d653230372d346466622d613164322d6336346539643039633965355c222c5c22706c61796572416464726573735c223a5c223078633537633831663064653631363462366663383433613931373161323230643265636134626533345c227d7d22", + msgSender: "0xc57c81f0de6164b6fc843a9171a220d2eca4be34", + timestamp: "1716246048", + transactionHash: + "0x77f2fcd3eecbd067f3786be862af2fb0e89775dbf8314d169c0e4f09bb752f7a", + erc20Deposit: null, +} as const; + const singleERC1155DepositInput = { id: "0x4ca2f6935200b9a782a78f408f640f17b29809d8-802", application: { @@ -80,4 +95,5 @@ export const inputResponses = { batchERC1155DepositInput, erc20DepositInput, erc721DepositInput, + nonPortalRelatedInput, } as const; From 1d2571ad427ad88801ef37af7b14f9c756fb87e9 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 21 May 2024 14:30:24 +1200 Subject: [PATCH 07/47] test: Add decoder test cases for struct encoded data and include some clean-up. --- .../components/specification/decoder.test.ts | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/web/test/components/specification/decoder.test.ts b/apps/web/test/components/specification/decoder.test.ts index 90ff54834..1e46824d6 100644 --- a/apps/web/test/components/specification/decoder.test.ts +++ b/apps/web/test/components/specification/decoder.test.ts @@ -2,6 +2,7 @@ import { erc1155BatchPortalAbi, erc20Abi } from "@cartesi/rollups-wagmi"; import { describe, it } from "vitest"; import { decodePayload } from "../../../src/components/specification/decoder"; import { Specification } from "../../../src/components/specification/types"; +import { encodedDataSamples } from "./encodedData.stubs"; const inputData = "0x24d15c67000000000000000000000000f08b9b4044441e43337c1ab6e941c4e59d5f73c80000000000000000000000004ca2f6935200b9a782a78f408f640f17b29809d800000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; @@ -77,16 +78,16 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d describe("For ABI Params", () => { it("should parse encoded data with human-readable ABI format", () => { - const sample = - "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000"; - const spec: Specification = { mode: "abi_params", abiParams: ["string name, uint amount, bool success"], name: "Wagmi Encoded Data", }; - const envelope = decodePayload(spec, sample); + const envelope = decodePayload( + spec, + encodedDataSamples.wagmiSample, + ); expect(envelope.error).not.toBeDefined(); expect(envelope.result).toEqual({ @@ -111,8 +112,6 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d const envelope = decodePayload(spec, singleERC1155DepositPayload); - console.log(envelope.error); - expect(envelope.error).not.toBeDefined(); expect(envelope.result).toEqual({ tokenAddress: "0x2960f4db2b0993ae5b59bc4a0f5ec7a1767e905e", @@ -180,5 +179,60 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d tokenIds: [2n, 1n], }); }); + + describe("Struct definition example cases", () => { + it("should support setting a separate struct definition to decode abi-encoded data", () => { + const spec: Specification = { + mode: "abi_params", + abiParams: [ + "Baz baz, uint amount, uint tokenIndex", + "struct Baz {uint256 allowance; bool success; address operator;}", + ], + name: "Separate struct definition", + }; + + const envelope = decodePayload( + spec, + encodedDataSamples.encodedDataSampleWithStruct, + ); + + expect(envelope.error).not.toBeDefined(); + expect(envelope.result).toEqual({ + amount: 1000n, + tokenIndex: 2n, + baz: { + allowance: 10n, + operator: "0x028367fE226CD9E5699f4288d512fE3a4a4a0012", + success: false, + }, + }); + }); + + it("should support setting inline struct definition to decode abi-encoded data", () => { + const spec: Specification = { + mode: "abi_params", + abiParams: [ + "(uint256 allowance, bool success, address operator) foo, uint amount, uint id", + ], + name: "Inline struct definition", + }; + + const envelope = decodePayload( + spec, + encodedDataSamples.encodedDataSampleWithStruct, + ); + + expect(envelope.error).not.toBeDefined(); + expect(envelope.result).toEqual({ + amount: 1000n, + id: 2n, + foo: { + allowance: 10n, + operator: "0x028367fE226CD9E5699f4288d512fE3a4a4a0012", + success: false, + }, + }); + }); + }); }); }); From caed66ab0f1efcb5e0e230c8eb16caa722e67f8a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 8 Jul 2024 07:09:01 +1200 Subject: [PATCH 08/47] feat: Improve error handling when set wrong slice-target. --- .../src/components/specification/decoder.ts | 33 ++++++++++++---- .../components/specification/decoder.test.ts | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/specification/decoder.ts b/apps/web/src/components/specification/decoder.ts index 81e7b9cb6..8b9e53dea 100644 --- a/apps/web/src/components/specification/decoder.ts +++ b/apps/web/src/components/specification/decoder.ts @@ -1,4 +1,14 @@ -import { T, cond, head, includes, pathEq, pathOr, pipe } from "ramda"; +import { + T, + cond, + head, + includes, + isEmpty, + isNil, + pathEq, + pathOr, + pipe, +} from "ramda"; import { AbiFunction, AbiFunctionSignatureNotFoundError, @@ -52,14 +62,14 @@ const getPieces = ( }; const addPiecesToEnvelope = (e: Envelope): Envelope => { - if (e.spec.mode === "abi_params") { + if (!e.error && e.spec.mode === "abi_params") { try { if (e.spec.sliceInstructions?.length) { e.spec.sliceInstructions.forEach((instruction, index) => { const { from, to, name, type } = instruction; const part = slice(e.input, from, to); const decodedPart = - type !== undefined + !isNil(type) && !isEmpty(type) ? head(decodeAbiParameters([{ type, name }], part)) : part; @@ -84,20 +94,27 @@ const addPiecesToEnvelope = (e: Envelope): Envelope => { }; const decodeTargetSliceAndAddToPieces = (e: Envelope): Envelope => { - if (e.spec.mode === "abi_params") { + if (!e.error && e.spec.mode === "abi_params") { const targetName = e.spec.sliceTarget; const piece = e.pieces.find((piece) => piece.name === targetName); - if (piece && piece.part) { - const pieces = getPieces(e.spec.abiParams, piece.part); - e.pieces.push(...pieces); + try { + if (piece && piece.part) { + const pieces = getPieces(e.spec.abiParams, piece.part); + e.pieces.push(...pieces); + } + } catch (error: any) { + const message = pathOr(error.message, ["shortMessage"], error); + const errorMeta = pathOr([], ["metaMessages"], error).join("\n"); + const extra = `Slice name: "${targetName}" (Is it the right one?)`; + e.error = new Error(`${message}\n\n${errorMeta}\n${extra}`); } } return e; }; const prepareResultFromPieces = (e: Envelope): Envelope => { - if (e.spec.mode === "abi_params") { + if (!e.error && e.spec.mode === "abi_params") { const sliceTarget = e.spec.sliceTarget; e.result = e.pieces.reduce((prev, { name, decodedPart }, index) => { /** diff --git a/apps/web/test/components/specification/decoder.test.ts b/apps/web/test/components/specification/decoder.test.ts index 1e46824d6..5a21a1aa9 100644 --- a/apps/web/test/components/specification/decoder.test.ts +++ b/apps/web/test/components/specification/decoder.test.ts @@ -180,6 +180,44 @@ You can look up the signature here: https://openchain.xyz/signatures?query=0x24d }); }); + it("should return an error when slice target is wrongly set", () => { + const spec: Specification = { + mode: "abi_params", + name: "1155 Batch Portal Deposit", + abiParams: [ + "uint[] tokenIds, uint[] amount, bytes baseLayer, bytes execLayer", + ], + sliceInstructions: [ + { + from: 0, + to: 20, + name: "tokenAddress", + }, + { + from: 20, + to: 40, + name: "from", + }, + { + from: 40, + name: "data", + }, + ], + sliceTarget: "from", + }; + + const envelope = decodePayload(spec, batchPayload); + + expect(envelope.error).toBeDefined(); + expect(envelope.error?.message).toEqual( + `Data size of 20 bytes is too small for given parameters. + +Params: (uint256[] tokenIds, uint256[] amount, bytes baseLayer, bytes execLayer) +Data: 0xa074683b5be015f053b5dceb064c41fc9d11b6e5 (20 bytes) +Slice name: "from" (Is it the right one?)`, + ); + }); + describe("Struct definition example cases", () => { it("should support setting a separate struct definition to decode abi-encoded data", () => { const spec: Specification = { From 7d0ec4239c3dc64f7c971c9e6848ab0d20bd92b3 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 8 Jul 2024 10:40:33 +1200 Subject: [PATCH 09/47] feat: Link and access to specification page. --- apps/web/src/app/specifications/page.tsx | 31 ++++++++++++++++++++++++ apps/web/src/components/layout/shell.tsx | 9 +++++++ 2 files changed, 40 insertions(+) create mode 100644 apps/web/src/app/specifications/page.tsx diff --git a/apps/web/src/app/specifications/page.tsx b/apps/web/src/app/specifications/page.tsx new file mode 100644 index 000000000..469aef99f --- /dev/null +++ b/apps/web/src/app/specifications/page.tsx @@ -0,0 +1,31 @@ +import { Group, Stack, Title } from "@mantine/core"; +import { Metadata } from "next"; +import { TbFileCode } from "react-icons/tb"; +import Breadcrumbs from "../../components/breadcrumbs"; +import { SpecificationView } from "../../components/specification/specificationView"; + +export const metadata: Metadata = { + title: "Decoding Specifications", +}; + +export default function SpecificationsPage() { + return ( + + + + + + Specifications + + + + + ); +} diff --git a/apps/web/src/components/layout/shell.tsx b/apps/web/src/components/layout/shell.tsx index ce071ea3b..07882941e 100644 --- a/apps/web/src/components/layout/shell.tsx +++ b/apps/web/src/components/layout/shell.tsx @@ -20,6 +20,7 @@ import { TbAdjustmentsHorizontal, TbApps, TbArrowsDownUp, + TbFileCode, TbHome, TbInbox, TbMoonStars, @@ -183,6 +184,14 @@ const Shell: FC<{ children: ReactNode }> = ({ children }) => { href="/connections" data-testid="connections-link" /> + } + href="/specifications" + data-testid="specifications-link" + /> Date: Mon, 8 Jul 2024 10:41:47 +1200 Subject: [PATCH 10/47] feat: Improve error information for specific error types. --- .../src/components/specification/decoder.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/specification/decoder.ts b/apps/web/src/components/specification/decoder.ts index 8b9e53dea..c02abab3e 100644 --- a/apps/web/src/components/specification/decoder.ts +++ b/apps/web/src/components/specification/decoder.ts @@ -10,9 +10,11 @@ import { pipe, } from "ramda"; import { + AbiDecodingDataSizeTooSmallError, AbiFunction, AbiFunctionSignatureNotFoundError, Hex, + InvalidAbiParametersError, decodeAbiParameters, decodeFunctionData, parseAbiParameters, @@ -105,9 +107,26 @@ const decodeTargetSliceAndAddToPieces = (e: Envelope): Envelope => { } } catch (error: any) { const message = pathOr(error.message, ["shortMessage"], error); - const errorMeta = pathOr([], ["metaMessages"], error).join("\n"); - const extra = `Slice name: "${targetName}" (Is it the right one?)`; - e.error = new Error(`${message}\n\n${errorMeta}\n${extra}`); + let errorMeta; + let extra; + + if (error instanceof AbiDecodingDataSizeTooSmallError) { + errorMeta = pathOr([], ["metaMessages"], error).join("\n"); + extra = `Slice name: "${targetName}" (Is it the right one?)`; + } + + if (error instanceof InvalidAbiParametersError) { + errorMeta = `ABI Parameters : [ ${e.spec.abiParams.join( + ",", + )} ]`; + extra = "Check the ABI parameters defined."; + } + + const errorMessage = `${message}\n\n${errorMeta ?? ""}\n${ + extra ?? "" + }`; + + e.error = new Error(errorMessage); } } return e; From c36d090818cbca40ba0806bef290dde9307bec19 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 8 Jul 2024 10:42:24 +1200 Subject: [PATCH 11/47] refactor: Export type for reuse in the form definitions. --- apps/web/src/components/specification/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/specification/types.ts b/apps/web/src/components/specification/types.ts index c68e1d2c0..a74062da4 100644 --- a/apps/web/src/components/specification/types.ts +++ b/apps/web/src/components/specification/types.ts @@ -24,7 +24,7 @@ export interface Predicate { conditions: Condition[]; } -interface SliceInstruction { +export interface SliceInstruction { /** Start index of the hex or byte-array*/ from: number; /** End index of the hex or byte-array. Undefined means getting from start onwards e.g. arbitrary data size.*/ From b939b0bee1dde878126ce7e39542c2f5f493ee96 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 8 Jul 2024 10:43:48 +1200 Subject: [PATCH 12/47] feat: Add form-context, byte-slices, human-readable-abi and conditions initial components. --- .../components/specification/formContext.tsx | 35 ++ .../specification/forms/ByteSlices.tsx | 367 ++++++++++++++++++ .../specification/forms/Conditions.tsx | 24 ++ .../specification/forms/HumanReadableABI.tsx | 135 +++++++ 4 files changed, 561 insertions(+) create mode 100644 apps/web/src/components/specification/formContext.tsx create mode 100644 apps/web/src/components/specification/forms/ByteSlices.tsx create mode 100644 apps/web/src/components/specification/forms/Conditions.tsx create mode 100644 apps/web/src/components/specification/forms/HumanReadableABI.tsx diff --git a/apps/web/src/components/specification/formContext.tsx b/apps/web/src/components/specification/formContext.tsx new file mode 100644 index 000000000..9ea2b7079 --- /dev/null +++ b/apps/web/src/components/specification/formContext.tsx @@ -0,0 +1,35 @@ +import { createFormContext } from "@mantine/form"; +import { Abi, Hex } from "viem"; +import { Modes, Predicate, SliceInstruction } from "./types"; + +/** + * Form context to support both specification form inputs and preview inputs + */ +export interface SpecFormValues { + name: string; + mode: Modes; + abiParamEntry: string; + abiParams: string[]; + abi?: string; + sliceInstructions: SliceInstruction[]; + sliceTarget?: string; + conditionals: Predicate[]; + encodedData?: string; +} + +export interface SpecTransformedValues { + name: string; + sliceTarget?: string; + sliceInstructions: SliceInstruction[]; + conditionals?: Predicate[]; + mode: Modes; + abi?: Abi; + abiParams?: readonly string[]; + encodedData?: Hex; + humanReadable: string; +} + +type TransformValues = (a: SpecFormValues) => SpecTransformedValues; + +export const [SpecFormProvider, useSpecFormContext, useSpecForm] = + createFormContext(); diff --git a/apps/web/src/components/specification/forms/ByteSlices.tsx b/apps/web/src/components/specification/forms/ByteSlices.tsx new file mode 100644 index 000000000..21494f490 --- /dev/null +++ b/apps/web/src/components/specification/forms/ByteSlices.tsx @@ -0,0 +1,367 @@ +import { + Accordion, + Button, + Checkbox, + Collapse, + Fieldset, + Group, + NumberInput, + Select, + Stack, + Switch, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useDisclosure } from "@mantine/hooks"; +import { AbiType } from "abitype"; +import { + any, + anyPass, + clone, + gt, + gte, + isEmpty, + isNil, + lt, + reject, +} from "ramda"; +import { FC, useEffect, useRef, useState } from "react"; +import { + TbArrowsDiagonal, + TbArrowsDiagonalMinimize2, + TbTrash, +} from "react-icons/tb"; +import { useSpecFormContext } from "../formContext"; +import { SliceInstruction } from "../types"; + +interface Props { + slices: SliceInstruction[]; + onSliceChange: (slices: SliceInstruction[]) => void; +} + +const InstructionsReview: FC = ({ slices, onSliceChange }) => { + if (slices.length === 0) return ""; + + const rows = slices.map((slice, idx) => ( + + {slice.name} + {slice.from} + {slice.to ?? "end of bytes"} + {`${isEmpty(slice.type) ? "Hex" : slice.type}`} + + + + + )); + + return ( + + + + Review your definition + + + + + + + + Name + + + From + + + To + + + Type + + + Action + + + + {rows} +
+
+
+
+ ); +}; + +const initialValues = { + slices: [] as SliceInstruction[], + sliceInput: { + name: "", + from: "", + to: "", + type: "", + }, +}; + +const isNilOrEmpty = anyPass([isNil, isEmpty]); + +const SliceInstructionFields: FC = () => { + const [checked, { toggle: toggleTarget }] = useDisclosure(false); + const [sliceTarget, setSliceTarget] = useState(null); + const [expanded, { toggle }] = useDisclosure(true); + const sliceNameRef = useRef(null); + const mainForm = useSpecFormContext(); + const form = useForm({ + initialValues: clone(initialValues), + validateInputOnChange: true, + validate: { + sliceInput: { + name: (value, values) => { + if (isNilOrEmpty(value)) return "Name is required!"; + + if ( + values.slices.length > 0 && + any((slice) => slice.name === value, values.slices) + ) { + return `Duplicated name. Check review`; + } + + return null; + }, + from: (value, values) => { + if (isNilOrEmpty(value)) return "From is required!"; + + if ( + !isNilOrEmpty(values.sliceInput.to) && + value > values.sliceInput.to + ) + return "From can't be bigger than To value."; + + const from = parseInt(value); + + if ( + values.slices.length > 0 && + any((slice) => { + if (slice.to === undefined || slice.to === null) { + return gte(from, slice.from); + } + + return gt(from, slice.from) && lt(from, slice.to); + }, values.slices) + ) { + return "Overlap with added entry! Check review."; + } + return null; + }, + }, + }, + }); + + const { slices, sliceInput } = form.getTransformedValues(); + + const sliceNames = slices.map((slice, idx) => slice.name ?? `slice-${idx}`); + + useEffect(() => { + mainForm.setFieldValue("sliceInstructions", clone(slices)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slices]); + + return ( + +
+ + Define a slice + + + sliceNameRef.current?.focus()} + > + + + + + + + + + + +
+ + { + form.setFieldValue("slices", slices); + }} + /> + + {sliceNames && sliceNames.length > 0 ? ( + + { + toggleTarget(); + if (sliceTarget !== null) { + setSliceTarget(null); + mainForm.setFieldValue( + "sliceTarget", + undefined, + ); + } + }} + label="Use ABI Parameter definition on" + /> +