Skip to content

feat(solana): payment suite, three unsigned-only tool plugins on a shared wasip2 core - #30

Open
zkasuran wants to merge 16 commits into
zeroclaw-labs:mainfrom
zkasuran:solana-payment-suite
Open

feat(solana): payment suite, three unsigned-only tool plugins on a shared wasip2 core#30
zkasuran wants to merge 16 commits into
zeroclaw-labs:mainfrom
zkasuran:solana-payment-suite

Conversation

@zkasuran

@zkasuran zkasuran commented Jul 17, 2026

Copy link
Copy Markdown

Summary

  • Base branch: main
  • What changed and why:
    • Three Solana tool plugins for the payment loop an agent can safely own:
      spl-transfer-build builds an unsigned SOL or SPL transfer against operator
      policy, payment-watch confirms a Solana Pay reference actually arrived, and
      nonce-status reports whether a durable nonce is usable. The agent proposes,
      the owner signs. No component in this PR can sign or broadcast.
    • The custody claim is pinned by tests now rather than asserted in prose. Each
      package carries tests/custody.rs, four tests: no signing crate in its 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 the test if it is
      called at all.
    • Three files were not rustfmt-clean, so this repository's required fmt job
      would have failed this branch: the shared core's config-sanitisation test and
      the https check in payment-watch and nonce-status. Formatted, re-vendored
      from libs/solana-core, digest re-pinned in all three copies. The wasm sizes do
      not move.
    • Policy is compiled into the component rather than prompted: an allowlist, a
      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.
    • Every manifest that requests config_read now declares a closed Draft 2020-12
      config_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 is
      the only contributor package in the repository that declares one.
    • A config error no longer echoes the value that caused it. rpc_url must be https, got '<value>' reached a model-visible ToolResult. An operator's
      rpc_url is the config value most likely to carry an API key.
    • Each package's two names are now pinned by a test. manifest.toml carries the
      package 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.rs pins both and the separator transform.
    • The ordering and reference rules cite where they come from: the Solana Pay
      specification, "Specification: Transfer Request", at https://docs.solanapay.com/spec .
  • Two ways to check the work without credentials: demo/run-demo.sh builds all
    three components, runs every test and drives them end to end against a local
    fake. demo/verify-devnet.sh re-reads the one real settlement from public
    devnet with no keypair and no wallet.
  • A page that walks the whole change, with every number on it traced back to the
    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.html in
    that repository, generated by a script that refuses to publish a number the
    artifacts do not contain.
  • Scope boundary: this PR adds three plugin packages, a shared
    libs/solana-core with byte-identical vendored copies plus the demo rig. It
    changes no existing plugin, no host code, no WIT and nothing under registry/.
  • Blast radius: additive. The three packages are new, they share a core that a
    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 puts
    the component validator into full mode across all 34 plugins.
  • Linked issue(s): Related config_schema is now required for every plugin that requests config_read (all 31 packages) #147. This PR migrates its own three packages and
    does not close the repo-wide migration.
  • Labels: no label permission on this account. Path labels should apply
    cleanly; risk:* and size:* 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.

$ ./demo/verify-all.sh
  nonce_status.wasm            332253 bytes  sha256 ffd4f0ad
  payment_watch.wasm           367973 bytes  sha256 7f6b8106
  spl_transfer_build.wasm      409058 bytes  sha256 d57ad6be
  7 of 7 checks pass

$ python3 demo/prove-teeth.py
  9 of 9 controls provoked their check
Claim Check What it reads
It cannot go looking for a key. verify-capabilities.py the capability list in both import surfaces
It cannot use a key if handed one. verify-no-ed25519.py no SHA-512 constants, with SHA-256 asserted present as a control
It cannot ask a node to submit. verify-rpc-surface.py every JSON-RPC method name in the data sections and the raw file
It carries no key material. verify-artifact-hygiene.py PEM armour, keypair-length base58, 64 character hex, keygen arrays
It is the tool the manifest declares. verify-artifact-hygiene.py the export section against manifest.toml
These bytes came from this source. verify-provenance.py commit, vendored digests, per-plugin source digests, artifact digests
Config cannot smuggle anything in. verify-config-closure.py the schema closed at every level, declared keys equal to keys read
It fails closed, mostly pre-network. verify-refusals.py every refusal the code constructs against the documented list

