diff --git a/Makefile.am b/Makefile.am index 9f5b7bed..c03a4091 100644 --- a/Makefile.am +++ b/Makefile.am @@ -66,6 +66,7 @@ src_libbitcoin_server_la_SOURCES = \ src/protocols/native/protocol_native_address.cpp \ src/protocols/native/protocol_native_block.cpp \ src/protocols/native/protocol_native_input.cpp \ + src/protocols/native/protocol_native_inscription.cpp \ src/protocols/native/protocol_native_output.cpp \ src/protocols/native/protocol_native_tx.cpp \ src/protocols/stratum_v1/protocol_stratum_v1.cpp diff --git a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj index feb21832..c614a59b 100644 --- a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj +++ b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj @@ -148,6 +148,7 @@ + diff --git a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj index bd7a8a8c..8e160fae 100644 --- a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj +++ b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj @@ -148,6 +148,7 @@ + diff --git a/include/bitcoin/server/interfaces/native.hpp b/include/bitcoin/server/interfaces/native.hpp index 5efb5e57..40805491 100644 --- a/include/bitcoin/server/interfaces/native.hpp +++ b/include/bitcoin/server/interfaces/native.hpp @@ -72,7 +72,9 @@ struct native_methods // TODO: move to admin interface (security). method<"log_subscribe", uint8_t, uint8_t>{ "version", "media" }, - method<"event_subscribe", uint8_t, uint8_t>{ "version", "media" } + method<"event_subscribe", uint8_t, uint8_t>{ "version", "media" }, + + method<"inscription", uint8_t, uint8_t, system::hash_cptr, uint32_t>{ "version", "media", "hash", "index" } }; template @@ -126,6 +128,8 @@ struct native_methods // TODO: move to admin interface (security). using log_subscribe = at<33>; using event_subscribe = at<34>; + + using inscription = at<35>; }; /// ?format=data|text|json (via query string). diff --git a/include/bitcoin/server/protocols/protocol_html.hpp b/include/bitcoin/server/protocols/protocol_html.hpp index 3e530b4d..88d7f35d 100644 --- a/include/bitcoin/server/protocols/protocol_html.hpp +++ b/include/bitcoin/server/protocols/protocol_html.hpp @@ -65,6 +65,9 @@ class BCS_API protocol_html const network::http::request& request={}) NOEXCEPT; virtual void send_chunk(system::data_chunk&& bytes, const network::http::request& request={}) NOEXCEPT; + virtual void send_typed_chunk(system::data_chunk&& bytes, + network::http::media_type type, + const network::http::request& request={}) NOEXCEPT; virtual void send_file(network::http::file&& file, network::http::media_type type, const network::http::request& request={}) NOEXCEPT; diff --git a/include/bitcoin/server/protocols/protocol_native.hpp b/include/bitcoin/server/protocols/protocol_native.hpp index 5c3cafed..80cb3980 100644 --- a/include/bitcoin/server/protocols/protocol_native.hpp +++ b/include/bitcoin/server/protocols/protocol_native.hpp @@ -177,6 +177,10 @@ class BCS_API protocol_native bool handle_get_event_subscribe(const code& ec, interface::event_subscribe, uint8_t version, uint8_t media) NOEXCEPT; + bool handle_get_inscription(const code& ec, interface::inscription, + uint8_t version, uint8_t media, const system::hash_cptr& hash, + uint32_t index) NOEXCEPT; + private: using media_type = network::http::media_type; static constexpr uint8_t text = to_value(media_type::text_plain); diff --git a/src/parsers/native_target.cpp b/src/parsers/native_target.cpp index 98448854..72afd769 100644 --- a/src/parsers/native_target.cpp +++ b/src/parsers/native_target.cpp @@ -318,6 +318,26 @@ code native_target(request_t& out, const std::string_view& path) NOEXCEPT return error::invalid_component; } } + else if (target == "inscription") + { + if (segment == segments.size()) + return error::missing_hash; + + const auto hash = to_hash(segments[segment++]); + if (!hash) return error::invalid_hash; + + params["hash"] = hash; + if (segment == segments.size()) + return error::missing_position; + + const auto component = segments[segment++]; + uint32_t index{}; + if (!to_number(index, component)) + return error::invalid_number; + + params["index"] = index; + method = "inscription"; + } else { return error::invalid_target; diff --git a/src/protocols/native/protocol_native.cpp b/src/protocols/native/protocol_native.cpp index 939cf34b..c420c94e 100644 --- a/src/protocols/native/protocol_native.cpp +++ b/src/protocols/native/protocol_native.cpp @@ -93,6 +93,9 @@ void protocol_native::start() NOEXCEPT // Admin endpoint methods (TODO: move to admin interface). SUBSCRIBE_NATIVE(handle_get_log_subscribe, _1, _2, _3, _4); SUBSCRIBE_NATIVE(handle_get_event_subscribe, _1, _2, _3, _4); + + // Inscription methods. + SUBSCRIBE_NATIVE(handle_get_inscription, _1, _2, _3, _4, _5, _6); protocol_html::start(); } diff --git a/src/protocols/native/protocol_native_inscription.cpp b/src/protocols/native/protocol_native_inscription.cpp new file mode 100644 index 00000000..1bca3617 --- /dev/null +++ b/src/protocols/native/protocol_native_inscription.cpp @@ -0,0 +1,288 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include + +#include +#include +#include +#include +#include + +namespace libbitcoin { +namespace server { + +using namespace system; +using namespace network::http; + +BC_PUSH_WARNING(NO_INCOMPLETE_SWITCH) +BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT) + +// Inscription parsing helpers. +// ---------------------------------------------------------------------------- +// private/static + +// Inscription mark bytes: OP_FALSE OP_IF OP_PUSHBYTES_3 "ord" +// Matches ordpool-parser's INSCRIPTION_MARK constant. +// https://github.com/ordpool-space/ordpool-parser +static constexpr uint8_t inscription_mark[] = + { 0x00, 0x63, 0x03, 0x6f, 0x72, 0x64 }; +static constexpr size_t inscription_mark_size = 6; + +// Field tags per the Ordinals protocol. +static constexpr uint8_t field_content_type = 0x01; +static constexpr uint8_t field_content_encoding = 0x09; + +// OP_0 / separator and OP_ENDIF byte values. +static constexpr uint8_t op_0 = 0x00; +static constexpr uint8_t op_endif = 0x68; + +struct inscription_t +{ + std::string content_type; + std::string content_encoding; // optional, e.g. "br" or "gzip" + data_chunk body; +}; + +// Read one pushdata operation at position p; advances p past the push. +// Returns data bytes for the push, or nullopt for non-pushdata opcodes. +static std::optional read_pushdata(const data_chunk& raw, + size_t& p) NOEXCEPT +{ + if (p >= raw.size()) + return std::nullopt; + + const auto op = static_cast(raw[p++]); + + // push_size_0..push_size_75: opcode value is the byte count. + if (op <= 0x4b) + { + if (p + op > raw.size()) return std::nullopt; + data_chunk out(raw.begin() + p, raw.begin() + p + op); + p += op; + return out; + } + + // OP_PUSHDATA1 (0x4c): next byte is size. + if (op == 0x4c) + { + if (p >= raw.size()) return std::nullopt; + const size_t size = raw[p++]; + if (p + size > raw.size()) return std::nullopt; + data_chunk out(raw.begin() + p, raw.begin() + p + size); + p += size; + return out; + } + + // OP_PUSHDATA2 (0x4d): next 2 bytes LE is size. + if (op == 0x4d) + { + if (p + 2 > raw.size()) return std::nullopt; + const size_t size = raw[p] | (static_cast(raw[p + 1]) << 8); + p += 2; + if (p + size > raw.size()) return std::nullopt; + data_chunk out(raw.begin() + p, raw.begin() + p + size); + p += size; + return out; + } + + // OP_PUSHDATA4 (0x4e): next 4 bytes LE is size. + if (op == 0x4e) + { + if (p + 4 > raw.size()) return std::nullopt; + const size_t size = + raw[p] | + (static_cast(raw[p + 1]) << 8) | + (static_cast(raw[p + 2]) << 16) | + (static_cast(raw[p + 3]) << 24); + p += 4; + if (p + size > raw.size()) return std::nullopt; + data_chunk out(raw.begin() + p, raw.begin() + p + size); + p += size; + return out; + } + + // Non-pushdata opcode: back up and return nullopt. + --p; + return std::nullopt; +} + +// Search raw script bytes for the inscription mark. +// Returns the offset of the mark, or raw.size() if not found. +static size_t find_inscription_mark(const data_chunk& raw) NOEXCEPT +{ + if (raw.size() < inscription_mark_size) + return raw.size(); + + for (size_t i = 0; i <= raw.size() - inscription_mark_size; ++i) + { + if (std::memcmp(raw.data() + i, inscription_mark, + inscription_mark_size) == 0) + return i; + } + return raw.size(); +} + +static std::optional parse_inscription( + const data_chunk& script_bytes) NOEXCEPT +{ + const auto mark = find_inscription_mark(script_bytes); + if (mark >= script_bytes.size()) + return std::nullopt; + + // Advance past OP_FALSE OP_IF OP_PUSHBYTES_3 "ord". + size_t p = mark + inscription_mark_size; + + inscription_t result; + + // Parse field-value pairs until OP_0 separator or OP_ENDIF. + while (p < script_bytes.size()) + { + const auto peek = script_bytes[p]; + if (peek == op_0) { ++p; break; } // separator: body follows + if (peek == op_endif) return result; // no body + + const auto tag = read_pushdata(script_bytes, p); + if (!tag.has_value()) return result; + + const auto val = read_pushdata(script_bytes, p); + if (!val.has_value()) return result; + + if (tag->size() == 1) + { + switch ((*tag)[0]) + { + case field_content_type: + result.content_type = + std::string(val->begin(), val->end()); + break; + case field_content_encoding: + result.content_encoding = + std::string(val->begin(), val->end()); + break; + default: + break; + } + } + } + + // Collect body chunks until OP_ENDIF. + while (p < script_bytes.size()) + { + if (script_bytes[p] == op_endif) break; + + const auto chunk = read_pushdata(script_bytes, p); + if (!chunk.has_value()) break; + result.body.insert(result.body.end(), chunk->begin(), chunk->end()); + } + + if (result.content_type.empty() && result.body.empty()) + return std::nullopt; + + return result; +} + +// Extract the tapscript leaf from a taproot input witness (BIP-341). +// Returns nullptr if the witness doesn't look like a tapscript spend. +static const data_chunk* tapscript_from_witness( + const chain::witness& witness) NOEXCEPT +{ + const auto& stack = witness.stack(); + if (stack.size() < 2) + return nullptr; + + const bool has_annex = chain::annex::is_annex_pattern(stack); + + const size_t effective = stack.size() - (has_annex ? 1 : 0); + if (effective < 2) + return nullptr; + + // Tapscript is second-to-last (effective index = effective - 2). + return stack[effective - 2].get(); +} + +// Handler. +// ---------------------------------------------------------------------------- + +bool protocol_native::handle_get_inscription(const code& ec, + interface::inscription, uint8_t, uint8_t media, + const hash_cptr& hash, uint32_t index) NOEXCEPT +{ + if (stopped(ec)) + return false; + + const auto& query = archive(); + const auto witness = query.get_witness( + query.to_point(query.to_tx(*hash), index)); + + if (is_null(witness) || !witness->is_valid()) + { + send_not_found(); + return true; + } + + const auto* script_bytes = tapscript_from_witness(*witness); + if (script_bytes == nullptr) + { + send_not_found(); + return true; + } + + const auto inscription = parse_inscription(*script_bytes); + if (!inscription.has_value()) + { + send_not_found(); + return true; + } + + switch (media) + { + case data: + { + // Serve raw content bytes with the inscription's content-type. + const auto type = content_media_type(inscription->content_type, + media_type::application_octet_stream); + send_typed_chunk(data_chunk{ inscription->body }, type); + return true; + } + case text: + send_text(encode_base16(inscription->body)); + return true; + case json: + { + boost::json::object object; + object.emplace("content_type", inscription->content_type); + object.emplace("content_base64", encode_base64(inscription->body)); + object.emplace("size", inscription->body.size()); + if (!inscription->content_encoding.empty()) + object.emplace("content_encoding", inscription->content_encoding); + + send_json(std::move(object), two * inscription->body.size() + 128); + return true; + } + } + + send_not_found(); + return true; +} + +BC_POP_WARNING() +BC_POP_WARNING() + +} // namespace server +} // namespace libbitcoin diff --git a/src/protocols/native/protocol_native_output.cpp b/src/protocols/native/protocol_native_output.cpp index 4b6f0e13..3cbacba3 100644 --- a/src/protocols/native/protocol_native_output.cpp +++ b/src/protocols/native/protocol_native_output.cpp @@ -205,6 +205,7 @@ bool protocol_native::handle_get_output_subscribe(const code& ec, return {}; } + BC_POP_WARNING() BC_POP_WARNING() diff --git a/src/protocols/protocol_html.cpp b/src/protocols/protocol_html.cpp index 26e04e61..0c0ea361 100644 --- a/src/protocols/protocol_html.cpp +++ b/src/protocols/protocol_html.cpp @@ -217,6 +217,19 @@ void protocol_html::send_chunk(system::data_chunk&& bytes, SEND(std::move(response), handle_complete, _1, error::success); } +void protocol_html::send_typed_chunk(system::data_chunk&& bytes, + media_type type, const request& request) NOEXCEPT +{ + BC_ASSERT(stranded()); + response response{ status::ok, request.version() }; + add_common_headers(response, request); + add_access_control_headers(response, request); + response.set(field::content_type, from_media_type(type)); + response.body() = std::move(bytes); + response.prepare_payload(); + SEND(std::move(response), handle_complete, _1, error::success); +} + void protocol_html::send_file(file&& file, media_type type, const request& request) NOEXCEPT { diff --git a/test/parsers/native_target.cpp b/test/parsers/native_target.cpp index ba5d4c8a..66f124a3 100644 --- a/test/parsers/native_target.cpp +++ b/test/parsers/native_target.cpp @@ -1341,4 +1341,68 @@ BOOST_AUTO_TEST_CASE(parsers__native_target__block_details_hash_extra_segment__e BOOST_REQUIRE_EQUAL(native_target(out, path), server::error::extra_segment); } +// inscription + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_valid__expected) +{ + const std::string path = "/v42/inscription/0000000000000000000000000000000000000000000000000000000000000042/0"; + + request_t request{}; + BOOST_REQUIRE(!native_target(request, path)); + BOOST_REQUIRE_EQUAL(request.method, "inscription"); + BOOST_REQUIRE(request.params.has_value()); + + const auto& params = request.params.value(); + BOOST_REQUIRE(std::holds_alternative(params)); + + const auto& object = std::get(request.params.value()); + BOOST_REQUIRE_EQUAL(object.size(), 3u); + + const auto version = std::get(object.at("version").value()); + BOOST_REQUIRE_EQUAL(version, 42u); + + const auto& any = std::get(object.at("hash").value()); + BOOST_REQUIRE(any.holds_alternative()); + + const auto& hash_cptr = any.get(); + BOOST_REQUIRE(hash_cptr); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_cptr), uint256_t{ 0x42 }); + + const auto index = std::get(object.at("index").value()); + BOOST_REQUIRE_EQUAL(index, 0u); +} + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_missing_hash__missing_hash) +{ + request_t out{}; + BOOST_REQUIRE_EQUAL(native_target(out, "/v3/inscription"), server::error::missing_hash); +} + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_invalid_hash__invalid_hash) +{ + request_t out{}; + BOOST_REQUIRE_EQUAL(native_target(out, "/v3/inscription/invalidhex/0"), server::error::invalid_hash); +} + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_missing_position__missing_position) +{ + const std::string path = "/v3/inscription/0000000000000000000000000000000000000000000000000000000000000000"; + request_t out{}; + BOOST_REQUIRE_EQUAL(native_target(out, path), server::error::missing_position); +} + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_invalid_index__invalid_number) +{ + const std::string path = "/v3/inscription/0000000000000000000000000000000000000000000000000000000000000000/invalid"; + request_t out{}; + BOOST_REQUIRE_EQUAL(native_target(out, path), server::error::invalid_number); +} + +BOOST_AUTO_TEST_CASE(parsers__native_target__inscription_extra_segment__extra_segment) +{ + const std::string path = "/v3/inscription/0000000000000000000000000000000000000000000000000000000000000000/0/extra"; + request_t out{}; + BOOST_REQUIRE_EQUAL(native_target(out, path), server::error::extra_segment); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/native/native_output.cpp b/test/protocols/native/native_output.cpp index d0c34c1e..cb48ed32 100644 --- a/test/protocols/native/native_output.cpp +++ b/test/protocols/native/native_output.cpp @@ -21,4 +21,31 @@ BOOST_FIXTURE_TEST_SUITE(native_tests, native_ten_block_setup_fixture) +using namespace system; +using namespace boost::beast; + +// inscription (http) +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(native__inscription__unknown_tx__not_found) +{ + const auto status = get_status( + "/v1/inscription/0000000000000000000000000000000000000000000000000000000000000000/0?format=json"); + BOOST_REQUIRE_EQUAL(status, http::status::not_found); +} + +BOOST_AUTO_TEST_CASE(native__inscription__unknown_tx__not_found_text) +{ + const auto status = get_status( + "/v1/inscription/0000000000000000000000000000000000000000000000000000000000000000/0?format=text"); + BOOST_REQUIRE_EQUAL(status, http::status::not_found); +} + +BOOST_AUTO_TEST_CASE(native__inscription__unknown_tx__not_found_data) +{ + const auto status = get_status( + "/v1/inscription/0000000000000000000000000000000000000000000000000000000000000000/0?format=data"); + BOOST_REQUIRE_EQUAL(status, http::status::not_found); +} + BOOST_AUTO_TEST_SUITE_END()