feat(antd): opt-in signed-quote exposure + stateless VerifyQuotes - #242
Conversation
bc14d47 to
419c57c
Compare
…ST + gRPC) Hosted payments fraud control (V2-854 work items 1 + 1b): in hosted mode the party that pays is not the party that collected the quotes, so the payer must be able to verify a payment batch offline before settling it. Exposure: `include_signed_quotes` on the three prepare endpoints. Wave-batch responses carry `signed_quotes[]` — one entry per payments[] triple with the full signed PaymentQuote and its ADR-0004 commitment sidecar as opaque base64(msgpack) bytes (sidecars matched to quotes by commitment_hash == pin; entries restricted to the payment intent's paid quote set, since the network quotes the whole close group but single-quote payments pay only the median). Default-off; existing consumers see no change. Merkle candidate exposure is blocked upstream (private candidate pools, commitments deliberately discarded) and rides V2-934. Verification: POST /v1/verify/quotes + gRPC VerifyService. Stateless offline port of ant-core's quote_commitment_binding_is_valid, run by the party about to pay on its own antd: quote-hash recomputation, ML-DSA-65 signature, paid-fields equality (amount == 3x the signed price — the single-node payment multiplier), and the ADR-0004 binding with exact on-curve pricing. Verdicts carry the extracted fields caller-side policy needs (timestamp, content, price, rewards address, key count, pinned). New direct dep ant-protocol = "=2.3.2", pinned to the locked transitive version; all PQC verification routed through its re-exports. Verified against a live local devnet: real-quote batch verifies; inflated amount, redirected payee, and fabricated quote_hash are all rejected with named rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Go client surface for the V2-854 daemon additions: PrepareOptions with IncludeSignedQuotes (PrepareUploadWithOptions / PrepareDataUploadWithOptions / PrepareChunkUploadWithOptions), SignedQuoteEntry parsing on both prepare results, and VerifyQuotes with per-entry verdict models. Quote and sidecar blobs stay opaque — the client relays them, only antd parses them. Generated protobuf refresh for the new fields + verify.proto service. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generated PrepareChunkRequest/PrepareFileUploadRequest/ PrepareDataUploadRequest structs gained include_signed_quotes and the prepare responses gained signed_quotes; antd-rust's exhaustive struct literals must name them. Requests default to false (no behaviour change); the antd-rust client surface for the new options/endpoint can follow separately if wanted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… protos; add verify.proto to the drift list main now ships antd-rust's tonic-build output in src/generated/ (6895d29) instead of compiling ../antd/proto at build time, so this branch's new proto surface (include_signed_quotes / signed_quotes on the prepare messages, the VerifyService) has to be regenerated and committed: ANTD_REGEN_PROTO=1 cargo test --test proto_drift verify.proto joins the drift test's PROTOS list so the committed module tracks the daemon's full API. The branch's earlier duplicate `#[allow(clippy::result_large_err)]` commit is dropped — main carries it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6fa8332 to
4689218
Compare
dirvine
left a comment
There was a problem hiding this comment.
Review of hosted-payments fraud control (V2-854) — APPROVE (head 4689218)
Reviewed by a 6-seat independent panel (crypto/protocol ×3, 1:1-invariant, proto/generated-consistency, transport/DoS), cross-checked against pinned ant-core ant-cli-v0.3.5 / ant-protocol =2.3.4. No blockers; no dissent.
Verified sound:
binding_is_valid(signed_quotes.rs:231–305) is a faithful one-for-one port of ant-corequote_commitment_binding_is_valid— shape,MAX_COMMITMENT_KEY_COUNTcap, forced on-curvecalculate_price, sidecar presence/size/parse, signature,commitment_hash==pin, key-count equality. Thecompute_address(quote.pub_key)identity substitution is sound: networkPeerId == BLAKE3(node key) == BLAKE3(quote.pub_key) == sender_peer_idin real flows.- Payment verification: 3× multiplier ==
SINGLE_NODE_PAYMENT_MULTIPLIER=3; genuine prepares pass with no false rejection; paid-set is 1:1 by shared source across all six prepare endpoints; no underpay/redirect/overflow path (checked U256, decimal-only parse). - Panic-free on untrusted input — every parse failure becomes a per-entry
valid:falseor a 400/InvalidArgument; empty batch isvalid:false; ML-DSA work onspawn_blocking. - Wire-compatible: new proto fields default-false, backward compatible both directions; REST and gRPC carry byte-identical quotes cross-verifiable over either transport.
Warnings (non-blocking, recommend follow-up):
- SDK wrapper lag —
antd-rustclient hardcodesinclude_signed_quotes: falseand never surfacessigned_quotes(REST or gRPC);antd-goGrpcClientneither sets the flag nor copiesGetSignedQuotes()and has noVerifyQuoteswrapper. Proto surface is correct; consumers must drop to raw generated proto types. Please fix in this PR or a tracked follow-up. - Caps enforced after deserialisation — 1024-entry cap (rest/verify.rs:22→24) and per-entry byte caps run after body parse/base64-decode. Bounded by the 100 MB daemon-wide
DefaultBodyLimit(not unbounded), but a route-specific tighter limit plus a pre-decodelen > MAX*4/3check would make the cap bound the deserialisation it targets. - gRPC ceiling ≠ REST ceiling — tonic default 4 MiB decode limit effectively caps gRPC at ~600–750 real entries vs REST 1024. Harmless for the stated 256-chunk case; raise the limit explicitly or document the asymmetry.
- No auth/rate-limit — consistent with antd's uniform loopback-trust posture (default binds 127.0.0.1), not a regression from this PR; note in docs for non-loopback exposure.
Threat-model caveat (by design, documented at docs/external-signer-flow.md:410–412): the verifier proves internal consistency, not signer legitimacy. A malicious customer-controlled instance can mint its own keypairs and fabricate a passing batch, redirecting payment to its own address (up to ~1M-key count). Gateway policy layer must enforce expiry + replay ledger, match verdicts 1:1 against payments[] by quote_hash, compare content to the chunks being paid for, and refuse merkle batches outright (merkle prepares expose no verifiable evidence — deferred to V2-934). Treat a green verdict as tamper-evident-and-on-curve, not network-vouched.
…flight; map it in all 11 gRPC SDKs REST's prepare-upload response has reported `total_chunks` and `already_stored_count` since antd 0.10.0; the gRPC PrepareUploadResponse never did, so gRPC external-signer consumers could not reconcile "why am I paying for K chunks when the file is M chunks". The gRPC SDK mappers either defaulted the two model fields to 0 (rust, java) or silently left them at the model default (everyone else). - upload.proto: `uint64 total_chunks = 13`, `uint64 already_stored_count = 14` (12 is signed_quotes from #242, 11 merkle_batches); `build_grpc_prepare_response` populates both on the wave-batch and merkle branches from the same `PreparedUpload` fields REST uses. - 11 gRPC SDKs (rust, go, csharp, java, kotlin, dart, elixir, ruby, swift, cpp, py) map the two fields onto their existing PrepareUploadResult model; each in-process gRPC test's PrepareFileUpload mock now sets 3/1 and the prepare-upload test asserts them. Kotlin's vendored protos synced (copyProtos task output). - antd-rust generated code regenerated (ANTD_REGEN_PROTO=1). - antd-dart/tool/generate_proto.sh: missing line continuation after upload.proto made the shell try to execute wallet.proto. Regenerated stubs for go/py/ruby/dart/elixir/swift follow in a separate commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, swift PrepareUploadResponse gains total_chunks (13) / already_stored_count (14). Regeneration also brings these SDKs stubs in line with proto changes that had landed without a regen: the 0.12 health fields (write_ready, connected_peers, routing_table_size, rebootstrap_threshold, last_store_ok_secs_ago) in health.proto, and #242 signed-quote additions in chunks.proto / common.proto / upload.proto. Go regenerated for upload.proto only to avoid protoc-gen-go version-stamp churn. New stub files (events, verify) that were never committed are deliberately not added; alias-only churn in dart wallet.pbgrpc.dart reverted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Hosted-payments fraud control (Linear V2-854): in hosted mode the party that pays is not the party that collected the quotes — the payer receives
payments[]triples from a customer-controlled instance and must not trust them (a fabricated triple could name any address and amount). This PR gives the payer the means to verify a batch offline before settling it, in two halves:1. Opt-in signed-quote exposure (
include_signed_quotes)All three prepare endpoints (upload / data / chunk, REST + gRPC) accept the flag. Wave-batch responses then carry
signed_quotes[]— one entry perpayments[]triple with the full signedPaymentQuoteand its ADR-0004 commitment sidecar as opaque base64(msgpack) bytes (only antd ever parses them):signed_quotes[]is guaranteed 1:1 withpayments[].commitment_hash(sidecar) == quote.commitment_pinat emit time, so each entry is self-contained.2. Stateless offline verification (
POST /v1/verify/quotes+ gRPCVerifyService)A pure function of the request — no network, wallet, or session state — run by "the party about to pay" on its own antd. Per entry (port of ant-core's client-side
quote_commitment_binding_is_valid, with the peer identity derived from the quote's ownpub_key):quote_hash == hash(signed_quote)— the triple is tied to a concrete quoterewards_addressexact,amount == 3× signed price(the single-node payment multiplier — mirrored as a documented local const since ant-core's ispub(crate))commitment_hash == pin, count equality, andprice == calculate_price(count)by exact recomputation (baseline== calculate_price(0))Verdicts carry the fields caller-side policy needs (timestamp, content XorName, price, rewards address, key count, pinned). Policy itself (expiry, replay ledgers, chunk-set equality, count caps) deliberately stays caller-side. CPU-bound work runs on
spawn_blocking; 1024-entry cap.Dependencies
ant-protocol = "=2.3.2"— pinned exactly to the already-locked transitive version soPaymentQuote/StorageCommitmentcan't fork in the graph. All PQC verification routed through its re-exports (no new saorsa deps).xor_nameas dev-dependency (test quote construction).ant-core pin unchanged (Rebased 2026-09-16 onto main (ant-core 0.8.0 /ant-cli-v0.3.3).ant-cli-v0.3.5): the exactant-protocolpin moves=2.3.2→=2.3.4to match main's lock. Checked both sides: ant-protocol 2.3.2→2.3.4 touches only its own Cargo manifests, and ant-core 0.6.0→0.8.0 touches no payment/quote code, so the mirroredSINGLE_NODE_PAYMENT_MULTIPLIERand the ported binding check are unaffected. No ant-client changes involved.Rebase note (2026-09-16)
main now commits antd-rust's tonic-build output (
src/generated/antd.v1.rs, 6895d29) instead of compiling../antd/protoat build time, so the new proto surface (prepareinclude_signed_quotes/signed_quotes,VerifyService) is regenerated and committed here, andverify.protojoins the drift test's list. The earlier duplicate#[allow(clippy::result_large_err)]commit is dropped — main carries it.Re-verified on Linux after the rebase: antd fmt/clippy/doc/test (79 tests), antd-rust fmt/clippy/doc/test (82 + drift), antd-go vet/mod verify/test/race.
Testing
VerifyQuotespasses on real network quotes; inflated amount, redirected payee, and fabricatedquote_hasheach rejected with the named rule. Two real-network corrections came out of this (paid-set filter; 3× multiplier) — both covered by unit tests now.Docs
docs/external-signer-flow.md(exposure + verification recipe + policy split),antd/openapi.yaml(new path + 5 schemas), proto comments throughout.Release note
Version bump left out per house convention (separate
chore/bump-*PR at release time — docs reference the feature as "antd 0.13.0").🤖 Generated with Claude Code