Skip to content

refactor: implement DOB/1 protocol #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 17 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
658 changes: 649 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ 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"
ckb-vm = { version = "0.24", features = ["asm"] }
thiserror = "1.0"
serde_json = "1.0"
hex = "0.4.3"
lazy-regex = "3.1.0"
image = "0.25.1"
reqwest = { version = "0.12.4", features = ["json"] }
jsonrpc-core = "18.0"
serde = { version = "1.0", features = ["serde_derive"] }
futures = "0.3"
lazy_static = { version = "1.4" }
ckb-vm = { version = "0.24", features = ["asm"] }
molecule = "0.8.0"

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

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,13 @@ refer to error definitions [here](https://github.com/sporeprotocol/dob-decoder-s
| 1026 | DecoderBinaryNotFoundInCell |
| 1027 | JsonRpcRequestError |
| 1028 | SystemTimeError |
| 1029 | JsonRpcReques |
| 1030 | FetchFromBtcNodeError |
| 1031 | InvalidBtcTransactionFormat |
| 1032 | InvalidInscriptionFormat |
| 1033 | InvalidInscriptionContentHexFormat |
| 1034 | EmptyInscriptionContent |
| 1035 | ExceededInscriptionIndex |
| 1036 | InvalidOnchainFsuriFormat |
| 1037 | FsuriNotFoundInConfig |
| 1038 | FetchFromIpfsError |
Binary file not shown.
9 changes: 9 additions & 0 deletions settings.mainnet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 @@ -21,6 +24,12 @@ dobs_cache_directory = "cache/dobs"
# expiration time indicator for cleaning whole dobs cache, zero means never clean
dobs_cache_expiration_sec = 300

# identifier that represents the maximum combination of DOB/1
dob1_max_combination = 5

# identifier that represents the maximum number of caching images
dob1_max_cache_size = 100

# 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
21 changes: 21 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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/testnet/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 @@ -21,6 +24,12 @@ dobs_cache_directory = "cache/dobs"
# expiration time indicator for cleaning whole dobs cache, zero means never clean
dobs_cache_expiration_sec = 300

# identifier that represents the maximum combination of DOB/1
dob1_max_combination = 5

# identifier that represents the maximum number of caching images
dob1_max_cache_size = 100

# 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 All @@ -47,6 +56,10 @@ hash_type = "data1"

# associate `code_hash` with the corresponding onchain information about `tx_hash` and `out_index`
# server will firstly search onchain decoders by `code_hash` in this configuration, if not found, cache will be used instead

#
# DOB/0
#
[[onchain_decoder_deployment]]
code_hash = "0xb82abd59ade361a014f0abb692f71b0feb880693c3ccb95b9137b73551d872ce"
tx_hash = "0xb2497dc3e616055125ef8276be7ee21986d2cd4b2ce90992725386cabcb6ea7f"
Expand All @@ -66,3 +79,11 @@ out_index = 0
code_hash = "0x13cac78ad8482202f18f9df4ea707611c35f994375fa03ae79121312dda9925c"
tx_hash = "0x4a8a0d079f8438bed89e0ece1b14e67ab68e2aa7688a5f4917a59a185e0f8fd5"
out_index = 0

#
# DOB/1
#
[[onchain_decoder_deployment]]
code_hash = "0xac35b0e6178dc4a89fb85194b4ac0b60eed2b6ce9f10bf7bf2ee76190c3a0071"
tx_hash = "0x1918a656f7c52ca5fbe7a903903c9bbc89d3e05525b4bb9f323fceb1a5bde51f"
out_index = 0
172 changes: 170 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#![allow(clippy::assigning_clones)]

use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
Expand All @@ -6,7 +9,9 @@ use std::sync::Arc;
use ckb_jsonrpc_types::{CellWithStatus, JsonBytes, OutPoint, Uint32};
use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey};
use jsonrpc_core::futures::FutureExt;
use lazy_regex::regex_replace_all;
use reqwest::{Client, Url};
use serde_json::Value;

use crate::types::Error;

Expand Down Expand Up @@ -77,9 +82,7 @@ impl RpcClient {
id: Arc::new(AtomicU64::new(0)),
}
}
}

impl RpcClient {
pub fn get_live_cell(&self, out_point: &OutPoint, with_data: bool) -> Rpc<CellWithStatus> {
jsonrpc!(
"get_live_cell",
Expand Down Expand Up @@ -114,3 +117,168 @@ impl RpcClient {
.boxed()
}
}

pub struct ImageFetchClient {
base_url: HashMap<String, Url>,
images_cache: VecDeque<(Url, Vec<u8>)>,
max_cache_size: usize,
}

impl ImageFetchClient {
pub fn new(base_url: &HashMap<String, String>, cache_size: usize) -> Self {
let base_url = base_url
.iter()
.map(|(k, v)| (k.clone(), Url::parse(v).expect("url")))
.collect::<HashMap<_, _>>();
Self {
base_url,
images_cache: VecDeque::new(),
max_cache_size: cache_size,
}
}

pub async fn fetch_images(&mut 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");
let cached_image = self.images_cache.iter().find(|(v, _)| v == &url);
if let Some((_, image)) = cached_image {
requests.push(async { Ok((url, true, image.clone())) }.boxed());
} else {
requests.push(
async move {
let image = parse_image_from_btcfs(&url, index).await?;
Ok((url, false, image))
}
.boxed(),
);
}
}
URI::IPFS(cid) => {
let url = self
.base_url
.get("ipfs")
.ok_or(Error::FsuriNotFoundInConfig)?
.join(&cid)
.expect("image url");
let cached_image = self.images_cache.iter().find(|(v, _)| v == &url);
if let Some((_, image)) = cached_image {
requests.push(async { Ok((url, true, image.clone())) }.boxed());
} else {
requests.push(
async move {
let image = reqwest::get(url.clone())
.await
.map_err(|_| Error::FetchFromIpfsError)?
.bytes()
.await
.map_err(|_| Error::FetchFromIpfsError)?
.to_vec();
Ok((url, false, image))
}
.boxed(),
);
}
}
}
}
let mut images = vec![];
let responses = futures::future::join_all(requests).await;
for response in responses {
let (url, from_cache, result) = response?;
images.push(result.to_vec());
if !from_cache {
self.images_cache.push_back((url, result));
if self.images_cache.len() > self.max_cache_size {
self.images_cache.pop_front();
}
}
}
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 uri.starts_with("btcfs://") {
let body = uri.chars().skip("btcfs://".len()).collect::<String>();
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 uri.starts_with("ipfs://") {
let hash = uri.chars().skip("ipfs://".len()).collect::<String>();
Ok(URI::IPFS(hash))
} else {
Err(Error::InvalidOnchainFsuriFormat)
}
}
}

async fn parse_image_from_btcfs(url: &Url, index: usize) -> Result<Vec<u8>, Error> {
// parse btc transaction
let btc_tx = reqwest::get(url.clone())
.await
.map_err(|_| Error::FetchFromBtcNodeError)?
.json::<Value>()
.await
.map_err(|_| Error::FetchFromBtcNodeError)?;
let vin = btc_tx
.get("vin")
.ok_or(Error::InvalidBtcTransactionFormat)?
.as_array()
.ok_or(Error::InvalidBtcTransactionFormat)?
.first()
.ok_or(Error::InvalidBtcTransactionFormat)?;
let mut witness = vin
.get("inner_witnessscript_asm")
.ok_or(Error::InvalidBtcTransactionFormat)?
.as_str()
.ok_or(Error::InvalidBtcTransactionFormat)?
.to_owned();

// parse inscription body
let mut images = vec![];
let header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 ";
while let (Some(start), Some(end)) = (witness.find("OP_IF"), witness.find("OP_ENDIF")) {
let inscription = &witness[start..end + "OP_ENDIF".len()];
if !inscription.contains(header) {
return Err(Error::InvalidInscriptionFormat);
}
let base_removed = inscription.replace(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);
witness = witness[end + "OP_ENDIF".len()..].to_owned();
}
if images.is_empty() {
return Err(Error::EmptyInscriptionContent);
}

let image = images
.get(index)
.cloned()
.ok_or(Error::ExceededInscriptionIndex)?;
Ok(image)
}
Loading