Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mean-yaks-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ckb-ccc/spore": minor
---

Migrate dob-render-sdk directly into spore module

7 changes: 6 additions & 1 deletion packages/spore/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@
},
"dependencies": {
"@ckb-ccc/core": "workspace:*",
"axios": "^1.11.0"
"axios": "^1.11.0",
"satori": "^0.10.13",
"svgson": "^5.3.1"
},
"peerDependencies": {
"satori": "^0.10.13"
Copy link
Contributor

Choose a reason for hiding this comment

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

high

satori is listed in both dependencies and peerDependencies. This is generally an anti-pattern and can lead to version conflicts and unexpected behavior for consumers of this library. If you expect the consumer to provide satori, it should only be in peerDependencies. If you intend to bundle it, it should only be in dependencies. Please clarify the intention and adjust the dependencies accordingly.

},
Comment on lines 65 to 67
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Having satori in both dependencies and peerDependencies is unconventional and can lead to versioning issues. If satori is a core dependency for this package, it should only be listed in dependencies. Please remove it from peerDependencies to avoid potential conflicts for consumers of this library.

"packageManager": "pnpm@10.8.1"
}
14 changes: 14 additions & 0 deletions packages/spore/src/__examples__/renderDob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, it } from "vitest";
import { renderByTokenKey } from "../dob/index.js";

describe("decodeDob [testnet]", () => {
it("should respose a decoded dob render data from a spore id", async () => {
// The spore id that you want to decode (must be a valid spore dob)
const sporeId =
"dc19e68af1793924845e2a4dbc23598ed919dcfe44d3f9cd90964fe590efb0e4";

// Decode from spore id
const dob = await renderByTokenKey(sporeId);
console.log(dob);
}, 60000);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This test case lacks assertions. A test should verify that the code behaves as expected, but this one only logs the result to the console. It will pass as long as no error is thrown. Please add expect assertions to validate the output of renderByTokenKey. Also, console.log should be removed from test files before merging.

Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This test case uses console.log to manually inspect the output but lacks automated assertions to verify the correctness of the renderByTokenKey function's output. Tests should have explicit assertions to be effective for regression testing.

Additionally, the test description "should respose" contains a typo and should be "should respond".

  it("should respond with decoded DOB render data from a spore id", async () => {
    // The spore id that you want to decode (must be a valid spore dob)
    const sporeId =
      "dc19e68af1793924845e2a4dbc23598ed919dcfe44d3f9cd90964fe590efb0e4";

    // Decode from spore id
    const dob = await renderByTokenKey(sporeId);
    expect(dob).toBeDefined();
    // TODO: Add more specific assertions about the 'dob' content to make this a more effective test.
  }, 60000);

Copy link
Contributor

Choose a reason for hiding this comment

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

high

This test is missing assertions to verify the output. It currently only checks that renderByTokenKey doesn't throw an error. Tests should include expect statements to validate the function's output against expected results. Also, there's a typo in the description ('respose' should be 'respond').

  it("should respond with a decoded dob render data from a spore id", async () => {
    // The spore id that you want to decode (must be a valid spore dob)
    const sporeId =
      "dc19e68af1793924845e2a4dbc23598ed919dcfe44d3f9cd90964fe590efb0e4";

    // Decode from spore id
    const dob = await renderByTokenKey(sporeId);
    console.log(dob);
    // TODO: Add assertions to validate the `dob` content.
    // For example: expect(dob).toContain('<svg');
  }, 60000);

});
1 change: 1 addition & 0 deletions packages/spore/src/dob/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./api/index.js";
export * from "./config/index.js";
export * from "./helper/index.js";
export * from "./render/index.js";
18 changes: 18 additions & 0 deletions packages/spore/src/dob/render/api/dobDecode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { config } from "../config";
import type { DobDecodeResponse } from "../types";

export async function dobDecode(tokenKey: string): Promise<DobDecodeResponse> {
const response = await fetch(config.dobDecodeServerURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: 2,
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The JSON-RPC request id is hardcoded. To avoid potential issues with concurrent requests, it's better practice to use a unique ID for each request. You could use a simple incrementing counter or a timestamp.

Suggested change
id: 2,
id: Date.now(),

jsonrpc: "2.0",
method: "dob_decode",
params: [tokenKey],
}),
});
return response.json() as Promise<DobDecodeResponse>;
Copy link
Contributor

