Skip to content

feat(plugins): PixZClaw — dual-rail BRL PIX + Solana Pay USDC invoicing (T0/T1) - #123

Open
capitv wants to merge 9 commits into
zeroclaw-labs:mainfrom
capitv:feat/pixzclaw-dual-rail-brl-usdc
Open

feat(plugins): PixZClaw — dual-rail BRL PIX + Solana Pay USDC invoicing (T0/T1)#123
capitv wants to merge 9 commits into
zeroclaw-labs:mainfrom
capitv:feat/pixzclaw-dual-rail-brl-usdc

Conversation

@capitv

@capitv capitv commented Jul 21, 2026

Copy link
Copy Markdown

PixZClaw

Charge in BRL from chat. Get paid over PIX or over USDC on Solana. The agent never holds a key.

Brazil already has instant bank payments (PIX, ~150M users). What it does not have is a way for a freelancer or shopkeeper to accept stablecoins without an exchange account, a PSP contract, or a custodial wallet. PixZClaw issues one invoice on two independent rails and verifies the on-chain one honestly.

Tracks A (payments) + E (shared wasm substrate).

Plugin Tier Role
brl-usdc-invoice T1 Real PIX Copia e Cola (EMV + CRC16-CCITT) and a Solana Pay USDC URL under one invoice_id
invoice-status T0 Verifies the USDC actually received by the merchant: PAID / UNDERPAID / OVERPAID
pixzclaw-brief T0 Merchant till: USDC/SOL balances, 24h close, 7d sparkline, recent activity

It checks the amount, not just that "something happened"

The interesting part is invoice-status. Counting successful signatures on a Solana Pay reference is not settlement — anyone can touch a reference, and a customer can underpay.

  1. The reference is derived deterministically: bs58(sha256("zc-inv-v1" || invoice_id || "|" || merchant)[0..32]). Same invoice, same address, no database.
  2. getSignaturesForAddress finds candidate transactions.
  3. For each, getTransaction reads meta.preTokenBalances / meta.postTokenBalances, filtered by mint == usdc_mint && owner == merchant, and computes the net delta credited to the merchant. This covers transfer and transferChecked without parsing instructions, and handles an ATA created inside the same transaction.
  4. Deltas are summed across recent transactions, so an invoice settled by two partial transfers reads as PAID, while a transaction that merely references the invoice contributes nothing.

If the RPC cannot return a transaction, the tool degrades to signature seen, amount unverified and never reports PAID. Underpayment reports the shortfall instead of silently passing.

Honest about what it cannot see

Bank PIX settlement is invisible on-chain. The tools never infer it: the PIX leg is reported paid only when the operator says so. No PSP integration, which keeps bank credentials out of the agent entirely. Saying "I don't know" was preferred over a plausible guess.

Fail-closed against prompt injection

Amount caps and a config-locked recipient are enforced inside the plugin, below the model. An injected charge 999999999 is refused by the cap; a merchant_override is ignored while recipient_locked is set, and the derived reference still uses the config merchant. Both are covered by tests.

Layout

Follows plugins/redact-text: pure core, thin #[cfg(target_family = "wasm")] shim, cdylib + rlib, structured logging via log-record, host tests that need no wasm toolchain and no network.

The shared Solana/PIX substrate (solana-wasm-core: decimal math, EMV+CRC16, Solana Pay encoding, reference derivation, JSON-RPC over an injected HttpTransport, output shaping) is vendored into each plugin under vendor/, so every plugin directory builds standalone from the CI snapshot. No solana-sdk, no solana-client.

  • 101 host testscargo test --locked in each plugin
  • cargo clippy --all-targets -- -D warnings clean, host and wasm32-wasip2
  • Built for wasm32-wasip2 against the vendored wit/v0
  • Permissions: config_read on all three, plus http_client on the two that read RPC
  • MIT OR Apache-2.0

Running in production

These are deployed on a Raspberry Pi 3 running ZeroClaw with the Telegram channel, issuing and checking real invoices. Deployment repo, install scripts, agent skills, and an overview page: https://github.com/capitv/pixzclaw-pi · https://capitv.github.io/pixzclaw-pi/

