Skip to content

Latest commit

 

History

460 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zeroclaw-solana

Two self-hosted ZeroClaw agents, both running now. One takes shop orders on WhatsApp and Telegram and settles them in mainnet USDC. The other publishes device-signed sensor readings into a typed on-chain account and sells them to other machines for stablecoin, per request. This repo is those two agents plus the plugins, on-chain programs, skills and SOPs they run on.

Reproducing it takes three commands. They need stdlib Python 3 and nothing else: no install, no key, no account. Together they run in 13 to 35 seconds, and two of the three never touch the network. QUICKSTART.md opens with them.

python3 scripts/verify_proof_offline.py     # re-verifies the captured transactions from bytes, no network
python3 scripts/certify_publish_tx.py       # drives seven injection shapes at the certifier, no network
python3 scripts/verify-proof.py             # re-checks every published claim against devnet

A real, unedited run of python3 scripts/verify-proof.py from a fresh clone of this repo, including one live claim that failed on the day it was captured.

That is a real run against a fresh clone, not a mockup: 10 static claims that cannot go red once true, and 5 live ones that can, one of which is red above because it currently is. A check that can only ever print PASS proves nothing about itself; scripts/verify_proof_selfcheck_control.py drives this one through all eight branches from a loopback server to prove it can fail. Captured on the date printed in its own title bar. Run verify-proof.py yourself and the state will likely differ by the time you do.

The ZeroClaw shop chat: a customer orders R$ 2, and the agent replies with a payment link and the conversion to 0.39 USDC on Solana mainnet at the quoted ECB rate

The still above links to a 2:15 demo. The landing page covers the same ground in text and ends with three checkout links you can open yourself: one has a single character changed in the recipient, and the page refuses it. Loading any of them sends nothing.

Start here

If you want Read
One image you can paste into a chat docs/assets/field-card.png. Regenerate it with python3 scripts/build-field-card.py
The whole submission on one page docs/ONE-PAGER.md
The same case at length, with the reasoning and the controls docs/ARGUMENT.md
What this is and why it is built this way docs/WRITEUP.md
To run it yourself QUICKSTART.md
Proof it is real, on chain docs/DEVNET-PROOF.md
Why each design call went the way it did, including what was rejected docs/DECISIONS.md
How it is tested, and what each layer cannot catch TESTING.md
What an adversarial audit found here, including what is still open docs/AUDIT.md
Eight things we believed that were wrong, and the measurement that killed each docs/WHAT-WE-GOT-WRONG.md
Every claim the demo video makes, with the command that re-derives it docs/video-claims.json
Ten verified defects found in the HOST this runs on, all reported upstream docs/HOST-SECURITY-AUDIT.md
The agent refusing an attack, verbatim docs/transcripts/
To poke the sanitizer yourself, no build needed the live microworld

What runs, and what it does not prove

A DePIN node that pays for itself. An ARM box takes an ambient temperature reading for Madinah, signs it with a key generated on that box, and lands it in a typed account owned by our oracle program, where a separate consumer program reads it and acts. A systemd timer keeps it publishing with no laptop involved. The same node also sells that reading per request over x402, so the machine earns the gas it spends: curl https://x402.perfpilot.dev/price returns an HTTP 402 challenge with a single price tier and a single-use nonce, and the nonce changes on every request. Three limits. That ARM box is an Ampere A1 on Oracle's free tier, measured at 0.00 EUR. Nobody owns that board; it is rented. Ampere Altra is genuinely ARM, so the word is accurate, and naming which kind costs nothing. The reading comes from a keyless public weather API on the current host rather than from a physical probe; a Raspberry Pi with a DHT11 is the hardware path, and the on-chain half is identical either way, because what is signed is the value and the device key, not the enclosure. And that x402 endpoint is a live demonstration rather than evidence: if the node is down you get a gateway error, whereas the on-chain claims below verify from captured bytes with no network at all.