Choose a reason for hiding this comment

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

high

This function does not handle network errors or non-successful HTTP status codes from the fetch call. If the request fails, it will throw an unhandled exception. It's important to check the response status and handle errors gracefully. Additionally, the type cast as Promise<DobDecodeResponse> on response.json() is incorrect and should be removed.

  const response = await fetch(config.dobDecodeServerURL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      id: 2,
      jsonrpc: "2.0",
      method: "dob_decode",
      params: [tokenKey],
    }),
  });
  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`Failed to decode DOB: ${response.status} ${response.statusText} - ${errorBody}`);
  }
  return response.json();

Copy link
Contributor

Choose a reason for hiding this comment

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

high

This function has a couple of issues that should be addressed:

  1. No Error Handling: The fetch call doesn't handle non-2xx HTTP responses. If the server returns an error (e.g., 404, 500), response.ok will be false, but the code will proceed to call response.json(), which can lead to an unhandled exception. It's crucial to check the response status and handle errors appropriately.
  2. Hardcoded JSON-RPC ID: The id is hardcoded to 2. It's a best practice to use a unique ID for each JSON-RPC request to correctly correlate requests and responses, especially in environments where multiple requests might be in-flight concurrently. Using Date.now() or a simple counter would be a better approach.
  const response = await fetch(config.dobDecodeServerURL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      id: Date.now(),
      jsonrpc: "2.0",
      method: "dob_decode",
      params: [tokenKey],
    }),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`);
  }

  return response.json() as Promise<DobDecodeResponse>;

}
29 changes: 29 additions & 0 deletions packages/spore/src/dob/render/background-color-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Key } from "./constants/key";
import type { ParsedTrait } from "./traits-parser";

export function getBackgroundColorByTraits(
traits: ParsedTrait[],
): ParsedTrait | undefined {
return traits.find((trait) => trait.name === (Key.BgColor as string));
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The type cast (Key.BgColor as string) is unnecessary here. Key.BgColor is an enum member with a string value, so it can be directly compared with trait.name.

Suggested change
return traits.find((trait) => trait.name === (Key.BgColor as string));
return traits.find((trait) => trait.name === Key.BgColor);

}

export function backgroundColorParser(
traits: ParsedTrait[],
options?: {
defaultColor?: string;
},
): string {
const bgColorTrait = getBackgroundColorByTraits(traits);
if (bgColorTrait) {
if (typeof bgColorTrait.value === "string") {
if (
bgColorTrait.value.startsWith("#(") &&
bgColorTrait.value.endsWith(")")
) {
return bgColorTrait.value.replace("#(", "linear-gradient(");
}
return bgColorTrait.value;
}
}
return options?.defaultColor || "#000";
}
83 changes: 83 additions & 0 deletions packages/spore/src/dob/render/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export type FileServerResult =
| string
| {
content: string;
content_type: string;
};

export type BtcFsResult = FileServerResult;
export type IpfsResult = FileServerResult;

export type BtcFsURI = `btcfs://${string}`;
export type IpfsURI = `ipfs://${string}`;

export type QueryBtcFsFn = (uri: BtcFsURI) => Promise<BtcFsResult>;
export type QueryIpfsFn = (uri: IpfsURI) => Promise<IpfsResult>;
export type QueryUrlFn = (uri: string) => Promise<FileServerResult>;

export class Config {
private _dobDecodeServerURL = "https://dob-decoder.ckbccc.com";
private _queryBtcFsFn: QueryBtcFsFn = async (uri) => {
console.log("requiring", uri);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

console.log statements should be removed from production code. This appears to be a leftover from debugging.

const response = await fetch(
`https://dob-decoder.ckbccc.com/restful/dob_extract_image?uri=${uri}&encode=base64`,
);
return {
content: await response.text(),
content_type: "",
};
};
Copy link
Contributor

Choose a reason for hiding this comment

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

high

This function has a hardcoded URL and lacks proper error handling.

  1. The URL https://dob-decoder.ckbccc.com is hardcoded. It should use this._dobDecodeServerURL to be consistent with the rest of the class and allow for configuration.
  2. There is no error handling for the fetch call. If the request fails, it will throw an unhandled exception. Please add a check for response.ok.
  3. The content_type is hardcoded to an empty string. It would be better to get this from the response headers.
  private _queryBtcFsFn: QueryBtcFsFn = async (uri) => {
    console.log("requiring", uri);
    const response = await fetch(
      `${this._dobDecodeServerURL}/restful/dob_extract_image?uri=${uri}&encode=base64`,
    );
    if (!response.ok) {
      throw new Error(`Failed to query BtcFs: ${response.status} ${response.statusText}`);
    }
    return {
      content: await response.text(),
      content_type: response.headers.get('content-type') || "",
    };
  };

Copy link
Contributor

Choose a reason for hiding this comment

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

high

This function has a couple of issues:

  1. Hardcoded URL: The URL https://dob-decoder.ckbccc.com is hardcoded, but there's a configurable _dobDecodeServerURL property in the Config class. This hardcoded URL should be replaced with this._dobDecodeServerURL to respect the configuration.
  2. Debug Logging: console.log should be removed from library code to avoid polluting the console in production environments.
  private _queryBtcFsFn: QueryBtcFsFn = async (uri) => {
    const response = await fetch(
      `${this._dobDecodeServerURL}/restful/dob_extract_image?uri=${uri}&encode=base64`,
    );
    return {
      content: await response.text(),
      content_type: response.headers.get('content-type') || '',
    };
  };


private _queryUrlFn = async (url: string) => {
const response = await fetch(url);
const blob = await response.blob();
return new Promise<IpfsResult>((resolve, reject) => {
const reader = new FileReader();

reader.onload = function () {
const base64 = this.result as string;
resolve(base64);
};
reader.onerror = (error) => {
reject(new Error(`FileReader error: ${error.type}`));
};
reader.readAsDataURL(blob);
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

The _queryUrlFn function uses FileReader, which is a browser-only API. This will cause the library to crash when used in a Node.js environment. To ensure this module is isomorphic (works in both browser and Node.js), you should avoid browser-specific APIs or provide a Node.js-specific implementation path.


private _queryIpfsFn = async (uri: IpfsURI) => {
const key = uri.substring("ipfs://".length);
const url = `https://ipfs.io/ipfs/${key}`;
return this._queryUrlFn(url);
};

get dobDecodeServerURL() {
return this._dobDecodeServerURL;
}

setDobDecodeServerURL(dobDecodeServerURL: string): void {
this._dobDecodeServerURL = dobDecodeServerURL;
}

setQueryBtcFsFn(fn: QueryBtcFsFn): void {
this._queryBtcFsFn = fn;
}

setQueryIpfsFn(fn: QueryIpfsFn): void {
this._queryIpfsFn = fn;
}

get queryBtcFsFn(): QueryBtcFsFn {
return this._queryBtcFsFn;
}

get queryIpfsFn(): QueryIpfsFn {
return this._queryIpfsFn;
}

get queryUrlFn(): QueryUrlFn {
return this._queryUrlFn;
}
}

export const config = new Config();
5 changes: 5 additions & 0 deletions packages/spore/src/dob/render/constants/key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum Key {
BgColor = "prev.bgcolor",
Prev = "prev",
Image = "IMAGE",
}
4 changes: 4 additions & 0 deletions packages/spore/src/dob/render/constants/regex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const ARRAY_REG = /\(%(.*?)\):(\[.*?\])/;
export const ARRAY_INDEX_REG = /(\d+)<_>$/;
export const GLOBAL_TEMPLATE_REG = /^prev<(.*?)>/;
export const TEMPLATE_REG = /^(.*?)<(.*?)>/;

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/spore/src/dob/render/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { config } from "./config";
export * from "./render-by-dob-decode-response";
export * from "./render-by-token-key";
export * from "./render-dob-bit";
export * from "./render-image-svg";
export * from "./render-text-params-parser";
export * from "./render-text-svg";
export * from "./svg-to-base64";
export * from "./traits-parser";
export * from "./types";
39 changes: 39 additions & 0 deletions packages/spore/src/dob/render/render-by-dob-decode-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Key } from "./constants/key";
import { renderDob1Svg } from "./render-dob1-svg";
import { renderImageSvg } from "./render-image-svg";
import { renderTextParamsParser } from "./render-text-params-parser";
import type { RenderProps } from "./render-text-svg";
import { renderTextSvg } from "./render-text-svg";
import { traitsParser } from "./traits-parser";
import type { DobDecodeResult, RenderPartialOutput } from "./types";

export function renderByDobDecodeResponse(
dob0Data: DobDecodeResult | string,
props?: Pick<RenderProps, "font"> & {
outputType?: "svg";
},
) {
if (typeof dob0Data === "string") {
dob0Data = JSON.parse(dob0Data) as DobDecodeResult;
}
if (typeof dob0Data.render_output === "string") {
dob0Data.render_output = JSON.parse(
dob0Data.render_output,
) as RenderPartialOutput[];
}
const { traits, indexVarRegister } = traitsParser(dob0Data.render_output);
for (const trait of traits) {
if (trait.name === "prev.type" && trait.value === "image") {
return renderImageSvg(traits);
}
// TODO: multiple images
if (
trait.name === (Key.Image as string) &&
trait.value instanceof Promise
) {
return renderDob1Svg(trait.value);
}
}
const renderOptions = renderTextParamsParser(traits, indexVarRegister);
return renderTextSvg({ ...renderOptions, font: props?.font });
}
13 changes: 13 additions & 0 deletions packages/spore/src/dob/render/render-by-token-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { dobDecode } from "./api/dobDecode";
import { renderByDobDecodeResponse } from "./render-by-dob-decode-response";
import type { RenderProps } from "./render-text-svg";

export async function renderByTokenKey(
tokenKey: string,
options?: Pick<RenderProps, "font"> & {
outputType?: "svg";
},
) {
const dobDecodeResponse = await dobDecode(tokenKey);
return renderByDobDecodeResponse(dobDecodeResponse.result, options);
}
109 changes: 109 additions & 0 deletions packages/spore/src/dob/render/render-dob-bit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import satori from "satori";
import SpaceGroteskBoldBase64 from "./fonts/SpaceGrotesk-Bold.base64";
import { traitsParser } from "./traits-parser";
import type { DobDecodeResult, RenderPartialOutput } from "./types";
import { base64ToArrayBuffer } from "./utils/string";

const iconBase64 =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwMCIgaGVpZ2h0PSIxMDAwIiB2aWV3Qm94PSIwIDAgMTAwMCAxMDAwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNTA0XzI4OCkiPgo8cmVjdCB3aWR0aD0iMTAwMCIgaGVpZ2h0PSIxMDAwIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTAwMCAwSDBWMTAwMEgxMDAwVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNTAwIDY0NS42NjlDNjE1LjE1NyA2NDUuNjY5IDcwOC42NjEgNTUyLjE2NSA3MDguNjYxIDQzNy4wMDhDNzA4LjY2MSAzOTAuMDkyIDY5My4yNDIgMzQ2Ljc4NSA2NjYuOTk1IDMxMi4wMDhDNjkyLjI1NyAyOTIuNjUxIDcwOC42NjEgMjYyLjQ2NyA3MDguNjYxIDIyOC4zNDZINTAwQzM4NC44NDMgMjI4LjM0NiAyOTEuMzM5IDMyMS44NSAyOTEuMzM5IDQzNy4wMDhDMjkxLjMzOSA1NTEuODM3IDM4NS4xNzEgNjQ1LjY2OSA1MDAgNjQ1LjY2OVpNNTAwIDMyMy44MTlDNTYyLjMzNiAzMjMuODE5IDYxMy4xODkgMzc0LjY3MiA2MTMuMTg5IDQzNy4wMDhDNjEzLjE4OSA0OTkuMzQ0IDU2Mi4zMzYgNTUwLjE5NyA1MDAgNTUwLjE5N0M0MzcuNjY0IDU1MC4xOTcgMzg2LjgxMSA0OTkuMzQ0IDM4Ni44MTEgNDM3LjAwOEMzODYuODExIDM3NC42NzIgNDM3LjY2NCAzMjMuODE5IDUwMCAzMjMuODE5WiIgZmlsbD0iIzAwREY5QiIvPgo8cGF0aCBkPSJNNTAwIDgxMS4zNTJDNDA0LjE5OSA4MTEuMzUyIDMwOC4zOTkgNzc0LjkzNCAyMzUuMjM2IDcwMS43NzJDMjQ2LjcxOSA2NzEuNTg4IDI3MS45ODIgNjQ2LjY1NCAzMDUuNzc0IDYzNy4xMzlDMzU5LjkwOCA2ODkuNjMzIDQyOS43OSA3MTUuODc5IDUwMCA3MTUuODc5QzU3MC4yMSA3MTUuODc5IDY0MC4wOTIgNjg5LjYzMyA2OTQuMjI2IDYzNy4xMzlDNzI4LjAxOCA2NDYuNjU0IDc1My4yODEgNjcxLjI2IDc2NC43NjQgNzAxLjc3MkM2OTEuOTI5IDc3NC42MDYgNTk1LjgwMSA4MTEuMzUyIDUwMCA4MTEuMzUyWiIgZmlsbD0iIzI0NzFGRSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzUwNF8yODgiPgo8cmVjdCB3aWR0aD0iMTAwMCIgaGVpZ2h0PSIxMDAwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=";

export function renderDobBit(
dob0Data: DobDecodeResult | string,
_props?: {
outputType?: "svg";
},
) {
if (typeof dob0Data === "string") {
dob0Data = JSON.parse(dob0Data) as DobDecodeResult;
}
if (typeof dob0Data.render_output === "string") {
dob0Data.render_output = JSON.parse(
dob0Data.render_output,
) as RenderPartialOutput[];
}
const { traits } = traitsParser(dob0Data.render_output);
const account =
traits.find((trait) => trait.name === "Account")?.value ?? "-";
let fontSize = 76;
if (typeof account === "string") {
if (account.length > 10) {
fontSize = fontSize / 2;
}
if (account.length > 20) {
fontSize = fontSize / 2;
}
if (account.length > 30) {
fontSize = fontSize * 0.75;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The logic for adjusting fontSize uses a series of if statements without else, which causes multiple conditions to be met and applied sequentially. For example, an account name with 25 characters will have its font size halved twice. If the intention is to apply only one adjustment based on the length, you should use else if statements for clarity and correctness.

    if (account.length > 30) {
      fontSize = (76 / 2 / 2) * 0.75;
    } else if (account.length > 20) {
      fontSize = 76 / 2 / 2;
    } else if (account.length > 10) {
      fontSize = 76 / 2;
    }

}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The logic for adjusting fontSize uses a series of if statements that are not mutually exclusive. This makes the code hard to understand and might lead to unintended cumulative font size reductions. For example, a string of length 25 will have its font size halved twice. If these conditions are meant to be exclusive, please use an if-else if structure for clarity and correctness. If the cumulative effect is intended, please add a comment to explain it.

const spaceGroteskBoldFont = base64ToArrayBuffer(SpaceGroteskBoldBase64);
return satori(
{
key: "container",
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "500px",
background: "#3A3A43",
color: "#fff",
height: "500px",
textAlign: "center",
},
children: [
{
type: "img",
props: {
src: iconBase64,
width: 100,
height: 100,
style: {
width: "100px",
height: "100px",
borderRadius: "100%",
marginBottom: "40px",
},
},
},
{
type: "div",
props: {
children: account,
style: {
marginBottom: "20px",
fontSize: `${fontSize}px`,
textAlign: "center",
},
},
},
{
type: "div",
props: {
children: ".bit",
style: {
fontSize: "44px",
padding: "4px 40px",
borderRadius: "200px",
background: "rgba(255, 255, 255, 0.10)",
},
},
},
],
},
},
{
width: 500,
height: 500,
fonts: [
{
name: "SpaceGrotesk",
data: spaceGroteskBoldFont,
weight: 700,
},
],
},
);
}
Loading