Custody 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:filesystem import on either surface, so
these components cannot go looking for a keypair. That is not the same as proving key
material cannot reach them, because wasi:cli/environment and wasi:cli/stdin are
imported 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.py plants SHA-512
constants, writes sendTransaction over a read method, injects PEM armour, ships one
plugin'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.

  • pre and post token balances were paired on (owner, mint), but one wallet can hold
    several token accounts for the same mint, so a call where nothing moved reported
    25,000,000 base units received. Paired on accountIndex now.
  • 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. The Solana Pay reference is public, so anyone can tag a transfer with
    it. A transfer in another mint satisfied an expected mint.
  • derive_ata hardcoded the classic token program as its middle seed while the
    builder 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.
  • the same discard on the nonce path, where the account's stored hash becomes the
    transaction's blockhash, so any 80 bytes could pick it. System ownership required.
  • one hostile getSignaturesForAddress reply took a component down: the summary
    sliced a signature at byte 12 and panicked inside a multibyte character.
  • from_base_units raised 10 to the power of a u8 straight off the wire, panicking
    in debug and wrapping in release at 20 decimals or more.
  • base58 decoding is quadratic and arguments come from the model. 50k characters
    measured 5.15 seconds. Refused above 44 characters, which a test proves is the
    longest encoding of 32 bytes.
  • read_compact_u16 accepted aliased length prefixes that solana-sdk rejects. Not
    reachable 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 = true
plus one [[plugins.entries]] per component. The desk that has been running these
components since 2026-07-29 is exactly that build: zeroclaw 0.8.3, wasmtime 45.0.3,
cranelift in the binary, enabled = true in its config.

  • Reviewer testing requested? Yes. Two commands, both offline or read-only.
  • Interface(s) exercised: cli. The components run under the host as tool
    plugins; no channel surface is touched.
  • Setup / preconditions: a Rust toolchain with the wasm32-wasip2 target,
    Python 3 and curl. No credentials, no wallet, no RPC key.
  • Steps to run:
    bash demo/run-demo.sh        # builds three components, runs every test, drives them end to end
    bash demo/verify-devnet.sh   # re-reads the devnet evidence from public RPC
  • Expected on this branch (after): RESULT: green, 273 test executions with 0
    failed, 18 of 18 scenarios clean, golden: MATCH, plus every invariant holding in
    the devnet verifier.
  • Prior behavior on main (before): neither script exists on main and none
    of 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 clean
tree, quoting stage 3 and the verdict.

$ bash demo/run-demo.sh
  3 components, 273 tests passed, 0 failed
  golden: MATCH, 18 cases, sha256 f51c2e86546f2d550968a327eb7dfe3643556651d1f9f6abc3aa562ccacf5d42
  scenarios: 18 clean of 18
  nonce-status 0.1.0:       88 tests passed, 0 failed, wasm 332,253 bytes
  payment-watch 0.1.0:      92 tests passed, 0 failed, wasm 367,973 bytes
  spl-transfer-build 0.1.0: 93 tests passed, 0 failed, wasm 409,058 bytes
  scenarios: 18 run, 3 unsigned transactions built, 9 refusals, 7 of those refused before any RPC call
  wall clock 21s
  RESULT: green, 21s, every number above came from this run

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.

$ bash demo/verify-devnet.sh
  ok    8aFmTm...RDe9Y holds 50000000 lamports, exactly the 0.05 SOL of invoice 001
  ok    owned by the system program / 2000000 lamports / 80 bytes of data
  ok    version 1 / state 1 / fee 5000
  note  authority 2PQcNtSophRAG7ZsHaDT87Zx8MNkCu3GPKsmrR2qthty
  note  resolves on this node: slot 479019906, finalized, err null
  every invariant holds on https://api.devnet.solana.com
  • Commands run and tail output: as above. Also git diff --check 23a5dcb..HEAD
    clean, plus the repo's own tools/ci/validate_components.sh via stage 1 of the rig:
    per component cargo test, host clippy, wasm32-wasip2 clippy, release build, all
    zero.
  • Beyond CI, what did you manually verify? That the three shipped .wasm
    files 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
    simulateTransaction with err: null, slot 480463485, fee 5000, 6812 compute
    units, memo e2e invoice #1. Also that both sandbox refusals produce zero
    transaction bytes.
  • What I did NOT verify: the shipped bytes against a host built from current
    upstream master. There the same 6 cases fail at Linker::instantiate, because
    this repo's wit/v0 pin and upstream master have diverged on one logging enum
    case. Rebuilt against upstream's own wit/v0 the same sources pass 6 of 6 and
    each component grows by exactly 13 bytes. This is a repository-level pin question,
    not a defect in these packages: tool.wit, plugin-info.wit and types.wit are
    byte-identical to the pin. Moving the pin to master would delete
    sockets.wit and ws-client.wit and strip four webhook items from channel.wit,
    which 30 channel plugins in this repo bind against. I am not proposing that move
    inside a Solana PR.
  • If any command was intentionally skipped, why: the mainnet stage of the rig is
    opt-in (MAINNET=1) because it is the only stage that leaves 127.0.0.1. Its
    captured output is committed under demo/artifacts/mainnet-readpath/.