One thing about it is checkable rather than demonstrated, and the grader is not ours. The challenge conforms to the x402 v2 spec as published, which you can confirm against @x402/core's own PaymentRequiredV2Schema in one command: cd scripts/x402-validator && npm ci --silent && node validate-challenge.mjs. It carries the pre-cutover body as a control that must be REJECTED, so a green result carries information instead of only being green, and it reads resource.url separately because the schema accepts a localhost value and so cannot tell you the advertised address is reachable. Conformance is a claim about the response shape; it says nothing about uptime, and the caveat above still stands.

A shop terminal that takes payments. A merchant agent on WhatsApp and Telegram quotes an order, hands the customer a tappable payment link, and confirms settlement only from the chain, never from the customer saying so. Confirmation requires the exact amount in base units, the exact mint, and the watched destination, all read from pre/postTokenBalances; the reference is an additional optional condition, not the check itself. A payment of the wrong amount, or of a token the payer minted themselves, does not settle an order. Brazilian orders are quoted in BRL at a stated rate and settled in USDC.

That rate is the one money-touching input a language model could otherwise invent, so it is taken off the model: scripts/rate_crosscheck.py reads the Brazilian central bank's published USD rate and refuses unless a second source agrees within a stated band, and it fails closed rather than guessing. check-pay-link-rate-agreement.py holds the pay path's copy of those constants to the original, because the deployed workspace gets exactly one file and cannot import the rest. The node runs this, so the model no longer supplies the rate on the live shop. Driven against the deployed file, a wrong figure is refused by name (expected: 19.14 for R$ 100 at 5.2236, BCB PTAX corroborated by ECB within 0.91%) and the correct one settles.

What the rate work does NOT close, stated because it is the remaining hole rather than a caveat: the order VALUE is still supplied by the caller. An implausible one is now refused in code, so "Table 4, R$ 0.05" no longer produces a link, but a PLAUSIBLE wrong amount still does: R$ 25 for a R$ 60 order sits inside any band a shop without a catalog can justify. One free parameter of two is gone and the second is narrowed rather than closed.

How each half is checked

The feed has published to devnet since 2026-07-25 and not one of its transactions has failed. Every 20 minutes is the median rather than a guarantee: the largest single gap is 61.5 minutes. The account holds the RegisterDevice call that created it plus one per reading, so the transaction count and the sequence number move together and both only climb. They read at least 1,516 and 1,514, measured 2026-08-15T19:41Z, and are higher by the time you run the commands below. The two differ by two rather than one because a consumer program has also read this feed once on chain, which is a transaction against the account that advances no sequence. One getSignaturesForAddress against JEtuZkcRzePbbLo8oiM26aqpbt1zJyLP4snvQCjVveg returns every transaction the device has ever sent, which is the complete history because the oldest of them is the account's own creation.

# the live sequence, and how long ago it last moved
FEED_PDA=JEtuZkcRzePbbLo8oiM26aqpbt1zJyLP4snvQCjVveg python3 scripts/feed_heartbeat.py

# re-checks every published claim against devnet. Stdlib only, nothing to install.
python3 scripts/verify-proof.py

Both use cases run on the same ARM node under systemd --user, and the two are not equally observable from outside. The DePIN feed is continuously checkable: it publishes on a timer and every reading lands on chain, so verify-proof.py can go red on it. The shop is a Telegram and WhatsApp client with no inbound port, and its trace is traffic-driven, so from outside a quiet shop and a stopped one look identical. That half is asserted here and machine-checked by a /health endpoint on the x402 gate, which asks systemd on the node directly.

A second and stronger check runs beside it, in the other direction. deploy/box_selfcheck.py runs on the node, compares the deployed bytes against the manifest written at deploy time, and publishes a verdict outward through the same tunnel at /selfcheck. Nothing reaches in, and the box reports on itself, which lets it see deployed bytes and running services that an external prober cannot. Inbound is not shut so much as impractical to automate: port 22 is blocked network-wide from the operator's location, and the one route that does work needs a browser and a session that expires, which is a cost rather than a wall.

Both halves of that are tracked and readable here. An hourly timer (deploy/zc-selfcheck.timer) recomputes the verdict, and scripts/verify-proof.py consumes it, distinguishing four outcomes by status code: a build predating the route prints PENDING and does not gate, a live route with no verdict FAILS because the timer stopped, and a stale verdict FAILS on an age derived from the file's mtime rather than from any field the writer controls. scripts/verify_proof_selfcheck_control.py drives all eight branches from a loopback server, so the claim is known to be capable of going red.

You can also ask the gate which commit its own binary was compiled from. That is a different fact from the deploy commit beside it, and the two are expected to differ: deployed_sha names the commit the workspace deploy came from, covering the files in deploy/deploy-targets.json, and a compiled binary is not one of them, so each can move without the other. verify-proof.py prints both, or read them directly:

curl -s https://x402.perfpilot.dev/selfcheck | jq '{deployed_sha, gate_build_commit, gate_build_commit_source}'

Read the source field alongside the commit. git means it was observed in the repository at build time; git-dirty means uncommitted code went into the binary, so the commit does not name what was built; env means a build flag asserted it; unavailable means the build had no repository at all.

The node serves that route, so the check reads the verdict and gates on it. The live count is derived from what actually gated rather than pinned: a claim the deployed build cannot yet answer prints PENDING and is never tallied as a verified one, and the count rises on its own when that deploy lands. Run scripts/verify-proof.py and each line names which of the four outcomes it hit.

Live on-chain evidence, all clickable, is in docs/DEVNET-PROOF.md. The verifier above reports static and live claims separately, and names what it does not cover.

Custody

The use cases run no fund-signing key at all. No plugin here holds a key that can move funds. Every plugin that builds a spend emits it unsigned, for a human to approve. One component signs, and naming it is more honest than the blanket claim: oracle-publish holds a fund-less device seed and emits a device-signed transaction with the fee-payer slot left empty, so a reading is attributable to the device while the signature that actually pays is still the operator's. Spends are additionally bounded on chain by the audited Solana Foundation Allowances program.

The cap demonstration uses one on purpose, bounded on chain, so the guarantee can be shown failing closed. It deliberately does the opposite of avoiding a key, because avoiding one proves nothing about what happens when an agent has one. A delegated session key, held by the agent, signs an over-cap transfer, and the audited program rejects it with custom error 0x12c (300, AmountExceedsLimit, defined in the upstream program's own source and IDL, cited in docs/MAINNET-PROOF.md). The chain refused the transfer; no plugin, no prompt and no operator had to be right for that to happen. A within-cap transfer signed by the same key settles normally, which is the control that stops the rejection being read as the key simply not working.

The same refusal now holds on mainnet with real USDC, because a rejection that costs nothing is a weaker claim than one that does. A 0.5 USDC cap, a 0.4 USDC spend that settled and moved value, and a 1.0 USDC spend the audited program refused with the same 0x12c. Deliberately sized so the over-cap amount stays inside the balance, since an attempt that also overdraws can be refused for insufficient funds and a rejection for the wrong reason proves nothing. Transactions, the reproduce command and the offline bundle are in docs/MAINNET-PROOF.md. The DePIN feed stays on devnet on the merits, and that page says why.

The two links below are the devnet pair, not the mainnet trio just described, and they are served by an endpoint whose retention is set by whoever runs it, so they may stop resolving at any time. Both still resolved when this line was last checked. The mainnet evidence is bytes in the repo for the same reason: python3 scripts/verify_proof_offline.py verifies both bundles offline with no network and cannot rot.

Kept because one of them opens on a failed transaction on purpose. The within-cap transfer settled; the over-cap transfer was refused on chain with custom program error 0x12c. That code is sourced, and the trap of re-sending the captured message at a different amount is explained, in docs/MAINNET-PROOF.md. The red error page is the evidence rather than a defect: the audited program refused a transfer the agent's own delegated key had already signed, and the transfer that settled is the control that stops the refusal being read as a key that does not work. Both were served by api.devnet.solana.com on 2026-08-01, eight days after they landed.

Retention on public devnet is set by whoever runs the endpoint and has moved before, so this rests on the captured bytes, not the links. They are in docs/proof-bundle/, and python3 scripts/verify_proof_offline.py re-checks the ed25519 signature over the exact serialized message with no network at all. If a link above has stopped resolving by the time you click it, that command still proves the same thing.

Human approval is a weak boundary on its own, because the sentence the human reads is one the model wrote. So the paths with a fixed intent never ask: scripts/broadcast_certified.py re-derives the intent from the exact serialized bytes and refuses an appended transfer, a swapped program or a spoofed feed. Run that check as a self-test with python3 scripts/certify_publish_tx.py, which puts seven injection shapes through it, and CI runs it on every push. Stated precisely, because this project's own argument is that a control which is claimed and enforced by no runtime path is worse than an absent one: what this repo proves is the MECHANISM, not the wiring. The scheduler driving the live node is operator-side configuration rather than a file in this tree, so a reader cannot confirm from the repo alone that the running timer invokes it. The bound that does not depend on this is the on-chain one below. Where intent is variable, a spend, the on-chain cap above is what holds, and it holds whether or not the operator was fooled.

The two use cases run no T2 fund-signer. Everything they touch sits at T0 (read-only) or T1 (builds a transaction the operator must sign), which the brief calls the sweet spot and which is the honest place for an LLM-driven system to stop.

One T1 component departs from the ladder's "secrets held: none" wording. oracle-publish holds a 32-byte device seed. It is fund-less by construction, and the code enforces that: the device is added as a readonly signer, and a hard check rejects any message whose fee payer is not signer index zero. So the device key can attest that a reading came from that device, and it can never pay a fee or source value. That is a narrower deviation than the ladder's wording allows for, and a wider capability than "holds no key" would have implied.

What the two use cases run on

Component Job Tier Network by default
oracle-publish Device-signed reading into a typed on-chain feed, behind a durable nonce T1 build, device co-signs devnet, because it writes to zeroclaw_oracle and that program is deployed there
payment-watch Confirms a payment only when amount, mint, destination and (when supplied) the Solana Pay reference all match on-chain T0 read-only mainnet
spl-transfer-build Unsigned SPL transfers that survive an approval queue via durable nonces T1 build mainnet
allowance-spend-build Spends bounded by the audited SF Allowances program T1 build mainnet, and its three real mainnet transactions are the custody proof
solana-core Shared wasm32-wasip2 core: transactions, PDAs, Token-2022, response-path sanitizer library none, it makes no network call
onchain/ zeroclaw_oracle and consumer_example, Anchor, live on devnet on-chain devnet
x402-feed-gate Sells one signed reading per paid request T0/T1, holds no key split: settlement is whatever X402_SETTLE_RPC_URL names and has settled on mainnet (3gSg3mQE…, 1.000000 USDC); the reading stays devnet, because the program owning the feed account is deployed there. The hosted endpoint runs the devnet default
skills/solana-pay Builds the payment URL. A skill, not a plugin, on purpose skill none, it builds a string

Read that last column as the compiled default with no config override, which is what a stranger gets on a fresh clone. Every row is a constant you can grep: grep -rn DEFAULT_RPC plugins/*/src/*.rs. Nothing signs on a read or a build, so the four mainnet rows cost nothing to run there, and pointing a risk check or a settlement check at mainnet is the difference between exercising it against real tokens and exercising it against a devnet toy.

solana-pay-request was built as a wasm plugin and then demoted to a skill, because building a URL is string work and string work does not need a sandbox. The reason first given for that call was wrong, and the correction is in docs/DECISIONS.md: the failure that matters is a well-formed URL carrying the wrong recipient, which a sandbox would not catch, so the guard is a hardcoded invariant in pay_link.py. The plugin stays in the tree as the evidence trail for that call rather than as part of the shipped path.

Three earlier plugins (token-risk-check, lending-health, depin-attest) predate the two use cases and are not part of them. They keep their own READMEs and tests. token-risk-check reads mainnet by default, and that is where the thing it looks for actually exists: the Token-2022 mint it was written to flag carries eight live extensions there, including a transfer hook and a permanent delegate, where the same address on devnet is a plain system account with no token data at all. lending-health names no cluster because it queries Kamino's REST API rather than an RPC, and Kamino runs on mainnet. depin-attest defaults to devnet, since it writes to the same devnet program oracle-publish does. depin-attest describes a T2 posture and is not run by either use case; oracle-publish is its T1 successor and is what actually publishes the live feed. The reasoning for both calls is in docs/DECISIONS.md.

The safety property

On-chain data is attacker-controlled. A token name or a memo can carry control characters, bidi overrides, or injection framing aimed at whatever reads it next, which for an agent is the model's own context. solana-core sanitizes every such value on the way back, including the paths that are easy to forget: JSON-RPC error messages, HTTP error bodies, and serde parse errors are attacker-influenceable too.

Run it. cargo run --example injection_demo in crates/solana-core feeds a 40 KB hostile token name carrying a bidi override, a zero-width space and injection framing through the real path, and shows it stripped, capped and labelled untrusted, on the data path and the error path.

The sanitizer contract is quantified over generated inputs rather than chosen ones, including idempotence, which is the property sanitizers most often fail. See TESTING.md.

Build and test

rustup target add wasm32-wasip2

cd crates/solana-core
cargo test --locked                                   # 150 tests, four suites
cargo test --test properties                          # 23 properties, 1024 cases each

cd plugins/<name>
cargo test --lib                                      # host tests, mocked RPC, no network
cargo build --target wasm32-wasip2 --release          # the shipped component

All of that runs on a clean runner on every push, plus the fail-closed certification self-test and all nine components in a matrix, with --locked throughout so a green run also proves the committed lockfiles are the ones that work. A second workflow re-verifies the published on-chain claims twice a day. A third re-checks interface parity against upstream HEAD, because the interface is unfrozen and drifting away from it once already came close to making every plugin fail to register; it runs daily at 05:41 UTC and has been running since 2026-07-25, so unlike the other two it reports on a moving target we do not control. Count its runs yourself rather than believing this sentence, with no account and no token:

python3 -c "import urllib.request,json; print(json.load(urllib.request.urlopen('https://api.github.com/repos/belumume/zeroclaw-solana/actions/workflows/host-drift.yml/runs?per_page=1'))['total_count'], 'runs')"

A workflow that does not exist returns 404 there, so a count is an answer rather than an endpoint that always succeeds. That endpoint allows 60 unauthenticated calls an hour per address, so an HTTP 403 means a shared address has spent them rather than anything about this repository. gh run list --workflow=host-drift.yml --limit 100 --json conclusion shows each run's verdict if you already have gh authenticated. See TESTING.md for what each of those can and cannot catch.

Building the ZeroClaw host needs three feature flags, and one of them removes a channel in silence if omitted. That is step 1 of QUICKSTART.md, worth reading before you build.

depin-attest also carries gated live-devnet tests (ZEROCLAW_DEVNET_PROOF=1) that broadcast for real. They are excluded from the normal run, which is mocked and offline.

Layout

crates/solana-core/       shared wasm32-wasip2 core (transactions, PDAs, sanitizer)
plugins/                  the tool plugins, one workspace each
onchain/                  zeroclaw_oracle + consumer_example (Anchor)
skills/solana-pay/        the payment-URL skill and its scripts
sops/                     the SOPs the agents run on a schedule
x402-feed-gate/           the paid-reading gate
webshop-pay/              the hosted payment page
e2e-localnet/             oracle flow against a validator
e2e-track-a/              the shop payment flow end to end
e2e-allowance/            the on-chain cap rejection
scripts/verify-proof.py   one-command live check of every on-chain claim
wit/                      the vendored tool-plugin WIT world

License

MIT. See LICENSE.

About

Self-hosted deny-by-default Solana agent: device-signed DePIN feed, x402 earning-node, merchant terminal. On-chain program bounds the agent.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages