diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 86c490003a3c..12f8fb97139f 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -33,7 +33,7 @@ import { ContainerDataExecutionOptimistic, ContainerData, } from "../../utils/index.js"; -import {fromU64Str, toU64Str, U64Str} from "../../utils/serdes.js"; +import {fromU64Str, fromGraffitiHex, toU64Str, U64Str, toGraffitiHex} from "../../utils/serdes.js"; import {ExecutionOptimistic} from "./beacon/block.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes @@ -380,9 +380,9 @@ export function getReqSerializers(): ReqSerializers { const produceBlock: ReqSerializers["produceBlock"] = { writeReq: (slot, randaoReveal, graffiti) => ({ params: {slot}, - query: {randao_reveal: toHexString(randaoReveal), graffiti}, + query: {randao_reveal: toHexString(randaoReveal), graffiti: toGraffitiHex(graffiti)}, }), - parseReq: ({params, query}) => [params.slot, fromHexString(query.randao_reveal), query.graffiti], + parseReq: ({params, query}) => [params.slot, fromHexString(query.randao_reveal), fromGraffitiHex(query.graffiti)], schema: { params: {slot: Schema.UintRequired}, query: {randao_reveal: Schema.StringRequired, graffiti: Schema.String}, diff --git a/packages/api/src/utils/serdes.ts b/packages/api/src/utils/serdes.ts index 12853a0e1067..8c6c8e6e4b93 100644 --- a/packages/api/src/utils/serdes.ts +++ b/packages/api/src/utils/serdes.ts @@ -1,4 +1,4 @@ -import {JsonPath} from "@chainsafe/ssz"; +import {fromHexString, JsonPath, toHexString} from "@chainsafe/ssz"; /** * Serialize proof path to JSON. @@ -66,3 +66,30 @@ export function fromU64StrOpt(u64Str: U64Str | undefined): U64 | undefined { export function toU64StrOpt(u64: U64 | undefined): U64Str | undefined { return u64 !== undefined ? toU64Str(u64) : undefined; } + +const GRAFFITI_HEX_LENGTH = 66; + +export function toGraffitiHex(utf8: string): string { + const hex = toHexString(new TextEncoder().encode(utf8)); + + if (hex.length > GRAFFITI_HEX_LENGTH) { + // remove characters from the end if hex string is too long + return hex.slice(0, GRAFFITI_HEX_LENGTH); + } + + if (hex.length < GRAFFITI_HEX_LENGTH) { + // right-pad with zeros if hex string is too short + return hex.padEnd(GRAFFITI_HEX_LENGTH, "0"); + } + + return hex; +} + +export function fromGraffitiHex(hex: string): string { + try { + return new TextDecoder("utf8").decode(fromHexString(hex)); + } catch { + // allow malformed graffiti hex string + return hex; + } +} diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index 92a061eeabc0..e6939614ec73 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -6,7 +6,7 @@ import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const ZERO_HASH = Buffer.alloc(32, 0); const ZERO_HASH_HEX = "0x" + ZERO_HASH.toString("hex"); const randaoReveal = Buffer.alloc(96, 1); -const graffiti = "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"; +const graffiti = "a".repeat(32); export const testData: GenericServerTestCases = { getAttesterDuties: { diff --git a/packages/api/test/unit/utils/serdes.test.ts b/packages/api/test/unit/utils/serdes.test.ts new file mode 100644 index 000000000000..c390e3e6b6da --- /dev/null +++ b/packages/api/test/unit/utils/serdes.test.ts @@ -0,0 +1,70 @@ +import {expect} from "chai"; +import {fromGraffitiHex, toGraffitiHex} from "../../../src/utils/serdes.js"; + +describe("utils / serdes", () => { + describe("toGraffitiHex", () => { + it("should convert a UTF-8 graffiti to hex", () => { + expect(toGraffitiHex("a".repeat(32))).to.equal( + "0x6161616161616161616161616161616161616161616161616161616161616161" + ); + }); + + it("should convert a graffiti with Unicode symbols to hex", () => { + expect(toGraffitiHex("🦇🔊".repeat(4))).to.equal( + "0xf09fa687f09f948af09fa687f09f948af09fa687f09f948af09fa687f09f948a" + ); + }); + + it("should trim the hex graffiti if it is too long", () => { + expect(toGraffitiHex("a".repeat(50))).to.equal(toGraffitiHex("a".repeat(32))); + }); + + it("should trim the hex graffiti if the last character is a Unicode symbol", () => { + expect(toGraffitiHex("a".repeat(31) + "🐼")).to.equal( + "0x61616161616161616161616161616161616161616161616161616161616161f0" + ); + }); + + it("should right-pad the hex graffiti with zeros if it is too short", () => { + expect(toGraffitiHex("a")).to.equal("0x6100000000000000000000000000000000000000000000000000000000000000"); + expect(toGraffitiHex("ab")).to.equal("0x6162000000000000000000000000000000000000000000000000000000000000"); + expect(toGraffitiHex("abc")).to.equal("0x6162630000000000000000000000000000000000000000000000000000000000"); + }); + }); + + describe("fromGraffitiHex", () => { + it("should convert a hex graffiti to UTF-8", () => { + expect(fromGraffitiHex("0x6161616161616161616161616161616161616161616161616161616161616161")).to.equal( + "a".repeat(32) + ); + }); + + it("should convert a hex graffiti with Unicode symbols to UTF-8", () => { + expect(fromGraffitiHex("0xf09fa687f09f948af09fa687f09f948af09fa687f09f948af09fa687f09f948a")).to.equal( + "🦇🔊".repeat(4) + ); + }); + + it("should convert a padded hex graffiti to UTF-8", () => { + expect(fromGraffitiHex("0x6100000000000000000000000000000000000000000000000000000000000000")).to.equal( + // null bytes will not be displayed/ignored later on + "a" + "\u0000".repeat(31) + ); + }); + + it("should decode a hex graffiti with a cut off Unicode character at the end", () => { + expect(fromGraffitiHex("0x61616161616161616161616161616161616161616161616161616161616161f0")).to.equal( + // last character will be displayed as � + "a".repeat(31) + "\ufffd" + ); + }); + + it("should not throw an error if an invalid hex graffiti is provided", () => { + expect(() => fromGraffitiHex("a")).to.not.throw(); + }); + + it("should return the provided graffiti string if decoding fails", () => { + expect(fromGraffitiHex("a")).to.equal("a"); + }); + }); +});