feat(plugins): PixZClaw — dual-rail BRL PIX + Solana Pay USDC invoicing (T0/T1) - #123
feat(plugins): PixZClaw — dual-rail BRL PIX + Solana Pay USDC invoicing (T0/T1)#123capitv wants to merge 9 commits into
Conversation
…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>
Validation run locally, since the workflows here need a maintainer to approve them
|
| 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.
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>
952f38e to
f2be9ae
Compare
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>
909c6ee to
5aa0056
Compare
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>
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).
brl-usdc-invoiceinvoice_idinvoice-statuspixzclaw-briefIt 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.bs58(sha256("zc-inv-v1" || invoice_id || "|" || merchant)[0..32]). Same invoice, same address, no database.getSignaturesForAddressfinds candidate transactions.getTransactionreadsmeta.preTokenBalances/meta.postTokenBalances, filtered bymint == usdc_mint && owner == merchant, and computes the net delta credited to the merchant. This coverstransferandtransferCheckedwithout parsing instructions, and handles an ATA created inside the same transaction.If the RPC cannot return a transaction, the tool degrades to
signature seen, amount unverifiedand 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 999999999is refused by the cap; amerchant_overrideis ignored whilerecipient_lockedis 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 vialog-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 injectedHttpTransport, output shaping) is vendored into each plugin undervendor/, so every plugin directory builds standalone from the CI snapshot. Nosolana-sdk, nosolana-client.cargo test --lockedin each plugincargo clippy --all-targets -- -D warningsclean, host andwasm32-wasip2wasm32-wasip2against the vendoredwit/v0config_readon all three, plushttp_clienton the two that read RPCRunning 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.