One deployment lesson shaped the output format: the host redacts high-entropy base58 in chat, which breaks a raw solana: URL. The invoice therefore delivers the Solana leg as a QR link (the QR still encodes the full pay URL) and keeps the PIX copy-paste code in a fenced block, so the whole message stays forwardable to the customer.

capitv and others added 4 commits July 21, 2026 15:56
…ng (T0/T1)

Three tool plugins for the Brazilian long tail: charge in BRL from chat,
let the payer settle over PIX or over USDC on Solana, and verify the USDC
leg on-chain. No plugin holds a key or signs anything.

| Plugin | Tier | Role |
|---|---|---|
| brl-usdc-invoice | T1 | One invoice, two rails: real PIX Copia e Cola (EMV+CRC16) and a Solana Pay USDC URL sharing an invoice_id |
| invoice-status | T0 | Verifies the USDC actually received by the merchant; PAID / UNDERPAID / OVERPAID |
| pixzclaw-brief | T0 | Merchant till: USDC/SOL balances, 24h close, 7d sparkline |

Design notes:

- Amount verification, not signature counting. invoice-status derives the
  Solana Pay reference from sha256("zc-inv-v1"||invoice_id||"|"||merchant),
  finds the settling transactions through getSignaturesForAddress, then
  reads meta.pre/postTokenBalances per transaction to compute the net USDC
  delta credited to the merchant for the configured mint. Partial transfers
  are summed; a transaction that merely touches the reference contributes
  nothing. When the RPC cannot return a transaction the tool degrades to
  "signature seen, amount unverified" and never reports PAID.

- Honest about PIX. Bank settlement is invisible on-chain, so the PIX leg is
  only ever reported as paid when the operator says so. The tools do not
  guess and do not integrate a PSP, which keeps bank credentials out of the
  agent entirely.

- Fail-closed against prompt injection. Amount caps and a config-locked
  recipient are enforced inside the plugin, below the model: an injected
  "charge 999999999" is refused, and a merchant override is ignored while
  recipient_locked is set. Covered by tests.

- Pure core, thin shim, per the redact-text reference layout. The shared
  Solana/PIX logic (amount math, EMV+CRC16, Solana Pay encoding, reference
  derivation, JSON-RPC, output shaping) lives in solana-wasm-core, vendored
  into each plugin under vendor/ so every plugin directory builds standalone.
  No solana-sdk, no solana-client.

101 host tests run without network or a wasm toolchain. Built for
wasm32-wasip2 against the vendored wit/v0. Permissions requested: config_read
(all three) plus http_client for the two that read RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…maps

The invoice-status README still described signature-only settlement, which
has not been how the plugin works since it started reading token balances.
It now documents the real flow (getSignaturesForAddress, then getTransaction
over up to MAX_VALUE_CHECKS successful signatures, summing pre/post token
balance deltas filtered by mint and owner), the full verdict table with the
99.5% tolerance, why a signature alone is never reported as PAID, and how
partial payments and reference spam are handled. Each claim points at the
test that proves it.

Also across the three plugins:

- LICENSE (MIT) added; READMEs state MIT OR Apache-2.0 at the user's option,
  matching Cargo.toml.
- Track A / Track E declared, with a one-sentence defense of each custody tier.
- Config tables and config.example.toml brought in line with the code:
  watch_hint on the invoice plugin, usdc_mint now load-bearing on the status
  plugin rather than reserved.
- The payment-reminder flow documented, including that the plugins hold no
  cron permission: the invoice only prints the offer and the status tool only
  emits a teardown hint, while the host schedules and cancels.
- "What we would build next" and "What fought us on wasm32-wasip2" sections
  added, plus an output-budget section with measured character counts.
- pixzclaw-brief README grew to cover the 24h close-out, the sparkline
  legend, relative timestamps, and the injected-clock design.

Worked examples are verbatim output captured by calling the real entry
points; product output stays in pt-BR because that is what the tools emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The project's headline claim is that USDC settlement is verified for
real. Three paths could still produce a verdict that was not true.

