diff --git a/.changeset/olive-dryers-live.md b/.changeset/olive-dryers-live.md new file mode 100644 index 00000000..59272911 --- /dev/null +++ b/.changeset/olive-dryers-live.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": minor +--- + +feat: compatible mode for molecule decode diff --git a/packages/core/src/molecule/codec.ts b/packages/core/src/molecule/codec.ts index 734b13f1..cf0af43a 100644 --- a/packages/core/src/molecule/codec.ts +++ b/packages/core/src/molecule/codec.ts @@ -18,13 +18,19 @@ import { export type CodecLike = { readonly encode: (encodable: Encodable) => Bytes; - readonly decode: (decodable: BytesLike) => Decoded; + readonly decode: ( + decodable: BytesLike, + config?: { isExtraFieldIgnored?: boolean }, + ) => Decoded; readonly byteLength?: number; }; export class Codec { constructor( public readonly encode: (encodable: Encodable) => Bytes, - public readonly decode: (decodable: BytesLike) => Decoded, + public readonly decode: ( + decodable: BytesLike, + config?: { isExtraFieldIgnored?: boolean }, // This is equivalent to "compatible" in the Rust implementation of Molecule. + ) => Decoded, public readonly byteLength?: number, // if provided, treat codec as fixed length ) {} @@ -43,7 +49,7 @@ export class Codec { } return encoded; }, - (decodable) => { + (decodable, config) => { const decodableBytes = bytesFrom(decodable); if ( byteLength !== undefined && @@ -53,7 +59,7 @@ export class Codec { `Codec.decode: expected byte length ${byteLength}, got ${decodableBytes.byteLength}`, ); } - return decode(decodable); + return decode(decodable, config); }, byteLength, ); @@ -69,10 +75,10 @@ export class Codec { return new Codec( (encodable) => this.encode((inMap ? inMap(encodable) : encodable) as Encodable), - (buffer) => + (buffer, config) => (outMap - ? outMap(this.decode(buffer)) - : this.decode(buffer)) as NewDecoded, + ? outMap(this.decode(buffer, config)) + : this.decode(buffer, config)) as NewDecoded, this.byteLength, ); } @@ -128,7 +134,7 @@ export function fixedItemVec( throw new Error(`fixedItemVec(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength < 4) { throw new Error( @@ -147,7 +153,10 @@ export function fixedItemVec( const decodedArray: Array = []; for (let offset = 4; offset < byteLength; offset += itemByteLength) { decodedArray.push( - itemCodec.decode(value.slice(offset, offset + itemByteLength)), + itemCodec.decode( + value.slice(offset, offset + itemByteLength), + config, + ), ); } return decodedArray; @@ -185,7 +194,7 @@ export function dynItemVec( throw new Error(`dynItemVec(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength < 4) { throw new Error( @@ -215,7 +224,7 @@ export function dynItemVec( const start = offsets[index]; const end = offsets[index + 1]; const itemBuffer = value.slice(start, end); - decodedArray.push(itemCodec.decode(itemBuffer)); + decodedArray.push(itemCodec.decode(itemBuffer, config)); } return decodedArray; } catch (e) { @@ -259,13 +268,13 @@ export function option( throw new Error(`option(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength === 0) { return undefined; } try { - return innerCodec.decode(buffer); + return innerCodec.decode(buffer, config); } catch (e) { throw new Error(`option(${e?.toString()})`); } @@ -290,7 +299,7 @@ export function byteVec( throw new Error(`byteVec(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength < 4) { throw new Error( @@ -304,7 +313,7 @@ export function byteVec( ); } try { - return codec.decode(value.slice(4)); + return codec.decode(value.slice(4), config); } catch (e: unknown) { throw new Error(`byteVec(${e?.toString()})`); } @@ -371,7 +380,7 @@ export function table< const packedTotalSize = uint32To(header.length + body.length + 4); return bytesConcat(packedTotalSize, header, body); }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength < 4) { throw new Error( @@ -379,15 +388,38 @@ export function table< ); } const byteLength = uint32From(value.slice(0, 4)); + const headerLength = uint32From(value.slice(4, 8)); + const actualFieldCount = (headerLength - 4) / 4; + if (byteLength !== value.byteLength) { throw new Error( `table: invalid buffer size, expected ${byteLength}, but got ${value.byteLength}`, ); } + + if (actualFieldCount < keys.length) { + throw new Error( + `table: invalid field count, expected ${keys.length}, but got ${actualFieldCount}`, + ); + } + + if (actualFieldCount > keys.length && !config?.isExtraFieldIgnored) { + throw new Error( + `table: invalid field count, expected ${keys.length}, but got ${actualFieldCount}, and extra fields are not allowed in the current configuration. If you want to ignore extra fields, set isExtraFieldIgnored to true.`, + ); + } const offsets = keys.map((_, index) => uint32From(value.slice(4 + index * 4, 8 + index * 4)), ); - offsets.push(byteLength); + // If there are extra fields, add the last offset to the offsets array + if (actualFieldCount > keys.length) { + offsets.push( + uint32From(value.slice(4 + keys.length * 4, 8 + keys.length * 4)), + ); + } else { + // If there are no extra fields, add the byte length to the offsets array + offsets.push(byteLength); + } const object = {}; for (let i = 0; i < offsets.length - 1; i++) { const start = offsets[i]; @@ -397,7 +429,7 @@ export function table< const payload = value.slice(start, end); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - Object.assign(object, { [field]: codec.decode(payload) }); + Object.assign(object, { [field]: codec.decode(payload, config) }); } catch (e: unknown) { throw new Error(`table.${field}(${e?.toString()})`); } @@ -466,7 +498,7 @@ export function union>>( throw new Error(`union.(${typeStr})(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); const fieldIndex = uint32From(value.slice(0, 4)); const keys = Object.keys(codecLayout); @@ -496,7 +528,7 @@ export function union>>( return { type: field, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - value: codecLayout[field].decode(value.slice(4)), + value: codecLayout[field].decode(value.slice(4), config), } as UnionDecoded; }, }); @@ -535,7 +567,7 @@ export function struct< return bytesFrom(bytes); }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); const object = {}; let offset = 0; @@ -543,7 +575,7 @@ export function struct< const payload = value.slice(offset, offset + codec.byteLength!); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - Object.assign(object, { [key]: codec.decode(payload) }); + Object.assign(object, { [key]: codec.decode(payload, config) }); } catch (e: unknown) { throw new Error(`struct.${key}(${(e as Error).toString()})`); } @@ -583,7 +615,7 @@ export function array( throw new Error(`array(${e?.toString()})`); } }, - decode(buffer) { + decode(buffer, config) { const value = bytesFrom(buffer); if (value.byteLength != byteLength) { throw new Error( @@ -594,7 +626,7 @@ export function array( const result: Array = []; for (let i = 0; i < value.byteLength; i += itemCodec.byteLength!) { result.push( - itemCodec.decode(value.slice(i, i + itemCodec.byteLength!)), + itemCodec.decode(value.slice(i, i + itemCodec.byteLength!), config), ); } return result; diff --git a/packages/core/src/molecule/predefined.ts b/packages/core/src/molecule/predefined.ts index 176da41c..fac9c97b 100644 --- a/packages/core/src/molecule/predefined.ts +++ b/packages/core/src/molecule/predefined.ts @@ -57,6 +57,13 @@ export const Bool: Codec = Codec.from({ export const BoolOpt = option(Bool); export const BoolVec = vector(Bool); +export const Byte: Codec = Codec.from({ + byteLength: 1, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); +export const ByteOpt = option(Byte); + export const Byte4: Codec = Codec.from({ byteLength: 4, encode: (value) => bytesFrom(value), diff --git a/packages/demo/src/app/connected/page.tsx b/packages/demo/src/app/connected/page.tsx index 772fef66..8351a669 100644 --- a/packages/demo/src/app/connected/page.tsx +++ b/packages/demo/src/app/connected/page.tsx @@ -51,6 +51,7 @@ const TABS: [ReactNode, string, keyof typeof icons, string][] = [ ["Hash", "/utils/Hash", "Barcode", "text-violet-500"], ["Mnemonic", "/utils/Mnemonic", "SquareAsterisk", "text-fuchsia-500"], ["Keystore", "/utils/Keystore", "Notebook", "text-rose-500"], + ["Molecule", "/utils/Molecule", "Hash", "text-emerald-500"], ]; /* eslint-enable react/jsx-key */ diff --git a/packages/demo/src/app/utils/(tools)/Molecule/DataInput.tsx b/packages/demo/src/app/utils/(tools)/Molecule/DataInput.tsx new file mode 100644 index 00000000..ee85f475 --- /dev/null +++ b/packages/demo/src/app/utils/(tools)/Molecule/DataInput.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from "react"; +import { ccc } from "@ckb-ccc/connector-react"; +import JsonView from "@uiw/react-json-view"; +import { useApp } from "@/src/context"; +import { Button } from "@/src/components/Button"; +import { Textarea } from "@/src/components/Textarea"; +import { darkTheme } from "@uiw/react-json-view/dark"; +import { Dropdown } from "@/src/components/Dropdown"; +export type UnpackType = + | string + | number + | undefined + | { [property: string]: UnpackType } + | UnpackType[]; + +type Props = { + codec: ccc.mol.Codec | undefined; + mode: "decode" | "encode"; +}; + +const formatInput = (input: string): string => { + if (!input.startsWith("0x")) { + return `0x${input}`; + } + return input; +}; + +const isBlank = (data: UnpackType): boolean => { + if (!data) { + return true; + } + return false; +}; + +export const DataInput: React.FC = ({ codec, mode }) => { + const [inputData, setInputData] = useState(""); + const [decodeResult, setDecodeResult] = useState(undefined); + const [encodeResult, setEncodeResult] = useState( + undefined, + ); + const { createSender } = useApp(); + const { log, error } = createSender("Molecule"); + + const handleDecode = () => { + if (!codec) { + error("please select codec"); + return; + } + try { + const result = codec.decode(formatInput(inputData)); + log("Successfully decoded data"); + setDecodeResult(result); + } catch (e: unknown) { + setDecodeResult(undefined); + error((e as Error).message); + } + }; + + const handleEncode = () => { + if (!codec) { + error("please select codec"); + return; + } + try { + const inputObject = JSON.parse(inputData); + const result = codec.encode(inputObject); + log("Successfully encoded data"); + setEncodeResult(ccc.hexFrom(result)); + } catch (e: unknown) { + setEncodeResult(undefined); + error((e as Error).message); + } + }; + + // If mode changes, clear the input data + useEffect(() => { + setInputData(""); + setDecodeResult(undefined); + setEncodeResult(undefined); + }, [mode]); + + return ( +
+
+ +
+