diff --git a/src/server/routes/contract/extensions/erc721/read/signaturePrepare.ts b/src/server/routes/contract/extensions/erc721/read/signaturePrepare.ts index 8396b471b..f9b848ec2 100644 --- a/src/server/routes/contract/extensions/erc721/read/signaturePrepare.ts +++ b/src/server/routes/contract/extensions/erc721/read/signaturePrepare.ts @@ -1,18 +1,25 @@ import { Type, type Static } from "@sinclair/typebox"; import { MintRequest721 } from "@thirdweb-dev/sdk"; -import { randomBytes } from "crypto"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { ZERO_ADDRESS, getContract, type Hex } from "thirdweb"; +import { getRandomValues } from "node:crypto"; +import { + ZERO_ADDRESS, + getContract, + isHex, + stringToHex, + uint8ArrayToHex, + type Hex, +} from "thirdweb"; import { primarySaleRecipient as getDefaultPrimarySaleRecipient, getDefaultRoyaltyInfo, } from "thirdweb/extensions/common"; -import type { GenerateMintSignatureOptions } from "thirdweb/extensions/erc721"; +import { decimals } from "thirdweb/extensions/erc20"; import { upload } from "thirdweb/storage"; +import { checksumAddress } from "thirdweb/utils"; import { getChain } from "../../../../../../utils/chain"; -import { logger } from "../../../../../../utils/logger"; -import { maybeBigInt } from "../../../../../../utils/primitiveTypes"; +import { prettifyError } from "../../../../../../utils/error"; import { thirdwebClient } from "../../../../../../utils/sdk"; import { createCustomError } from "../../../../../middleware/error"; import { @@ -84,76 +91,74 @@ const responseSchema = Type.Object({ responseSchema.example = { result: { - result: { - mintPayload: { - uri: "ipfs://...", - currency: "0x0000000000000000000000000000000000000000", - uid: "0x3862386334363135326230303461303939626136653361643131343836373563", - to: "0x...", - royaltyRecipient: "0x...", - primarySaleRecipient: "0x...", + mintPayload: { + uri: "ipfs://...", + currency: "0x0000000000000000000000000000000000000000", + uid: "0x3862386334363135326230303461303939626136653361643131343836373563", + to: "0x...", + royaltyRecipient: "0x...", + primarySaleRecipient: "0x...", + }, + typedDataPayload: { + domain: { + name: "TokenERC721", + version: "1", + chainId: 84532, + verifyingContract: "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", }, - typedDataPayload: { - domain: { - name: "TokenERC721", - version: "1", - chainId: 84532, - verifyingContract: "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", - }, - types: { - MintRequest: [ - { - name: "to", - type: "address", - }, - { - name: "royaltyRecipient", - type: "address", - }, - { - name: "royaltyBps", - type: "uint256", - }, - { - name: "primarySaleRecipient", - type: "address", - }, - { - name: "uri", - type: "string", - }, - { - name: "price", - type: "uint256", - }, - { - name: "currency", - type: "address", - }, - { - name: "validityStartTimestamp", - type: "uint128", - }, - { - name: "validityEndTimestamp", - type: "uint128", - }, - { - name: "uid", - type: "bytes32", - }, - ], - }, - message: { - uri: "ipfs://test", - currency: "0x0000000000000000000000000000000000000000", - uid: "0xmyuid", - to: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", - royaltyRecipient: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", - primarySaleRecipient: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", - }, - primaryType: "MintRequest", + types: { + MintRequest: [ + { + name: "to", + type: "address", + }, + { + name: "royaltyRecipient", + type: "address", + }, + { + name: "royaltyBps", + type: "uint256", + }, + { + name: "primarySaleRecipient", + type: "address", + }, + { + name: "uri", + type: "string", + }, + { + name: "price", + type: "uint256", + }, + { + name: "currency", + type: "address", + }, + { + name: "validityStartTimestamp", + type: "uint128", + }, + { + name: "validityEndTimestamp", + type: "uint128", + }, + { + name: "uid", + type: "bytes32", + }, + ], }, + message: { + uri: "ipfs://test", + currency: "0x0000000000000000000000000000000000000000", + uid: "0xmyuid", + to: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", + royaltyRecipient: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", + primarySaleRecipient: "0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d", + }, + primaryType: "MintRequest", }, }, }; @@ -179,7 +184,7 @@ export async function erc721SignaturePrepare(fastify: FastifyInstance) { }, }, handler: async (request, reply) => { - const { chain, contractAddress } = request.params; + const { chain: chainSlug, contractAddress } = request.params; const { metadata, to, @@ -191,17 +196,16 @@ export async function erc721SignaturePrepare(fastify: FastifyInstance) { uid, } = request.body; - const chainId = await getChainIdFromChain(chain); + const chainId = await getChainIdFromChain(chainSlug); + const chain = await getChain(chainId); const contract = await getContract({ client: thirdwebClient, - chain: await getChain(chainId), + chain, address: contractAddress, }); - let primarySaleRecipient = request.body.primarySaleRecipient; let royaltyRecipient = request.body.royaltyRecipient; let royaltyBps = request.body.royaltyBps; - if (!royaltyRecipient || !royaltyBps) { try { const [defaultRoyaltyRecipient, defaultRoyaltyBps] = @@ -212,68 +216,100 @@ export async function erc721SignaturePrepare(fastify: FastifyInstance) { royaltyRecipient = royaltyRecipient ?? defaultRoyaltyRecipient; royaltyBps = royaltyBps ?? defaultRoyaltyBps; } catch (e) { - logger({ - level: "error", - message: "Could not get default royalty info.", - service: "server", - error: e, - }); throw createCustomError( - "Could not get default royalty info.", + `Could not get default royalty info: ${prettifyError(e)}`, StatusCodes.BAD_REQUEST, "DEFAULT_ROYALTY_INFO", ); } } + let primarySaleRecipient = request.body.primarySaleRecipient; if (!primarySaleRecipient) { try { primarySaleRecipient = await getDefaultPrimarySaleRecipient({ contract, }); } catch (e) { - logger({ - level: "error", - message: "Could not get default primary sale recipient.", - service: "server", - error: e, - }); throw createCustomError( - "Could not get default primary sale recipient.", + `Could not get default primary sale recipient: ${prettifyError(e)}`, StatusCodes.BAD_REQUEST, "DEFAULT_PRIMARY_SALE_RECIPIENT", ); } } - const mintPayload = await generateMintSignaturePayload({ - metadata, - to, - price, - priceInWei: maybeBigInt(priceInWei), - currency, - primarySaleRecipient, + const parsedCurrency = currency || ZERO_ADDRESS; + + let parsedPrice = 0n; + if (priceInWei) { + parsedPrice = BigInt(priceInWei); + } else if (price) { + let _decimals = 18; + if ( + !( + parsedCurrency === ZERO_ADDRESS || + parsedCurrency === "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" + ) + ) { + const contract = getContract({ + chain, + client: thirdwebClient, + address: parsedCurrency, + }); + _decimals = await decimals({ contract }); + } + parsedPrice = BigInt(Number.parseFloat(price) * 10 ** _decimals); + } + + let parsedUri = ""; + if (metadata) { + parsedUri = + typeof metadata === "string" + ? metadata + : await upload({ + client: thirdwebClient, + files: [metadata], + }); + } + + let parsedUid: Hex; + if (uid) { + if (isHex(uid)) { + // 0x + 32-byte hex + if (uid.length !== 66) { + throw createCustomError( + '"uid" must be a valid 32-byte hex string.', + StatusCodes.BAD_REQUEST, + "INVALID_UID", + ); + } + parsedUid = uid; + } else { + parsedUid = stringToHex(uid, { size: 32 }); + } + } else { + parsedUid = uint8ArrayToHex(getRandomValues(new Uint8Array(32))); + } + + const mintPayload: Static = { + uri: parsedUri, + uid: parsedUid, + currency: parsedCurrency, + price: parsedPrice.toString(), + to: checksumAddress(to), royaltyRecipient, - royaltyBps, - validityStartTimestamp: validityStartTimestamp - ? new Date(validityStartTimestamp * 1000) - : undefined, - validityEndTimestamp: validityEndTimestamp - ? new Date(validityEndTimestamp * 1000) - : undefined, - uid: uid as Hex, - }); - const sanitizedMintPayload: Static = { - ...mintPayload, - price: mintPayload.price.toString(), - royaltyBps: mintPayload.royaltyBps.toString(), - validityStartTimestamp: Number(mintPayload.validityStartTimestamp), - validityEndTimestamp: Number(mintPayload.validityEndTimestamp), + royaltyBps: royaltyBps.toString(), + primarySaleRecipient, + validityStartTimestamp: + validityStartTimestamp ?? toSeconds(new Date(0)), + validityEndTimestamp: + validityEndTimestamp ?? toSeconds(tenYearsFromNow()), }; reply.status(StatusCodes.OK).send({ result: { - mintPayload: sanitizedMintPayload, + mintPayload: mintPayload, typedDataPayload: { domain: { name: "TokenERC721", @@ -291,7 +327,7 @@ export async function erc721SignaturePrepare(fastify: FastifyInstance) { ], MintRequest: MintRequest721, }, - message: sanitizedMintPayload, + message: mintPayload, primaryType: "MintRequest", }, }, @@ -300,73 +336,9 @@ export async function erc721SignaturePrepare(fastify: FastifyInstance) { }); } -type GenerateMintSignaturePayloadOptions = Omit< - GenerateMintSignatureOptions["mintRequest"], - "royaltyRecipient" | "primarySaleRecipient" | "royaltyBps" -> & { - royaltyRecipient: string; - primarySaleRecipient: string; - royaltyBps: number; -}; - -/** - * Helper functions copied from v5 SDK. - * The logic to generate a mint signature is not exported. - */ -export async function generateMintSignaturePayload( - mintRequest: GenerateMintSignaturePayloadOptions, -) { - const currency = mintRequest.currency || ZERO_ADDRESS; - const [price, uri, uid] = await Promise.all([ - // price - (async () => { - if (mintRequest.priceInWei) { - return mintRequest.priceInWei; - } - if (mintRequest.price) { - return mintRequest.price; - } - return 0n; - })(), - // uri - (async () => { - if (mintRequest.metadata) { - if (typeof mintRequest.metadata === "object") { - return await upload({ - client: thirdwebClient, - files: [mintRequest.metadata], - }); - } - return mintRequest.metadata; - } - return ""; - })(), - // uid - mintRequest.uid || (await randomBytesHex()), - ]); - - const startTime = mintRequest.validityStartTimestamp || new Date(0); - const endTime = mintRequest.validityEndTimestamp || tenYearsFromNow(); - - return { - uri, - currency, - uid, - price, - to: mintRequest.to, - royaltyRecipient: mintRequest.royaltyRecipient, - royaltyBps: mintRequest.royaltyBps, - primarySaleRecipient: mintRequest.primarySaleRecipient, - validityStartTimestamp: dateToSeconds(startTime), - validityEndTimestamp: dateToSeconds(endTime), - }; -} - -const randomBytesHex = (length = 32) => randomBytes(length).toString("hex"); - const tenYearsFromNow = () => new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10); -const dateToSeconds = (date: Date) => { - return BigInt(Math.floor(date.getTime() / 1000)); +const toSeconds = (date: Date) => { + return Math.floor(date.getTime() / 1000); }; diff --git a/test/e2e/tests/routes/signaturePrepare.test.ts b/test/e2e/tests/routes/signaturePrepare.test.ts new file mode 100644 index 000000000..25e8c0ca3 --- /dev/null +++ b/test/e2e/tests/routes/signaturePrepare.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { setup } from "../setup"; + +describe("signaturePrepareRoute", () => { + test("Prepare a signature with upload, no uid, no royalty/sale recipients", async () => { + const { engine, backendWallet } = await setup(); + + const res = await engine.erc721.erc721SignaturePrepare( + "84532", + "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", + { + metadata: { + description: "Test description", + image: "ipfs://...", + name: "My NFT", + attributes: [ + { + trait_type: "test type", + value: "test value", + }, + ], + }, + validityEndTimestamp: 1729194714, + validityStartTimestamp: 1728589914, + to: backendWallet, + }, + ); + + const expected = { + result: { + mintPayload: { + uri: "DO_NOT_ASSERT", + to: backendWallet, + price: "0", + currency: "0x0000000000000000000000000000000000000000", + primarySaleRecipient: "0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85", + royaltyRecipient: "0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85", + royaltyBps: "0", + validityStartTimestamp: 1728589914, + validityEndTimestamp: 1729194714, + uid: "DO_NOT_ASSERT", + }, + typedDataPayload: { + domain: { + name: "TokenERC721", + version: "1", + chainId: 84532, + verifyingContract: "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", + }, + types: { + EIP712Domain: [ + { + name: "name", + type: "string", + }, + { + name: "version", + type: "string", + }, + { + name: "chainId", + type: "uint256", + }, + { + name: "verifyingContract", + type: "address", + }, + ], + MintRequest: [ + { + name: "to", + type: "address", + }, + { + name: "royaltyRecipient", + type: "address", + }, + { + name: "royaltyBps", + type: "uint256", + }, + { + name: "primarySaleRecipient", + type: "address", + }, + { + name: "uri", + type: "string", + }, + { + name: "price", + type: "uint256", + }, + { + name: "currency", + type: "address", + }, + { + name: "validityStartTimestamp", + type: "uint128", + }, + { + name: "validityEndTimestamp", + type: "uint128", + }, + { + name: "uid", + type: "bytes32", + }, + ], + }, + message: { + uri: "DO_NOT_ASSERT", + to: backendWallet, + price: "0", + currency: "0x0000000000000000000000000000000000000000", + primarySaleRecipient: "0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85", + royaltyRecipient: "0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85", + royaltyBps: "0", + validityStartTimestamp: 1728589914, + validityEndTimestamp: 1729194714, + uid: "DO_NOT_ASSERT", + }, + primaryType: "MintRequest" as const, + }, + }, + }; + + // These fields are dynamic, do not assert them. + expected.result.mintPayload.uri = res.result.mintPayload.uri; + expected.result.typedDataPayload.message.uri = res.result.mintPayload.uri; + expected.result.mintPayload.uid = res.result.mintPayload.uid; + expected.result.typedDataPayload.message.uid = res.result.mintPayload.uid; + + expect(res).toEqual(expected); + }); + + test("Prepare a signature with provided hex uid", async () => { + const { engine, backendWallet } = await setup(); + + const res = await engine.erc721.erc721SignaturePrepare( + "84532", + "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", + { + metadata: "ipfs://...", + validityEndTimestamp: 1729194714, + validityStartTimestamp: 1728589914, + to: backendWallet, + uid: "0x25d29226fc7c310ed308c1eea8a3ed2d9f660d873ba6348b6649da4cae3877a4", + }, + ); + + expect(res.result.mintPayload.uid).toEqual( + "0x25d29226fc7c310ed308c1eea8a3ed2d9f660d873ba6348b6649da4cae3877a4", + ); + expect(res.result.mintPayload.uid).toEqual( + "0x25d29226fc7c310ed308c1eea8a3ed2d9f660d873ba6348b6649da4cae3877a4", + ); + }); + + test("Prepare a signature with string uid", async () => { + const { engine, backendWallet } = await setup(); + + const res = await engine.erc721.erc721SignaturePrepare( + "84532", + "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", + { + metadata: "ipfs://...", + validityEndTimestamp: 1729194714, + validityStartTimestamp: 1728589914, + to: backendWallet, + uid: "my-test-uuid", + }, + ); + + expect(res.result.mintPayload.uid).toEqual( + "0x6d792d746573742d757569640000000000000000000000000000000000000000", + ); + expect(res.result.mintPayload.uid).toEqual( + "0x6d792d746573742d757569640000000000000000000000000000000000000000", + ); + }); + + test("Prepare a signature with invalid hex uid", async () => { + const { engine, backendWallet } = await setup(); + + let threw = false; + try { + await engine.erc721.erc721SignaturePrepare( + "84532", + "0x5002e3bF97F376Fe0480109e26c0208786bCDDd4", + { + metadata: "ipfs://...", + validityEndTimestamp: 1729194714, + validityStartTimestamp: 1728589914, + to: backendWallet, + uid: "0x25d29226fc7c310ed308c1eea8a3ed2d", + }, + ); + } catch { + threw = true; + } + + expect(threw).toBeTrue(); + }); +});