Security & Privacy Impact (required)

  • Custody tier, declared. T1 Build for spl-transfer-build: it returns an
    unsigned transaction and holds no key, so a human or the host signs. T0 Read
    for payment-watch and nonce-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_read from encrypted-at-rest storage rather than
    anywhere a model can see. Nothing here reaches T2, because nothing here can sign.
  • Threat model. Nine entry points are written down with what stops each one plus
    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-watch reads balance deltas from whichever
    node 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.
  • New permissions, capabilities, or file system access scope? Yes. Each manifest
    requests http_client and config_read and nothing else. No filesystem access, no
    sockets, no websockets. config_read is now paired with a closed config_schema
    so 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.
  • New external network calls? Yes. One Solana JSON-RPC endpoint, https only,
    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 the
    watcher). Everything in the demo talks to 127.0.0.1 unless MAINNET=1.
  • Secrets / tokens / credentials handling changed? Yes, in the safe direction.
    These components hold no key and cannot sign or broadcast. This push also stops the
    https check echoing the supplied rpc_url into an error, since that string reaches
    a model-visible ToolResult and an operator RPC URL commonly carries an API key in
    its 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.
  • PII, real identities, or personal data in diff, tests, fixtures, or docs? No.
    Fixtures use devnet addresses and the well-known mvines test wallet.
  • Prompt injection or untrusted model-visible text introduced/changed? Yes,
    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 __config
    cannot move the allowlist, the caps or the endpoint. The demo's
    refuse-injected-argument scenario shows the refusal happening before any RPC call.
  • If any Yes, describe the risk and mitigation: covered inline above. The residual
    risk 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-watch reports what it saw rather than asserting a payment is good, plus why
    the verifier script hard-codes the addresses so a reader can point it at their own
    node.

Compatibility (required)

  • Backward compatible? Yes. Three new packages, one new libs/ crate, one new
    demo directory. No existing package, host contract or WIT file changes.
  • Config / env / CLI surface changed? Yes, additively. Three new plugin config
    sections, documented in each README. Once the typed-config host lands,
    [[plugins.entries]] is keyed on the zpi1_ instance id and the READMEs show the
    zeroclaw config set flow rather than a name-keyed block.
  • Rust/MSRV/toolchain floor changed? No. Built with the toolchain this repo's CI
    already uses, plus the wasm32-wasip2 target the validator requires.
  • Upgrade steps: none. Nothing existing moves.

Rollback (required for medium/high-risk PRs)

Low risk, additive: git revert <sha> removes the three packages and the demo. No
feature 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:48
    UTC
    . 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.
  • Health cron 43 6,18 * * * UTC, restricted to nonce_status and payment_watch.
  • The desk agent that drives them is default deny on tools:
    allowed_tools = ["nonce_status", "payment_watch", "spl_transfer_build", "memory_recall"]
    in [risk_profiles.supervised], with spl_transfer_build deliberately excluded from
    auto_approve so every build hits the human checkpoint while the two read-only tools
    run 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_task plus model_switch under attack. A deny list made it route to
    spawn_subagent instead, 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.
  • The box holds fifteen consecutive scheduled fires with no gap, 2026-07-29 06:43
    through 2026-08-05 06:49 UTC. Every one reports Nonce: READY against the real
    devnet nonce account. The live trace file is capped and only held the last eight, so
    the rest were recovered from its rotated .tmp copies; each entry in the published
    ledger 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.
  • The three .wasm files the desk holds are byte-identical, by sha256, to what a
    fresh clone of this branch builds: d57ad6be spl-transfer-build, 7f6b8106
    payment-watch, ffd4f0ad nonce-status. Checked on the box at 2026-08-04 03:43
    UTC 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 fresh
    clone.

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-build on 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.sh asserts 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 finalized at slot 479019906 today. Read it
as a convenience, not as the proof.

