Compare commits

..

6 Commits

Author SHA1 Message Date
Khushboo-dev-cpp
d911d1105e
feat: add doc tests (#63) 2026-08-07 12:59:59 +02:00
Khushboo-dev-cpp
098927c20a
chore: pin to 0.2.1 blockchain (#62) 2026-08-06 15:49:56 +02:00
Petar Radovic
df845c65a5
feat: add missing ffi impl wrappers (#57) 2026-07-29 11:50:53 +02:00
Dario Lipicar
581323e9e5
bump module-builder (#55) 2026-07-27 14:31:15 +03:00
Daniel
2946e0f853 Change version to 0.0.999 2026-07-13 11:27:25 +02:00
Daniel Sanchez
1b0b5a45b4
fix: Update join blend (#53)
* Update and simplify join blend method

* Update and simplify join blend method

* Update refs from latest ffi

* Fix commit version
2026-07-13 10:38:52 +02:00
13 changed files with 42165 additions and 600 deletions

265
.github/workflows/doctests.yml vendored Normal file
View File

@ -0,0 +1,265 @@
name: blockchain-module Doc-Tests
# Runs the executable blockchain-module doc-tests end-to-end via the shared
# doctest CLI:
# - blockchain-module-config.test.yaml: packages and installs THIS commit of
# the blockchain module as an .lgx, starts a logoscore daemon, loads the
# module, and generates node configs (with bootstrap peers, and with
# skip_ibd for a peerless run).
# - blockchain-module-runtime.test.yaml: takes that config and drives a real
# node lifecycle — start, chain state, wallet addresses, stop.
# Both run in one invocation so they share a single HTML report.
#
# Both specs run the node with NO bootstrap peers (skip_ibd). That is
# deliberate: a node configured with peers spends startup trying to fetch a
# chain tip from each, and aborts bootstrap if none answer — so a CI job that
# depends on a live network fails whenever the network moves, the peers rotate,
# or the node's chainsync protocol version changes. With IBD skipped the node
# comes up against its own genesis in under a second and every assertion is
# deterministic.
#
# ──────────────────────────────────────────────────────────────────────────────
# One-time setup required for the clickable report links to work:
#
# 1. Repo Settings → Pages → "Build and deployment" → Source: "Deploy from a
# branch", Branch: `gh-pages` / `(root)`. (The publish-report job creates
# the gh-pages branch on its first run.)
# 2. Nothing else — GITHUB_TOKEN already has the permissions granted below.
#
# Each run publishes the two-column HTML report to:
# https://<owner>.github.io/<repo>/pr-<N>/<os>/ (pull requests)
# https://<owner>.github.io/<repo>/main/<os>/ (pushes to master)
# and (for PRs) posts/updates a comment with the links.
#
# Note: pull requests opened from forks get a read-only GITHUB_TOKEN, so the
# Pages push and PR comment are skipped for them — the downloadable artifact is
# still produced.
# ──────────────────────────────────────────────────────────────────────────────
on:
pull_request:
branches: [master, main]
push:
branches: [master, main]
concurrency:
group: doctests-${{ github.ref }}
cancel-in-progress: true
jobs:
doctests:
name: blockchain-module doc-tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# Linux only, unlike the other repos' doc-tests. The specs build the
# module's .lgx, which compiles the bundled logos_blockchain native
# library — and this repo builds for Linux exclusively (ci.yml is
# ubuntu-latest; release.yml is ubuntu-24.04 + ubuntu-24.04-arm). With no
# darwin job anywhere there is no darwin binary cache to substitute from,
# so a macOS runner compiles the whole Rust node from source every time:
# measured at ~4.5 min on ubuntu against >15 min and still going on macOS.
#
# Add macos-latest back once this repo ships a darwin build that
# populates the cache — at that point the doctest gets it nearly free.
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
# Resolve the commit under test. For pull requests this is the PR's head
# commit (not the synthetic merge commit); for pushes it's the pushed
# commit. Passed to --release-for below so the doc-test packages THIS
# commit of logos-blockchain-module instead of the latest published flake.
#
# Fork PRs are the exception: their head commit lives in the fork, not in
# logos-blockchain/logos-blockchain-module, so nix could not fetch
# `github:logos-blockchain/logos-blockchain-module/<sha>`. We blank the SHA
# for forks (--release-for repo= → pins that repo to latest), so the
# doc-test still runs for fork PRs, just against master.
- name: Resolve commit under test
id: commit
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ] && \
[ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
echo "sha=" >> "$GITHUB_OUTPUT"
echo "Fork PR detected — doc-test will run against latest master."
else
echo "sha=${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT"
fi
# The runner is the shared `doctest` CLI, invoked directly via its flake
# (github:logos-co/logos-doctest). The flake bundles Python + PyYAML
# (+ rich), so no pip install step is needed.
#
# --release-for pins the {release} placeholder for logos-blockchain-module
# to the commit under test, so
# `github:logos-blockchain/logos-blockchain-module{release}` in the spec
# becomes `.../<sha>`. Every other repo URL still resolves to latest.
- name: Run blockchain-module doc-tests
run: |
# Both specs run in one invocation so they share a single --report.
# --continue-on-fail so the run walks every step and the published
# report is complete. The job still fails (non-zero exit) if any step
# failed; this only changes whether we stop early.
#
# No --output-dir: it only supports a single spec. Without it the specs
# run in per-spec temp workdirs and still land in one --report.
nix run github:logos-co/logos-doctest -- run \
doctests/blockchain-module-config.test.yaml \
doctests/blockchain-module-runtime.test.yaml \
--verbose \
--continue-on-fail \
--release-for logos-blockchain-module=${{ steps.commit.outputs.sha }} \
--report "${{ runner.temp }}/blockchain-doctest-report.html"
- name: Stage report for upload
if: always()
shell: bash
run: |
mkdir -p report-out
# Name it index.html so the published directory URL renders directly.
if [ -f "${{ runner.temp }}/blockchain-doctest-report.html" ]; then
cp "${{ runner.temp }}/blockchain-doctest-report.html" report-out/index.html
else
echo "<h1>No report produced</h1>" > report-out/index.html
fi
- name: Upload blockchain-module execution report
if: always()
uses: actions/upload-artifact@v4
with:
name: blockchain-doctest-report-${{ matrix.os }}
path: report-out/index.html
if-no-files-found: warn
- name: Verify markdown generation
run: |
for spec in blockchain-module-config blockchain-module-runtime; do
nix run github:logos-co/logos-doctest -- generate \
"doctests/$spec.test.yaml" \
--release-for logos-blockchain-module=${{ steps.commit.outputs.sha }} \
-o "/tmp/$spec.md"
test -s "/tmp/$spec.md"
done
echo "Generated markdown successfully"
publish-report:
name: Publish report to GitHub Pages
needs: doctests
# Run even when tests fail — a failing run is exactly when you want to open
# the report. Skip on forks, where GITHUB_TOKEN can't push or comment.
if: ${{ always() && github.event.pull_request.head.repo.fork != true }}
runs-on: ubuntu-latest
permissions:
contents: write # push to the gh-pages branch
pull-requests: write # post/update the PR comment
# Serialize Pages pushes so two refs can't race on the gh-pages branch.
concurrency:
group: gh-pages-publish
cancel-in-progress: false
steps:
- name: Download all reports
uses: actions/download-artifact@v4
with:
path: artifacts
# No `name:` → downloads every artifact into artifacts/<name>/...
- name: Arrange site directory
id: arrange
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="pr-${{ github.event.pull_request.number }}"
else
BASE="main"
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
mkdir -p "site/$BASE"
found=""
for os in ubuntu-latest; do
src="artifacts/blockchain-doctest-report-$os/index.html"
if [ -f "$src" ]; then
mkdir -p "site/$BASE/$os"
cp "$src" "site/$BASE/$os/index.html"
found="$found $os"
fi
done
echo "found=$found" >> "$GITHUB_OUTPUT"
# Landing page for this ref linking to each OS report.
{
echo "<!doctype html><meta charset=utf-8>"
echo "<title>blockchain-module doc-test reports — $BASE</title>"
echo "<style>body{font:16px system-ui;margin:40px;max-width:640px}a{color:#2563eb}</style>"
echo "<h1>blockchain-module doc-test reports</h1>"
echo "<p><strong>$BASE</strong> · commit <code>${GITHUB_SHA::7}</code></p><ul>"
for os in ubuntu-latest; do
if [ -d "site/$BASE/$os" ]; then
echo "<li><a href=\"./$os/\">$os</a></li>"
fi
done
echo "</ul>"
} > "site/$BASE/index.html"
- name: Deploy to gh-pages
if: steps.arrange.outputs.found != ''
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
keep_files: true # don't wipe other PRs' directories
commit_message: "Publish blockchain-module doc-test report for ${{ steps.arrange.outputs.base }} (${{ github.sha }})"
- name: Comment on PR with report links
if: ${{ github.event_name == 'pull_request' && steps.arrange.outputs.found != '' }}
uses: actions/github-script@v7
with:
script: |
const base = "${{ steps.arrange.outputs.base }}";
const owner = context.repo.owner;
const repo = context.repo.repo;
const root = `https://${owner}.github.io/${repo}/${base}`;
const oses = "${{ steps.arrange.outputs.found }}".trim().split(/\s+/).filter(Boolean);
const links = oses.map(os => `- [\`${os}\` report](${root}/${os}/)`).join("\n");
const marker = "<!-- blockchain-doctest-report-links -->";
const body =
`${marker}\n` +
`### 📊 blockchain-module doc-test report\n\n` +
`This commit of the blockchain module, packaged as an \`.lgx\` and ` +
`driven through a logoscore daemon — config generation and a full ` +
`node lifecycle — rendered alongside the commands actually run and ` +
`their output (updated each run, commit \`${context.sha.slice(0,7)}\`):\n\n` +
`${links}\n\n` +
`_Pages can take a minute to update after the run finishes._`;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: context.issue.number, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: context.issue.number, body });
}

8
.gitignore vendored
View File

@ -16,3 +16,11 @@ state/
# AI
.codex
# Doc-test output: generated artifacts are disposable, but keep the rendered
# .md that the docs embed.
doctests/outputs/*
!doctests/outputs/*.md
# Disposable scratch dir for doc-test previews (kept separate so it never
# clobbers the committed .md in doctests/outputs).
doctests/preview-outputs
doctest-local

View File

@ -0,0 +1,209 @@
name: "Generating and Inspecting a Logos Blockchain Node Config"
output: blockchain-module-config.md
release: ""
intro: |
`blockchain_module` wraps the [`logos-blockchain`](https://github.com/logos-blockchain/logos-blockchain)
node as a Logos module. Before a node can run it needs a **user config** — a YAML
file describing the keystore, the network ports, the bootstrap peers to sync
from, and where the node keeps its state, database and logs.
The module generates that file for you through `generate_user_config`, so the
operator never has to hand-write YAML. This doc-test drives that call through
the headless `logoscore` daemon and inspects what comes out, then shows what
happens when you try to generate a second config into the same instance.
> No node is started here — config generation is entirely local and needs no
> network. For starting and stopping a node see the companion
> [`blockchain-module-runtime`](./blockchain-module-runtime.md) doc-test.
what_you_build: "A generated `user_config.yaml` for a Logos blockchain node, produced through the module's `generate_user_config` call and inspected for the values that were asked for."
what_you_learn:
- How a Logos module is packaged as `.lgx` and installed with `lgpm`
- How to call a module's methods headlessly through the `logoscore` daemon
- What `generate_user_config` writes, and which knobs it accepts
- Why a second generation into the same instance is refused, and what to use instead
- Why the generated config's paths land under the module's own per-instance directory
prerequisites:
- |
**Nix** with flakes enabled (see [nixos.org](https://nixos.org/download.html)).
- "**A Linux or macOS machine.** Nothing here needs a display or a network peer."
sections:
- title: "Build the Logos daemon"
step: true
text: |
Build the Logos runtime CLI from its published flake. The result outputs a
binary named `logoscore` under a symlinked directory named `./logos`.
`logoscore` is the headless frontend for
[`logos-liblogos`](https://github.com/logos-co/logos-liblogos) — it brings in
the whole module-runtime stack we need to load and call the module.
steps:
- title: "Build the CLI"
run: "nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos"
code_block: |
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
check_file: "logos/bin/logoscore"
post_text: |
The build produces `logos/bin/logoscore` plus bundled runtime libraries
and a `logos/modules/` directory containing the built-in
`capability_module` (required for the auth handshake when loading
modules).
- title: "Build the lgpm package manager"
step: true
text: |
`lgpm` installs `.lgx` packages into a modules directory so the daemon can
discover them.
steps:
- title: "Build lgpm"
run: "nix build 'github:logos-co/logos-package-manager' --out-link ./lgpm"
code_block: |
nix build 'github:logos-co/logos-package-manager' --out-link ./lgpm
check_file: "lgpm/bin/lgpm"
- title: "Build and install the blockchain module"
step: true
text: |
The module's flake exposes an `.lgx` package — the Logos distribution format.
Building it compiles the Qt plugin *and* the bundled `logos_blockchain`
native library it links against, so this step is the slow one.
steps:
- title: "Build the .lgx package"
run: "nix build 'github:logos-blockchain/logos-blockchain-module{release}#lgx' --out-link ./blockchain-lgx"
code_block: |
nix build 'github:logos-blockchain/logos-blockchain-module{release}#lgx' --out-link ./blockchain-lgx
- title: "Install it into a modules directory"
text: "Install the package so the daemon can find it:"
run: "./lgpm/bin/lgpm --modules-dir ./modules install --dir ./blockchain-lgx"
code_block: |
lgpm --modules-dir ./modules install --dir ./blockchain-lgx
expect_contains:
- "blockchain_module"
check_file: "modules/blockchain_module/manifest.json"
- title: "Load the module in the daemon"
step: true
text: |
Start the daemon pointed at the modules directory, then load
`blockchain_module`. Loading only brings the plugin into the process — it
does **not** start a blockchain node.
steps:
- title: "Start the daemon"
run: "./logos/bin/logoscore stop >/dev/null 2>&1; sleep 2; ./logos/bin/logoscore daemon --modules-dir ./modules --persistence-path ./data >/dev/null 2>&1 &"
code_block: |
logoscore daemon --modules-dir ./modules --persistence-path ./data
- title: "Load the module"
run: "sleep 8; ./logos/bin/logoscore load-module blockchain_module"
code_block: |
logoscore load-module blockchain_module
expect_contains:
- "blockchain_module"
- title: "Inspect the methods it exposes"
text: |
`module-info` lists the `Q_INVOKABLE` methods — the same names you can
`call`. Note `generate_user_config`, `start`, `stop`, and the
`wallet_*` family:
run: "./logos/bin/logoscore module-info blockchain_module"
code_block: |
logoscore module-info blockchain_module
expect_contains:
- "generate_user_config"
- "start"
- "stop"
- title: "Generate a config with bootstrap peers"
step: true
text: |
`generate_user_config` takes a JSON object of arguments. Every field is
optional — an empty object produces a working default config. Here we pass
the two that matter most for joining a network: the bootstrap peers to sync
from, and the ports to listen on.
`use_persistence_paths` routes the config — and the node's `state`, `db`
and `logs` directories — under the module's own per-instance directory,
which is always writable. That is the safe default.
steps:
- title: "Write the arguments"
file:
path: gen-args.json
content: |
{
"initial_peers": [
"/ip4/65.109.51.37/udp/3000/quic-v1/p2p/12D3KooWFrouXfmrR4nsLMtE7wu15DoMJ6VtoUtHinREZCvbWHar"
],
"net_port": 3000,
"blend_port": 3001,
"output": "user_config.yaml",
"use_persistence_paths": true
}
- title: "Generate the config"
run: "./logos/bin/logoscore call blockchain_module generate_user_config @gen-args.json"
code_block: |
logoscore call blockchain_module generate_user_config @gen-args.json
expect_contains:
- '"success":true'
- "user_config.yaml"
post_text: |
The call returns the absolute path of the file it wrote — that path is
what you hand to `start`, as the runtime doc-test does.
`output` is worth passing even though it is optional. Omit it and the
call still reports success but returns an **empty** path, leaving you
nothing to start the node with.
- title: "Generating a second config into the same instance"
step: true
text: |
A natural next move is to generate a *second* config — say a peerless one
with `skip_ibd: true` — alongside the first. That does **not** work, and the
error is worth seeing because it is the same one operators hit when they
re-run generation over an existing setup.
The keystore is created once per instance directory. A second
`generate_user_config` against the same instance refuses rather than
overwriting it:
steps:
- title: "Write the arguments"
file:
path: gen-args-solo.json
content: |
{
"skip_ibd": true,
"net_port": 3100,
"blend_port": 3101,
"output": "solo-user-config.yaml",
"use_persistence_paths": true
}
- title: "Try to generate a second config"
run: "./logos/bin/logoscore call blockchain_module generate_user_config @gen-args-solo.json"
code_block: |
logoscore call blockchain_module generate_user_config @gen-args-solo.json
expect_contains:
- "Keystore file exists. Use `update` command."
post_text: |
Refusing is the right behaviour — silently regenerating a keystore would
discard the keys the previous config's node identity depends on. To
change an existing setup use `update_user_config`; to get a genuinely
separate configuration, use a separate instance directory.
This is also why `skip_ibd` is demonstrated in the companion
[`blockchain-module-runtime`](./blockchain-module-runtime.md) doc-test
rather than here: that spec generates exactly one config, with
`skip_ibd: true`, and then starts a node from it.
- title: "Shut down"
step: true
text: "Stop the daemon. No node was ever started, so there is nothing else to clean up."
steps:
- title: "Stop the daemon"
run: "./logos/bin/logoscore stop"
code_block: |
logoscore stop

View File

@ -0,0 +1,321 @@
name: "Starting and Stopping a Logos Blockchain Node"
output: blockchain-module-runtime.md
release: ""
intro: |
This doc-test takes the config produced in
[`blockchain-module-config`](./blockchain-module-config.md) and actually runs a
node with it: start, query the chain state, read the wallet's known addresses,
and stop again.
It runs the node **with no bootstrap peers** (`skip_ibd: true`). That is
deliberate. A node configured with peers spends its startup trying to fetch a
chain tip from each one, and if none answer it aborts bootstrap — so a test
that depends on a live network is a test that fails whenever the network moves,
the peers rotate, or the node's chainsync protocol version changes. With IBD
skipped the node comes up against its own genesis state in well under a second
and every assertion below is deterministic.
What that buys you is coverage of the whole local lifecycle: every service
reaching ready, the wallet initialising, the chain state being queryable, and a
clean shutdown. What it does not cover is syncing from a real network — see the
note at the end.
what_you_build: "A running Logos blockchain node, started headlessly from a generated config, queried for its chain and wallet state, and shut down cleanly."
what_you_learn:
- How to start and stop a blockchain node through the module's API
- Which services the node brings up, and what "ready" looks like for each
- How to read chain state with `get_cryptarchia_info`
- How the wallet exposes its known addresses and their balances
- Why skipping IBD is the right choice for a reproducible test, and what it leaves untested
prerequisites:
- |
**Nix** with flakes enabled (see [nixos.org](https://nixos.org/download.html)).
- "**A Linux or macOS machine.** No display and no network peers are required."
sections:
- title: "Build the tools and install the module"
step: true
text: |
Same preparation as the [config doc-test](./blockchain-module-config.md):
the runtime CLI, the package manager, and this commit of the module
packaged as an `.lgx`. Building the `.lgx` compiles the bundled
`logos_blockchain` native library, so it is the slow step.
steps:
- title: "Build the Logos daemon CLI"
run: "nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos"
code_block: |
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
check_file: "logos/bin/logoscore"
- title: "Build lgpm"
run: "nix build 'github:logos-co/logos-package-manager' --out-link ./lgpm"
code_block: |
nix build 'github:logos-co/logos-package-manager' --out-link ./lgpm
check_file: "lgpm/bin/lgpm"
- title: "Build the module's .lgx package"
run: "nix build 'github:logos-blockchain/logos-blockchain-module{release}#lgx' --out-link ./blockchain-lgx"
code_block: |
nix build 'github:logos-blockchain/logos-blockchain-module{release}#lgx' --out-link ./blockchain-lgx
- title: "Install it into a modules directory"
run: "./lgpm/bin/lgpm --modules-dir ./modules install --dir ./blockchain-lgx"
code_block: |
lgpm --modules-dir ./modules install --dir ./blockchain-lgx
expect_contains:
- "blockchain_module"
check_file: "modules/blockchain_module/manifest.json"
- title: "Start the daemon and load the module"
step: true
text: |
Run the daemon and load the plugin. Loading brings the plugin into the
process — it does **not** start a blockchain node.
steps:
- title: "Start the daemon"
run: "./logos/bin/logoscore stop >/dev/null 2>&1; sleep 2; ./logos/bin/logoscore daemon --modules-dir ./modules --persistence-path ./data >/dev/null 2>&1 &"
code_block: |
logoscore daemon --modules-dir ./modules --persistence-path ./data
- title: "Load the module"
run: "sleep 8; ./logos/bin/logoscore load-module blockchain_module"
code_block: |
logoscore load-module blockchain_module
expect_contains:
- "blockchain_module"
- title: "Confirm the node is not running yet"
step: true
text: |
Every node-dependent call guards on a running node and reports so plainly.
This is worth seeing before starting, because it is the error you will meet
most often in practice:
steps:
- title: "Ask for chain info with no node"
run: "./logos/bin/logoscore call blockchain_module get_cryptarchia_info"
code_block: |
logoscore call blockchain_module get_cryptarchia_info
expect_contains:
- "The node is not running."
- title: "Generate a solo config"
step: true
text: |
Generate a config with no bootstrap peers. `skip_ibd` empties the IBD peer
list, so the node will skip Initial Block Download rather than attempt to
sync from anyone.
steps:
- title: "Write the arguments"
file:
path: runtime-args.json
content: |
{
"skip_ibd": true,
"net_port": 3200,
"blend_port": 3201,
"output": "user_config.yaml",
"use_persistence_paths": true
}
- title: "Generate it"
run: "./logos/bin/logoscore call blockchain_module generate_user_config @runtime-args.json"
code_block: |
logoscore call blockchain_module generate_user_config @runtime-args.json
expect_contains:
- '"success":true'
- "user_config.yaml"
post_text: |
`output` matters here. Omit it and the call still succeeds but returns an
empty path, leaving you nothing to hand to `start`. Give it a relative
path and the module resolves it under the instance's persistence
directory and returns the absolute path it wrote.
- title: "Confirm skip_ibd emptied the peer list"
text: |
`skip_ibd` writes an empty IBD peer list into the config, which is what
makes the node skip bootstrap rather than try to sync:
run: "CFG=$(find . $HOME/.logoscore -name user_config.yaml -path '*blockchain_module*' 2>/dev/null | head -1); echo using $CFG; grep -A1 'ibd:' $CFG"
code_block: |
grep -A1 'ibd:' <generated>/user_config.yaml
expect_contains:
- "peers: []"
- title: "Shorten the bootstrap period"
text: |
A generated config carries `prolonged_bootstrap_period: '3600.000000000'`
— the node stays in `Bootstrapping` mode for a full hour before
switching to `Online`. That is sensible for joining a real network and
useless for a test, so patch it down. `generate_user_config` does not
expose this knob, so we edit the YAML directly.
run: "CFG=$(find . $HOME/.logoscore -name user_config.yaml -path '*blockchain_module*' 2>/dev/null | head -1); sed \"s/prolonged_bootstrap_period: '3600.000000000'/prolonged_bootstrap_period: '5.000000000'/\" $CFG > $CFG.tmp && mv $CFG.tmp $CFG && grep -n prolonged_bootstrap_period $CFG"
code_block: |
# sed -i differs between GNU and BSD/macOS, so write and move instead.
sed "s/prolonged_bootstrap_period: '3600.000000000'/prolonged_bootstrap_period: '5.000000000'/" \
<generated>/user_config.yaml > tmp && mv tmp <generated>/user_config.yaml
expect_contains:
- "prolonged_bootstrap_period: '5.000000000'"
- title: "Start the node"
step: true
text: |
`start` takes the config path and a deployment name. Passing an empty
deployment uses the built-in default.
The node brings its services up in order — tracing, storage, network,
cryptarchia, wallet, mempool. Because IBD is skipped there is no bootstrap
phase to wait through, so `start` returns once the runtime is up.
steps:
- title: "Start it"
run: "CFG=$(find . $HOME/.logoscore -name user_config.yaml -path '*blockchain_module*' 2>/dev/null | head -1); ./logos/bin/logoscore call blockchain_module start $CFG ''"
code_block: |
logoscore call blockchain_module start <generated>/user_config.yaml ""
expect_contains:
- '"success":true'
post_text: |
The daemon's output records the startup sequence. Three lines are worth
knowing, because they are the ones that distinguish a healthy peerless
start from the failures you meet in practice:
```
chain::service: genesis time is already in the past: finishing AwaitingGenesisTime phase with no-op
bootstrap::ibd: Skipping IBD as no peers are configured
chain_network_service: Initial Block Download completed successfully
```
The first confirms the generated config's genesis is already live — a
config whose genesis is in the *future* parks the node in
`AwaitingGenesisTime` indefinitely, with no error to explain it. The
second and third are `skip_ibd` doing its job: bootstrap is bypassed
rather than attempted and failed.
- title: "Query the chain"
step: true
text: |
With the node up, `get_cryptarchia_info` reports the consensus state: the
current tip, the last immutable block, and the slot. On a freshly started
node with nothing synced, tip and LIB are both genesis.
steps:
- title: "Read chain state"
run: "./logos/bin/logoscore call blockchain_module get_cryptarchia_info"
code_block: |
logoscore call blockchain_module get_cryptarchia_info
expect_contains:
- '"success":true'
- '"mode\":\"Bootstrapping'
post_text: |
The reply carries `height`, `slot`, `tip`, `lib` and `mode`. Immediately
after start the mode is `Bootstrapping`.
- title: "Wait for the chain to come Online"
text: |
After the (shortened) prolonged-bootstrap period the mode flips to
`Online`. This is the transition the blend service is waiting on when it
logs *"Waiting for chain to become Online mode"*.
run: "sleep 15 && ./logos/bin/logoscore call blockchain_module get_cryptarchia_info"
code_block: |
logoscore call blockchain_module get_cryptarchia_info
expect_contains:
- '"mode\":\"Online'
post_text: |
Note `height` and `slot` are still `0`. A node with no peers reaches
`Online` but does not by itself start producing blocks — see the closing
section.
- title: "Read the wallet"
step: true
text: |
The wallet is initialised from the keystore in the generated config, so it
knows its addresses immediately — no sync required. Balances are a separate
lookup against the ledger, and on an unsynced chain they reflect genesis
state only.
steps:
- title: "List known addresses"
run: "./logos/bin/logoscore call blockchain_module wallet_get_known_addresses"
code_block: |
logoscore call blockchain_module wallet_get_known_addresses
expect_contains:
- '"success":true'
post_text: |
Five addresses, returned as a JSON array. These are what the module's UI
lists in its Accounts panel, and the ones it then queries balances for
one at a time.
- title: "Ask for a balance before the chain has synced"
text: |
Feed one of those addresses straight back into `wallet_get_balance`.
On a node that has not synced, the call **fails** — even though the
address came from the module's own list a moment earlier:
run: "./logos/bin/logoscore call blockchain_module wallet_get_balance \"$(./logos/bin/logoscore call blockchain_module wallet_get_known_addresses | grep -o '[0-9a-f]\\{64\\}' | head -1)\""
code_block: |
ADDR=$(logoscore call blockchain_module wallet_get_known_addresses | grep -o '[0-9a-f]\{64\}' | head -1)
logoscore call blockchain_module wallet_get_balance "$ADDR"
expect_contains:
- "Unknown wallet address."
post_text: |
Two different notions of "known" are in play: the wallet holds the
*key*, but the *ledger* has no entry for that address until the chain
has progressed far enough to contain one. The error wording describes
the second and reads like the first, which is why it looks — wrongly —
like a malformed address.
This is asserted here deliberately. It is the current behaviour, it is
reachable in seconds without a network, and it is the single most
confusing thing a new operator meets. If the node later returns `0` or a
distinct "not synced" error instead, this assertion should fail and be
updated — which is exactly what a doc-test is for.
- title: "Stop the node"
step: true
text: "Shut the node down. `stop` is idempotent in the sense that stopping an already-stopped node reports plainly rather than failing hard."
steps:
- title: "Stop it"
run: "./logos/bin/logoscore call blockchain_module stop"
code_block: |
logoscore call blockchain_module stop
expect_contains:
- '"success":true'
- title: "Confirm it is down"
run: "./logos/bin/logoscore call blockchain_module get_cryptarchia_info"
code_block: |
logoscore call blockchain_module get_cryptarchia_info
expect_contains:
- "The node is not running."
- title: "Stop the daemon"
run: "./logos/bin/logoscore stop"
code_block: |
logoscore stop
- title: "What this does not cover"
text: |
Skipping IBD keeps this doc-test fast and deterministic. What it leaves out
was established by running it, not assumed:
- **The chain does not advance.** A peerless node reaches `Online` and then
stays at `height: 0`, `slot: 0` indefinitely — observed over several
minutes. Genesis being in the past and bootstrap completing are not
sufficient to make a lone node produce blocks. So **block streams,
non-zero balances and transfers cannot be asserted here**, and this
doc-test does not pretend to.
- **Syncing from peers.** Bootstrap, block download and the chainsync
protocol handshake are never exercised. A node whose peers speak a
different chainsync version fails with `AllPeersFailed` — a real and
common failure, reachable only against a live network.
Covering transactions therefore needs either a multi-node fixture or
whatever the node uses to make a single node leader-eligible. The node's own
cucumber suite (`tests/src/cucumber`) already runs manual-node scenarios
with deployment overrides such as
`time.chain_start_time = now_plus_seconds(0)`, and is the better place to
look before attempting it here.
Two knobs that are *not* reachable through `generate_user_config` and had to
be patched into the YAML directly — worth exposing on the FFI if this
pattern is repeated:
- `prolonged_bootstrap_period` (the hour-long `Bootstrapping` hold)
- `time.chain_start_time` (deployment-side, controls genesis)

28
doctests/run.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# Run this repo's doc-tests end-to-end and regenerate their Markdown into
# ./outputs/. The runner is the shared `doctest` CLI
# (https://github.com/logos-co/logos-doctest), invoked via its flake.
#
# Override the runner with DOCTEST, e.g. to use a local checkout:
# DOCTEST="nix run path:../../logos-doctest --" ./run.sh
#
set -euo pipefail
cd "$(dirname "$0")"
read -r -a DOCTEST <<< "${DOCTEST:-nix run github:logos-co/logos-doctest --}"
if [ -e outputs ]; then chmod -R u+w outputs 2>/dev/null || true; fi
rm -rf outputs && mkdir -p outputs
for spec in *.test.yaml; do
name="$(basename "${spec%.test.yaml}")"
echo "==> Running ${spec}"
"${DOCTEST[@]}" run "${spec}" --verbose --output-dir ./outputs/
echo "==> Generating outputs/${name}.md"
"${DOCTEST[@]}" generate "${spec}" -o "outputs/${name}.md"
done
echo "==> Cleaning build artifacts from outputs/ (keeps .md and images/)"
"${DOCTEST[@]}" clean ./outputs --verbose 2>/dev/null || true
echo "==> Done."

41150
flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@
description = "Logos Blockchain Module - Qt6 Plugin";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder?ref=38ddf92c1f240f4e420d300a1fbabb1609d5db01";
logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=0.2.0-rc.2";
logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.6";
logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=0.2.1";
};
outputs = inputs@{ logos-module-builder, ... }:

View File

@ -144,7 +144,7 @@ namespace {
std::string state_path_data;
std::string storage_path_data;
std::string logs_path_data;
bool ibd_val;
bool skip_ibd_val;
std::string log_filter_data;
std::string kms_file_data;
@ -235,12 +235,12 @@ namespace {
ffi_args.logs_path = nullptr;
}
// ibd (bool -> const bool*)
if (args.contains("ibd") && args["ibd"].is_boolean()) {
ibd_val = args["ibd"].get<bool>();
ffi_args.ibd = &ibd_val;
// skip_ibd (bool -> const bool*)
if (args.contains("skip_ibd") && args["skip_ibd"].is_boolean()) {
skip_ibd_val = args["skip_ibd"].get<bool>();
ffi_args.skip_ibd = &skip_ibd_val;
} else {
ffi_args.ibd = nullptr;
ffi_args.skip_ibd = nullptr;
}
// log_filter (string -> const char*)
@ -263,15 +263,45 @@ namespace {
} // namespace
void LogosBlockchainModule::on_new_block_callback(const char* block) {
if (s_instance) {
fprintf(stderr, "Received new block: %s\n", block);
json j;
j["block"] = std::string(block);
s_instance->newBlock(j.dump());
// SAFETY:
// We are getting an owned pointer here which is freed after this callback is called, so there is no need to
// free the resource here as we are copying the data!
if (!s_instance || !block) {
return;
}
fprintf(stderr, "Received new block: %s\n", block);
json j;
j["block"] = std::string(block);
s_instance->newBlock(j.dump());
// SAFETY:
// We are getting an owned pointer here which is freed after this callback is called, so there is no need to
// free the resource here as we are copying the data!
}
// The stream callbacks pass the FFI's JSON through unwrapped (it is already a
// complete JSON document with the node's HTTP stream schema). A NULL pointer
// means the stream ended; it is forwarded as the JSON literal `null` so
// termination stays in-band on the same event.
void LogosBlockchainModule::on_processed_block_callback(const char* event) {
if (!s_instance) {
return;
}
if (!event) {
fprintf(stderr, "Processed block stream ended.\n");
s_instance->processedBlock("null");
return;
}
s_instance->processedBlock(std::string(event));
}
void LogosBlockchainModule::on_lib_block_callback(const char* event) {
if (!s_instance) {
return;
}
if (!event) {
fprintf(stderr, "LIB block stream ended.\n");
s_instance->libBlock("null");
return;
}
s_instance->libBlock(std::string(event));
}
LogosBlockchainModule::LogosBlockchainModule() {
@ -414,7 +444,15 @@ StdLogosResult LogosBlockchainModule::start(const std::string& config_path, cons
s_instance = this;
OperationStatus subscribe_status = subscribe_to_new_blocks(node, on_new_block_callback);
return result::from_operation_status(subscribe_status);
if (!is_ok(&subscribe_status)) {
return result::err(operation_status::take_message(subscribe_status));
}
OperationStatus processed_status = subscribe_to_processed_blocks(node, on_processed_block_callback);
if (!is_ok(&processed_status)) {
return result::err(operation_status::take_message(processed_status));
}
OperationStatus lib_status = subscribe_to_lib_blocks(node, on_lib_block_callback);
return result::from_operation_status(lib_status);
}
StdLogosResult LogosBlockchainModule::stop() {
@ -425,7 +463,7 @@ StdLogosResult LogosBlockchainModule::stop() {
s_instance = nullptr;
OperationStatus status = stop_node(node);
OperationStatus status = shutdown_node(node);
if (!is_ok(&status)) {
fprintf(stderr, "Could not stop the node: %s\n", operation_status::take_message(status).c_str());
}
@ -906,6 +944,31 @@ StdLogosResult LogosBlockchainModule::channel_deposit_with_notes(
return result::ok(bytes_to_hex(reinterpret_cast<const uint8_t*>(&value), ADDRESS_BYTES));
}
StdLogosResult LogosBlockchainModule::get_channel_state(const std::string& channel_id_hex) const {
if (!node) {
return result::err("The node is not running.");
}
const std::vector<uint8_t> bytes = parse_address_hex(channel_id_hex);
if (bytes.empty() || static_cast<int>(bytes.size()) != ADDRESS_BYTES) {
return result::err("Invalid channel_id (64 hex characters required).");
}
auto [value, error] = ::get_channel_state(node, bytes.data());
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
std::string out(value);
OperationStatus free_status = free_cstring(value);
if (!is_ok(&free_status)) {
fprintf(
stderr, "Failed to free channel state string: %s\n", operation_status::take_message(free_status).c_str()
);
}
return result::ok(std::move(out));
}
StdLogosResult LogosBlockchainModule::wallet_get_claimable_vouchers() const {
if (!node) {
return result::err("The node is not running.");
@ -936,26 +999,50 @@ StdLogosResult LogosBlockchainModule::wallet_get_claimable_vouchers() const {
return result::ok(obj.dump());
}
StdLogosResult LogosBlockchainModule::wallet_fund_tx(const std::string& request_json) const {
if (!node) {
return result::err("The node is not running.");
}
auto [value, error] = ::wallet_fund_tx(node, request_json.c_str());
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
std::string out(value);
OperationStatus free_status = free_cstring(value);
if (!is_ok(&free_status)) {
fprintf(stderr, "Failed to free funded tx string: %s\n", operation_status::take_message(free_status).c_str());
}
return result::ok(std::move(out));
}
// Transactions
StdLogosResult LogosBlockchainModule::submit_signed_transaction(const std::string& signed_tx_json) const {
if (!node) {
return result::err("The node is not running.");
}
auto [value, error] = ::submit_signed_transaction(node, signed_tx_json.c_str());
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
return result::ok(bytes_to_hex(reinterpret_cast<const uint8_t*>(&value), TX_HASH_BYTES));
}
// Blend
StdLogosResult LogosBlockchainModule::blend_join_as_core_node(
const std::string& provider_id_hex,
const std::string& zk_id_hex,
const std::string& locked_note_id_hex,
const std::vector<std::string>& locators
const std::string& locator,
const std::string& locked_note_id_hex
) const {
if (!node) {
return result::err("The node is not running.");
}
const std::vector<uint8_t> provider_id_bytes = parse_address_hex(provider_id_hex);
if (provider_id_bytes.empty() || static_cast<int>(provider_id_bytes.size()) != ADDRESS_BYTES) {
return result::err("Invalid provider_id_hex (64 hex characters required).");
}
const std::vector<uint8_t> zk_id_bytes = parse_address_hex(zk_id_hex);
if (zk_id_bytes.empty() || static_cast<int>(zk_id_bytes.size()) != ADDRESS_BYTES) {
return result::err("Invalid zk_id_hex (64 hex characters required).");
if (locator.empty()) {
return result::err("Invalid locator (must not be empty).");
}
const std::vector<uint8_t> locked_note_id_bytes = parse_address_hex(locked_note_id_hex);
@ -963,21 +1050,7 @@ StdLogosResult LogosBlockchainModule::blend_join_as_core_node(
return result::err("Invalid locked_note_id_hex (64 hex characters required).");
}
// locators_ptrs holds raw pointers into the std::strings (valid as long as `locators` lives).
std::vector<const char*> locators_ptrs;
locators_ptrs.reserve(locators.size());
for (const std::string& locator : locators) {
locators_ptrs.push_back(locator.c_str());
}
auto [value, error] = ::blend_join_as_core_node(
node,
provider_id_bytes.data(),
zk_id_bytes.data(),
locked_note_id_bytes.data(),
locators_ptrs.data(),
locators_ptrs.size()
);
auto [value, error] = ::blend_join_as_core_node(node, locator.c_str(), locked_note_id_bytes.data());
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
@ -1067,10 +1140,21 @@ StdLogosResult LogosBlockchainModule::get_cryptarchia_info() const {
json obj;
obj["lib"] = bytes_to_hex(reinterpret_cast<const uint8_t*>(value->lib), ADDRESS_BYTES);
obj["lib_slot"] = static_cast<int64_t>(value->lib_slot);
obj["tip"] = bytes_to_hex(reinterpret_cast<const uint8_t*>(value->tip), ADDRESS_BYTES);
obj["slot"] = static_cast<int64_t>(value->slot);
obj["height"] = static_cast<int64_t>(value->height);
obj["mode"] = (value->mode == State::Online) ? "Online" : "Bootstrapping";
switch (value->mode) {
case State::Online:
obj["mode"] = "Online";
break;
case State::NotStarted:
obj["mode"] = "NotStarted";
break;
default:
obj["mode"] = "Bootstrapping";
break;
}
OperationStatus free_status = free_cryptarchia_info(value);
if (!is_ok(&free_status)) {
@ -1078,3 +1162,53 @@ StdLogosResult LogosBlockchainModule::get_cryptarchia_info() const {
}
return result::ok(obj.dump());
}
StdLogosResult LogosBlockchainModule::get_block_events(const std::string& header_id_hex) const {
if (!node) {
return result::err("The node is not running.");
}
const std::vector<uint8_t> bytes = parse_address_hex(header_id_hex);
if (bytes.empty() || static_cast<int>(bytes.size()) != ADDRESS_BYTES) {
return result::err("Header ID must be 64 hex characters (32 bytes).");
}
auto [value, error] = ::get_block_events(node, reinterpret_cast<const HeaderId*>(bytes.data()));
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
std::string out(value);
OperationStatus free_status = free_cstring(value);
if (!is_ok(&free_status)) {
fprintf(
stderr, "Failed to free block events string: %s\n", operation_status::take_message(free_status).c_str()
);
}
return result::ok(std::move(out));
}
// Time
StdLogosResult LogosBlockchainModule::get_time_info() const {
if (!node) {
return result::err("The node is not running.");
}
auto [value, error] = ::get_time_info(node);
if (!is_ok(&error)) {
return result::err(operation_status::take_message(error));
}
json obj;
obj["slot_duration_ms"] = static_cast<int64_t>(value->slot_duration_ms);
obj["genesis_time_unix_ms"] = value->genesis_time_unix_ms;
obj["current_slot"] = static_cast<int64_t>(value->current_slot);
obj["current_epoch"] = value->current_epoch;
OperationStatus free_status = free_time_info(value);
if (!is_ok(&free_status)) {
fprintf(stderr, "Failed to free time info: %s\n", operation_status::take_message(free_status).c_str());
}
return result::ok(obj.dump());
}

View File

@ -99,6 +99,16 @@ public:
) const;
[[nodiscard]] StdLogosResult leader_claim() const;
[[nodiscard]] StdLogosResult wallet_get_claimable_vouchers() const;
// Funds an unsigned transaction: request_json is passed through to the
// node's wallet fund endpoint (same JSON schema as the HTTP `/wallet/fund`
// request body); returns the funded transaction as JSON.
[[nodiscard]] StdLogosResult wallet_fund_tx(const std::string& request_json) const;
// Transactions
// Submits a signed transaction: signed_tx_json is passed through to the
// node (same JSON schema as the HTTP `/mantle/transact` request body);
// returns the transaction hash hex on success.
[[nodiscard]] StdLogosResult submit_signed_transaction(const std::string& signed_tx_json) const;
// Channel
// Amount-based deposit: the binding selects funding notes itself (splitting a
@ -128,12 +138,15 @@ public:
const std::string& optional_tip_hex
) const;
// State of the channel with the given 32-byte channel ID, as JSON (same
// schema as the node's `/mantle/channel/{id}` HTTP endpoint). Fails with a
// not-found error when the channel does not exist yet.
[[nodiscard]] StdLogosResult get_channel_state(const std::string& channel_id_hex) const;
// Blend
[[nodiscard]] StdLogosResult blend_join_as_core_node(
const std::string& provider_id_hex,
const std::string& zk_id_hex,
const std::string& locked_note_id_hex,
const std::vector<std::string>& locators
const std::string& locator,
const std::string& locked_note_id_hex
) const;
// Explorer
@ -143,6 +156,13 @@ public:
// Cryptarchia
[[nodiscard]] StdLogosResult get_cryptarchia_info() const;
// Events emitted by the block with the given 32-byte header ID, as JSON.
[[nodiscard]] StdLogosResult get_block_events(const std::string& header_id_hex) const;
// Time
// Consensus time info as JSON:
// { slot_duration_ms, genesis_time_unix_ms, current_slot, current_epoch }
[[nodiscard]] StdLogosResult get_time_info() const;
// clang-format off
// Clang-format only handles public/private/protected, so it miss-indents this section.
@ -152,6 +172,19 @@ logos_events:
// blockJson is the full block serialized as JSON.
// ReSharper disable once CppFunctionIsNotImplemented
void newBlock(const std::string& blockJson);
// Fired per processed block. eventJson carries the block plus the chain
// state after processing it (same schema as the node's
// `/cryptarchia/blocks/stream` HTTP endpoint; transaction ids at
// `transactions[].mantle_tx.hash`). When the stream ends, fired exactly
// once with the JSON literal `null` — restart the node subscription (via
// stop/start) to keep receiving events.
// ReSharper disable once CppFunctionIsNotImplemented
void processedBlock(const std::string& eventJson);
// Fired per newly finalized (LIB) block. blockInfoJson uses the same
// schema as the node's `/cryptarchia/lib/stream` HTTP endpoint. When the
// stream ends, fired exactly once with the JSON literal `null`.
// ReSharper disable once CppFunctionIsNotImplemented
void libBlock(const std::string& blockInfoJson);
// clang-format on
private:
@ -160,6 +193,10 @@ private:
// Static instance for C callback (C API doesn't support user data)
static LogosBlockchainModule* s_instance;
// C-compatible callback function
// C-compatible callback functions. The stream callbacks receive NULL
// exactly once when their stream ends; that is forwarded as the JSON
// literal `null` on the corresponding event.
static void on_new_block_callback(const char* block);
static void on_processed_block_callback(const char* event);
static void on_lib_block_callback(const char* event);
};

View File

@ -12,6 +12,23 @@
#include "logos_blockchain_module.h"
// Recorded payloads of the most recent stream events, so tests can assert what
// the trampolines forwarded (including the `null` end-of-stream sentinel).
std::string g_lastNewBlockEventJson;
std::string g_lastProcessedBlockEventJson;
std::string g_lastLibBlockEventJson;
void LogosBlockchainModule::newBlock(const std::string& blockJson) {
g_lastNewBlockEventJson = blockJson;
emitEventImpl_("newBlock", nullptr);
}
void LogosBlockchainModule::processedBlock(const std::string& eventJson) {
g_lastProcessedBlockEventJson = eventJson;
emitEventImpl_("processedBlock", nullptr);
}
void LogosBlockchainModule::libBlock(const std::string& blockInfoJson) {
g_lastLibBlockEventJson = blockInfoJson;
emitEventImpl_("libBlock", nullptr);
}

View File

@ -16,6 +16,12 @@ std::string g_lastGeneratedStatePath;
std::string g_lastGeneratedStoragePath;
std::string g_lastGeneratedLogsPath;
// Captures the callbacks passed to the subscription calls so tests can drive
// the streams (including the NULL end-of-stream sentinel).
BlockCallback g_lastNewBlockCallback = nullptr;
BlockCallback g_lastProcessedBlockCallback = nullptr;
BlockCallback g_lastLibBlockCallback = nullptr;
static char s_fakeNode = 0;
static CryptarchiaInfo s_fakeCryptarchiaInfo = {};
@ -58,8 +64,8 @@ NodeResult start_lb_node(const char* config_path, const char* deployment) {
return result;
}
OperationStatus stop_node(LogosBlockchainNode* node) {
LOGOS_CMOCK_RECORD("stop_node");
OperationStatus shutdown_node(LogosBlockchainNode* node) {
LOGOS_CMOCK_RECORD("shutdown_node");
return make_status(0);
}
@ -137,9 +143,22 @@ StringResult get_peer_id(const char* config_path) {
OperationStatus subscribe_to_new_blocks(LogosBlockchainNode* node, BlockCallback callback) {
LOGOS_CMOCK_RECORD("subscribe_to_new_blocks");
g_lastNewBlockCallback = callback;
return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_new_blocks"));
}
OperationStatus subscribe_to_processed_blocks(LogosBlockchainNode* node, BlockCallback callback) {
LOGOS_CMOCK_RECORD("subscribe_to_processed_blocks");
g_lastProcessedBlockCallback = callback;
return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_processed_blocks"));
}
OperationStatus subscribe_to_lib_blocks(LogosBlockchainNode* node, BlockCallback callback) {
LOGOS_CMOCK_RECORD("subscribe_to_lib_blocks");
g_lastLibBlockCallback = callback;
return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_lib_blocks"));
}
BalanceResult get_balance(LogosBlockchainNode* node, const uint8_t* address, const void* reserved) {
LOGOS_CMOCK_RECORD("get_balance");
BalanceResult result;
@ -218,6 +237,15 @@ OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers) {
return make_status(0);
}
StringResult get_channel_state(LogosBlockchainNode* node, const uint8_t* channel_id) {
LOGOS_CMOCK_RECORD("get_channel_state");
StringResult result;
const char* json = LOGOS_CMOCK_RETURN_STRING("get_channel_state");
result.value = json ? strdup(json) : nullptr;
result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_channel_state_error"));
return result;
}
// Wallet-notes mock storage (up to 4 notes)
static WalletNote s_mockNotes[4];
@ -251,6 +279,23 @@ OperationStatus free_wallet_notes(WalletNotes notes) {
return make_status(0);
}
StringResult wallet_fund_tx(LogosBlockchainNode* node, const char* request_json) {
LOGOS_CMOCK_RECORD("wallet_fund_tx");
StringResult result;
const char* json = LOGOS_CMOCK_RETURN_STRING("wallet_fund_tx");
result.value = json ? strdup(json) : nullptr;
result.error = make_status(LOGOS_CMOCK_RETURN(int, "wallet_fund_tx_error"));
return result;
}
SubmitTransactionResult submit_signed_transaction(LogosBlockchainNode* node, const char* signed_tx_json) {
LOGOS_CMOCK_RECORD("submit_signed_transaction");
SubmitTransactionResult result;
memset(result.value, 0xFA, sizeof(Hash));
result.error = make_status(LOGOS_CMOCK_RETURN(int, "submit_signed_transaction_error"));
return result;
}
FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments) {
LOGOS_CMOCK_RECORD("channel_deposit");
FfiChannelDepositResult result;
@ -272,11 +317,8 @@ FfiChannelDepositResult channel_deposit_with_notes(
BlendHashResult blend_join_as_core_node(
LogosBlockchainNode* node,
const uint8_t* provider_id,
const uint8_t* zk_id,
const uint8_t* locked_note_id,
const char** locators,
size_t locators_count)
const char* locator,
const uint8_t* locked_note_id)
{
LOGOS_CMOCK_RECORD("blend_join_as_core_node");
BlendHashResult result;
@ -316,6 +358,7 @@ CryptarchiaInfoResult get_cryptarchia_info(LogosBlockchainNode* node) {
LOGOS_CMOCK_RECORD("get_cryptarchia_info");
CryptarchiaInfoResult result;
memset(&s_fakeCryptarchiaInfo, 0, sizeof(s_fakeCryptarchiaInfo));
s_fakeCryptarchiaInfo.lib_slot = static_cast<uint64_t>(LOGOS_CMOCK_RETURN(int, "cryptarchia_lib_slot"));
s_fakeCryptarchiaInfo.slot = static_cast<uint64_t>(LOGOS_CMOCK_RETURN(int, "cryptarchia_slot"));
s_fakeCryptarchiaInfo.height = static_cast<uint64_t>(LOGOS_CMOCK_RETURN(int, "cryptarchia_height"));
s_fakeCryptarchiaInfo.mode = static_cast<State>(LOGOS_CMOCK_RETURN(int, "cryptarchia_mode"));
@ -331,6 +374,34 @@ OperationStatus free_cryptarchia_info(CryptarchiaInfo* info) {
return make_status(0);
}
StringResult get_block_events(LogosBlockchainNode* node, const HeaderId* header_id) {
LOGOS_CMOCK_RECORD("get_block_events");
StringResult result;
const char* json = LOGOS_CMOCK_RETURN_STRING("get_block_events");
result.value = json ? strdup(json) : nullptr;
result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_block_events_error"));
return result;
}
static TimeInfo s_fakeTimeInfo = {};
TimeInfoResult get_time_info(LogosBlockchainNode* node) {
LOGOS_CMOCK_RECORD("get_time_info");
TimeInfoResult result;
s_fakeTimeInfo.slot_duration_ms = static_cast<uint64_t>(LOGOS_CMOCK_RETURN(int, "time_slot_duration_ms"));
s_fakeTimeInfo.genesis_time_unix_ms = static_cast<int64_t>(LOGOS_CMOCK_RETURN(int, "time_genesis_time_unix_ms"));
s_fakeTimeInfo.current_slot = static_cast<uint64_t>(LOGOS_CMOCK_RETURN(int, "time_current_slot"));
s_fakeTimeInfo.current_epoch = static_cast<uint32_t>(LOGOS_CMOCK_RETURN(int, "time_current_epoch"));
result.value = &s_fakeTimeInfo;
result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_time_info_error"));
return result;
}
OperationStatus free_time_info(TimeInfo* info) {
LOGOS_CMOCK_RECORD("free_time_info");
return make_status(0);
}
OperationStatus free_cstring(char* s) {
LOGOS_CMOCK_RECORD("free_cstring");
free(s);

View File

@ -45,7 +45,7 @@ typedef struct {
} OperationStatus;
// Consensus state enum
typedef enum { Bootstrapping, Online } State;
typedef enum { Bootstrapping, Online, NotStarted } State;
// Key type for generate_key / add_key
typedef enum { Ed25519, Zk } KeyType;
@ -62,7 +62,7 @@ typedef struct {
const char* state_path;
const char* storage_path;
const char* logs_path;
const bool* ibd;
const bool* skip_ibd;
const char* log_filter;
const char* kms_file;
} GenerateConfigArgs;
@ -134,12 +134,21 @@ typedef struct {
// Cryptarchia consensus info
typedef struct {
uint8_t lib[32];
uint64_t lib_slot;
uint8_t tip[32];
uint64_t slot;
uint64_t height;
State mode;
} CryptarchiaInfo;
// Time service info
typedef struct {
uint64_t slot_duration_ms;
int64_t genesis_time_unix_ms;
uint64_t current_slot;
uint32_t current_epoch;
} TimeInfo;
// Result types (C++ structured bindings decompose these)
typedef struct { LogosBlockchainNode* value; OperationStatus error; } NodeResult;
typedef struct { uint64_t value; OperationStatus error; } BalanceResult;
@ -152,6 +161,8 @@ typedef struct { ClaimableVouchers value; OperationStatus error; } FfiClaimableV
typedef struct { Hash value; OperationStatus error; } BlendHashResult;
typedef struct { char* value; OperationStatus error; } StringResult;
typedef struct { CryptarchiaInfo* value; OperationStatus error; } CryptarchiaInfoResult;
typedef struct { TimeInfo* value; OperationStatus error; } TimeInfoResult;
typedef struct { Hash value; OperationStatus error; } SubmitTransactionResult;
// Block event callback
typedef void (*BlockCallback)(const char* block_json);
@ -162,8 +173,12 @@ bool is_ok(const OperationStatus* status);
// Lifecycle
OperationStatus generate_user_config(GenerateConfigArgs args);
NodeResult start_lb_node(const char* config_path, const char* deployment);
OperationStatus stop_node(LogosBlockchainNode* node);
OperationStatus shutdown_node(LogosBlockchainNode* node);
OperationStatus subscribe_to_new_blocks(LogosBlockchainNode* node, BlockCallback callback);
// Streams: each event is a JSON C string; the callback is invoked exactly once
// with NULL when the stream ends.
OperationStatus subscribe_to_processed_blocks(LogosBlockchainNode* node, BlockCallback callback);
OperationStatus subscribe_to_lib_blocks(LogosBlockchainNode* node, BlockCallback callback);
// Config management
OperationStatus update_user_config(const char* user_config_path, const char* keystore_path);
@ -209,6 +224,10 @@ FfiWalletNotesResult get_wallet_notes(
const uint8_t* wallet_address,
const HeaderId* optional_tip);
OperationStatus free_wallet_notes(WalletNotes notes);
StringResult wallet_fund_tx(LogosBlockchainNode* node, const char* request_json);
// Transactions
SubmitTransactionResult submit_signed_transaction(LogosBlockchainNode* node, const char* signed_tx_json);
// Channel
FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments);
@ -217,15 +236,13 @@ FfiChannelDepositResult channel_deposit_with_notes(
const ChannelDepositWithNotesArguments* arguments);
FfiClaimableVouchersResult get_claimable_vouchers(LogosBlockchainNode* node, const HeaderId* optional_tip);
OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers);
StringResult get_channel_state(LogosBlockchainNode* node, const uint8_t* channel_id);
// Blend
BlendHashResult blend_join_as_core_node(
LogosBlockchainNode* node,
const uint8_t* provider_id,
const uint8_t* zk_id,
const uint8_t* locked_note_id,
const char** locators,
size_t locators_count);
const char* locator,
const uint8_t* locked_note_id);
// Explorer
StringResult get_block(LogosBlockchainNode* node, const HeaderId* header_id);
@ -235,6 +252,11 @@ StringResult get_transaction(LogosBlockchainNode* node, const TxHash* tx_hash);
// Cryptarchia
CryptarchiaInfoResult get_cryptarchia_info(LogosBlockchainNode* node);
OperationStatus free_cryptarchia_info(CryptarchiaInfo* info);
StringResult get_block_events(LogosBlockchainNode* node, const HeaderId* header_id);
// Time
TimeInfoResult get_time_info(LogosBlockchainNode* node);
OperationStatus free_time_info(TimeInfo* info);
// Memory management
OperationStatus free_cstring(char* s);

View File

@ -292,7 +292,7 @@ LOGOS_TEST(wallet_get_known_addresses_without_node_returns_error) {
LOGOS_TEST(blend_join_as_core_node_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
StdLogosResult result = module.blend_join_as_core_node(VALID_HEX, VALID_HEX, VALID_HEX, {"locator1"});
StdLogosResult result = module.blend_join_as_core_node("locator1", VALID_HEX);
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "not running"));
}
@ -321,6 +321,42 @@ LOGOS_TEST(get_cryptarchia_info_without_node_returns_error) {
LOGOS_ASSERT_FALSE(module.get_cryptarchia_info().success);
}
LOGOS_TEST(get_block_events_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
LOGOS_ASSERT_FALSE(module.get_block_events(VALID_HEX).success);
}
LOGOS_TEST(get_time_info_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
LOGOS_ASSERT_FALSE(module.get_time_info().success);
}
LOGOS_TEST(get_channel_state_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
StdLogosResult result = module.get_channel_state(VALID_HEX);
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "not running"));
}
LOGOS_TEST(wallet_fund_tx_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
StdLogosResult result = module.wallet_fund_tx("{}");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "not running"));
}
LOGOS_TEST(submit_signed_transaction_without_node_returns_error) {
auto t = LogosTestContext("blockchain_module");
LogosBlockchainModule module;
StdLogosResult result = module.submit_signed_transaction("{}");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "not running"));
}
// ============================================================================
// Node lifecycle (start / stop)
// ============================================================================
@ -334,9 +370,36 @@ LOGOS_TEST(start_succeeds_with_mocked_dependencies) {
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT(t.cFunctionCalled("start_lb_node"));
LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_new_blocks"));
LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_processed_blocks"));
LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_lib_blocks"));
delete module;
}
LOGOS_TEST(start_fails_when_processed_blocks_subscription_fails) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
LogosBlockchainModule module;
t.mockCFunction("start_lb_node").returns(1);
t.mockCFunction("subscribe_to_new_blocks").returns(0);
t.mockCFunction("subscribe_to_processed_blocks").returns(1);
LOGOS_ASSERT_FALSE(module.start(tmpDir.filePath("config.json"), "").success);
}
LOGOS_TEST(start_fails_when_lib_blocks_subscription_fails) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
LogosBlockchainModule module;
t.mockCFunction("start_lb_node").returns(1);
t.mockCFunction("subscribe_to_new_blocks").returns(0);
t.mockCFunction("subscribe_to_processed_blocks").returns(0);
t.mockCFunction("subscribe_to_lib_blocks").returns(1);
LOGOS_ASSERT_FALSE(module.start(tmpDir.filePath("config.json"), "").success);
}
LOGOS_TEST(start_returns_1_when_already_running) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
@ -354,7 +417,7 @@ LOGOS_TEST(stop_succeeds_with_running_node) {
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT_TRUE(module->stop().success);
LOGOS_ASSERT(t.cFunctionCalled("stop_node"));
LOGOS_ASSERT(t.cFunctionCalled("shutdown_node"));
delete module;
}
@ -475,27 +538,15 @@ LOGOS_TEST(wallet_transfer_funds_rejects_invalid_optional_tip) {
// blend_join_as_core_node validation
LOGOS_TEST(blend_join_rejects_invalid_provider_id) {
LOGOS_TEST(blend_join_rejects_empty_locator) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
StdLogosResult result = module->blend_join_as_core_node("short", VALID_HEX, VALID_HEX, {});
StdLogosResult result = module->blend_join_as_core_node("", VALID_HEX);
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "provider_id"));
delete module;
}
LOGOS_TEST(blend_join_rejects_invalid_zk_id) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
StdLogosResult result = module->blend_join_as_core_node(VALID_HEX, "short", VALID_HEX, {});
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "zk_id"));
LOGOS_ASSERT_TRUE(contains(result.error, "locator"));
delete module;
}
@ -505,7 +556,7 @@ LOGOS_TEST(blend_join_rejects_invalid_locked_note_id) {
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
StdLogosResult result = module->blend_join_as_core_node(VALID_HEX, VALID_HEX, "short", {});
StdLogosResult result = module->blend_join_as_core_node("locator1", "short");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "locked_note_id"));
delete module;
@ -537,6 +588,30 @@ LOGOS_TEST(get_transaction_rejects_invalid_hex) {
delete module;
}
LOGOS_TEST(get_block_events_rejects_invalid_hex) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
StdLogosResult result = module->get_block_events("tooshort");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "64 hex"));
delete module;
}
LOGOS_TEST(get_channel_state_rejects_invalid_hex) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
StdLogosResult result = module->get_channel_state("bad");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "channel_id"));
delete module;
}
// ============================================================================
// 0x prefix handling
// ============================================================================
@ -1025,6 +1100,104 @@ LOGOS_TEST(wallet_get_claimable_vouchers_returns_error_on_ffi_failure) {
delete module;
}
LOGOS_TEST(wallet_fund_tx_returns_json_on_success) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("wallet_fund_tx").returns(R"({"mantle_tx":{"ops":[]}})");
t.mockCFunction("wallet_fund_tx_error").returns(0);
StdLogosResult result = module->wallet_fund_tx(R"({"tx":{},"funding_keys":[]})");
LOGOS_ASSERT_TRUE(result.success);
LOGOS_ASSERT_TRUE(contains(result.value.get<std::string>(), "mantle_tx"));
LOGOS_ASSERT(t.cFunctionCalled("wallet_fund_tx"));
LOGOS_ASSERT(t.cFunctionCalled("free_cstring"));
delete module;
}
LOGOS_TEST(wallet_fund_tx_returns_error_on_ffi_failure) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("wallet_fund_tx_error").returns(1);
StdLogosResult result = module->wallet_fund_tx("{}");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "mock error"));
delete module;
}
// Transactions
LOGOS_TEST(submit_signed_transaction_returns_tx_hash) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("submit_signed_transaction_error").returns(0);
StdLogosResult result = module->submit_signed_transaction("{}");
LOGOS_ASSERT_TRUE(result.success);
// Mock fills hash with 0xFA -> hex "fafa...fa" (64 chars)
std::string hash = result.value.get<std::string>();
LOGOS_ASSERT_EQ(static_cast<int>(hash.length()), 64);
LOGOS_ASSERT_TRUE(hash.substr(0, 2) == "fa");
LOGOS_ASSERT(t.cFunctionCalled("submit_signed_transaction"));
delete module;
}
LOGOS_TEST(submit_signed_transaction_returns_error_on_ffi_failure) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("submit_signed_transaction_error").returns(1);
StdLogosResult result = module->submit_signed_transaction("{}");
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "mock error"));
delete module;
}
// Channel state
LOGOS_TEST(get_channel_state_returns_json_on_success) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_channel_state").returns(R"({"tip":"abc","inscriptions":[]})");
t.mockCFunction("get_channel_state_error").returns(0);
StdLogosResult result = module->get_channel_state(VALID_HEX);
LOGOS_ASSERT_TRUE(result.success);
LOGOS_ASSERT_TRUE(contains(result.value.get<std::string>(), "inscriptions"));
LOGOS_ASSERT(t.cFunctionCalled("get_channel_state"));
LOGOS_ASSERT(t.cFunctionCalled("free_cstring"));
delete module;
}
LOGOS_TEST(get_channel_state_returns_error_on_ffi_failure) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_channel_state_error").returns(1);
StdLogosResult result = module->get_channel_state(VALID_HEX);
LOGOS_ASSERT_FALSE(result.success);
LOGOS_ASSERT_TRUE(contains(result.error, "mock error"));
delete module;
}
// Blend
LOGOS_TEST(blend_join_as_core_node_returns_declaration_id) {
@ -1035,8 +1208,7 @@ LOGOS_TEST(blend_join_as_core_node_returns_declaration_id) {
t.mockCFunction("blend_join_as_core_node_error").returns(0);
std::vector<std::string> locators = {"locator1", "locator2"};
StdLogosResult result = module->blend_join_as_core_node(VALID_HEX, VALID_HEX, VALID_HEX, locators);
StdLogosResult result = module->blend_join_as_core_node("locator1", VALID_HEX);
LOGOS_ASSERT_TRUE(result.success);
// Mock fills hash with 0xCD -> hex "cdcd...cd" (64 chars)
std::string declarationId = result.value.get<std::string>();
@ -1054,7 +1226,7 @@ LOGOS_TEST(blend_join_as_core_node_returns_error_on_ffi_failure) {
t.mockCFunction("blend_join_as_core_node_error").returns(1);
StdLogosResult result = module->blend_join_as_core_node(VALID_HEX, VALID_HEX, VALID_HEX, {});
StdLogosResult result = module->blend_join_as_core_node("locator1", VALID_HEX);
LOGOS_ASSERT_FALSE(result.success);
delete module;
}
@ -1159,6 +1331,7 @@ LOGOS_TEST(get_cryptarchia_info_returns_json_on_success) {
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_cryptarchia_info_error").returns(0);
t.mockCFunction("cryptarchia_lib_slot").returns(90);
t.mockCFunction("cryptarchia_slot").returns(100);
t.mockCFunction("cryptarchia_height").returns(50);
t.mockCFunction("cryptarchia_mode").returns(1); // Online
@ -1173,11 +1346,26 @@ LOGOS_TEST(get_cryptarchia_info_returns_json_on_success) {
LOGOS_ASSERT_TRUE(contains(json, "Online"));
LOGOS_ASSERT_TRUE(contains(json, "lib"));
LOGOS_ASSERT_TRUE(contains(json, "tip"));
LOGOS_ASSERT_TRUE(contains(json, "\"lib_slot\":90"));
LOGOS_ASSERT(t.cFunctionCalled("get_cryptarchia_info"));
LOGOS_ASSERT(t.cFunctionCalled("free_cryptarchia_info"));
delete module;
}
LOGOS_TEST(get_cryptarchia_info_not_started_mode) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_cryptarchia_info_error").returns(0);
t.mockCFunction("cryptarchia_mode").returns(2); // NotStarted
StdLogosResult result = module->get_cryptarchia_info();
LOGOS_ASSERT_TRUE(contains(result.value.get<std::string>(), "NotStarted"));
delete module;
}
LOGOS_TEST(get_cryptarchia_info_bootstrapping_mode) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
@ -1204,6 +1392,147 @@ LOGOS_TEST(get_cryptarchia_info_returns_error_on_ffi_failure) {
delete module;
}
LOGOS_TEST(get_block_events_returns_json_on_success) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_block_events").returns(R"([{"type":"inscription"}])");
t.mockCFunction("get_block_events_error").returns(0);
StdLogosResult result = module->get_block_events(VALID_HEX);
LOGOS_ASSERT_TRUE(result.success);
LOGOS_ASSERT_TRUE(contains(result.value.get<std::string>(), "inscription"));
LOGOS_ASSERT(t.cFunctionCalled("get_block_events"));
LOGOS_ASSERT(t.cFunctionCalled("free_cstring"));
delete module;
}
LOGOS_TEST(get_block_events_returns_error_on_ffi_failure) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_block_events_error").returns(1);
LOGOS_ASSERT_FALSE(module->get_block_events(VALID_HEX).success);
delete module;
}
// Time
LOGOS_TEST(get_time_info_returns_json_on_success) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_time_info_error").returns(0);
t.mockCFunction("time_slot_duration_ms").returns(2000);
t.mockCFunction("time_genesis_time_unix_ms").returns(1700000);
t.mockCFunction("time_current_slot").returns(1234);
t.mockCFunction("time_current_epoch").returns(7);
StdLogosResult result = module->get_time_info();
LOGOS_ASSERT_TRUE(result.success);
std::string json = result.value.get<std::string>();
LOGOS_ASSERT_TRUE(contains(json, "\"slot_duration_ms\":2000"));
LOGOS_ASSERT_TRUE(contains(json, "\"genesis_time_unix_ms\":1700000"));
LOGOS_ASSERT_TRUE(contains(json, "\"current_slot\":1234"));
LOGOS_ASSERT_TRUE(contains(json, "\"current_epoch\":7"));
LOGOS_ASSERT(t.cFunctionCalled("get_time_info"));
LOGOS_ASSERT(t.cFunctionCalled("free_time_info"));
delete module;
}
LOGOS_TEST(get_time_info_returns_error_on_ffi_failure) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
t.mockCFunction("get_time_info_error").returns(1);
LOGOS_ASSERT_FALSE(module->get_time_info().success);
delete module;
}
// ============================================================================
// Stream events (trampolines drive logos_events; end of stream = JSON null)
// ============================================================================
// Captured by the mock subscribe calls (mock_logos_blockchain.cpp).
extern BlockCallback g_lastNewBlockCallback;
extern BlockCallback g_lastProcessedBlockCallback;
extern BlockCallback g_lastLibBlockCallback;
// Recorded by the event stubs (event_stubs.cpp).
extern std::string g_lastNewBlockEventJson;
extern std::string g_lastProcessedBlockEventJson;
extern std::string g_lastLibBlockEventJson;
LOGOS_TEST(processed_block_stream_forwards_json_and_null_sentinel) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT_TRUE(g_lastProcessedBlockCallback != nullptr);
g_lastProcessedBlockEventJson.clear();
g_lastProcessedBlockCallback(R"({"header_id":"abc","transactions":[]})");
LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string(R"({"header_id":"abc","transactions":[]})"));
g_lastProcessedBlockCallback(nullptr);
LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string("null"));
delete module;
}
LOGOS_TEST(lib_block_stream_forwards_json_and_null_sentinel) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT_TRUE(g_lastLibBlockCallback != nullptr);
g_lastLibBlockEventJson.clear();
g_lastLibBlockCallback(R"({"header_id":"def","slot":9})");
LOGOS_ASSERT_EQ(g_lastLibBlockEventJson, std::string(R"({"header_id":"def","slot":9})"));
g_lastLibBlockCallback(nullptr);
LOGOS_ASSERT_EQ(g_lastLibBlockEventJson, std::string("null"));
delete module;
}
// The legacy new-block stream never sends NULL, but the trampoline must not
// crash if it ever does (regression test for the added guard).
LOGOS_TEST(new_block_callback_ignores_null_pointer) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT_TRUE(g_lastNewBlockCallback != nullptr);
g_lastNewBlockEventJson.clear();
g_lastNewBlockCallback(nullptr);
LOGOS_ASSERT_EQ(g_lastNewBlockEventJson, std::string());
delete module;
}
// Stream events are not delivered after the module stops (s_instance cleared).
LOGOS_TEST(stream_events_not_delivered_after_stop) {
auto t = LogosTestContext("blockchain_module");
TempDir tmpDir;
auto* module = createStartedModule(t, tmpDir);
LOGOS_ASSERT_TRUE(module != nullptr);
LOGOS_ASSERT_TRUE(module->stop().success);
g_lastProcessedBlockEventJson.clear();
g_lastProcessedBlockCallback(R"({"header_id":"abc"})");
LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string());
delete module;
}
// ============================================================================
// Config management (operate on file paths, no running node required)
// ============================================================================