Skip to content
Merged
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
17 changes: 16 additions & 1 deletion crates/snapshot-load/src/atomicassets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub fn all_collections() -> Vec<&'static str> {
pub struct SchemaRegistry {
schemas: HashMap<String, HashMap<String, Vec<atomicdata::Field>>>,
collection_format: Vec<atomicdata::Field>,
/// The contract `version` from the `tokenconfigs` table (v2). Empty on pre-v2 chains that lack it
/// — `map_config` then omits the field rather than emitting an invalid `""`.
version: String,
}

impl SchemaRegistry {
Expand Down Expand Up @@ -114,6 +117,7 @@ pub fn build_schema_registry(
let aa = nm("atomicassets")?;
let schemas_t = nm("schemas")?;
let config_t = nm("config")?;
let tokenconfigs_t = nm("tokenconfigs")?;

let mut reg = SchemaRegistry::default();

Expand All @@ -131,6 +135,7 @@ pub fn build_schema_registry(
filters: vec![
Filter::CodeTable(aa, schemas_t),
Filter::CodeTable(aa, config_t),
Filter::CodeTable(aa, tokenconfigs_t),
],
};

Expand Down Expand Up @@ -165,6 +170,11 @@ pub fn build_schema_registry(
) {
reg.collection_format = fields;
}
} else if row.table == tokenconfigs_t {
// tokenconfigs (v2) → the contract `version` the API's /config reports.
if let Some(v) = data.get("version").and_then(Value::as_str) {
reg.version = v.to_string();
}
}
Ok(())
};
Expand Down Expand Up @@ -339,9 +349,14 @@ pub fn map_offer(ctx: &RowCtx, data: Value) -> Value {

/// `config` singleton → config doc. Counters are live on-chain (S); `supported_tokens` is flattened to
/// `{token_contract, token_symbol, token_precision}`.
pub fn map_config(ctx: &RowCtx, data: Value) -> Value {
pub fn map_config(ctx: &RowCtx, data: Value, reg: &SchemaRegistry) -> Value {
let mut doc = Map::new();
doc.insert("contract".into(), json!(ctx.code));
// `version` comes from the `tokenconfigs` table (read in the pre-pass), NOT the `config` row.
// Omit it entirely when the chain has no tokenconfigs (pre-v2) rather than emit an invalid "".
if !reg.version.is_empty() {
doc.insert("version".into(), json!(reg.version));
}
for f in [
"asset_counter",
"template_counter",
Expand Down
2 changes: 1 addition & 1 deletion crates/snapshot-load/src/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ pub fn map_row(
)),
("atomicassets", "config") => Some((
atomicassets::COLL_AA_CONFIG,
atomicassets::map_config(ctx, data),
atomicassets::map_config(ctx, data, schema_reg),
)),
// AtomicMarket state (the `atomicmarket`/`atomic` preset).
("atomicmarket", "sales") => Some((
Expand Down
109 changes: 109 additions & 0 deletions crates/wseg-build/src/aa_binfmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,92 @@ pub fn decode_template(b: &[u8]) -> (i32, u64, Vec<Attr>) {
(template_id, schema, immutable)
}

// ── config singleton (TABLE_AA_CONFIG) ─────────────────────────────────────────────────────────────
/// Encode the AtomicAssets config singleton. `supported_tokens` = (token_contract name, symbol,
/// precision). Layout: `version | contract(u64) | u16 ver_len | ver | u16 fmt_len | <schema-format blob>
/// | u16 n_tokens | (u64 token_contract, u8 sym_len, sym, u8 precision) × n`.
pub fn encode_config(
contract: u64,
version: &str,
collection_format: &[(String, String)],
supported_tokens: &[(u64, String, i64)],
) -> Vec<u8> {
let mut o = Vec::new();
o.push(ASSET_VERSION);
pu64(&mut o, contract);
let vb = version.as_bytes();
let vlen = vb.len().min(u16::MAX as usize);
pu16(&mut o, vlen as u16);
o.extend_from_slice(&vb[..vlen]);
let fmt = encode_schema_format(collection_format);
// Cap the length prefix AND the appended bytes together so the header can never desync the body.
let flen = fmt.len().min(u16::MAX as usize);
pu16(&mut o, flen as u16);
o.extend_from_slice(&fmt[..flen]);
let ntok = supported_tokens.len().min(u16::MAX as usize);
pu16(&mut o, ntok as u16);
for (tc, sym, prec) in supported_tokens.iter().take(ntok) {
pu64(&mut o, *tc);
let sb = sym.as_bytes();
let sl = sb.len().min(255);
o.push(sl as u8);
o.extend_from_slice(&sb[..sl]);
o.push(*prec as u8);
}
Comment on lines +397 to +411
o
}
Comment on lines +384 to +413
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

There are two potential serialization/deserialization mismatch bugs in encode_config:

  1. The collection_format encoded blob (fmt) has its length prefix capped at u16::MAX, but the entire fmt vector is appended to the output buffer. If fmt.len() > u16::MAX, this will cause the decoder to desynchronize and corrupt subsequent fields.
  2. The supported_tokens length prefix is cast to u16 without capping, but the entire list is serialized. If supported_tokens.len() > u16::MAX, the length prefix will truncate (wrap around), but all tokens will still be written, leading to a decoder desynchronization.

We should cap both the length prefixes and the serialized slices/iterators to u16::MAX.

pub fn encode_config(
    contract: u64,
    version: &str,
    collection_format: &[(String, String)],
    supported_tokens: &[(u64, String, i64)],
) -> Vec<u8> {
    let mut o = Vec::new();
    o.push(ASSET_VERSION);
    pu64(&mut o, contract);
    let vb = version.as_bytes();
    let vlen = vb.len().min(u16::MAX as usize);
    pu16(&mut o, vlen as u16);
    o.extend_from_slice(&vb[..vlen]);
    let fmt = encode_schema_format(collection_format);
    let flen = fmt.len().min(u16::MAX as usize);
    pu16(&mut o, flen as u16);
    o.extend_from_slice(&fmt[..flen]);
    let num_tokens = supported_tokens.len().min(u16::MAX as usize);
    pu16(&mut o, num_tokens as u16);
    for (tc, sym, prec) in supported_tokens.iter().take(num_tokens) {
        pu64(&mut o, *tc);
        let sb = sym.as_bytes();
        let sl = sb.len().min(255);
        o.push(sl as u8);
        o.extend_from_slice(&sb[..sl]);
        o.push(*prec as u8);
    }
    o
}


/// Decoded config singleton: `(contract, version, collection_format, supported_tokens)`,
/// where each supported token is `(token_contract, symbol, precision)`.
pub type DecodedConfig = (u64, String, Vec<(String, String)>, Vec<(u64, String, u8)>);

/// Decode the config singleton. Fully bounds-checked — a truncated/corrupt blob returns `None`
/// instead of panicking.
pub fn decode_config(b: &[u8]) -> Option<DecodedConfig> {
if b.is_empty() {
return None;
}
let mut p = 1usize;
if b.len() < p + 8 {
return None;
}
let contract = gu64(b, &mut p);
if b.len() < p + 2 {
return None;
}
let vlen = gu16(b, &mut p) as usize;
if b.len() < p + vlen + 2 {
return None;
}
let version = String::from_utf8_lossy(&b[p..p + vlen]).into_owned();
p += vlen;
let fmt_len = gu16(b, &mut p) as usize;
if b.len() < p + fmt_len + 2 {
return None;
}
let collection_format = decode_schema_format(&b[p..p + fmt_len]);
p += fmt_len;
let n = gu16(b, &mut p) as usize;
let mut tokens = Vec::with_capacity(n);
for _ in 0..n {
if b.len() < p + 9 {
return None;
}
let tc = gu64(b, &mut p);
let sl = b[p] as usize;
p += 1;
if b.len() < p + sl + 1 {
return None;
}
let sym = String::from_utf8_lossy(&b[p..p + sl]).into_owned();
p += sl;
let prec = b[p];
p += 1;
tokens.push((tc, sym, prec));
}
Some((contract, version, collection_format, tokens))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -506,4 +592,27 @@ mod tests {
assert_eq!(schema, crate::name::encode("pokemon"));
assert_eq!(attrs, immutable);
}

#[test]
fn config_round_trips() {
let fmt = vec![
("name".to_string(), "string".to_string()),
("img".to_string(), "ipfs".to_string()),
];
let tokens = vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4i64)];
let blob = encode_config(crate::name::encode("atomicassets"), "1.2.0", &fmt, &tokens);
let (c, v, f, t) = decode_config(&blob).expect("decode config");
assert_eq!(c, crate::name::encode("atomicassets"));
assert_eq!(v, "1.2.0");
assert_eq!(f, fmt);
assert_eq!(
t,
vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4u8)]
);

// truncated/empty blobs return None instead of panicking (bounds-checked decoder).
assert!(decode_config(&[]).is_none());
assert!(decode_config(&blob[..blob.len() - 1]).is_none());
assert!(decode_config(&blob[..3]).is_none());
}
Comment on lines +597 to +617
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Update the test to handle the new Option return type of decode_config.

Suggested change
fn config_round_trips() {
let fmt = vec![
("name".to_string(), "string".to_string()),
("img".to_string(), "ipfs".to_string()),
];
let tokens = vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4i64)];
let blob = encode_config(crate::name::encode("atomicassets"), "1.2.0", &fmt, &tokens);
let (c, v, f, t) = decode_config(&blob);
assert_eq!(c, crate::name::encode("atomicassets"));
assert_eq!(v, "1.2.0");
assert_eq!(f, fmt);
assert_eq!(t, vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4u8)]);
}
fn config_round_trips() {
let fmt = vec![
("name".to_string(), "string".to_string()),
("img".to_string(), "ipfs".to_string()),
];
let tokens = vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4i64)];
let blob = encode_config(crate::name::encode("atomicassets"), "1.2.0", &fmt, &tokens);
let (c, v, f, t) = decode_config(&blob).expect("failed to decode config");
assert_eq!(c, crate::name::encode("atomicassets"));
assert_eq!(v, "1.2.0");
assert_eq!(f, fmt);
assert_eq!(t, vec![(crate::name::encode("eosio.token"), "EOS".to_string(), 4u8)]);
}

}
62 changes: 61 additions & 1 deletion crates/wseg-build/src/aa_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::collections::HashMap;
use mongodb::bson::{Bson, Document};

