Verify your alkane on SUBFROST
You deployed an alkane. Anyone can read its bytecode on chain, and nobody can read its intent. Verification closes that gap. You hand the explorer your source and the recipe you built it with, the explorer rebuilds it, and if the rebuilt wasm matches the bytes on chain, your alkane page shows the badge and serves your source next to it.
This page is the practical guide. Verifying alkanes explains the verdicts in more depth, and Reproducible builds goes through the reproduction formula axis by axis.
How it works
A verification has five steps. Each one exists for a reason, and knowing the reason makes failures easy to read.
-
You submit. A
POSTto the verify endpoint carries the alkane id and your source, either a public git URL or the whole tree packed into the request as an inline bundle. The recipe can be a handful of flat fields or a full BuildInfo document. The API checks everything before it spends a build: every problem comes back at once, each one naming the field that caused it. -
The recipe fills itself in. The explorer reads your alkane's on-chain bytecode and reverses the build environment out of it. A Rust wasm carries more than code. Panic messages embed file paths, and those paths spell out your build
HOME, your toolchain directory, the git revisions of your git dependencies and the registry layout. Theproducerssection records the rustc and clang versions. Whatever you leave out of the request is taken from there, and whatever you send overrides it. -
The build runs in isolation. Your build scripts are code from the internet as far as we are concerned, so they run where they can do no harm. Each job gets a fresh virtual machine on a separate host, built from a sealed image and thrown away afterwards. The VM accepts no inbound connections and can reach the public internet only on the ports needed to fetch sources and crates. It has no route to any SUBFROST system. Inside the VM the work splits in two containers: a fetch step that downloads your dependencies, then a build step with no network at all. Both run read-only, unprivileged, with every capability dropped.
-
The build is reproducible. The VM rebuilds with the exact toolchain your bytecode names, from a pinned image, at fixed paths. The toolchain matters because every rustc release generates different code. The paths matter because they are embedded in the wasm. The
rust-srccomponent matters because its presence changes how standard library paths are written. When your repo commits aCargo.lock, that lockfile is used as is. When it does not, the crates.io index is frozen to your deploy date so dependency resolution gives the versions you had then. -
The bytes are compared and the result is recorded. The rebuilt wasm is compared with the decompressed on-chain bytecode, section by section. A match is promoted on its own, with no human in the loop, and stored under the sha256 of the bytecode, so every clone of the same template shows as verified too. The alkane page then shows the verdict, a browsable copy of the exact source tree that was built, and the reproduction recipe.
Nothing in this flow is trusted because of who sent it. A wrong recipe simply fails to match, so any active API key may submit.
Verified and reproducible are different claims
The explorer gives one of four verdicts. Two of them earn the badge.
| Verdict | What the explorer shows | What it proves |
|---|---|---|
reproducible | Reproducible | The rebuilt wasm has the same sha256 as the on-chain bytecode. Byte for byte, your published source produces what is deployed. |
verified | ≈ Verified (not byte-exact) | The code section is byte-identical, or at least 97% of the non-custom bytes match. The logic is the same; a few bytes elsewhere differ, usually embedded paths or a C object compiled on a different host. |
partial | Partial | At least 90% matches. No badge. Some part of the recipe is still wrong. |
mismatch | Mismatch | Below 90%. No badge. This source does not produce this bytecode. |
reproducible is the strong claim and the one to aim for. verified tells a reader the deployed logic is your source, while admitting the artifact is not a bit-exact rebuild. The status response gives you both hashes, so you can always see which one you got and why.
Quick start
You need an active SUBFROST API key (API Keys). The key goes in the path.
export SUBFROST_API_KEY=... # your key
export API=https://explorer.subfrost.io/api/v1/$SUBFROST_API_KEY
1. Public git repo
curl -sS -X POST "$API/verify" \
-H 'content-type: application/json' \
-d '{
"alkane": "4:797",
"repo_url": "https://github.com/kungfuflex/alkanes-rs",
"commit": "d787cdd0",
"package": "free-mint"
}'
Add "subdir": "path/to/crate" when the crate is not at the root of the repo. The clone happens inside the build VM, so only public repos on github.com, gitlab.com, codeberg.org or bitbucket.org work this way. Anything else goes in an inline bundle.
2. Inline bundle (private, self-hosted or deleted repos)
An inline bundle is your source tree as a gzipped tarball, base64 encoded, sent in the request. No git access is needed and nothing is fetched from your servers. The tree you send is the tree that is built and the tree that is published on your alkane's source page, so bundle exactly the workspace the alkane builds from.
Pack it from the workspace root, the directory that holds the Cargo.toml with [workspace] (or the crate's own Cargo.toml for a single crate) and your Cargo.lock:
cd path/to/workspace
tar czf /tmp/src.tgz --exclude=./target --exclude=./.git --exclude=./node_modules .
base64 -w0 /tmp/src.tgz > /tmp/src.b64 # macOS: base64 -i /tmp/src.tgz -o /tmp/src.b64
Build the request body with jq. --rawfile reads the bundle from a file, which matters because a large bundle does not fit in a single shell argument:
jq -n --rawfile b64 /tmp/src.b64 '{
alkane: "2:96782",
repo_url: "inline://subminer/alkanes/subminer-treasury@2a5739e1",
package: "subminer-treasury",
inline_src_b64: ($b64 | rtrimstr("\n"))
}' > /tmp/body.json
The inline:// label is provenance only. It is shown on the alkane page and never fetched, so use something that tells a reader where the tree came from, such as a repo path and a commit. Leave repo_url out and the explorer labels the bundle inline:///<block>/<tx>.
3. Dry run first
A dry run does every check a real submission does and builds nothing. It unpacks your bundle the way the build VM will, hashes it, confirms the alkane has bytecode on chain, and shows the recipe the explorer reversed from that bytecode. Dry runs have their own, larger rate limit, so iterate here.
curl -sS -X POST "$API/verify?dry_run=1" \
-H 'content-type: application/json' --data-binary @/tmp/body.json | jq
{
"ok": true,
"dry_run": true,
"alkane": "2:96782",
"request_hash": "5e0c…",
"bundle": {
"b64_bytes": 22192, "gz_bytes": 16643,
"sha256": "b8ab8162…", "unpacked_bytes": 71680,
"files": 7, "dirs": 2, "root_dir": "",
"has_cargo_toml": true, "has_cargo_lock": true
},
"onchain": { "sha256": "…", "size": 223578 },
"recipe_from_bytecode": {
"rustc": "1.91.0 (…)", "clang": "18.1.3",
"home_dir": "/home/ubuntu", "host_triple": "x86_64-unknown-linux-gnu",
"git_deps": [ … ]
},
"warnings": []
}
Read warnings carefully. A missing Cargo.lock, a target/ directory in the bundle or a rust-toolchain.toml that will be ignored all show up here.
4. Submit and poll
Send the same body without dry_run:
curl -sS -X POST "$API/verify" -H 'content-type: application/json' \
--data-binary @/tmp/body.json > /tmp/run.json
RUN=$(jq -r .run_id /tmp/run.json)
The response is the new run, queued. Poll its status until done is true. A build takes a few minutes; the worked example below took about eight and a half.
while :; do
curl -sS "$API/verify/$RUN" > /tmp/status.json
jq -r '"\(.status) \(.stage // "")"' /tmp/status.json
[ "$(jq -r .done /tmp/status.json)" = true ] && break
sleep "$(jq -r '.poll_after_seconds // 15' /tmp/status.json)"
done
jq '{verdict_class, match_pct, hashes, note}' /tmp/status.json
Sending the identical request again while it is still building returns the same run with "deduplicated": true instead of queuing a second build.
5. Read the result
{
"verdict_class": "reproducible",
"match_pct": 100,
"hashes": {
"expected": { "sha256": "035c0653…", "size": 223578 },
"built": { "sha256": "035c0653…", "size": 223578 },
"byte_exact": true
}
}
expected is the on-chain bytecode and built is what the build VM produced. Equal hashes mean reproducible. When they differ, match_pct and sections tell you where, and logs_tail holds the end of the build log for a failed build.
The alkane page at https://explorer.subfrost.io/alkane/<id> shows the verdict and the recipe, and https://explorer.subfrost.io/alkane/<id>/source serves the verified tree. For tooling, the verification record is one call:
curl -sS "$API/verification/2/96782" | jq
It returns verified, verdict_class, the on-chain bytecode sha256 (hashed by the explorer itself), built_sha256, byte_exact, the source provenance and the recipe that was used.
Worked example: SUBMINER treasury 2:96782
The SUBMINER treasury lives in a private repo, so it was verified from an inline bundle of seven files, about 22 KB of base64. The request carried these recipe fields alongside the bundle:
{
"alkane": "2:96782",
"repo_url": "inline://subminer/alkanes/subminer-treasury@2a5739e1",
"package": "subminer-treasury",
"rustc": "1.91.0",
"clang_version": "18",
"home_dir": "/home/ubuntu",
"host_triple": "x86_64-unknown-linux-gnu",
"build_env": "CLANG_SOURCE=ubuntu\nREMAP_PREFIX=/root/.rustup=/home/ubuntu/.rustup;/home/ubuntu/src=/home/ubuntu/subminer/alkanes"
}
The build ran in about eight and a half minutes and came back verified at 99.986%, shown as ≈ Verified (not byte-exact) on the alkane page with its source. The code section is identical. Sixteen bytes in the data section are not, and the reason is worth knowing because it catches many builds.
The original was built with its workspace at /home/ubuntu/subminer/alkanes and its toolchain under /home/ubuntu/.rustup. The build VM lays the source out at $HOME/src and the toolchain under /root/.rustup, so the recipe used REMAP_PREFIX to rewrite those paths with --remap-path-prefix. The rewrite worked for the paths themselves. But any non-empty RUSTFLAGS changes the hash cargo gives each compilation unit, and that hash is part of the directory a build script writes its output to: target/…/build/<crate>-<hash>/out/generated.rs. The crate includes generated code, its panic locations carry that path, and the hash inside it no longer matched. Rebuilt in the original layout with empty RUSTFLAGS, the same source is byte-exact.
The lesson is in the tips below: build what you deploy in a layout the verifier can repeat without remapping.
Request reference
POST /api/v1/{key}/verify, JSON body. The schema is published at /schemas/verify-request-v1.json.
Only alkane and a source are required. Every other field is reversed from the bytecode when you leave it out, and overrides that reversal when you send it.
| Field | Type | Default | Notes |
|---|---|---|---|
alkane | string | required | "block:tx", both decimal. Can come from build_info.identity.alkane_id instead. |
repo_url | string | inline:///<block>/<tx> with a bundle | An https clone URL on github.com, gitlab.com, codeberg.org or bitbucket.org, or an inline:// label for a bundle. |
inline_src_b64 | string | none | base64 of a .tar.gz of the tree. Line breaks are tolerated. |
commit | string | default branch | Git ref to check out. Provenance only for a bundle. |
package | string | default members | cargo -p. |
subdir | string | tree root | The crate's directory inside the tree. Relative, no ... |
rustc | string | from the bytecode | An exact release such as 1.91.0, or a dated channel such as nightly-2025-03-20. stable is refused because it cannot reproduce a past build. |
clang_version | string | from the bytecode | 18 or 18.1.3. Only matters when the crate compiles C, for example secp256k1. |
home_dir | string | from the bytecode | The build HOME, for example /home/alice. CARGO_HOME is $HOME/.cargo unless CARGO_HOME_OVERRIDE says otherwise. |
host_triple | string | from the bytecode | x86_64-unknown-linux-gnu, x86_64-apple-darwin, x86_64-pc-windows-msvc. Apple Silicon and aarch64 Linux have no build runner and are refused. |
git_pins | string | from the bytecode | One https-url rev [bare|rev] per line. |
git_url, alkanes_rev, git_source_form | string | alkanes-rs, from the lock, bare | A single git dependency pin, the older form of git_pins. |
git_date, freeze_commit | string | the deploy date | For builds without a Cargo.lock: freeze the crates.io index and unpinned git deps to a date or an index-archive commit. |
build_env | string or array | none | KEY=VALUE lines from an allow-list: CLANG_SOURCE, USE_HOMEBREW_CLANG, NO_RUST_SRC, REMAP_PREFIX, CARGO_HOME_OVERRIDE, PUBLISH_PATHS, COMMITTED_LOCK, FREE_MINT_BUILD_IN_PROGRESS, GIT_SOURCE_FORM, GIT_DATE, TYPO_FIX, the Windows and macOS build directory knobs, and CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS restricted to --remap-path-prefix rules. |
typo_fix | boolean | false | Rewrites a https:/ typo in Cargo.toml before resolving. |
build_info | object | none | A BuildInfo document, below. |
dry_run | boolean | false | Same as ?dry_run=1. |
An unknown field is ignored with a warning, so a typo such as reop_url shows up in warnings instead of silently doing nothing.
BuildInfo reference
BuildInfo is the self-contained build description alkanes-cli build-info produces. Send it as build_info and the explorer maps each axis onto the recipe. A partial document is fine; only source is required. Flat fields in the same request win over BuildInfo values, and each override is reported in warnings. The schema is at /schemas/build-info-1.0.0.json.
| BuildInfo field | Required | Effect |
|---|---|---|
schema_version | no | Must be 1.0.0 when present. |
identity.alkane_id | no | Used as alkane when the body has none; must equal it when both are sent. |
identity.target_sha256 | no | Must equal the on-chain bytecode sha256, or the request is refused with target_sha256_mismatch. A cheap guard against verifying the wrong build. |
identity.target_source | no | onchain. A local-file BuildInfo describes a wasm that is not deployed yet and is refused. |
source.kind | yes | git or inline. |
source.repo, commit, subdir, package | repo for git | As the flat fields. |
source.inline_files | for inline | [{path, content}]. Text as is, binary as base64:<data>. The explorer packs them into a bundle, sorted, with fixed timestamps, so the same files always give the same bundle hash. |
source.features | no | Must be empty. The build VM builds default features only. |
source.build_command | no | Recorded. The build is always cargo build --release --target wasm32-unknown-unknown [-p package]. |
toolchain.rustc_version | no | rustc. |
toolchain.host_triple | no | host_triple. A toolchain directory name such as 1.86.0-aarch64-apple-darwin is read as its triple. |
toolchain.target | no | Must be wasm32-unknown-unknown. |
toolchain.os_image, rustc_commit, linker | no | Recorded, not used. |
c_toolchain.version | no | clang_version. |
c_toolchain.source.method | no | apt sets CLANG_SOURCE=ubuntu, homebrew-bottle sets CLANG_SOURCE=homebrew, llvm-release uses the upstream LLVM release of that version. |
environment.home | no | home_dir. |
environment.cargo_home | no | CARGO_HOME_OVERRIDE when it is not $HOME/.cargo. |
environment.build_env | no | Appended to build_env. |
environment.remap_path_prefix | no | FROM=TO rules, sent as REMAP_PREFIX. Read the tips before you use it. |
registry.mode | no | committed-lock needs nothing; the builder detects your Cargo.lock. time-machine uses archive_commit as freeze_commit and timestamp as git_date. |
git_deps | no | Each {url, rev, source_form} becomes a git_pins line. |
cargo_lock_b64 | no | With inline files, written to the tree root as Cargo.lock when the files have none. |
resolved_deps, result, notes | no | Accepted and ignored. |
Source types
https:// on a public forge is cloned inside the build VM. Submodules come along. Credentials in the URL are refused, and so is every other host.
inline://<label> with inline_src_b64 builds the tree you sent. This is the way to verify a private, self-hosted or deleted repo, and the one that gives readers the exact tree that was built. The label can be anything printable up to 256 characters; inline:/// with three slashes and a path is a common style.
build_info.source.inline_files is the same thing in BuildInfo form, packed into a bundle for you. Send one or the other, not both.
Reproducibility tips
Most failed verifications come from a handful of causes. The dry run response and the status hashes tell you which one you hit.
Paths and RUSTFLAGS
The wasm embeds absolute paths from three places: your registry and git checkouts under CARGO_HOME, the standard library under RUSTUP_HOME when rust-src is installed, and any build script output under target/. Your own crate's source files are embedded relative to the workspace, so the workspace location usually does not matter, except when a build script generates code.
See what your wasm carries before you submit:
strings target/wasm32-unknown-unknown/release/my_alkane.wasm | grep -E '^/|/\.cargo/|/\.rustup/|/build/.*/out/' | sort -u | head -40
--remap-path-prefix can rewrite paths, and the worked example shows its cost: any non-empty RUSTFLAGS changes cargo's unit hashes, and those hashes appear in build script output paths. If your crate has a build.rs that writes code, do not rely on remapping. Build the wasm you deploy in a layout the build VM repeats as is. The build VM's default layout is HOME=/root, CARGO_HOME=/root/.cargo, RUSTUP_HOME=/root/.rustup, with the workspace at /root/src. A build that ran there needs no remap at all:
docker run --rm -v "$PWD":/root/src -w /root/src ubuntu:24.04 bash -c '
apt-get update -qq && apt-get install -y -qq curl build-essential clang protobuf-compiler >/dev/null
curl -sSf https://sh.rustup.rs | sh -s -- -y -q --profile minimal --default-toolchain 1.91.0 -t wasm32-unknown-unknown
. /root/.cargo/env
cargo build --release --locked --target wasm32-unknown-unknown -p my-alkane'
Deploy the wasm that command produced, then verify with clang_version: "18", build_env: "CLANG_SOURCE=ubuntu\nNO_RUST_SRC=1" and your bundle. The minimal profile has no rust-src, which is why NO_RUST_SRC=1 is in the recipe.
rust-src
With the rust-src component installed, rustc writes standard library panic paths as $RUSTUP_HOME/toolchains/<toolchain>/lib/rustlib/src/rust/library/…. Without it they read /rustc/<commit>/library/…, which is the same on every machine. The build VM installs rust-src unless the recipe says NO_RUST_SRC=1. If strings shows /rustc/ paths and no rustlib/src, send NO_RUST_SRC=1. If it shows rustlib/src under a RUSTUP_HOME other than /root/.rustup, expect the path problem above.
Lockfile
Commit and bundle the Cargo.lock you deployed with. With it, the build uses exactly those versions with --locked. Without it, the build resolves against the crates.io index as of your deploy date, which usually works and sometimes does not, because a lockfile resolved weeks before the deploy picks older versions than the deploy date implies.
Features
The build runs with default features. If you deployed with --features mainnet, make that feature set the default in the crate's Cargo.toml ([features] default = ["mainnet"]), commit it, and bundle that tree. The build output is identical; only how the features were selected changes.
Profile and opt-level
[profile.release] comes from your workspace Cargo.toml, so opt-level, lto, codegen-units, strip and panic are whatever your tree says. Bundle the same Cargo.toml you built with, including the workspace root's when the crate is a member. A .cargo/config.toml in the tree is honoured as well, so keep it if you built with it.
wasm-opt and other post-processing
The build VM runs nothing after cargo: no wasm-opt, no wasm-strip. It compares the raw cargo output. If you post-processed the wasm before deploying, the bytes cannot match. Deploy the cargo output directly when you want a verifiable alkane.
Toolchain and host
rust-toolchain.toml files are removed before the build; the toolchain is the rustc in the recipe, or the one recorded in the bytecode. cargo hashes the build host into every crate, so a wasm built on macOS or Windows only reproduces on that host type. Intel macOS and Windows builds go to build VMs of that type. Apple Silicon builds cannot reproduce byte-exact on any other machine and are refused, so build on x86_64 Linux when you want a verifiable deploy.
clang
Only crates that compile C, such as secp256k1, depend on clang. Its vendor and version string is written into the wasm. CLANG_SOURCE=ubuntu selects the distribution clang of the major you name, and CLANG_SOURCE=homebrew a Homebrew LLVM build.
Limits
| Limit | Value |
|---|---|
| Inline bundle | 8 MiB of base64 (about 6 MiB of tar.gz) |
| Unpacked bundle | 256 MiB |
| Entries in a bundle | 20,000 |
| Path length in a bundle | 255 bytes |
| Submissions | 6 per API key per hour |
| Dry runs | 60 per API key per hour |
| Build time | 90 minutes, then the run fails |
| Concurrent builds | a small shared queue; when it is full you get queue_full |
Errors
Every error response has the same shape. error is a readable message, code is stable for programs, field is a JSON Pointer into your request body, and errors lists every problem found, not only the first.
{
"ok": false,
"error": "rustc must be an exact release (1.86.0) or a dated channel (nightly-2025-03-20), got \"stable\"",
"code": "invalid_field",
"field": "/rustc",
"errors": [
{ "code": "invalid_field", "field": "/rustc", "message": "…" },
{ "code": "invalid_field", "field": "/build_env", "message": "build_env key PATH is not allowed" }
]
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_body | The body is not a JSON object. |
| 400 | invalid_field, invalid_type, missing_field | A field is malformed, has the wrong type, or is required and absent. See field. |
| 400 | unsupported_field | A BuildInfo axis the build VMs cannot honour, such as non-default features. |
| 400 | conflicting_fields, alkane_id_mismatch | Two parts of the request disagree. |
| 400 | bundle_invalid_base64, bundle_not_gzip, bundle_corrupt | The bundle cannot be read. |
| 400 | bundle_unsafe_path, bundle_special_file | The bundle has absolute or .. paths, or device files. |
| 400 | bundle_no_cargo_toml, subdir_not_in_bundle | The builder would not find your crate. |
| 401 | unauthorized | Missing or inactive API key. |
| 404 | no_bytecode | The alkane has no bytecode on chain. |
| 404 | run_not_found | No run with that id. |
| 409 | target_sha256_mismatch | Your BuildInfo describes different bytecode. |
| 413 | bundle_too_large, bundle_too_large_unpacked | Over a bundle limit. |
| 422 | unsupported_host | No build runner exists for that host triple. |
| 429 | rate_limited, queue_full | Slow down; Retry-After says how long. |
| 503 | runner_unavailable | Builds for that host are paused for maintenance. Retry later. |
| 502, 504 | upstream_unreachable, upstream_timeout | The verification service did not answer. Check the verification record before you resubmit. |
Warnings never block a submission. The common ones are no_lockfile, bundle_has_junk, rust_toolchain_ignored, unknown_field, overridden and ignored_field.
Status reference
GET /api/v1/{key}/verify/{run_id}
| Field | Meaning |
|---|---|
status | queued, building, then one of verified, unverified, failed, error. |
stage | Progress inside building, such as resolving or building. |
done, poll_after_seconds | Stop polling when done is true. |
verdict, verdict_class | The raw verdict and the class the alkane page shows: reproducible, verified, code_match, partial, mismatch, pending, unverified. |
proven | True for the badge verdicts. |
hashes | expected (on chain) and built, each {sha256, size}, and byte_exact. |
match_pct, sections | How close a non-exact build came, per wasm section. |
logs_tail, logs_truncated | The last 16 KiB of the build log. |
links | Paths to the status, the record and the alkane pages. {key} stands for your key. |
FAQ
Is my private source published?
The tree you bundle is shown on the alkane's source page once it verifies, because that is what verification proves. Bundle only the workspace the alkane builds from. PUBLISH_PATHS in build_env limits the published files to the paths you list, for a monorepo where other members are private.
Can a submission break someone else's verified alkane?
No. A result is only recorded when the rebuild matches, and a weaker verdict never replaces a stronger one for the same bytecode.
My build failed. Where do I look?
logs_tail in the status response. Compile errors, a missing Cargo.lock entry with --locked, or a dependency that needs a system library are the usual causes. Run the same build yourself in the container from the tips first; if it fails there it fails in the build VM too.
I got verified, not reproducible. Should I resubmit?
Only with a changed recipe. Identical requests produce identical results. Compare the two hashes, look at which sections differ, and work through the tips. For a contract you have not deployed yet, the reliable route to reproducible is to build the deploy wasm in the default layout shown above.
Do clones of my template need their own verification?
No. Results are stored by bytecode hash, so every alkane that runs the same bytecode shows the same verdict.
Can I verify before I deploy?
Not through this endpoint; it compares against bytecode on chain. Reproduce the build locally with alkanes-cli build-info, deploy that wasm, then verify.
Next steps
- Verifying alkanes: the verdicts and real examples.
- Reproducible builds: every reproduction axis in depth.
- Verify and Attest: the API reference, including the admin attest route.
- Source API: read a verified alkane's files.