1. Spam could mask a real payment
   status_tool.rs scanned only the 5 newest successful signatures, so
   six successful dust transactions touching the reference pushed the
   genuine payment out of the window and a paid invoice answered
   PENDING. Cost of the attack: six network fees.

   Now every successful signature the lookback returns is scanned, with
   an early stop once the invoice is covered. The cap is 64, and
   signatures beyond it are counted as unscanned rather than ignored.

   That accounting is the point: received_units is a LOWER BOUND
   whenever part of the scan is missing. A lower bound is enough to
   confirm a payment that it already covers, but never enough to assert
   a shortfall. Publishing "UNDERPAID … faltam 7.27" because the public
   RPC rate-limited us halfway through is the same lie as claiming PAID
   without checking, pointed the other way. An incomplete scan that has
   not covered the invoice now degrades to SIG OK.

2. Reused invoice_id could settle the wrong invoice
   auto_invoice_id was sha256(amount|description|merchant) with no time
   and no nonce, so two "R$ 10" charges on different days minted the
   same id, therefore the same reference, and yesterday's payment
   marked today's invoice PAID with a receipt. No attacker needed --
   charging the same amount twice reproduced it.

   The auto id is now salted with the issuance instant, supplied by the
   wasm shim so the core stays pure. It fails closed: an implausible
   timestamp refuses to mint an id rather than mint a colliding one.
   Explicit invoice_ids are still honoured as given, and the READMEs now
   say they must be unique per sale. Reference derivation is unchanged
   and still reproducible from the id.

3. Verification ran on f64 with a 0.5% tolerance
   A payer could send 0.5% less and receive a settlement receipt -- R$ 5
   short on a R$ 1,000 invoice -- and the sum itself was floating point,
   in the one part of the system advertised as real verification. The
   issuing side was already exact u128 integer arithmetic; the
   verification side was the loose one.

   Both sides are integers now. The RPC's uiTokenAmount.amount and
   decimals are used directly, mismatched decimals across transactions
   refuse to produce a verdict instead of summing nonsense, and the
   tolerance band is gone.

Also corrected the comment, the README and the tool description that
each claimed the old 5-signature scan already prevented masking. It
prevented one spam transaction, not six. A README that misdescribes the
security property is worse than no README.

Verified: 135 tests pass (83 core, 20 + 25 + 7 plugins), clippy
--all-targets -D warnings clean on all three, vendor-core.sh --check
reports no drift, rustfmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The upstream fmt job runs `git diff --check` against the merge base before
it runs rustfmt, and that check was failing: the vendored core carried two
markdown hard-break trailing spaces in its README and a blank line at EOF in
its Cargo.toml. The blank line was not hand-written — stripping the `[workspace]`
section during vendoring left behind the blank line that had separated it, so
every re-vendor reproduced it. The vendoring script now collapses trailing
newlines to exactly one, which fixes the cause rather than the symptom.

Version moves to 0.3.1 so the three plugins match the bytes that are actually
installed and demonstrated on the Raspberry Pi, instead of naming a 0.3.0 that
no longer corresponds to any shipped artifact.

No behavior change: 135 tests still pass (52 across the plugins, 83 in the
core) and `cargo clippy --all-targets -- -D warnings` is clean on all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@capitv

capitv commented Jul 25, 2026

Copy link
Copy Markdown
Author

Validation run locally, since the workflows here need a maintainer to approve them

validate.yml has not run on this PR — first-time contributor workflows wait for
approval — so this is what the same checks produce on my machine, plus one real
failure they caught and I have now fixed.

fmt — one genuine failure, fixed

git diff --check against the merge base was failing before this push. The
vendored core carried two markdown hard-break trailing spaces in its README and a
blank line at EOF in its Cargo.toml. The blank line was not hand-written: the
vendoring script cuts the crate's standalone [workspace] section, and that cut
left behind the blank line which had separated it, so every re-vendor reproduced
it. Fixed at the cause — the script now collapses trailing newlines to exactly
one — rather than by editing the generated file.