use crate::aa_binfmt::{
encode_asset, encode_posting_hybrid, encode_schema_format, encode_template, Attr,
encode_asset, encode_config, encode_posting_hybrid, encode_schema_format, encode_template, Attr,
};
use crate::aa_tables::*;
use crate::name;
Expand Down Expand Up @@ -67,6 +67,8 @@ pub struct AtomicBuilder {
all_ids: Vec<u64>,
/// Data-attribute fields to inverted-index (low-cardinality facets; default `["rarity"]`).
data_fields: Vec<String>,
/// The config singleton blob (`/v1/config`), set by the `atomicassets-config` doc.
config: Option<Vec<u8>>,
stats: AaStats,
}

Expand All @@ -90,20 +92,50 @@ impl AtomicBuilder {
by_data: HashMap::new(),
all_ids: Vec::new(),
data_fields,
config: None,
stats: AaStats::default(),
}
}

/// Dispatch one Mongo doc by collection. Unknown collections are ignored.
pub fn push(&mut self, coll: &str, d: &Document) {
match coll {
"atomicassets-config" => self.push_config(d),
"atomicassets-schemas" => self.push_schema(d),
"atomicassets-templates" => self.push_template(d),
"atomicassets-assets" => self.push_asset(d),
_ => {}
}
}

