Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a299979
feat: make decoders cache refreshable by adding a new setting option
ashuralyk Nov 21, 2025
37bdec4
feat: add new method for the extraction of inner svg of dob render ou…
ashuralyk Aug 7, 2025
594a474
feat: complete test of svg extractor
ashuralyk Aug 8, 2025
f4baebc
chore: update extractor
ashuralyk Aug 8, 2025
446b126
feat: basic text render functionality prepared
ashuralyk Aug 20, 2025
a9df851
chore: fix clippy
ashuralyk Aug 21, 2025
9632287
chore: solve gemini assistant report
ashuralyk Aug 28, 2025
b94e031
feat: adjust direction structure of puretext and add 24 hours time ex…
ashuralyk Aug 28, 2025
36c84ac
chore: add config item for decoders expiration
ashuralyk Aug 28, 2025
6666e23
chore: fix gemini reports
ashuralyk Sep 9, 2025
232bb07
bug: replace witness to witness_view
ashuralyk Sep 9, 2025
9b21c08
feat: support decoding http url
ashuralyk Sep 9, 2025
1762716
chore: allow CORS
ashuralyk Sep 9, 2025
18fbb92
chore: apply bgcolor
ashuralyk Sep 9, 2025
0d97f11
chore: adapt to client integration
ashuralyk Sep 9, 2025
2ec1413
chore: bypass https certificate verify
ashuralyk Sep 10, 2025
2a0e322
feat: achieve svg text to path transformation
ashuralyk Sep 11, 2025
be98e33
feat: RESTful Api
ashuralyk Sep 11, 2025
f1fea14
feat: enable RESTful api
ashuralyk Sep 11, 2025
d74fcc1
bug: fix w4 to w3
ashuralyk Sep 11, 2025
b80581d
bug: arbitrary image type and hide background style if no bgcolor
ashuralyk Sep 11, 2025
e14a327
bug: solve dob/1 fallback issue
ashuralyk Sep 12, 2025
e37f5fa
feat: purge svg handler, keep fsuri extractor alive
ashuralyk Sep 16, 2025
c851382
chore: use query to accept fsuri
ashuralyk Sep 20, 2025
f1e138b
chore: enable CORS for RESTful api
ashuralyk Sep 28, 2025
6823344
chore: accurate file elapsed time calculation
ashuralyk Dec 3, 2025
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
942 changes: 739 additions & 203 deletions Cargo.lock

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,37 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
base64 = "0.22.1"
ckb-sdk = "3.2.0"
ckb-types = "0.116.1"
ckb-jsonrpc-types = "0.116.1"
ckb-hash = "0.116.1"
thiserror = "1.0"
serde_json = "1.0"
hex = "0.4.3"
reqwest = { version = "0.12.4", features = ["json"] }
svg = "0.15"
ttf-parser = "0.24"
lyon = "1.0"
reqwest = { version = "0.12.4", features = ["json", "rustls-tls", "trust-dns"] }
jsonrpc-core = "18.0"
serde = { version = "1.0", features = ["serde_derive"] }
futures = "0.3"
lazy_static = { version = "1.4" }
lazy_static = "1.4"
lazy-regex = "3.1.0"
chrono = { version = "0.4", features = ["serde"] }
ckb-vm = { version = "0.24", features = ["asm"] }
axum = { version = "0.7", features = ["macros"] }

spore-types = { git = "https://github.com/sporeprotocol/spore-contract", rev = "81315ca" }

jsonrpsee = { version = "0.22.3", features = ["server", "macros"], optional = true }
jsonrpsee = { version = "0.26", features = ["server", "macros"], optional = true }
tower-http = { version = "0.6", features = ["cors"], optional = true }
tower = { version = "0.5", optional = true }
toml = { version = "0.8.2", optional = true }
tokio = { version = "1.37", features = ["rt", "signal"], optional = true }
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"], optional = true }

[features]
default = ["standalone_server", "render_debug"]
standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber"]
standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower"]
render_debug = []
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,57 @@ $ RUST_LOG=dob_decoder_server=debug cargo run

And then, try it out:

**Get protocol versions:**

