Everything below reproduces the two running use cases from a clean machine: the DePIN talking node (device-signed on-chain sensor feed + consumer) and the shop terminal (Solana Pay over Telegram/WhatsApp with human-gated, allowance-bounded spends). Times assume a Linux box or WSL2 Ubuntu. No step needs a secret of ours; everywhere a key appears you create your own.
Repo map (two repos): the ZeroClaw host is upstream, github.com/zeroclaw-labs/zeroclaw
(cloned in step 1). Everything this submission adds is in THIS repo
(github.com/belumume/zeroclaw-solana): the plugins, the solana-pay skill, the on-chain
zeroclaw_oracle + consumer_example programs (onchain/), the e2e-* reproducibility harnesses,
the x402-feed-gate node, and the webshop-pay pay page. Steps 2-7 run from a clone of this repo.
Start here. These three need stdlib Python 3 and nothing else: no pip install, no
virtualenv, no Rust, no Solana CLI, no API key, no config file, no account anywhere. Clone
this repo and run them. Two of the three never touch the network.
python3 scripts/verify_proof_offline.py # 0.8s, no network at all
python3 scripts/certify_publish_tx.py # 0.2s, no network at all
python3 scripts/verify-proof.py # about 10s, queries public devnet
All three together take between 13 and 35 seconds, exit 0. That spread is real and measured,
not a hedge: four independent runs gave 13.1s, 22.8s, 24.1s and 34.1s on two machines. Re-derive
with
time bash -c 'python3 scripts/verify_proof_offline.py && python3 scripts/certify_publish_tx.py && python3 scripts/verify-proof.py'.
Nearly all of that variance is the third command, which waits on a public RPC you do not
control. The two offline figures are stable: an independent run from a clean clone measured
0.63s and 0.58s against the 0.8s and 0.2s quoted above. Those two import no network library at
all, which is checkable without running them:
grep -c urllib scripts/verify_proof_offline.py scripts/certify_publish_tx.py prints a count
of zero for each file.
verify_proof_offline.py re-verifies the on-chain record from bytes committed in this
repo. For every captured transaction it checks the ed25519 signature against the exact
serialized message and decodes the instructions, so you read what was actually signed rather
than what a document says was signed. It runs five self-tests first, one positive control and
four negative, and refuses to report if any of them stops behaving, so a broken verifier
cannot print a pass. Last line:
PASS all 2 bundles verified offline: devnet-transactions.json, mainnet-transactions.json
That covers 32 devnet and 3 mainnet transactions, and it holds whether or not any RPC still serves them. Among them you can read the durable-nonce replay guard on every publish, and the over-cap transfer the on-chain allowance program refused, on both chains.
certify_publish_tx.py drives the fail-closed action certifier over one good publish
transaction and seven injected shapes, and prints 8/8 cases correct. The seven refusals are
an injected third-instruction SOL transfer, a token-program instruction swapped in for the
publish, a plain System transfer where the nonce advance belongs, a publish aimed at a
spoofed feed account, a versioned (v0) message, which this path does not emit and which
would misalign every offset if read as legacy, and two POSITIONAL attacks: our real feed
present in the instruction but seated third behind an attacker's feed and device, and the
same seated second. Those last two matter because Anchor binds accounts by POSITION and
tolerates extras as remaining_accounts, so asking only whether our feed appears somewhere
in the list certifies a publish that lands on someone else's.
verify-proof.py is the only one that needs the network, and it needs no credential for
it. It queries public devnet and prints PASS or FAIL per claim, ending in 10/10 static claims plus every live claim it could gate. The split matters: only the live ones can go red.
The live count is derived from what actually gated rather than pinned, so it rises as each
optional claim starts gating, and a claim reporting PENDING is never tallied as verified.
Or open any explorer link in docs/DEVNET-PROOF.md; the programs, the feed sequence history,
and the devnet x402 settlements are all public devnet. The gate has also settled once on
mainnet-beta, for 1.000000 USDC, while serving a reading from that same devnet feed. The
settlement is
3gSg3mQE9vA5X9CmFBxGEY2EFSAMXGhaC1HrUDbH8uA3MQhuaVjCdHjb1kshyzTqWKRALa9EQPeKja2Hk2rWcF2f.
Settlement and feed-read now take separate RPC endpoints, which is what makes that split possible
(see the run block below).
That settlement came from a locally-run gate with the split configured. The hosted endpoint at
x402.perfpilot.dev still runs the devnet default on both endpoints, so the challenge you get
from the curl below quotes the devnet mint and a payment against it settles on devnet. Both
statements are true at once and the distinction is the point: the capability is real and proven on
mainnet, and the box you can poke is not currently pointed there.
Still no host build, and none of these needs a key.
| Demo | Runtime | What it proves |
|---|---|---|
cd crates/solana-core && cargo run --example injection_demo |
51-105s cold, 2s warm (needs Rust; the cold figure is dominated by your machine, and both ends of that range are measured) | Feeds a 40,077-byte hostile token name carrying a bidi override, a zero-width space and injection framing through the real sanitizer. Prints it capped to 96 chars with 4 control characters cut, any bidi/zero-width residual: false, and the same neutralization on the error path. |
Open sanitizer-microworld/index.html in a browser |
instant, no build | The same sanitizer, compiled to wasm and embedded in the page as base64, running on whatever you type. Six presets, or paste your own. A bidi override plus a zero-width space reports invisible characters removed: 2 and renders the result under WHAT REACHES THE MODEL carrying the untrusted label. The page requests nothing over the network; the only fetch a browser makes on it is its own automatic favicon.ico lookup. python3 sanitizer-microworld/check_page.py confirms the blob is present and the script parses. |
curl -s https://x402.perfpilot.dev/price |
under 2s (on Windows see the note below this table) | The earning node answering live. Returns HTTP 402 with ONE price tier (one feed reading), the devnet USDC mint, the pay-to address, and a single-use memo nonce that changes on every request. Re-derive the tier count rather than trusting it, because it moved once when the day-pass tier was withdrawn from the deployed binary: python -c "import urllib.request as u,json; o=u.build_opener(type('N',(u.HTTPErrorProcessor,),{'http_response':lambda s,q,r:r,'https_response':lambda s,q,r:r})); print(len(json.loads(o.open(u.Request('https://x402.perfpilot.dev/price',headers={'User-Agent':'Mozilla/5.0'}),timeout=25).read())['accepts']))" prints 1. Note 402 is the correct answer here, so a bare urlopen raises and discards the body; the opener above is what reads it on one line. This one is a live demonstration rather than evidence: if the node is down you get a gateway error, whereas the two offline checks above verify from committed bytes. Its SHAPE is checkable though, by a grader that is not ours: cd scripts/x402-validator && npm ci --silent && node validate-challenge.mjs runs it past @x402/core's own PaymentRequiredV2Schema, with the pre-cutover body as a control that must be rejected. |
On Windows, that last curl can hang for about a minute and exit 35 without printing anything.
That is schannel refusing the handshake because it cannot reach the CA's revocation responder,
not the node being down. Add --ssl-revoke-best-effort, or read the endpoint with Python, which
ships its own TLS stack:
U=https://x402.perfpilot.dev/price
curl -s --ssl-revoke-best-effort "$U"
python3 -c "import urllib.request as u,sys
r=u.Request(sys.argv[1],headers={'User-Agent':'Mozilla/5.0'})
try: x=u.urlopen(r); print(x.status); print(x.read().decode())
except u.HTTPError as e: print(e.code); print(e.read().decode())" "$U"The try/except is load-bearing rather than defensive: urlopen raises on any 4xx, and 402 is
the whole point of this endpoint, so the obvious one-liner tracebacks on a perfectly healthy
response. Catching HTTPError and reading e.code / e.read() is how you see the challenge.
Do not reach for --ssl-no-revoke, which disables revocation checking globally to work around
one host.
The two test suites also need no host build, which is worth saying plainly because the build section below reads as though they do:
(cd crates/solana-core && cargo test) # 150 tests, ~30s, no network
(cd x402-feed-gate && cargo test) # 77 tests, ~100s, no network
227 green, from a clean clone, with Rust and nothing else. Re-derive the counts by summing the
test result: lines rather than trusting these figures. Both crates run more than one target,
and the totals above are the sum across all of them: cargo test in x402-feed-gate runs the
lib target (30) as well as the bin (47), and crates/solana-core runs its lib plus three
integration suites.
Without a toolchain the same totals come from the test attributes, none of which is #[ignore]d:
grep -rcE '^\s*#\[(tokio::)?test\]' crates/solana-core x402-feed-gate --include='*.rs'. The cd is required rather than
stylistic: there is no cargo manifest at the repo root, for the reason given in step 2.
Everything below this line needs a toolchain, and step 0 lists what. After step 1 you can also
run zeroclaw sop validate. The full evening path (host + plugins + a wired bot + a live turn)
is steps 1-7 below.
For verification: none of the below. The three checks at the top of this page and the three demos under them need stdlib Python 3, plus Rust for the one that says so and a browser for the one that says so. Everything listed here is for steps 1-7, which build and run the agents.
Needed for steps 1-6, the full evening path:
- Rust stable >= 1.96 (
rustup update stable), plusrustup target add wasm32-wasip2 - The Solana CLI (
solana,solana-keygen). The reference environment runssolana-cli 2.1.19from the Agave client. Used from step 0 onward for keys and airdrops. - A funded devnet keypair for the operator:
solana-keygen new, thensolana airdrop 2 --url devnet - A Telegram bot token (@BotFather then
/newbot, 2 minutes) - An Anthropic API key with credit on it. Step 3 wires it as the agent's model provider, and without it the agent parses config and answers nothing. Only the live agent turns need it; none of the verification above does.
zeroclawresolvable on your PATH. Step 1 builds the host inside its own clone, so the binary lands at<host-clone>/target/release/zeroclawand steps 2 onward call a barezeroclaw. Symlink it, copy it into~/.local/bin, or add that directory to PATH before step 2, or every command from step 2 down fails with "command not found" and the cause is one directory away from where you are reading.
Needed only for step 7, which is optional because our programs are already deployed:
- Anchor CLI 0.31.0, the version
onchain/Anchor.tomlpins. Check withanchor --version; a different minor version changes the generated IDL and the deploy flow. - Several more devnet SOL than the 2 above. Measured on chain: the two deployed programs hold 1.4983 and 1.3649 SOL of rent-exempt reserve, so deploying your own pair costs about 2.87 SOL against a 2 SOL airdrop. Step 7 says why you almost certainly do not want to.
Plugins are not in release binaries, so build the host from source at the pinned release:
git clone https://github.com/zeroclaw-labs/zeroclaw && cd zeroclaw
git checkout b119cc09 # verified against this commit, 2026-08-01
# Check the interface BEFORE patching anything, because the answer changed upstream
# and a patch applied blindly now ADDS A DUPLICATE VARIANT and breaks the build:
grep -c memory-audit wit/v0/logging.wit
# 1 -> upstream already carries it. Patch NOTHING. This is the case at b119cc09.
# 0 -> you are on an older commit that predates it. Add it in two places, or no
# plugin will register (the component model matches interfaces nominally, so
# a missing variant fails at instantiation, not at compile):
# wit/v0/logging.wit -> add `memory-audit,` after `note,`
# crates/zeroclaw-plugins/src/component_logging.rs -> add the matching arm:
# PluginAction::MemoryAudit => Action::Note,
cargo build --release --features plugins-wasm,plugins-wasm-cranelift,whatsapp-web
The umbrella feature alone integrates the runtime without a JIT backend, so every plugin will report "failed to load code." You need both. (Judges score Tier-3 against exactly this build, per the brief.)
whatsapp-web is the third flag and it is easy to lose. It is not in default-channels,
and WhatsAppWebChannel::new is #[cfg(feature = "whatsapp-web")], so omitting it removes
the channel with no error anywhere: [channels.whatsapp.<alias>] still parses, channel doctor simply does not list it, and every inbound message is dropped in silence. We lost the
channel to exactly this on 2026-07-25 during an unrelated rebuild. Telegram alone needs only
the first two flags.
The reliable confirmation is the daemon's own startup banner, which is the only place the host states what it actually constructed:
zeroclaw daemon 2>&1 | grep 'Channels:' # must list whatsapp.<alias>
If you want a static check before running anything, grep the binary for wacore (the WhatsApp
storage layer, which links only under this feature). Do not grep for whatsapp: the
cloud-API channel and the config schema compile unconditionally, so that matches on a host with
no web channel at all. And do not grep for whatsapp_rust, which we tried first: it is absent
even from a correct build, so it reports failure on a working host.
Verified against host commit b119cc09 (2026-08-01), which needs NO patch because upstream
now carries memory-audit itself. The earlier pin bcf1f25 was the head of one of this
project's own PR branches, so it resolved through the GitHub API from here and was
unreachable in anyone else's clone; that is why step 1 now checks the interface instead of
prescribing a patch.
wit/v0 is explicitly experimental and unfrozen, so it moves under you, and the failure is
silent until load time. Before building the plugins, compare your host's plugin-action enum
against this repo's vendored copy:
diff <(sed -n '/enum plugin-action/,/}/p' <path-to-host>/wit/v0/logging.wit) \
<(sed -n '/enum plugin-action/,/}/p' wit/v0/logging.wit)
Empty diff means you are in sync. The diff can be non-empty in either direction and they need opposite fixes, and getting it backwards costs a day:
- Host has a variant this repo lacks. Add it to
wit/v0/logging.withere and rebuild all plugins. - This repo has a variant the host lacks. This happens on any host commit predating
upstream restoring
memory-auditon 2026-07-23, which the current pin postdates. Patch the host: add the variant to itswit/v0/logging.wit, and add the one arm the compiler will then demand,PluginAction::MemoryAudit => Action::Note,incrates/zeroclaw-plugins/src/component_logging.rs. Do not delete the variant from this repo instead; the shipped components carry it and would all need rebuilding.
Component-model interfaces match nominally, so one enum variant of difference makes the
whole interface a different type and every plugin fails to REGISTER (registered: 0, plus a
linker error on zeroclaw:plugin/logging) even though cargo build and every test pass.
Two commands settle it rather than trusting either side:
strings <plugin>.wasm | grep -c memory-audit # expect 1; a stale build gives 0
./scripts/check-host-compat.sh <path-to-host> # compares all four wit files + all 9 components
check-host-compat.sh is the one to run if you only run one. It refuses a COMPATIBLE verdict
while any plugin is unbuilt, so it cannot pass you on partial evidence.
There is deliberately no cargo manifest at the repo root, so cargo has to run inside a
crate directory and a bare cargo build here fails with "could not find Cargo.toml".
Every plugin is its own workspace on purpose: solana-sdk does not compile for
wasm32-wasip2 inside a WIT component, and the devnet harnesses depend on it, so a root
workspace would drag it into every component's graph. The isolation is the reason the wire
format is hand-decoded at all.
From this repo:
for d in plugins/*/; do (cd "$d" && cargo build --target wasm32-wasip2 --release \
&& cp target/wasm32-wasip2/release/*.wasm .) || break; done
zeroclaw plugin install ./plugins/<name>/ # per plugin; repeat as needed
zeroclaw config set plugins.enabled true
Each component lands at plugins/<name>/target/wasm32-wasip2/release/<name>.wasm with the
hyphens in its name turned to underscores, so payment-watch produces payment_watch.wasm.
That is the path the strings <plugin>.wasm check in step 1 wants.
The cp is not tidying, and dropping it is what breaks the next line. Every manifest.toml
declares a BARE FILENAME (wasm_path = "payment_watch.wasm"), so plugin install looks for the
component BESIDE the manifest and exits with WASM file not found when it is only in the target
directory. A plugin directory holding its manifest and its component side by side is the unit you
distribute, which is why the manifest points there rather than into a build layout that belongs to
whoever compiled it. scripts/check-quickstart-plugin-install.py executes the loop above and
asserts the manifests resolve afterwards, so this paragraph and that command cannot drift apart.
Unset CARGO_TARGET_DIR before that loop if you keep it set. Cargo then writes outside target/,
the cp finds nothing, || break stops after the first plugin, and the loop still exits 0, so
you get one built component, none installable, and no failure to go on.
Each plugin dir carries manifest.toml (minimal permissions) and a README with its config
keys, custody tier and threat model. Seven of the nine plugins carry a captured
prompt-injection transcript. The two that do not, token-risk-check and lending-health,
carry the threat model without a captured attack, because an injection reaching them has
nothing to redirect.
Being read-only is not what decides that. Three plugins are T0 read-only:
token-risk-check, lending-health and payment-watch.
payment-watch holds no key and signs nothing, and it carries a transcript anyway, because
the verdict it returns is what a shop acts on when it hands over goods, and the on-chain
memo it reads back is attacker-controlled. What predicts a transcript is whether an
injection has something to redirect: a transaction for four of them, the recipient inside
the payment URL for solana-pay-request, and a settle-or-not verdict for payment-watch.
zeroclaw agents create demo
zeroclaw config set risk_profiles.demo.level supervised
zeroclaw config set agents.demo.risk_profile demo
zeroclaw config set agents.demo.model_provider "anthropic.default"
zeroclaw config set --no-interactive providers.models.anthropic.default.api_key <YOUR_KEY>
zeroclaw config set providers.models.anthropic.default.model claude-sonnet-5
Every spend builder stays human-gated. The auto-approved set is wider than "reads", and
saying so here rather than leaving a reviewer to diff it against the block: it also carries
shell, memory_store, http_request and web_fetch. What bounds those is not the
model's judgement. Channel turns run jailed to the agent workspace, egress for both fetch
tools is allowlisted to four hosts a few lines down, and cron_add was deliberately
removed. The shop's payment link comes from the solana-pay skill, not a plugin (the
tier demotion in the write-up), so solana_pay_request is deliberately absent here:
zeroclaw config set --no-interactive risk_profiles.demo.auto_approve \
'["token_risk_check","kamino_lending_health","payment_watch","oracle_publish_reading","shell","memory_recall","memory_store","cron_list","glob_search","file_read","http_request","web_fetch","calculator"]'
This is the list the running shop actually uses, regenerated from the live config rather than written from memory. The two drift: the documented list omitted two read-only risk tools that were live, so a reviewer auditing the documented posture would be auditing a posture nobody ran. Regenerating it from the live config is what keeps them equal.
cron_add is deliberately not here, though it used to be. A shop agent has no reason to
create a scheduled job while taking an order, and a cron entry is persistence that outlives
the turn it was planted in. cron_list is a read and stays. The DePIN publisher uses an OS
scheduler rather than zeroclaw cron, so nothing loses a capability.
Read this if you followed an earlier copy of this page. Until 2026-08-05 this section
told you to point payment_watch at devnet and to pass a devnet mint on every call. Both
instructions are now wrong, and following them is the exact failure the old text warned
about, with the chain the other way round: the watcher would poll devnet for a payment that
settled on mainnet, the order would sit at NOT_YET forever, and nothing would error.
The shop moved to mainnet, so the two sides now agree by default. payment_watch compiles
with DEFAULT_RPC = https://api.mainnet-beta.solana.com and defaults its mint to mainnet
USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v); the pay page settles on mainnet in
that same real USDC. No rpc_url override and no explicit mint argument are needed.
Re-derive both halves rather than trusting this paragraph:
grep -n DEFAULT_RPC plugins/payment-watch/src/watch.rs # api.mainnet-beta.solana.com
grep -c "api.devnet" webshop-pay/src/app.js # 0
grep -n "api.mainnet-beta" webshop-pay/src/app.js # the page's own RPC
Optional and recommended, since the plugin asks a second independent endpoint to re-derive a settle-worthy payment from its own copy of the chain (a corroborator sharing the primary's host is refused, because the same party answering twice is not corroboration):
zeroclaw config set --no-interactive tools.payment_watch.config.corroborating_rpc_urls \
'["https://mainnet.helius-rpc.com/?api-key=YOUR_KEY"]'
If you point the shop at your own devnet deployment instead, the override still exists and
it is now two settings rather than one: set tools.payment_watch.config.rpc_url to
https://api.devnet.solana.com and pass the devnet USDC mint
(4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU) on every call, because omitting mint
falls back to the mainnet one.
The second profile the node uses, documented here because an undocumented security profile is not a reviewable one:
zeroclaw config set --no-interactive risk_profiles.depin.auto_approve \
'["oracle_publish_reading","http_request","web_fetch","shell","memory_recall","memory_store","file_read","glob_search","calculator"]'
Egress for the two built-in fetch tools is allowlisted rather than left open, because an unset
allowlist defaults to ["*"]. These four are every host those two tools may reach.
They are NOT every host the agent contacts, and the distinction is the whole point of reading
this rather than trusting it. The Solana plugins do not call out through these tools at all:
each one's compiled wasm imports network-http and makes its own outbound request, which you
can read out of the shipped artifact with python3 scripts/check-custody-tier.py. Four of them
default to mainnet. Per the host's own sandboxing chapter these gates are tool-specific, so this
allowlist governs the two tools named below and does not govern plugin egress. Constrain a
plugin's endpoint with its rpc_url override, not with this list.
zeroclaw config set --no-interactive http_request.allowed_domains \
'["api.frankfurter.dev","api.devnet.solana.com","api.rugcheck.xyz","api.kamino.finance"]'
zeroclaw config set --no-interactive web_fetch.allowed_domains \
'["api.frankfurter.dev","api.devnet.solana.com","api.rugcheck.xyz","api.kamino.finance"]'
zeroclaw security status --agent demo shows the whole posture. To confirm the posture above
is the one you are actually running, rather than the one this page claims:
python3 scripts/check-config-drift.py
It compares your live config against this document and exits non-zero on any difference, naming anything running-but-undocumented. Written because the two had silently drifted here: a reviewer auditing a document is auditing the document.
zeroclaw config set channels.telegram.shop.bot_token <YOUR_BOT_TOKEN>
zeroclaw config set channels.telegram.shop.enabled true
zeroclaw config set channels.telegram.shop.stream_mode multi_message
zeroclaw config set --no-interactive security.leak_detection.enabled false
zeroclaw config set skills.allow_scripts true
zeroclaw config set skill_bundles.default.directory shared/skills/default
zeroclaw config set agents.demo.skill_bundles '["default"]'
multi_message: inpartialmode the final segment replaces the streamed draft, which silently ate the payment URL mid-conversation. Multi-message makes the link its own permanent, tap-to-copy message.leak_detection.enabled false: the outbound leak detector's entropy tier redacts public base58 addresses, and its deterministictoken=pattern eats Solana Pay's mandatoryspl-token=parameter, so every payment link left the shop as[REDACTED_…]garbage. This agent holds no key that can move funds, so an outbound regex is not what is protecting you here: signing lives outside the agent, the recipient is pinned in the page that transfers, and the spend ceiling is enforced on-chain. We deliberately do not claim the jail "denies it any real secret", which is broader than what we verified (a filesystem flag), and the assets at risk are not secrets anyway. If you hold secrets elsewhere, keep the detector on and expect broken links until upstream grows an allowlist.shellis auto-approved, which is the line in this posture a reviewer should push on hardest. It is what lets the agent run the pay-link generator and the earnings summarizer without a human in the loop on every order, and it is bounded by the workspace jail, which is a filesystem flag and not a sandbox. The reason that trade is acceptable HERE is the same reason stated above: this agent holds no key that can move funds, so the worst a redirectedshellreaches is a jailed working directory holding two stdlib scripts and a copy of the SOPs. It is not acceptable by default. If you point this profile at an agent that does hold a signing key, takeshellout first, because nothing else in this configuration is standing between it and that key.allow_scripts: script-bearing skills are deny-by-default; our skill ships a 20-line stdlib reference generator you can audit at a glance.
cp -r skills/solana-pay ~/.zeroclaw/shared/skills/default/solana-pay
mkdir -p ~/.zeroclaw/agents/demo/workspace/tools
cp skills/solana-pay/scripts/gen_reference.py ~/.zeroclaw/agents/demo/workspace/tools/
cp skills/solana-pay/scripts/pay_link.py ~/.zeroclaw/agents/demo/workspace/tools/ # wraps solana: -> tappable pay-page link
cp x402-feed-gate/scripts/summarize_earnings.py ~/.zeroclaw/agents/demo/workspace/tools/ # x402 earnings report (SOP reads it)
cp demo/confirm_settlements.py ~/.zeroclaw/agents/demo/workspace/tools/ # settlement verdicts read off the chain, never composed
# Two SOPs, not three. `payment-confirmation` is deliberately NOT installed: its confirmation step
# is a sentence telling the model not to invent a settlement, which is not a binding, and on
# 2026-07-26 the agent reported one from a substitute script when the tool was missing.
for s in evening-reconciliation node-earnings-report; do
cp -r sops/$s ~/.zeroclaw/agents/demo/workspace/sops/
cp -r sops/$s ~/.zeroclaw/data/sops/ # CLI tooling reads here
done
zeroclaw skills test solana-pay # 3/3
zeroclaw sop validate # ✅
The generator copy into workspace/tools/ matters: channel turns run jailed to the agent
workspace, so a skill referencing its own directory breaks in channels even though it
works from the CLI.
The SOP above is not installed because its confirmation step is prose. The replacement is
deploy/announce_settlements.sh, which derives every field of the outgoing message from the chain
via confirm_settlements.py and hands the bytes to zeroclaw channel send. No model is in that
path at any point.
It was runnable by hand and scheduled by nothing, which is the same as not running:
mkdir -p ~/.zeroclaw/bin ~/.config/systemd/user
cp deploy/announce_settlements.sh ~/.zeroclaw/bin/ # fixed path: a unit cannot locate this repo
cp deploy/zc-announce.service deploy/zc-announce.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now zc-announce.timer
systemctl --user list-timers zc-announce.timer # NEXT/LEFT populated = armed
systemctl --user start zc-announce.service # one run now
journalctl --user -u zc-announce.service -n 20 # "nothing to announce" is the healthy idle line
Note ~/zeroclaw is the host clone from step 2, not this repo, so the unit cannot reach the
script there. The copy above is what gives it a fixed path, and the script reads nothing relative to
its own directory, so it behaves identically from either location.
Both cp lines are also covered by python3 deploy/sync_workspace.py --apply in the drift-gate
section below, which is the maintained path: it copies from the same map the checker verifies
against, so a file placed by hand here and a file the checker looks for cannot diverge. Use the
explicit copies if you are installing the announcer alone; use the deployer once you want the box to
stay in step with a commit.
A minute is affordable because most runs end at the first step with nothing to announce, and it is what makes "the owner hears within about a minute of settlement" true rather than aspirational.
Two things worth knowing before you enable it. The service is Type=oneshot, so a SUCCEEDED run
reports ActiveState=inactive and reads as dead to a naive check. deploy/box_selfcheck.py reads
Result as well, for exactly this. And the script sends FIRST and commits to the ledger AFTER, so a
failed send re-announces on the next tick rather than swallowing a confirmation; a visible duplicate
is recoverable and a silent miss is not, when the customer has already paid.
If the binary or the confirmer is absent it exits 2 and says which, rather than announcing nothing quietly.
deploy/box_selfcheck.py runs on the box and asserts that the deployed skills and tools are
byte-identical to a named commit, that the network-bearing config fields still say mainnet, and that
no retired mint has drifted into agent state. That last one is a prohibition on known-bad values
rather than a scan for anything unfamiliar, because a Solana Pay reference key is a fresh random
address per order and agent memory legitimately fills with them; the deployed files, which we write
and hash, are still held to the stricter "nothing unfamiliar" rule. That verdict is worth more than anything an
outside prober can produce, because it can see deployed bytes and running services. That only holds
if something schedules it and something retrieves it.
python3 deploy/make_invariants.py # writes the manifest the check compares against
python3 deploy/sync_workspace.py # dry run: what on the box differs from this commit
python3 deploy/sync_workspace.py --apply # places the checker, the manifest and the units
systemctl --user daemon-reload
systemctl --user enable --now zc-selfcheck.timer
systemctl --user start zc-selfcheck.service
cat ~/.zeroclaw/state/box-selfcheck.json | head -20 # the verdict the gate publishes
The two python3 lines replace a cp sequence that was wrong in a way nothing could report. It
copied box_selfcheck.py and the units by hand and never copied the manifest at all, so
following it literally produced a box whose checker could not find its input. It also left the
checker outside the deploy map, which made it the one file on the box that could drift with nothing
able to notice: the manifest check compares the files in files, and the checker was not among
them. Both are closed now, and the second one is visible in the count: the manifest pins 22 files
where it used to pin 21, and corrupting the deployed checker makes its own manifest check report
bin/box_selfcheck.py: <a> != <b>.
sync_workspace.py refuses rather than guessing whenever the deploy would be incoherent: a dirty
working tree (the bytes are not any commit, so the sha it records would be a claim the box cannot
support), a manifest generated from a different commit than the files being copied, or a manifest
that was never generated. It writes ~/.zeroclaw/DEPLOYED_SHA last, so the box never advertises a
commit whose files failed to land, and it prints the systemctl commands rather than running them,
because a copied unit file is inert until systemd reloads it.
The gate serves it at /selfcheck, over the tunnel it already runs, so nothing has to reach into
the box. Three responses, and the status code carries the meaning rather than the body: 200 with
the verdict, 503 when the route is live but no verdict is on disk (the timer is not running),
404 when the deployed gate predates the route. scripts/verify-proof.py reads all three and
treats only the 404 as pending, because "not shipped yet" and "the check stopped running" need
opposite responses.
A verdict that says ok while being hours old is the failure this is built to make visible, so the
served body carries age_seconds derived from the file's own mtime rather than from any field
inside it, and the verifier fails on a stale one.
The unit exits 1 when the box has drifted, which marks it failed in
systemctl --user list-units --failed. That is deliberate: it is a second local signal that costs
nothing, and the verdict file is written before that exit, so a drifted run still publishes what it
found.
Detail lines are redacted before they are written, since this is served publicly: the home path
becomes ~ and a chat recipient becomes <recipient>. Mint and merchant addresses are left alone
on purpose: they are public constants and they are exactly what the checks assert, so hiding them
would leave a green checker saying nothing.
zeroclaw daemon # gateway + channels + cron scheduler
Config is read at STARTUP. If you change any zeroclaw config set value after the daemon is
already running (for example the leak-detection line above), restart it (Ctrl-C, then
zeroclaw daemon) or the change silently will not take effect.
Send /bind <code> (printed at startup) to your bot, then talk to it:
a customer wants to pay 25 USDC for order #1, make me the payment link
WhatsApp (optional): the daemon prints a pairing QR (channels.whatsapp.shop.session_path
enables Web mode, no Meta account). Scan it from WhatsApp under Linked devices. The code
rotates about every 20 seconds and the daemon stops emitting after roughly two minutes, so
any route that copies an image to a phone is slower than the rotation and hands over a code
that is already dead. Serving the live one is what works:
python3 scripts/qr_live_server.py # serves on 127.0.0.1 port 8899, self-refreshing
It reads the daemon log, reconstructs the modules from the half-block art (each text row carries two module rows, so this is lossless), and emits SVG, which needs no imaging library. It self-tests before serving and prints the grid size, because a page that renders nothing looks exactly like the pairing window having closed. The log path and port are constants at the top of the file; the default is the reference node's, so edit it if yours differs. A terminal font that distorts the QR is a rendering problem in your terminal rather than in the code, and this sidesteps it by never asking the terminal to draw it.
Keep this bound to localhost. Do not tunnel it. The page serves a live pairing QR, and a
pairing QR is a bearer credential: whoever scans it links their device to your shop's WhatsApp
session. The obvious move when the daemon runs on a remote box is to expose port 8899 through
something like cloudflared tunnel --url http://127.0.0.1:8899 so you can scan it from your
phone. That publishes the credential to the whole internet with no authentication in front of
it. We did exactly that on the reference node and left it up for five days before catching it.
Use SSH port-forwarding instead, which reaches the same page from your phone's network without
publishing anything:
ssh -L 8899:127.0.0.1:8899 <your-node> # then open http://127.0.0.1:8899 locally
Stop the server once the session is linked. It has no reason to keep running afterwards.
Fund the paying wallet before you open the link. The hosted pay page at
zeroclaw-shop-pay.pages.dev settles in mainnet USDC
(mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v). SOL alone cannot settle a payment
whichever network you are on: SOL covers the transaction fee, the transfer itself is the
SPL token, so the paying wallet needs both.
Two ways to run it, and the second costs nothing:
- Against the hosted page, on mainnet. Real USDC, and the demo order is 0.25 USDC plus a fee under a tenth of a cent. Fund the wallet you will pay from with at least the order amount.
- Against your own devnet deployment, free. Apply the two-setting devnet override
documented above (
tools.payment_watch.config.rpc_urltohttps://api.devnet.solana.com, and pass the devnet USDC mint4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDUon every call), servewebshop-pay/yourself, and take free devnet USDC fromfaucet.circle.com(choose Solana Devnet). Every claim in this document is checkable this way without spending.
Get the network wrong and the wallet returns an opaque internal error. The pay page pre-checks the balance and names the shortfall instead, which is the faster signal, but the underlying fix is funding the wallet on the network the page is actually using.
If you paired WhatsApp, sanity-check that the channel is actually live before trusting it:
the daemon's startup banner must list whatsapp.<alias>. If it lists only Telegram, you
built the host without whatsapp-web (step 1).
Ours are already live on devnet, and you do not need to deploy anything to check any claim
on this page. zeroclaw_oracle is EFCRmE5wFLoo5zJ4cu4J6rbQjmkiok8FmDekTGGXrCKn and
consumer_example is B2scuv95pA7yA3Kj36wmfoSVZ94WZfUmtwsfr9Kw39Pt, both public devnet, both
verified as executable by verify-proof.py in the first section of this page. Every reading
the node has published is readable from the chain by anyone, and the offline verifier checks
the signatures on 20 captured publishes without a network connection at all. Re-derive that
count with python3 scripts/verify_proof_offline.py | grep -c publish_reading.
Deploying your own copy is worth doing if you want to change the programs, and it costs real devnet SOL: the two deployed programs hold 1.4983 and 1.3649 SOL of rent-exempt reserve, so a fresh pair runs about 2.87 SOL against the 2 SOL that step 0 airdrops. Budget more airdrops, or skip this block and read from ours.
The programs live in onchain/, an isolated Anchor 0.31 workspace:
cd onchain
anchor keys sync # generate program keypairs, sync declare_id! + Anchor.toml
anchor build
anchor deploy --provider.cluster devnet # deploys zeroclaw_oracle + consumer_example
anchor idl init --provider.cluster devnet \
-f target/idl/zeroclaw_oracle.json $(solana address -k target/deploy/zeroclaw_oracle-keypair.json)
anchor idl init publishes the IDL on-chain so the explorer decodes instruction names instead of
"Unknown"; it ignores the ANCHOR_* env vars, so pass --provider.cluster devnet and keep the payer
at the default ~/.config/solana/id.json.
Then register a device feed and schedule the publisher:
- the agent turn calls
oracle_publish_reading(device key signs inside the sandbox, durable nonce, range/kind/sequence gates) - the host completes the fee-payer slot and broadcasts (the
.toolscompletion pattern) - schedule it every 20 minutes with your OS scheduler or
zeroclaw cron(scripts/verify-proof.pytreats a feed older than 90 minutes as stale, so a slower cadence will report your own node as dead)
Verify on explorer: the feed account's sequence increments with each run; the consumer
program (act_on_feed) proves the feed is consumable, not a memo.
| Symptom | Cause and fix |
|---|---|
plugins discovered: N, registered: 0 |
missing plugins-wasm-cranelift feature |
| headless turn hangs forever | a tool waits on the [Y]es/[N]o approval prompt; auto-approve it or run attended |
| skill works in CLI, "blocked by security policy" in channels | workspace jail: put runnable files in workspace/tools/ |
payment link arrives as [REDACTED_…] |
leak detector (step 4) |
| link appeared while streaming, gone in final message | stream_mode partial replacement; use multi_message |
sop list says none exist, files are right there |
SOP.toml needs a [sop] table + root-level [[triggers]]; SOP.md needs a ## Steps heading with 1. **Title**: body items, which is the form both shipped SOPs use |
| agent loops retrying an http fetch | http_request does not follow redirects; a 301 host move returns Cloudflare HTML; point skills at the exact current host |
| customer asks how to actually pay | the chat channels are TEXT-ONLY (no image send) and a solana: URI is not clickable in chat, so the shop sends a tappable https pay-page link (tools/pay_link.py wraps the solana: URL). The page renders a scannable QR (phone wallets) + a Connect-wallet-and-pay button (desktop extensions). Deploy your own page from webshop-pay/ (npx wrangler pages deploy webshop-pay) and set its URL in pay_link.py, or reuse the reference deployment pay_link.py already defaults to: https://zeroclaw-shop-pay.pages.dev/ (the page is static and stateless, safe to share) |
| bot replays an old/broken link | it memorized its own earlier output; zeroclaw memory clear --key <id> --yes |
| wallet shows "This dApp could be malicious" / "Failed to simulate" | Phantom-specific wording (each wallet's engine differs: Phantom uses Blowfish, Solflare/Backpack/Jupiter use Blockaid, Glow its own; severity ranges from an overridable warn to Solflare hard-blocking). Two independent causes stack, both overridable, neither a permanent block: (a) the paying wallet is unfunded or on the wrong network so the tx can't simulate, cleared by funding it on Devnet (solana airdrop 1 <addr> --url devnet); (b) the domain is brand-new, so reputation shows a soft "new domain" notice that self-clears in days. Our reference domain is verified NOT on Phantom's static blocklist, so it is never a hard block. Warning-free demo path: a phone QR-scan (a transfer-request skips the dApp-connect reputation flow), or a custom apex domain |
| the pay page auto-picks one wallet | it does not; the page enumerates every installed wallet via the Wallet Standard (@wallet-standard/app getWallets()) and presents a picker (the mechanism Jupiter's Unified Wallet Kit uses), persisting your last choice in localStorage. The only hardcoded wallet list is a legacy fallback that fires solely when no Wallet-Standard wallet is present |
Turn the feed into a per-request revenue stream. Build and run the gate:
cd x402-feed-gate && cargo build --release --example pay_client
X402_SELLER_WALLET=<your wallet> X402_MINT=<usdc mint> X402_FEED_PDA=<your feed> \
X402_EARNINGS_LOG=~/.zeroclaw/agents/demo/workspace/x402-earnings.jsonl \
X402_NETWORK=solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 \
X402_RESOURCE_URL=https://<your public host>/reading cargo run --release
To settle in real money while selling a devnet feed, add the two split RPC variables. Both
default to X402_RPC_URL, so a run that omits them is unchanged:
X402_READ_RPC_URL=https://api.devnet.solana.com \
X402_SETTLE_RPC_URL=https://api.mainnet-beta.solana.com \
X402_NETWORK=solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp \
X402_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
The gate prints both endpoints at startup, labelling which one moves money, so a half-applied split is visible rather than silent. Reading and settling were one client until 2026-08-05; they are separate because they are separate concerns and can honestly live on different clusters. The mint must match the settle cluster: the devnet USDC mint does not exist on mainnet-beta.
Those last two are not optional decoration, and the recipe omitted them until 2026-08-05.
Without X402_NETWORK the gate advertises solana-devnet, the v1 friendly form, which the
published v2 schema rejects. Without X402_RESOURCE_URL it falls back to
http://localhost:{port}/reading, and that is the trap: the schema accepts a localhost
resource url, so the challenge validates while advertising an address no payer can reach.
Check both rather than assuming, with the pinned reference grader:
cd scripts/x402-validator && npm ci --silent
node validate-challenge.mjs # the live reference node
node validate-challenge.mjs ../../body.json # or a challenge you captured from your own
It ships the pre-cutover body as a control that must be REJECTED, so a pass carries
information rather than just printing green, and it reads resource.url separately
because the schema alone cannot tell you that value is public.
GET /price returns the 402 menu; a client pays with pay_client (builds + signs a
TransferChecked + Memo) and retries GET /reading with the X-PAYMENT header. The gate
verifies the bytes, settles on-chain, and serves the reading. No facilitator, no key custody.
See x402-feed-gate/README.md for the threat model and the live devnet proof.
X402_EARNINGS_LOG points the gate's per-sale ledger at the agent workspace, so the
node-earnings-report SOP (installed in step 5) can read it and announce the node's daily
x402 revenue to the owner's channel ("the node that pays for itself"). Runs on a 20:00 cron.