/// `atomicassets-config` (singleton) → the config blob served at `/v1/config`.
fn push_config(&mut self, d: &Document) {
let contract = doc_str(d, "contract").map(name::encode).unwrap_or(0);
let version = doc_str(d, "version").unwrap_or("").to_string();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid emitting a blank config version

In the Docker/bootstrap path added here, aa-build reads atomicassets-config documents created by snapshot-load --tables atomic; I checked crates/snapshot-load/src/atomicassets.rs::map_config, and that producer writes contract, counters, collection_format, supported_tokens, and block_num, but never a version field. As a result every real segment built by this flow falls through to "", so /atomicassets/v1/config serves an empty version even though the binary format and API payload now claim to carry it; this should be sourced from server/contract config or deliberately omitted rather than silently encoding an invalid value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — sourced properly rather than emitting a blank.

653c71a: the snapshot-load schema-registry pre-pass now also reads atomicassets:tokenconfigs (the v2 {standard, version} table where the contract version actually lives — not the config row) and stashes it on SchemaRegistry; map_config emits version when present and omits the field on pre-v2 chains that lack tokenconfigs, rather than encoding "". 756ed87 (companion serve PR) makes /config drop the field when empty too.

Validated on Jungle 4: tokenconfigs.version = "1.3.1" → the Mongo config doc and /atomicassets/v1/config now report "1.3.1".

let mut fmt: Vec<(String, String)> = Vec::new();
if let Ok(arr) = d.get_array("collection_format") {
for f in arr {
if let Bson::Document(fd) = f {
if let (Some(n), Some(t)) = (doc_str(fd, "name"), doc_str(fd, "type")) {
fmt.push((n.to_string(), t.to_string()));
}
}
}
}
let mut tokens: Vec<(u64, String, i64)> = Vec::new();
if let Ok(arr) = d.get_array("supported_tokens") {
for t in arr {
if let Bson::Document(td) = t {
let tc = doc_str(td, "token_contract").map(name::encode).unwrap_or(0);
let sym = doc_str(td, "token_symbol").unwrap_or("").to_string();
let prec = doc_i64(td, "token_precision").unwrap_or(0);
tokens.push((tc, sym, prec));
}
}
}
self.config = Some(encode_config(contract, &version, &fmt, &tokens));
}