$ base=$(git merge-base origin/main HEAD); git diff --check "$base" -- plugins/
$ echo $?
0

cargo fmt --all -- --check passes on all three plugins. It still fails on nine
other plugins in the tree (bluesky, mattermost, mochat, notion, reddit,
telegram, twitter, wechat) — pre-existing debt that the transitional
baseline policy correctly warns about rather than failing, since this PR does not
touch their .rs files.

registry — structure guard clean

$ for dir in plugins/*/; do for f in manifest.toml Cargo.toml Cargo.lock; do
    [ -f "$dir$f" ] || echo "MISSING $dir$f"; done; done
$ echo $?
0

registry.json is deliberately untouched — the three plugins are new names, so
there is no prior version whose bytes could shift under an existing identity.

Components — tests and lints

Plugin cargo test cargo clippy --all-targets -- -D warnings
brl-usdc-invoice 20 passed, 0 failed clean
invoice-status 25 passed, 0 failed clean
pixzclaw-brief 7 passed, 0 failed clean
solana-wasm-core (vendored into each) 83 passed, 0 failed clean

135 tests total, no network in any of them: the RPC layer sits behind an injected
HttpTransport, so tests feed recorded JSON.

What I could not run here, stated plainly

The wasm32-wasip2 link step is blocked on this machine by a Windows
Smart App Control policy that refuses to execute wasm-component-ld
(os error 4551). Compilation for the target reaches the linker — so the code
type-checks and codegens for wasm — but I cannot produce the component locally
and will not claim otherwise. The components in the published release were built
on Linux from these exact sources.

Version

Bumped to 0.3.1 so the three plugins name the bytes that are actually installed
and demonstrated in production, instead of a 0.3.0 that no longer corresponds
to any shipped artifact.

If it helps review

The T0 verification is runnable without a ZeroClaw host at all:
examples/verify-live
calls invoice_status::status_tool::fetch_and_status — the same function the
component calls — and swaps only the injected transport (waki over wasi:http
in the plugin, curl there). It prints the address it is about to read and an
explorer link to it before it prints a verdict.

Happy to rebase or split this if any of it is easier to review in pieces.

capitv and others added 3 commits July 25, 2026 16:36
Found by reading a real invoice off a production Raspberry Pi. The PIX code in
the card was structurally corrupt — two CRCs, two country fields, a payload
nested inside a payload — and it carried a correct checksum, so nothing
anywhere reported a problem. A customer's bank app would simply have rejected
it.

Two defects, one visible and one underneath it.

The visible one: the operator had pasted the whole "Copia e Cola" their bank
generated into `pix_key`, instead of the key inside it. A PIX key never
contains "br.gov.bcb.pix"; a BR Code always does. That is now a refusal with a
message saying where the key actually is.

The one underneath: `tlv()` formats a length with `{len:02}`, which does not
truncate — it widens. A value of 100 bytes writes three digits and shifts every
field after it, and the CRC is then computed over the already-shifted bytes. So
the corruption authenticates itself, and the failure surfaces at the customer's
bank rather than here. Field lengths are now checked against what two digits can
describe, and `build_pix_payload` returns `Result` so an unrepresentable field
stops the invoice instead of shipping a code that certifies its own damage.

This is the same rule the amount verification already follows: when the tool
cannot produce a correct answer it says so, rather than producing a confident
wrong one.

139 tests (87 core, 52 plugins), four of them new: the real misconfiguration as
a regression fixture, the length boundary from both sides, and a walk that
parses a built payload back field by field and requires it to end exactly on a
boundary — the check that would have caught this the day it was written.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plugin asked qrserver for 320x320. A Solana Pay URL carrying recipient,
mint, amount and reference runs past 240 characters, so at that size each
module came out about two pixels wide. It looked correct in a screenshot and
failed at the only thing it exists for: being read by a phone camera off a chat
window that has already recompressed the image. Reported from a Raspberry Pi in
production — the QR in the invoice card would not scan.

640x640 with the standard four-module quiet zone puts modules back around ten
pixels. The endpoint is free and the cost is a larger PNG.

No test asserted a size, which is why nothing caught it. One does now: a QR
nobody can scan is a broken payment rail, not a cosmetic detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The USDC leg was QR-only, and a QR assumes a second screen. The product's own
instruction is "forward this message to your customer" — so the customer reads
it on the phone they would pay from, and cannot scan their own screen. The PIX
rail never had this problem: its payload sits in a code block, one tap to copy,
paste into any bank app. The Solana rail now works the same way, with the QR
kept for the cross-device case.

It was omitted for a real reason: the host redacts high-entropy base58 in chat
and the line came through as [REDACTED]. What changed is the evidence. The QR
link survives redaction while carrying the same base58 percent-encoded inside
it, which says the filter matches on text shape rather than on content. So the
raw line may survive too. That is a hypothesis about someone else's host, not a
fact, and the code comment says so — it gets verified against a live agent
before anyone relies on it.

A markdown link with a `solana:` scheme would be the nicer affordance and is
deliberately not used: Telegram validates URLs and can reject the message
outright, which would drop the whole invoice rather than one link.

Two tests changed sides. `format_qr_both_rails_no_raw_solana_line` asserted the
absence this commit removes; it is now
`format_qr_both_rails_and_a_copyable_solana_line`, and the integration test
matches. Both carry the reasoning, so the next person to read them sees a
decision rather than a leftover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@capitv
capitv force-pushed the feat/pixzclaw-dual-rail-brl-usdc branch from 952f38e to f2be9ae Compare July 28, 2026 00:18
The previous commit added a copyable `solana:` line so a customer reading the
forwarded message on their phone could pay without a second screen. Tested
against a live ZeroClaw host, it comes back as:

    solana:[REDACTED_HIGH_ENTROPY_TOKEN]?amount=10&spl-[REDACTED_SECRET]&…

Two filters fire: high-entropy base58 on the recipient and the reference, and
the `token=` parameter name on the mint. The line is unusable.

My reasoning for trying was wrong in a specific way worth recording. I argued
the redaction matched on text shape, because the QR link survives while
carrying the same base58. It does survive — and the base58 sits in it
*literally*, not encoded. So the filter is not shape-based at all: it skips
content inside `https://` URLs. There is no arrangement of the same bytes that
gets a payment URI through as text.

So the constraint is real and the honest thing is to state it rather than route
around it. The card now says paying in USDC needs another device to scan from.
A customer reading a forwarded message on the phone they would pay from cannot
scan their own screen, and for them the usable rail is PIX. That is the same
limitation a card terminal's QR has, and the product says so instead of
implying otherwise — the same rule it already follows for PIX settlement it
cannot see.

The test that asserted the line's presence goes back to asserting its absence,
carrying the measured output so the next person to have this idea finds the
result before spending a release on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@capitv
capitv force-pushed the feat/pixzclaw-dual-rail-brl-usdc branch from 909c6ee to 5aa0056 Compare July 28, 2026 15:15
The agent rewrote the invoice card in its own words, and the footer went with
it — the line carrying `teto R$ 1000 · destino travado=sim`, which is the only
place the card shows the spend limit and the locked recipient. For a product
whose claim is that the agent holds no keys and cannot redirect funds, that is
the worst line to lose.

It was obeying the instruction exactly. The system line said "preserve o código
PIX e os links de QR intactos", so it preserved the PIX payload and the QR
links and treated everything else as its own to paraphrase. Naming what to
protect grants permission to edit the rest — the same failure a SOUL rule had
in the same words, fixed there and not here.

The line now asks for the whole block verbatim, and says why: every line was
tested against Telegram, so a reworded one is an untested hypothesis going to
the person about to pay.

Tested, because both times this broke it broke silently: the assertion now
requires the demand itself, not just that some system line exists.

This channel reaches the model without touching the host's configuration, which
matters — the workspace SOUL file is not reliably the system prompt, and an
operator who cannot open the dashboard has no way to fix it there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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