feat(solana): payment suite, three unsigned-only tool plugins on a shared wasip2 core - #30
feat(solana): payment suite, three unsigned-only tool plugins on a shared wasip2 core#30zkasuran wants to merge 16 commits into
Conversation
…e-status on a shared wasip2 core Three tool components plus libs/solana-core, the wasm32-wasip2-friendly Solana substrate they share (JSON-RPC shapes, base58, PDA/ATA derivation, message serialization, instruction builders, durable nonces, Solana Pay URLs, exact decimal amounts, fail-closed policy). spl-transfer-build returns an unsigned SOL/SPL transfer under operator policy: recipient allowlist and per-mint caps enforced in the pure core, optional durable-nonce mode so the transaction survives an approval queue. payment-watch confirms settlement by Solana Pay reference key. nonce-status diagnoses the nonce account. All three: pure core + thin shim, host tests with mocked RPC, structured logging, minimal permissions, MIT.
… them The repository validates a plugin by copying plugins/<name> plus wit/v0 into a snapshot directory and running cargo there. A path dependency on ../../libs/solana-core does not exist inside that snapshot, so all three plugins failed to resolve their dependencies before any test ran. Each plugin now carries the core at plugins/<name>/solana-core and depends on it by a relative path that stays inside the plugin. libs/solana-core remains the single source of truth and libs/solana-core/vendor.sh re-copies it into every consumer, so the copies are generated, never hand-edited. To keep "one core" true in substance, each plugin pins a sha256 over the vendored sources and manifest in tests/vendored_core.rs. Editing one copy without re-vendoring the rest turns that plugin red. The copies are byte identical today (6032044173b83eb5b38f268c6f182a33c63320cfab3f5a0ac60785f47d2bda20). Listing the core as a workspace member also runs its own vector suite inside each plugin, so the byte-layout tests now execute wherever the plugin is validated instead of only at the old workspace root. Verified per plugin in a reconstructed snapshot (plugin dir plus wit/v0 only): cargo test --locked, cargo clippy --locked --all-targets -D warnings, the same clippy for wasm32-wasip2, cargo build --locked --target wasm32-wasip2 --release, and cargo fmt --check. All green.
Picks up the deterministic plugin quality gate and the WIT ABI alignment. The upstream WIT change touches channel, sockets and ws-client; this suite builds against tool.wit, so there is no overlap.
The modular solana crates compile to wasm32-wasip2 as libraries now, so the old 'solana-sdk does not compile' justification is stale. The real reason for the hand-rolled core is auditability: zero deps, every byte layout readable in-repo and pinned to spec vectors. Say that instead.
demo/run-demo.sh builds the three components, runs every suite, then drives the real compiled components through 18 scenarios against a local fake RPC on localhost. It prints counts rather than a green tick: 196 tests passing, the wasm byte size per component, 3 unsigned transactions built with their sha256, 9 refusals of which 7 refuse before any RPC call. Scenario output is diffed against demo/golden/local-fake.json so a drifted line fails the run. Runs from a clean clone with no credentials and nothing beyond localhost, about two minutes cold and under 20 seconds warm. A mainnet read-path capture is committed under demo/artifacts/mainnet-readpath/, request and response bodies included, with getGenesisHash in the record so the cluster is checkable. Mainnet accepted a simulation of an unsigned transfer this builder produced. That is a simulation, not a settlement: nothing signs, broadcasts or holds a key here, and the sender in the capture is a public wallet that is not ours. demo/out/ and target-shared/ are build output and stay ignored.
…label demo/verify-devnet.sh lets a stranger re-check this suite's one real settlement with no keypair, no wallet and no RPC credentials. It reads five accounts and one signature, and it separates two kinds of claim. The invariants fail the run: the supplier holds exactly 50,000,000 lamports, which is the whole of invoice 001 and the only payment ever sent to that address, and the nonce account is system-owned, 2,000,000 lamports, 80 bytes, decoding to version 1, state 1, a 5,000 lamport fee and the owner as authority. demo/decode-nonce.py does that decode from the same 80-byte layout nonce-status parses, so the plugin's claim is checked without the plugin. The settlement signature is reported rather than asserted. Devnet history depth is a property of whichever node answers rather than a promise: this signature returned nothing from the public endpoint on 2026-07-30 and returns finalized at slot 479019906 today. The balance is the durable proof, the signature is the convenience. Also drops "T1" from the nonce-status README. That tier vocabulary is not this repo's, and the maintainer asked a contributor to remove it in the PR zeroclaw-labs#25 review.
Three changes, all from the maintainer's own bar. A config error no longer repeats the value that caused it. `rpc_url must be https, got '<value>'` reached a model-visible ToolResult, and an operator's rpc_url is the config value most likely to carry an API key. It now says what is required and nothing about what was supplied, in the shared core and in both plugin-local checks. A test in the core pins it, so all three components carry the guarantee and it cannot regress silently: it feeds a URL with a credential in the path and asserts the message contains neither the value nor the credential. Every manifest that requests config_read now declares a closed Draft 2020-12 config_schema, which issue zeroclaw-labs#147 makes a biconditional: the host will reject a manifest carrying either without the other. Root is an object with additionalProperties = false and one explicit type per property, which is what makes the grant enumerable. rpc_url is required in all three, allow_recipients and caps are required for spl-transfer-build because an empty allowlist or empty caps already refuses every transfer, and nonce_account stays optional because absent means fresh-blockhash mode. allow_recipients and caps stay strings because the guest parses both with split(','); declaring them as arrays without converting the guest in the same commit would fail the whole parse rather than degrade. The READMEs now show the instance-key flow, since once the typed-config host lands `[[plugins.entries]]` is keyed on the zpi1_ instance id and legacy name-keyed entries are not consulted. Also drops the trailing blank lines at the end of demo/mainnet-readpath.py, which `git diff --check` flags and the repo's required fmt job runs under set -e.
The refusal text changed when the error stopped echoing the operator's rpc_url, so the two plain-http scenarios and the golden they feed both move with it. Same 18 cases, same verdicts, one string.
…e names The ordering and reference rules this suite implements now cite where they come from: the Solana Pay specification, "Specification: Transfer Request", at https://docs.solanapay.com/spec . Reference keys ride the transfer instruction "in the order provided as read-only, non-signer keys" and that instruction is last, which is why validators index the reference and why getSignaturesForAddress on the reference is the correct lookup rather than watching a wallet and hoping one invoice is in flight. Each package also has two names that are not interchangeable, and nothing pinned the pair. manifest.toml carries the package id an operator installs and configures, the component exports the tool name the model calls, and the host resolves config by the first while dispatching by the second. A rename on either side breaks the other half silently: config lands on a package nobody dispatches to, or the model calls a tool with no config. tests/tool_identity.rs pins both names and the separator transform, in all three packages. Also corrects the host build line. Upstream's Cargo.toml states every backend feature implies plugins-wasm, so the pair was redundant.
These three packages rest on one claim: an agent holding them cannot move funds. Nothing in the tree failed if a later diff broke that, so each package now carries tests/custody.rs. Four tests each: no signing crate in the locked dependency graph, no key-material spelling in any source the package compiles (its own and the vendored core's), only read-only JSON-RPC methods reachable through request_body, and no argument or config key able to carry a key, checked against the real parser with a transport that fails if it is called. The repository's required fmt job would have failed this branch. Three files were not rustfmt-clean: the core's config-sanitisation test, and the https check in payment-watch and nonce-status. Formatted, re-vendored from libs/solana-core, and the vendored-core digest re-pinned in all three copies. The wasm sizes do not move. Also corrects the vendor script's name where the digest test names it.
|
Ran this repository's own gate locally at
Two things came out of it. The The unsigned-only guarantee has a test now, not just a claim. Each package carries A review would be welcome whenever you have time. The one step I cannot do from here is |
… the mint's real owner An RPC endpoint is untrusted input and so is a tool argument, so both are now treated that way. Nine defects, each found by a test written to break something and each fixed rather than documented. Money, in order of how much it could cost somebody: - `parse_token_deltas` paired pre and post balances on (owner, mint), but one wallet can hold several token accounts for the same mint. A call where nothing moved reported 25000000 base units received. Paired on accountIndex now, and an index that changes mint or decimals between pre and post is refused. - an unparsable pre-balance counted as zero, so the whole post balance read as received. `"1e9"` was accepted. Typed refusal now. - `expected_amount.zip(mint)` meant a caller who named only the mint got no mint check at all, and the Solana Pay reference is public so anyone can tag a transfer with it. A transfer in another mint satisfied an expected mint. Mint and amount are independent filters now. Correctness, the Token-2022 hole: - `derive_ata` hardcoded the classic token program as its middle seed and `spl_transfer_checked` hardcoded its own program id, while the builder read the mint account and discarded the owner. A Token-2022 mint has the same 82 byte base layout, so decimals parsed fine, a classic ATA was derived and the built transaction named no Token-2022 program anywhere. It could never execute. `derive_ata(wallet, mint, token_program)` takes the program explicitly with no default, the owner read from the mint decides the derivation and the instruction, an owner that is neither token program is refused by name and the digest says Token-2022 so the human signing knows. - the same discard on the nonce path. A durable-nonce transaction carries the account's stored hash, so any 80 bytes could pick it. Refused unless the account is owned by the system program. Availability: - one hostile `getSignaturesForAddress` reply took the component down: the summary sliced a signature at byte 12 and panicked on a char boundary inside a multibyte character. - `from_base_units` raised 10 to the power of a u8 straight off the wire, which panics in debug and wraps in release for decimals of 20 or more. - base58 decoding is quadratic and arguments come from the model. 50k characters measured 5.15 seconds. Refused over 44 characters, which a test proves is the longest encoding of 32 bytes. Wire fidelity: - `read_compact_u16` accepted aliased length prefixes, where `[0x80, 0x00]` decoded as zero. solana-sdk's short-vec rejects that as an alias. Not reachable from these three components, which encode and never decode, so it is hardening of a public decoder rather than a live bug. Every config property in all three manifests now carries a description, which the maintainer's own template asks for and which config, the one surface an operator types by hand, was missing. Tests: 220 executions across three components to 273, 114 distinct to 161, in three new `tests/robustness.rs` files plus three inline core tests. Every one was run against the unfixed source first. `cargo clippy -D warnings` clean on host and wasm32-wasip2, `cargo fmt --check` clean, no new dependency.
Seven checks, stdlib Python, offline, about a second each. Each one reads an artifact or a manifest in this tree and exits nonzero if a property does not hold, so nothing in the submission asks to be believed. - `verify-capabilities.py` reads the capability list out of both import surfaces. `wasi:filesystem`, `wasi:sockets` and `wasi:random/random` are absent, so the component cannot go looking for a keypair. WASI says a binary can only do what its host agreed to let it do, so absence is the whole argument. - `verify-no-ed25519.py` closes the half capabilities cannot. RFC 8032 makes SHA-512 mandatory for Ed25519, three calls per signature, and there is no SHA-512 in these bytes. SHA-256 is asserted present in the component that derives addresses, because a probe that finds nothing has to be shown capable of finding something. - `verify-rpc-surface.py` proves the bytes name no method a node would act on. - `verify-artifact-hygiene.py` proves no embedded key material and that the exports are the tool the manifest declares. - `verify-provenance.py` ties this commit to the vendored dependencies and the artifact digests. - `verify-config-closure.py` proves the schema is closed at every object level and that the declared keys are exactly the keys the code reads. - `verify-refusals.py` proves the documented refusal set is the real one. `verify-all.sh` runs them all and anchors the output to the digests it read. `--report` prints the same thing as markdown for a submission form, so no number is ever typed by hand. `prove-teeth.py` breaks each property on a throwaway copy and requires the matching check to go red, because a check nobody has seen fail is decoration. So the custody claim is two proofs with a precise division of labour: it cannot seek a key, because it has no filesystem capability. It cannot use a key, because it has no way to compute the signature. What is not proven is stated in the same breath: HTTP egress exists, so nothing here rules out a remote signer.
House style bans them in outward words, and these were in strings a user reads as well as in doc comments a maintainer reads in the diff. Fifty four of them across the shared core and the three plugins, rewritten to a colon where the dash introduced a definition, a comma where it was a pause and a full stop where it joined two statements. The upstream plugins are untouched. Five of the fifty four were in scenario output, so this necessarily moves three things with it: the wasm digests, the vendored core digest the three plugins pin, and demo/golden/local-fake.json. The golden was re-recorded and its drift checked field by field first: every tx_sha256, tx_bytes, tx_base64, rpc_calls and ok value is identical, and only the five prose strings moved. The suite is re-pinned to the new bytes in the same commit, so no check is left pointing at a build that no longer exists.
The seven checks and the negative controls now run as stage 5 of demo/run-demo.sh, against the bytes that same run staged, with both logs left in demo/out/. A claim that does not hold fails the run, and so does a negative control that leaves its check green. Before this they were two commands somebody had to know about. Now the one command a reviewer already runs proves its own output.
One table from claim to check to the bytes it reads, the two commands, and why the capability argument is the platform's rather than ours, quoting WASI's own security page and its Capabilities document. The custody claim is stated as the two proofs it actually is: it cannot seek a key because it has no filesystem capability, and it cannot use one because there is no SHA-512 in the bytes and RFC 8032 makes SHA-512 mandatory for Ed25519. The limits section is the point of the file. HTTP egress rules out nothing about a remote signer. An operator can still hand the component a secret. A 32 byte secret in base58 is the same shape as a pubkey, so the key scan cannot catch that form and says so. The pre-RPC subset rests on a static ordering argument that prints its own reasoning. The digests reproduce on the same toolchain rather than universally, because these were built with 1.97.1 while CI pins 1.96.1.
Summary
mainspl-transfer-buildbuilds an unsigned SOL or SPL transfer against operatorpolicy,
payment-watchconfirms a Solana Pay reference actually arrived, andnonce-statusreports whether a durable nonce is usable. The agent proposes,the owner signs. No component in this PR can sign or broadcast.
package carries
tests/custody.rs, four tests: no signing crate in its lockeddependency graph, no key-material spelling in any source the package compiles
(its own and the vendored core's), only read-only JSON-RPC methods reachable
through
request_bodyand no argument or config key able to carry a key,checked against the real parser with a transport that fails the test if it is
called at all.
fmtjobwould have failed this branch: the shared core's config-sanitisation test and
the https check in
payment-watchandnonce-status. Formatted, re-vendoredfrom
libs/solana-core, digest re-pinned in all three copies. The wasm sizes donot move.
per-mint cap cross-checked against the mint's own decimals, an https-only RPC
that no call argument can redirect, plus unknown config keys refused. The code carries 44 refusal guards
and 37 of them return before the component's first HTTP call. Nine of the
eighteen demo scenarios exercise them and seven of those refuse with a measured
zero RPC calls.
config_readnow declares a closed Draft 2020-12config_schema, which issue config_schema is now required for every plugin that requests config_read (all 31 packages) #147 makes a biconditional. As of this push it isthe only contributor package in the repository that declares one.
rpc_url must be https, got '<value>'reached a model-visibleToolResult. An operator'srpc_urlis the config value most likely to carry an API key.manifest.tomlcarries thepackage id the host resolves config by, the component exports the tool name the
host dispatches by. A rename on either side used to break the other half
silently.
tests/tool_identity.rspins both and the separator transform.specification, "Specification: Transfer Request", at https://docs.solanapay.com/spec .
demo/run-demo.shbuilds allthree components, runs every test and drives them end to end against a local
fake.
demo/verify-devnet.shre-reads the one real settlement from publicdevnet with no keypair and no wallet.
run that printed it: https://zkasuran.github.io/zeroclaw-payment-desk/ . It
carries the custody boundary as a diagram, the 18 recorded scenarios verbatim, a
policy sandbox that replays the recorded run, the seven byte checks with what each
one reads, the nine negative controls, a nine row threat model whose third column
is the residual risk each control leaves behind, a 32 attack prompt-injection
drill against the running desk plus the limits. Its source is
docs/index.htmlinthat repository, generated by a script that refuses to publish a number the
artifacts do not contain.
libs/solana-corewith byte-identical vendored copies plus the demo rig. Itchanges no existing plugin, no host code, no WIT and nothing under
registry/.digest test pins across all four copies. The only cross-cutting effect is on
CI cost: 56 of the 131 changed paths sit outside
plugins/<name>/, which putsthe component validator into full mode across all 34 plugins.
does not close the repo-wide migration.
cleanly;
risk:*andsize:*need a maintainer.Every claim in this PR is a property of the shipped bytes
Two commands, no toolchain, no credentials, no network, about a second each.
verify-capabilities.pyverify-no-ed25519.pyverify-rpc-surface.pyverify-artifact-hygiene.pyverify-artifact-hygiene.pymanifest.tomlverify-provenance.pyverify-config-closure.pyverify-refusals.pyCustody is two proofs rather than one. The split is the point. A component can only do
what its imports allow, which is WASI's own framing rather than ours: "A WASI binary
can only do what its host has agreed to let it do. A component without a filesystem
import cannot read files." There is no
wasi:filesystemimport on either surface, sothese components cannot go looking for a keypair. That is not the same as proving key
material cannot reach them, because
wasi:cli/environmentandwasi:cli/stdinareimported by Rust's standard library. So the other half is proven from the other side:
Ed25519 is defined in terms of SHA-512, three calls per signature under RFC 8032
section 5.1. There is no SHA-512 in these bytes. It cannot seek a key. It could not use
one if it were handed it.
Every check ships with a negative control.
prove-teeth.pyplants SHA-512constants, writes
sendTransactionover a read method, injects PEM armour, ships oneplugin's bytes under another's name, reopens a config schema, names a config field for
key material, drops a refusal from the documented list and moves a recorded digest.
Each one has to turn its check red or the harness fails. A check nobody has seen fail
is decoration. Full map in
docs/VERIFIABLE-CLAIMS.md,including what each proof does not cover.
Nine defects this found in our own code, before a reviewer did
Writing tests to break things rather than to confirm them turned up nine real
defects. Three of them were about money.
(owner, mint), but one wallet can holdseveral token accounts for the same mint, so a call where nothing moved reported
25,000,000 base units received. Paired on
accountIndexnow.received.
"1e9"was accepted. Typed refusal now.expected_amount.zip(mint)meant a caller who named only the mint got no mintcheck at all. The Solana Pay reference is public, so anyone can tag a transfer with
it. A transfer in another mint satisfied an expected mint.
derive_atahardcoded the classic token program as its middle seed while thebuilder discarded the mint account's owner. A Token-2022 mint parsed fine, a classic
ATA was derived and the built transaction named no Token-2022 program at all, so it
could never execute. The owner now decides the derivation and the instruction. An
owner that is neither token program is refused by name.
transaction's blockhash, so any 80 bytes could pick it. System ownership required.
getSignaturesForAddressreply took a component down: the summarysliced a signature at byte 12 and panicked inside a multibyte character.
from_base_unitsraised 10 to the power of au8straight off the wire, panickingin debug and wrapping in release at 20 decimals or more.
measured 5.15 seconds. Refused above 44 characters, which a test proves is the
longest encoding of 32 bytes.
read_compact_u16accepted aliased length prefixes that solana-sdk rejects. Notreachable from these three components, so it is hardening of a public decoder.
Testing (required)
How you can test (when useful)
Plugins are not in the release binaries, so this needs a source-built host:
cargo build --release --features plugins-wasm-cranelift, then[plugins] enabled = trueplus one
[[plugins.entries]]per component. The desk that has been running thesecomponents since 2026-07-29 is exactly that build:
zeroclaw 0.8.3, wasmtime 45.0.3,cranelift in the binary,
enabled = truein its config.cli. The components run under the host as toolplugins; no channel surface is touched.
wasm32-wasip2target,Python 3 and curl. No credentials, no wallet, no RPC key.
RESULT: green, 273 test executions with 0failed, 18 of 18 scenarios clean,
golden: MATCH, plus every invariant holding inthe devnet verifier.
main(before): neither script exists onmainand noneof the three packages do, so the same steps have nothing to run.
How I tested
Everything below came from a run on this branch at
5895e0f, from a cleantree, quoting stage 3 and the verdict.
273 is test executions across three components. Distinct tests are 161, because the
shared core's tests run once inside each component. Both numbers are honest and
the second is the one to compare against a single-package entry.
git diff --check 23a5dcb..HEADclean, plus the repo's own
tools/ci/validate_components.shvia stage 1 of the rig:per component
cargo test, host clippy,wasm32-wasip2clippy, release build, allzero.
.wasmfiles are byte-identical to what a fresh clone builds and to what the desk holds,
by sha256, re-checked on the box on 2026-08-04 at 03:43 UTC. That the components instantiate and execute against
a real host: 6 of 6 cases pass against a host built at the WIT this repo pins.
That an unsigned transaction this suite built is accepted by devnet
simulateTransactionwitherr: null, slot 480463485, fee 5000, 6812 computeunits, memo
e2e invoice #1. Also that both sandbox refusals produce zerotransaction bytes.
upstream
master. There the same 6 cases fail atLinker::instantiate, becausethis repo's
wit/v0pin and upstreammasterhave diverged on one logging enumcase. Rebuilt against upstream's own
wit/v0the same sources pass 6 of 6 andeach component grows by exactly 13 bytes. This is a repository-level pin question,
not a defect in these packages:
tool.wit,plugin-info.witandtypes.witarebyte-identical to the pin. Moving the pin to
masterwould deletesockets.witandws-client.witand strip four webhook items fromchannel.wit,which 30 channel plugins in this repo bind against. I am not proposing that move
inside a Solana PR.
opt-in (
MAINNET=1) because it is the only stage that leaves127.0.0.1. Itscaptured output is committed under
demo/artifacts/mainnet-readpath/.Security & Privacy Impact (required)
spl-transfer-build: it returns anunsigned transaction and holds no key, so a human or the host signs. T0 Read
for
payment-watchandnonce-status. Secrets held by any of the three: none.The only secret an operator holds for this is an RPC key, which reaches the
component through
config_readfrom encrypted-at-rest storage rather thananywhere a model can see. Nothing here reaches T2, because nothing here can sign.
the residual risk it leaves, at
https://zkasuran.github.io/zeroclaw-payment-desk/#threat-model . The three worth
naming here, because they are the ones this PR cannot fix: a compromised RPC node
can fake a settlement, since
payment-watchreads balance deltas from whichevernode answers and the operator chooses that endpoint; a mint with a transfer hook or
a fee extension can move less than an instruction names, which is why settlement is
read from deltas rather than from the instruction; a 32 byte secret in base58 has
the same shape as an ordinary pubkey, so the key-material scan cannot separate them.
Third parties in the trust boundary: one RPC provider. No MCP server, no
facilitator, no remote signer, no custodian.
requests
http_clientandconfig_readand nothing else. No filesystem access, nosockets, no websockets.
config_readis now paired with a closedconfig_schemaso the grant is enumerable: the host validates the operator's values and rejects an
unknown key before the component starts. The guest checks them again rather
than trusting the host's copy.
taken from operator config. No call argument can redirect it, which is a refusal
test rather than a claim (
refuse-plain-http-endpoint, the same for thewatcher). Everything in the demo talks to
127.0.0.1unlessMAINNET=1.These components hold no key and cannot sign or broadcast. This push also stops the
https check echoing the supplied
rpc_urlinto an error, since that string reachesa model-visible
ToolResultand an operator RPC URL commonly carries an API key inits path. A test in the shared core feeds a URL with a credential in the path and
asserts the message contains neither the value nor the credential, so all three
components carry the guarantee.
Fixtures use devnet addresses and the well-known
mvinestest wallet.deliberately. It is tested. These tools return text a model reads. They also
read arguments a model wrote. The mitigation is that policy is never taken
from arguments:
injection_cannot_spoof_config(
plugins/spl-transfer-build/tests/builder.rs:260) asserts an injected__configcannot move the allowlist, the caps or the endpoint. The demo's
refuse-injected-argumentscenario shows the refusal happening before any RPC call.Yes, describe the risk and mitigation: covered inline above. The residualrisk we do not close is a dishonest RPC endpoint: an operator who configures a
hostile node can be lied to about balances and confirmations. That is why
payment-watchreports what it saw rather than asserting a payment is good, plus whythe verifier script hard-codes the addresses so a reader can point it at their own
node.
Compatibility (required)
libs/crate, one newdemo directory. No existing package, host contract or WIT file changes.
sections, documented in each README. Once the typed-config host lands,
[[plugins.entries]]is keyed on thezpi1_instance id and the READMEs show thezeroclaw config setflow rather than a name-keyed block.already uses, plus the
wasm32-wasip2target the validator requires.Rollback (required for medium/high-risk PRs)
Low risk, additive:
git revert <sha>removes the three packages and the demo. Nofeature flag, no migration, no state.
Running it, not just building it
The three components are not a demo that ran once. A desk instance has been running
them continuously:
zeroclaw-desk.service,NRestarts=0. It has run since 2026-07-29 01:49:48UTC. Restarted twice by hand and never once by a crash: 2026-08-02 08:17:28 UTC
then 2026-08-04 03:42:58 UTC, each time to pick up the components built from the
head this PR points at.
43 6,18 * * *UTC, restricted tononce_statusandpayment_watch.allowed_tools = ["nonce_status", "payment_watch", "spl_transfer_build", "memory_recall"]in
[risk_profiles.supervised], withspl_transfer_builddeliberately excluded fromauto_approveso every build hits the human checkpoint while the two read-only toolsrun the health cron unattended. That narrowing came out of a 32 attack prompt-injection
drill against this desk on 2026-08-04. The drill found the agent reaching for
shell,llm_taskplusmodel_switchunder attack. A deny list made it route tospawn_subagentinstead, so default deny went in. All 35 turns were re-run against it:zero attacks proposed any tool, zero reached the transfer builder. The residual is
measured rather than claimed at 1 in 5 for the strongest social engineering. The
checkpoint was then approved on purpose to show the plugin refusing an off-allowlist
recipient in one second with no transaction built. Transcripts, per turn traces and the
drivers are published at https://github.com/zkasuran/zeroclaw-payment-desk/tree/main/injection-drill.
through 2026-08-05 06:49 UTC. Every one reports
Nonce: READYagainst the realdevnet nonce account. The live trace file is capped and only held the last eight, so
the rest were recovered from its rotated
.tmpcopies; each entry in the publishedledger carries the file it came from, at
https://github.com/zkasuran/zeroclaw-payment-desk/blob/main/evidence/cron-fires.json.
The desk was down on 07-27 and 07-28, before that ledger starts. Fires up to 08-03
ran the 07-26 build and the 08-02 build of this source. Fires from 2026-08-04 06:43
UTC onward run the components built from this head.
.wasmfiles the desk holds are byte-identical, by sha256, to what afresh clone of this branch builds:
d57ad6bespl-transfer-build,7f6b8106payment-watch,
ffd4f0adnonce-status. Checked on the box at 2026-08-04 03:43UTC and re-checked 2026-08-05 09:19 UTC, with the copies they replaced kept beside
them as
*.wasm.pre-2026-08-04.Both sides of that comparison are
sha256sum, one on the box and one on a freshclone.
The honest part: the job was created on 07-26 10:59 and its first run was a catch-up
on 07-28 23:30, so five scheduled fires were missed before the service was made
durable. That gap is in the record because leaving it out would make the streak mean
less.
The devnet evidence and how to read it
One real settlement stands behind this suite: invoice 001, an unsigned transfer built
by
spl-transfer-buildon 2026-07-26, signed outside the agent by the owner.The durable claim is the balance. The supplier holds exactly 50,000,000 lamports,
which is the whole of that invoice and the only payment ever sent to that address, and
the nonce account still decodes to an initialised durable nonce with the owner as
authority.
demo/verify-devnet.shasserts both and fails if either moves.The signature is reported rather than asserted, because devnet history depth is a
property of whichever node answers rather than a promise. It returned nothing from the
public endpoint on 2026-07-30 and returns
finalizedat slot 479019906 today. Read itas a convenience, not as the proof.
Reference-verified settlements: 0. Invoice 001 predates the reference book, so
payment-watchcorrectly refused to verify it by wallet alone. That number is herebecause it is the number a reviewer should poke.
Where this fits the sponsor's brief
The bounty welcomes PIX, USDC reconciliation and BRL invoicing. We built the USDC
reconciliation half and not a PIX rail.
The part worth naming is narrower than a rail. Superteam Brasil's own Solana AI Kit
singles out
/audit-infraand its supply-chain submodules because that hygiene iswhat teams skip when they ship fast. An agent runtime installs third-party plugins
that touch money. The question an operator cannot answer today is what a plugin
can do before they install it. These three answer it from their own bytes in one
command, which is the gap this PR is really aimed at.
payment-watchkeys on the Solana Payreference rather than on a wallet, which is exactly the reconciliation problem a
merchant has when several invoices share one address. Claiming a Brazilian payment
rail we did not build would be easy to check and wrong.
What this does not prove
absolute path, so it exercises that lineage rather than the bytes shipped here. The
shipped bytes are covered by the rig and by the host-compat run described above.
paid.
rustc 1.97.1 while this repository's CI pins 1.96.1, plus
validate_components.shbuilds from a temporary snapshot path that ends up inside panic strings.
verify-provenance.pyrecords the toolchain next to the digests for that reason.rules out a component handing bytes to a remote signer. What rules out asking a
node to submit is the RPC surface check plus the code path and host egress policy.
the key scan cannot separate them by shape. It catches a full 64 byte keypair, PEM
armour, hex and the keygen JSON array, which are the four forms a key gets pasted
in.
AI assistance
AI assistance (Claude, Anthropic) was used in developing this change, including a
synthesised narration track on the accompanying video. The design, review and
verification are the author's. Verified before this push: the numbers in the testing
section above, each produced by a command in a run on this branch.