```bash
$ curl -H 'content-type: application/json' -d '{
"id": 1,
"jsonrpc": "2.0",
"method": "dob_protocol_version",
"params": []
}' http://localhost:8090
```

**Decode a spore ID:**

```bash
$ echo '{
"id": 2,
"jsonrpc": "2.0",
"method": "dob_decode",
"params": [
"<spore_id in hex format without 0x prefix>"
"4f7fb83a65dae9b95c21e55d5776a84f17bb6377681befeedb20a077ce1d8aad"
]
}' \
| curl -H 'content-type: application/json' -d @- \
http://localhost:8090
```

**Decode and extract SVG from another example spore ID on mainnet:**

```bash
$ echo '{
"id": 3,
"jsonrpc": "2.0",
"method": "dob_decode_svg",
"params": [
"0x577bf0de0dcffe2811fa827480a700bc800c8e1e9606615b1484baeea2cba830"
]
}' \
| curl -H 'content-type: application/json' -d @- \
http://localhost:8090
```
Comment on lines +71 to +82

Choose a reason for hiding this comment

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

medium

The documentation has been updated to include an example for a dob_decode_svg method. However, the pull request description states that this method was purged, and I can't find its implementation in the new code. This is confusing for users. Please remove this example from the README to align the documentation with the actual implementation.

Comment on lines +69 to +82

Choose a reason for hiding this comment

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

medium

The example for dob_decode_svg seems to be outdated. The PR description mentions that this method was removed, and it's not present in the new RPC interface. Please remove this example from the documentation.


**Extract image from a btcfs or ipfs path:**

```bash
$ echo '{
"id": 4,
"jsonrpc": "2.0",
"method": "dob_extract_image_from_fsuri",

Choose a reason for hiding this comment

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

medium

The RPC method name in this example is incorrect. The implementation in src/server/jsonrpc.rs uses dob_extract_image, but the example shows dob_extract_image_from_fsuri. Please update the example to use the correct method name.

Suggested change
"method": "dob_extract_image_from_fsuri",
"method": "dob_extract_image",

"params": [
"btcfs://5895004e95c8a4b80f05f5314d310067a703134515d82effc2ec6eba0dda3fc9i0",
"base64"
]
}' \
| curl -H 'content-type: application/json' -d @- \
Expand Down
6 changes: 6 additions & 0 deletions settings.mainnet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ protocol_versions = [
# connect to the RPC of CKB node
ckb_rpc = "https://mainnet.ckb.dev/"

# connect to the image fetcher service
image_fetcher_url = { btcfs = "https://mempool.space/api/tx/", ipfs = "https://ipfs.io/ipfs/" }

# address that rpc server running at in case of standalone server mode
rpc_server_address = "0.0.0.0:8090"

Expand All @@ -19,6 +22,9 @@ dobs_cache_directory = "cache/dobs"
# expiration time indicator for cleaning whole dobs cache, zero means never clean
dobs_cache_expiration_sec = 300

# expiration time in minutes dedicated for type_id and type_script decoder cache, zero means always update
decoders_cache_expiration_minutes = 1440

# all deployed on-chain Spore contracts binary hash (order from new to old)
# refer to: https://github.com/sporeprotocol/spore-contract/blob/master/docs/VERSIONS.md
[[available_spores]]
Expand Down
11 changes: 10 additions & 1 deletion settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,27 @@ protocol_versions = [
# connect to the RPC of CKB node
ckb_rpc = "https://testnet.ckbapp.dev/"

# connect to the image fetcher service
image_fetcher_url = { btcfs = "https://mempool.space/api/tx/", ipfs = "https://ipfs.io/ipfs/" }

# address that rpc server running at in case of standalone server mode
rpc_server_address = "0.0.0.0:8090"

# address that restful api server running at
restful_server_address = "0.0.0.0:8091"

# directory that stores decoders on hard-disk, including on-chain and off-chain binary files
decoders_cache_directory = "cache/decoders"
decoders_cache_directory = "cache/decoders/testnet"

# directory that stores DOBs rendering results on hard-disk
dobs_cache_directory = "cache/dobs"

# expiration time indicator for cleaning whole dobs cache, zero means never clean
dobs_cache_expiration_sec = 300

# expiration time in minutes dedicated for type_id and type_script decoder cache, zero means always update
decoders_cache_expiration_minutes = 1440

# all deployed on-chain Spore contracts binary hash (order from new to old)
# refer to: https://github.com/sporeprotocol/spore-contract/blob/master/docs/VERSIONS.md
[[available_spores]]
Expand Down
166 changes: 166 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
Expand All @@ -9,7 +10,9 @@ use ckb_jsonrpc_types::{
use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey, Tx};
use ckb_types::H256;
use jsonrpc_core::futures::FutureExt;
use lazy_regex::{regex, regex_replace_all};
use reqwest::{Client, Url};
use serde_json::Value;

use crate::types::Error;

Expand Down Expand Up @@ -150,3 +153,166 @@ impl RpcClient {
.boxed()
}
}

#[derive(Clone)]
pub struct ImageFetchClient {
base_url: HashMap<String, Url>,
}

impl ImageFetchClient {
pub fn new(base_url: &HashMap<String, Url>) -> Self {
Self {
base_url: base_url.clone(),
}
}

pub async fn fetch_images(&self, images_uri: &[String]) -> Result<Vec<Vec<u8>>, Error> {
let mut requests = vec![];
for uri in images_uri {
match uri.try_into()? {
URI::BTCFS(tx_hash, index) => {
let url = self
.base_url
.get("btcfs")
.ok_or(Error::FsuriNotFoundInConfig)?
.join(&tx_hash)
.expect("image url");

Choose a reason for hiding this comment

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

high

Using expect() can lead to a panic if the URL join operation fails, for instance, if tx_hash contains characters that are not allowed in a URL path segment. It's safer to handle the Result and return a proper error to make the client more robust.

Suggested change
.expect("image url");
.join(&tx_hash)
.map_err(|e| Error::InvalidOnchainFsuriFormat)?;

Comment on lines +178 to +179

Choose a reason for hiding this comment

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

medium

Using .expect() can cause the server to panic if joining the URL fails, for example if the transaction hash is not a valid URL path segment. It's better to handle this potential error gracefully by converting it into a Result.

Suggested change
.join(&tx_hash)
.expect("image url");
.join(&tx_hash)
.map_err(|_| Error::InvalidOnchainFsuriFormat)?;

requests.push(parse_image_from_btcfs(url, index).boxed());
}
URI::IPFS(cid) => {
let url = self
.base_url
.get("ipfs")
.ok_or(Error::FsuriNotFoundInConfig)?
.join(&cid)
.expect("image url");
Comment on lines +178 to +188

Choose a reason for hiding this comment

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

medium

Using .expect() will cause the program to panic if joining the URL fails. While it's unlikely with the current URIs, it's better to handle this potential failure gracefully by returning a Result. This makes the client more robust.

Suggested change
.join(&tx_hash)
.expect("image url");
requests.push(parse_image_from_btcfs(url, index).boxed());
}
URI::IPFS(cid) => {
let url = self
.base_url
.get("ipfs")
.ok_or(Error::FsuriNotFoundInConfig)?
.join(&cid)
.expect("image url");
.join(&tx_hash)
.map_err(|_| Error::InvalidOnchainFsuriFormat)?;
requests.push(parse_image_from_btcfs(url, index).boxed());
}
URI::IPFS(cid) => {
let url = self
.base_url
.get("ipfs")
.ok_or(Error::FsuriNotFoundInConfig)?
.join(&cid)
.map_err(|_| Error::InvalidOnchainFsuriFormat)?;

Comment on lines +187 to +188

Choose a reason for hiding this comment

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

medium

Using .expect() can cause the server to panic if joining the URL fails, for example if the CID is not a valid URL path segment. It's better to handle this potential error gracefully by converting it into a Result.

Suggested change
.join(&cid)
.expect("image url");
.join(&cid)
.map_err(|_| Error::InvalidOnchainFsuriFormat)?;

requests.push(
async move {
let image = reqwest::get(url.clone())
.await
.map_err(|e| Error::FetchFromIpfsError(e.to_string()))?
.bytes()
.await
.map_err(|e| Error::FetchFromIpfsError(e.to_string()))?
.to_vec();
Ok(image)
}
.boxed(),
);
}
}
}
let mut images = vec![];
let responses = futures::future::join_all(requests).await;
for response in responses {
images.push(response?);
}
Ok(images)
}
}

#[allow(clippy::upper_case_acronyms)]
enum URI {
BTCFS(String, usize),
IPFS(String),
}

impl TryFrom<&String> for URI {
type Error = Error;

fn try_from(uri: &String) -> Result<Self, Error> {
if let Some(body) = uri.strip_prefix("btcfs://") {
let parts: Vec<&str> = body.split('i').collect::<Vec<_>>();
if parts.len() != 2 {
return Err(Error::InvalidOnchainFsuriFormat);
}
let tx_hash = parts[0].to_string();
let index = parts[1]
.parse()
.map_err(|_| Error::InvalidOnchainFsuriFormat)?;
Ok(URI::BTCFS(tx_hash, index))
} else if let Some(body) = uri.strip_prefix("ipfs://") {
let hash = body.to_string();
Ok(URI::IPFS(hash))
} else {
Err(Error::InvalidOnchainFsuriFormat)
}
}
Comment on lines +220 to +240

Choose a reason for hiding this comment

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

high

The URI::try_from implementation is missing support for ckbfs, which is mentioned in the pull request title ("...like btcfs, ipfs and ckbfs"). To be feature-complete as per the title, ckbfs URI parsing should be added here. You would also need to add a CKBFS variant to the URI enum.

}

async fn parse_image_from_btcfs(url: Url, index: usize) -> Result<Vec<u8>, Error> {
// parse btc transaction
let btc_tx = reqwest::get(url)
.await
.map_err(|e| Error::FetchFromBtcNodeError(e.to_string()))?
.json::<Value>()
.await
.map_err(|e| Error::FetchFromBtcNodeError(e.to_string()))?;
let vin = btc_tx
.get("vin")
.ok_or(Error::InvalidBtcTransactionFormat(
"vin not found".to_string(),
))?
.as_array()
.ok_or(Error::InvalidBtcTransactionFormat(
"vin not an array".to_string(),
))?
.first()
.ok_or(Error::InvalidBtcTransactionFormat(
"vin is empty".to_string(),
))?;
let witness = vin
.get("inner_witnessscript_asm")
.ok_or(Error::InvalidBtcTransactionFormat(
"inner_witnessscript_asm not found".to_string(),
))?
.as_str()
.ok_or(Error::InvalidBtcTransactionFormat(
"inner_witnessscript_asm not a string".to_string(),
))?
.to_owned();

// parse inscription body
let mut images = vec![];
let mut witness_view = witness.as_str();

// flexible header pattern, e.g. OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2
// note: arbitrary part is the specification of image type
let header_pattern = regex!(
r#"OP_IF\s+OP_PUSHBYTES_3\s+444f42\s+OP_PUSHBYTES_1\s+01\s+OP_PUSHBYTES_(\d+)\s+([0-9a-fA-F]+)\s+OP_0\s+OP_PUSHDATA2\s+"#
);
Comment on lines +281 to +283

Choose a reason for hiding this comment

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

medium

The regex header_pattern includes capture groups for the length of the content type and the content type itself. However, these captures are not being used in the parsing logic. This is a missed opportunity to perform validation on the inscription header, for example, by checking the content type. Using these captures would make the parsing more robust.


while let (Some(start), Some(end)) = (witness_view.find("OP_IF"), witness_view.find("OP_ENDIF"))
{
if start >= end {
return Err(Error::InvalidInscriptionFormat(
"bad start and end position".to_string(),
));
}
Comment on lines +285 to +291

Choose a reason for hiding this comment

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

high

The current logic for finding inscription envelopes uses witness_view.find("OP_IF") and witness_view.find("OP_ENDIF"). This approach will not correctly handle nested OP_IF/OP_ENDIF blocks, as it will match the first OP_IF with the first OP_ENDIF it finds, which may not be the correct corresponding one. This could lead to incorrect parsing of valid Bitcoin scripts.

To fix this, consider implementing a more robust parsing mechanism, such as a stack-based approach, to correctly identify matching OP_IF and OP_ENDIF pairs.

let inscription = &witness_view[start..end + "OP_ENDIF".len()];

if let Some(captures) = header_pattern.captures(inscription) {
let matched_header = &captures[0];
let base_removed = inscription.replace(matched_header, "");
let hexed = regex_replace_all!(r#"\s?OP\_\w+\s?"#, &base_removed, "");
let image = hex::decode(hexed.as_bytes())
.map_err(|_| Error::InvalidInscriptionContentHexFormat)?;
images.push(image);
} else {
Comment on lines +294 to +301

Choose a reason for hiding this comment

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

medium

The regex for the inscription header captures the content type of the inscription, but this information is not used. The captures are only used to get the full matched header string for removal. If the content type is important for any downstream processing or validation, it should be extracted and potentially returned from this function. If it's not needed, consider simplifying the regex to not capture these groups for a minor performance improvement and clearer intent.

return Err(Error::InvalidInscriptionFormat(
"HEADER pattern not found".to_string(),
));
}

witness_view = &witness_view[end + "OP_ENDIF".len()..];
}
if images.is_empty() {
return Err(Error::EmptyInscriptionContent);
}

let image = images
.get(index)
.cloned()
.ok_or(Error::ExceededInscriptionIndex)?;
Ok(image)
}
28 changes: 25 additions & 3 deletions src/decoder/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ fn build_type_script_search_option(type_script: Script) -> CellQueryOptions {
CellQueryOptions::new_type(type_script)
}

fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool {
if minutes == 0 {
return true;
}
let metadata = match std::fs::metadata(file_path) {
Ok(m) => m,
Err(_) => return true, // File doesn't exist or we can't access it, so it's "old".
};

let file_time = match metadata.modified().or_else(|_| metadata.created()) {
Ok(time) => time,
Err(_) => return true, // Can't get a timestamp, assume it's old.
};

match file_time.elapsed() {
Ok(elapsed) => elapsed.as_secs() >= minutes * 60,
Err(_) => true, // System clock is earlier than file time, assume it's old to be safe.
}
}

fn build_batch_search_options(
type_args: &[u8; 32],
available_script_ids: &[ScriptId],
Expand Down Expand Up @@ -80,7 +100,9 @@ pub fn decode_spore_content(content: &[u8]) -> Result<(Value, String), Error> {
return Ok((serde_json::Value::String(dna.clone()), dna));
}

let value: Value = serde_json::from_slice(content).map_err(|_| Error::DOBContentUnexpected)?;
// if content is not a valid JSON, treat it as a DNA string
let value: Value = serde_json::from_slice(content)
.unwrap_or(Value::String(String::from_utf8_lossy(content).to_string()));
let dna = match &value {
serde_json::Value::String(_) => &value,
serde_json::Value::Array(array) => array.first().ok_or(Error::DOBContentUnexpected)?,
Expand Down Expand Up @@ -267,7 +289,7 @@ pub async fn parse_decoder_path(
DecoderLocationType::TypeId => {
let hash = decoder.hash.as_ref().ok_or(Error::DecoderHashNotFound)?;
decoder_path.push(format!("type_id_{}.bin", hex::encode(hash)));
if !decoder_path.exists() {
if file_older_than_minutes(&decoder_path, settings.decoders_cache_expiration_minutes) {
let decoder_search_option = build_type_id_search_option(hash.clone().into());
let decoder_binary = fetch_decoder_binary(rpc, decoder_search_option).await?;
std::fs::write(decoder_path.clone(), decoder_binary)
Expand All @@ -284,7 +306,7 @@ pub async fn parse_decoder_path(
"type_script_{}.bin",
hex::encode(script.calc_script_hash().raw_data())
));
if !decoder_path.exists() {
if file_older_than_minutes(&decoder_path, settings.decoders_cache_expiration_minutes) {
let decoder_search_option = build_type_script_search_option(script);
let decoder_binary = fetch_decoder_binary(rpc, decoder_search_option).await?;
std::fs::write(decoder_path.clone(), decoder_binary)
Expand Down
1 change: 1 addition & 0 deletions src/decoder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::{
pub(crate) mod helpers;
use helpers::*;

#[derive(Clone)]
pub struct DOBDecoder {
rpc: RpcClient,
settings: Settings,
Expand Down
Loading