fn push_schema(&mut self, d: &Document) {
let (Some(coll), Some(sch)) = (doc_str(d, "collection_name"), doc_str(d, "schema_name"))
else {
Expand Down Expand Up @@ -355,6 +387,19 @@ impl AtomicBuilder {
});
tables.push(tmpl_sorted);

// config singleton (one sentinel-keyed entry), if the `atomicassets-config` doc was seen.
if let Some(cfg) = self.config.take() {
tables.push(Table {
Comment on lines +391 to +392
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve config during compaction

When a segment that contains the new TABLE_AA_CONFIG is later compacted, aa_live::compact_with rebuilds the output through AtomicBuilder::new() and only replays schemas, templates, and assets; there is no raw config setter/carry-over path, so self.config remains None and this branch omits the config table from the compacted segment. In deployments that use the live overlay/compaction path, /atomicassets/v1/config will work on the initial snapshot-built segment but disappear after the first compaction.

Useful? React with 👍 / 👎.

table_id: TABLE_AA_CONFIG,
index: vec![IndexEntry {
key: SENTINEL_KEY,
off: 0,
len: cfg.len() as u32,
}],
arena: cfg,
});
}
Comment on lines +390 to +401

self.stats.bytes = tables.iter().map(|t| t.arena.len() as u64).sum();
write_segment(out, tables)?;
Ok(self.stats)
Expand Down Expand Up @@ -402,6 +447,14 @@ fn doc_u32(d: &Document, k: &str) -> u32 {
_ => 0,
}
}
fn doc_i64(d: &Document, k: &str) -> Option<i64> {
match d.get(k) {
Some(Bson::Int32(i)) => Some(*i as i64),
Some(Bson::Int64(i)) => Some(*i),
Some(Bson::Double(f)) => Some(*f as i64),
_ => None,
}
}

/// Canonical string form of a bson value, matching what the API filters on.
fn bson_canon(b: &Bson) -> String {
Expand All @@ -428,6 +481,13 @@ mod tests {
#[test]
fn builds_a_tiny_segment() {
let mut b = AtomicBuilder::new(vec!["rarity".to_string()]);
// config singleton flows through the builder (push_config → finish() emit) without panicking.
b.push(
"atomicassets-config",
&doc! { "contract": "atomicassets",
"collection_format": [ {"name":"name","type":"string"} ],
"supported_tokens": [ {"token_contract":"eosio.token","token_symbol":"WAX","token_precision":8i32} ] },
);
b.push(
"atomicassets-schemas",
&doc! { "collection_name": "col", "schema_name": "sch",
Expand Down
5 changes: 5 additions & 0 deletions crates/wseg-build/src/aa_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub const TABLE_AA_COLL_FWD: u32 = 20;
/// by `(template_mint, asset_id)` — the materialized "sort by mint" ordering, reconstructed from the
/// snapshot (rank within each template) so a history-looking sort stays sub-µs (no Elasticsearch).
pub const TABLE_AA_SORTED_TMPL: u32 = 21;
/// Config singleton: sentinel key [`SENTINEL_KEY`], blob = contract / version / collection_format /
/// supported_tokens (the `/atomicassets/v1/config` payload).
pub const TABLE_AA_CONFIG: u32 = 22;

/// The single-entry key used by presorted-ordering tables (one blob, looked up by a fixed key).
pub const SENTINEL_KEY: u64 = 0;
Expand Down Expand Up @@ -159,6 +162,8 @@ mod tests {
TABLE_AA_SORTED_ID,
TABLE_AA_TMPL_FWD,
TABLE_AA_COLL_FWD,
TABLE_AA_SORTED_TMPL,
TABLE_AA_CONFIG,
];
let mut seen = std::collections::HashSet::new();
for id in ids {
Expand Down
4 changes: 3 additions & 1 deletion crates/wseg-build/src/bin/aa_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ async fn main() -> Result<()> {

let t0 = Instant::now();
let mut b = AtomicBuilder::new(fields);
// schemas first (full doc — need `format`), then templates, then assets (projected).
// config first (singleton — contract/collection_format/supported_tokens), then schemas (need
// `format`), then templates, then assets (projected).
stream(&db, "atomicassets-config", doc! {}, 0, &mut b, t0).await?;
stream(&db, "atomicassets-schemas", doc! {}, 0, &mut b, t0).await?;
stream(
&db,
Expand Down
24 changes: 24 additions & 0 deletions deploy/atomicassets/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copy to .env and adjust. `docker compose` auto-loads .env.

# Host directory that CONTAINS the Jungle 4 snapshot .bin (mounted read-only at /snap).
# The WSL node keeps it at ~/chains/jungle (e.g. snapshot-…-jungle4-v8-0269541093.bin).
# Point this at a path your Docker host can bind-mount (a Windows path, or copy the .bin here).
SNAPSHOT_DIR=./snapshot

# Chain name (Mongo db = <MONGO_PREFIX>_<CHAIN>; snapshot-load derives block_num from the filename).
CHAIN=jungle4
MONGO_PREFIX=hyperion

# Comma-separated decoded data-attribute facets to inverted-index in the segment.
AA_DATA_FIELDS=rarity

# Cap assets streamed into the segment for a fast first pass (0 = all).
ASSET_LIMIT=0

# Host port mappings.
MONGO_PORT=27018
WORMDB_GATEWAY_PORT=6390
WORMDB_WIRE_PORT=6389

# Mongo WiredTiger cache (GiB).
MONGO_CACHE_GB=2
5 changes: 5 additions & 0 deletions deploy/atomicassets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Out-of-band build output (the wormdb binary) + local snapshot + env.
bin/
snapshot/
.env
*.wseg
17 changes: 17 additions & 0 deletions deploy/atomicassets/Dockerfile.tools
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Rust tools image: snapshot-load (snapshot -> Mongo) + aa-build (Mongo -> .wseg).
# Build context = hyperion-tools repo root (the cargo workspace).
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
# Pure-Rust deps (rs_abieos rust-backend, rustls, ruzstd, mongodb driver) -> no C toolchain needed.
RUN cargo build --release -p snapshot-load \
&& cargo build --release -p wseg-build --bin aa-build

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates bash coreutils findutils && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/snapshot-load /usr/local/bin/snapshot-load
COPY --from=builder /build/target/release/aa-build /usr/local/bin/aa-build
COPY deploy/atomicassets/entrypoint-loader.sh /entrypoint-loader.sh
RUN chmod +x /entrypoint-loader.sh
11 changes: 11 additions & 0 deletions deploy/atomicassets/Dockerfile.wormdb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Runtime image for the wormdb-server binary (lightapi + atomicassets domains composed).
# The binary is built OUT-OF-BAND by ./build-wormdb.sh (a 4-repo Zig build) into ./bin/wormdb,
# then copied onto a slim runtime here — same pattern as the Light-API preview release.
# Build context = hyperion-tools repo root.
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates bash curl libsodium23 && rm -rf /var/lib/apt/lists/*
COPY deploy/atomicassets/bin/wormdb /usr/local/bin/wormdb
COPY deploy/atomicassets/wormdb.json /etc/wormdb.json
COPY deploy/atomicassets/entrypoint-server.sh /entrypoint-server.sh
RUN chmod +x /usr/local/bin/wormdb /entrypoint-server.sh
Loading
Loading