Reference-verified settlements: 0. Invoice 001 predates the reference book, so
payment-watch correctly refused to verify it by wallet alone. That number is here
because 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-infra and its supply-chain submodules because that hygiene is
what 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-watch keys on the Solana Pay
reference 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

  • No third-party audit and no formal proofs.
  • The host end-to-end suite pins the 2026-07-17 build of byte-identical source by
    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.
  • The demo's stage 3 data is a local fake. Nothing in the rig was signed, broadcast or
    paid.
  • The digests reproduce on the same toolchain, not universally. These were built with
    rustc 1.97.1 while this repository's CI pins 1.96.1, plus validate_components.sh
    builds from a temporary snapshot path that ends up inside panic strings.
    verify-provenance.py records the toolchain next to the digests for that reason.
  • HTTP egress is imported, because that is how an RPC read works, so nothing here
    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.
  • A 32 byte secret in base58 is 41 to 44 characters and so is an ordinary pubkey, so
    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.

…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.
zkasuran and others added 8 commits July 25, 2026 19:41
… 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.
@zkasuran zkasuran changed the title feat(solana): payment suite — spl-transfer-build, payment-watch, nonce-status on a shared wasip2 core feat(solana): payment suite, three unsigned-only tool plugins on a shared wasip2 core Aug 1, 2026
…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.
@zkasuran

zkasuran commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ran this repository's own gate locally at 19d1b26, because a workflow run has never
fired on this PR. Every job in .github/workflows/validate.yml except the component
shards for the 31 untouched plugins, which I cannot stage here.

  • fmt: git diff --check clean against the merge base, cargo fmt --check clean in
    all three packages. It was not clean before this push. See below.
  • changes: tools/ci/plan_matrix.py reports mode=full, count=34, with strict and
    release both resolving to exactly the three new packages.
  • registry: 17 packager tests and 36 CI-tooling tests pass. The structure guard
    passes. --check-history preserves 24 generated release entries with 0 canonical
    metadata refreshes. --check-metadata matches 12 indexed entries and lists our three
    as pending unpublished sources.
  • wit-drift: wit/v0 is byte-identical to zeroclaw@e112ce6b, the commit
    wit/UPSTREAM_REF pins.
  • components for the three changed packages, through
    tools/ci/validate_components.sh: cargo test --locked, clippy on the host, clippy
    on wasm32-wasip2, release build. Every rc 0. 220 test executions with 0 failed
    (nonce-status 71, payment-watch 74, spl-transfer-build 75; 114 distinct, because the
    shared core's 53 run once inside each package). wasm 332,052 / 366,822 / 406,680
    bytes.
  • End to end: demo/run-demo.sh green in 94s from a clean tree with the build
    directories removed. 18 of 18 scenarios, golden 64608e7e MATCH, 3 unsigned
    transactions built, 9 refusals, 7 of those refusing before any RPC call.

Two things came out of it.

The fmt job would have failed this branch. Three files were not rustfmt-clean:
the shared core's config-sanitisation test and the https check in payment-watch and
nonce-status. Rust files change in all three packages here, so that job errors rather
than warning, which means Validate Required Gate could not have gone green. Every
fork PR in this repository sits at action_required, so no run has ever printed it.
Fixed in this push, re-vendored from libs/solana-core, digest re-pinned in all three
copies. The wasm sizes do not move.

The unsigned-only guarantee has a test now, not just a claim. Each package carries
tests/custody.rs, four tests: no signing crate in its locked dependency graph, no
key-material spelling in any source the package compiles including the vendored core,
only read-only JSON-RPC methods reachable through request_body and no argument or
config key able to carry a key, that last one checked against the real parser with a
transport that fails the test if it is called at all. curve25519-dalek stays allowed
and is named in the test as the exception: it does the on-curve check that separates an
address from a PDA and it cannot sign. The positive half was already in the core's
suite, where envelope_has_zero_signatures decodes a built transaction and asserts the
64-byte signature region is zero.

A review would be welcome whenever you have time. The one step I cannot do from here is
start the workflow, since a fork PR needs your approval to run it.

… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant