From 5b1362d2df5e40f82857a8cc72ffe348800fd66a Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 24 Aug 2026 10:47:15 +0100 Subject: [PATCH 1/4] feat(antd): opt-in signed-quote exposure + stateless VerifyQuotes (REST + gRPC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- antd/Cargo.lock | 2 + antd/Cargo.toml | 2 + antd/build.rs | 1 + antd/openapi.yaml | 186 ++++++++++ antd/proto/antd/v1/chunks.proto | 5 + antd/proto/antd/v1/common.proto | 16 + antd/proto/antd/v1/upload.proto | 14 + antd/proto/antd/v1/verify.proto | 82 +++++ antd/src/grpc/mod.rs | 4 +- antd/src/grpc/service.rs | 178 ++++++++- antd/src/main.rs | 1 + antd/src/rest/chunks.rs | 25 ++ antd/src/rest/mod.rs | 2 + antd/src/rest/upload.rs | 40 +- antd/src/rest/verify.rs | 46 +++ antd/src/signed_quotes.rs | 631 ++++++++++++++++++++++++++++++++ antd/src/types.rs | 119 ++++++ docs/external-signer-flow.md | 76 ++++ 18 files changed, 1415 insertions(+), 15 deletions(-) create mode 100644 antd/proto/antd/v1/verify.proto create mode 100644 antd/src/rest/verify.rs create mode 100644 antd/src/signed_quotes.rs diff --git a/antd/Cargo.lock b/antd/Cargo.lock index eb0659eb..5f4e2713 100644 --- a/antd/Cargo.lock +++ b/antd/Cargo.lock @@ -879,6 +879,7 @@ name = "antd" version = "0.12.1" dependencies = [ "ant-core", + "ant-protocol", "axum 0.8.9", "base64", "blake3", @@ -905,6 +906,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "xor_name", ] [[package]] diff --git a/antd/Cargo.toml b/antd/Cargo.toml index d6acb5fe..b67a8194 100644 --- a/antd/Cargo.toml +++ b/antd/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT OR Apache-2.0" ant-core = { git = "https://github.com/WithAutonomi/ant-client", tag = "ant-cli-v0.3.5" } # ant-core 0.8.0 release; NetworkHealth helper (V2-1037) + ant-protocol 2.3.4 line. Lock pins saorsa-core 0.27.3: the FIRST release containing the runtime re-bootstrap recovery (V2-1036, saorsa-core#153) that /health's write_ready semantics depend on — 0.27.2 predates it despite its tag date (was 0.6.0 / ant-cli-v0.3.3) self_encryption = "0.36.0" evmlib = "0.9.1" # must track ant-core's transitive evmlib (via ant-protocol) to avoid a second copy in the graph +ant-protocol = "=2.3.4" # pinned exactly to ant-core's transitive ant-protocol (ant-cli-v0.3.5 / ant-core 0.8.0 line; was =2.3.2 on 0.6.0) — a different version would fork PaymentQuote/StorageCommitment in the graph axum = { version = "0.8", features = ["macros"] } tower-http = { version = "0.6", features = ["cors", "limit", "trace"] } tonic = "0.12" @@ -33,6 +34,7 @@ toml = "0.8" [dev-dependencies] tower = { version = "0.5", features = ["util"] } +xor_name = "5" [build-dependencies] tonic-build = "0.12" diff --git a/antd/build.rs b/antd/build.rs index a752de43..5357afcf 100644 --- a/antd/build.rs +++ b/antd/build.rs @@ -27,6 +27,7 @@ fn main() -> Result<(), Box> { "proto/antd/v1/upload.proto", "proto/antd/v1/events.proto", "proto/antd/v1/wallet.proto", + "proto/antd/v1/verify.proto", ], &["proto"], )?; diff --git a/antd/openapi.yaml b/antd/openapi.yaml index b1d8647e..3f5ad178 100644 --- a/antd/openapi.yaml +++ b/antd/openapi.yaml @@ -524,6 +524,38 @@ paths: "503": $ref: "#/components/responses/ServiceUnavailable" + /v1/verify/quotes: + post: + tags: [verify] + operationId: verifyQuotes + summary: Verify signed payment quotes offline + description: >- + Stateless offline verification of signed quotes (added in antd + 0.13.0): quote-hash recomputation, ML-DSA-65 signature, paid-fields + equality against each entry's payment triple, and the ADR-0004 + resolve-before-pay commitment binding with exact on-curve pricing. + Pure function of the request — no network, wallet, or session state. + Run it on a daemon you trust (your own), never the counterparty's. + Malformed entries yield per-entry valid:false verdicts, not transport + errors; only an unparseable body or an oversized batch is a 400. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyQuotesRequest" + responses: + "200": + description: Per-entry verdicts + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyQuotesResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + /v1/upload/prepare: post: tags: [upload] @@ -779,6 +811,14 @@ components: data: type: string description: Base64-encoded chunk bytes (stored verbatim, no self-encryption) + include_signed_quotes: + type: boolean + default: false + description: >- + When true, the wave-batch response additionally carries the full + signed quotes + ADR-0004 commitment sidecars (signed_quotes) so a + hosted-payments gateway can verify the batch offline via + /v1/verify/quotes before paying. Added in antd 0.13.0. PrepareChunkResponse: type: object @@ -819,6 +859,15 @@ components: rpc_url: type: string description: EVM RPC URL for submitting transactions + signed_quotes: + type: array + items: + $ref: "#/components/schemas/SignedQuoteEntry" + description: >- + Present only when the request set include_signed_quotes and the + payment type is wave_batch: one entry per payments[] quote for + offline verification via /v1/verify/quotes. Merkle prepares omit + it. Added in antd 0.13.0. FinalizeChunkRequest: type: object @@ -985,6 +1034,14 @@ components: ant-core exposes data_prepare_upload_with_visibility (tracked as ant-client PR #73). Use /v1/upload/prepare with a file path today. + include_signed_quotes: + type: boolean + default: false + description: >- + When true, the wave-batch response additionally carries the full + signed quotes + ADR-0004 commitment sidecars (signed_quotes) so a + hosted-payments gateway can verify the batch offline via + /v1/verify/quotes before paying. Added in antd 0.13.0. PrepareUploadRequest: type: object @@ -1002,6 +1059,14 @@ components: the same external-signer payment batch and surfaces its network address on /v1/upload/finalize via data_map_address. Omitting this field preserves pre-0.5.0 (private) behavior. + include_signed_quotes: + type: boolean + default: false + description: >- + When true, the wave-batch response additionally carries the full + signed quotes + ADR-0004 commitment sidecars (signed_quotes) so a + hosted-payments gateway can verify the batch offline via + /v1/verify/quotes before paying. Added in antd 0.13.0. PrepareUploadResponse: type: object @@ -1063,6 +1128,15 @@ components: How many of total_chunks were already stored on-network and excluded from payment + PUT. The external signer pays for (total_chunks - already_stored_count) chunks. Added in antd 0.10.0. + signed_quotes: + type: array + items: + $ref: "#/components/schemas/SignedQuoteEntry" + description: >- + Present only when the request set include_signed_quotes and the + payment type is wave_batch: one entry per payments[] quote for + offline verification via /v1/verify/quotes. Merkle prepares omit + it. Added in antd 0.13.0. PoolCommitmentEntry: type: object @@ -1090,6 +1164,118 @@ components: type: string description: Node price (decimal string) + SignedQuoteEntry: + type: object + required: [quote_hash, quote] + description: >- + One payments[] quote in full signed form. quote and + commitment_sidecar are opaque base64 blobs — pass them to + /v1/verify/quotes unchanged; only antd parses them. + properties: + quote_hash: + type: string + description: Quote hash (hex with 0x prefix) — matches the payments[] entry + quote: + type: string + description: base64(msgpack-serialized signed PaymentQuote), opaque + commitment_sidecar: + type: string + description: >- + base64(msgpack-serialized StorageCommitment) — the ADR-0004 + sidecar the quote's commitment_pin resolves to. Absent for + baseline quotes. + + VerifyQuotesRequest: + type: object + required: [entries] + properties: + entries: + type: array + maxItems: 1024 + items: + $ref: "#/components/schemas/VerifyQuoteEntry" + + VerifyQuoteEntry: + type: object + required: [quote_hash, rewards_address, amount, signed_quote] + description: >- + One entry to verify: the payment triple the caller was asked to pay + plus the opaque signed artifacts from the prepare response's + signed_quotes. + properties: + quote_hash: + type: string + description: Quote hash the payer was asked to pay (hex, 32 bytes) + rewards_address: + type: string + description: Rewards address the payer was asked to pay (hex with 0x prefix) + amount: + type: string + description: Amount the payer was asked to pay (atto tokens, decimal string) + signed_quote: + type: string + description: base64(msgpack) signed PaymentQuote — from signed_quotes[].quote + commitment_sidecar: + type: string + description: >- + base64(msgpack) StorageCommitment sidecar — from + signed_quotes[].commitment_sidecar. Required when the quote is + commitment-bound. + + VerifyQuotesResponse: + type: object + required: [valid, entries] + properties: + valid: + type: boolean + description: True only when entries is non-empty and every entry verified + entries: + type: array + items: + $ref: "#/components/schemas/VerifyQuoteVerdict" + + VerifyQuoteVerdict: + type: object + required: [quote_hash, valid] + description: >- + Per-entry verification verdict. The extracted fields are populated as + soon as the signed quote deserializes — even when a later check + fails — so policy layers can see what the quote claimed. + properties: + quote_hash: + type: string + description: Echo of the request entry's quote_hash + valid: + type: boolean + description: >- + True when every check passed: hash recomputation, ML-DSA-65 + signature, paid-fields equality, and the ADR-0004 commitment + binding with exact on-curve pricing. + error: + type: string + description: The first failing rule, by name. Absent when valid. + timestamp_unix_secs: + type: integer + format: int64 + description: Quote timestamp (unix seconds) — for the caller's expiry policy + content: + type: string + description: The chunk address the quote covers (hex, 32 bytes) + price: + type: string + description: The signed price (atto tokens, decimal string) + rewards_address: + type: string + description: The signed rewards address (hex with 0x prefix) + committed_key_count: + type: integer + description: >- + Claimed ADR-0004 storage-commitment key count (0 = baseline + quote) — for the caller's count-plausibility cap. + pinned: + type: boolean + description: Whether the quote pins a storage commitment + PaymentEntry: type: object required: [quote_hash, rewards_address, amount] diff --git a/antd/proto/antd/v1/chunks.proto b/antd/proto/antd/v1/chunks.proto index 9597dad2..d490431a 100644 --- a/antd/proto/antd/v1/chunks.proto +++ b/antd/proto/antd/v1/chunks.proto @@ -42,6 +42,8 @@ message PutChunkResponse { message PrepareChunkRequest { // Raw chunk bytes — at most one ant-protocol chunk. bytes data = 1; + // Same semantics as PrepareFileUploadRequest.include_signed_quotes. + bool include_signed_quotes = 2; } // Mirrors REST `PrepareChunkResponse`. Single-chunk publishes are always @@ -77,6 +79,9 @@ message PrepareChunkResponse { // EVM RPC URL for submitting transactions. Empty when // `already_stored == true`. string rpc_url = 9; + // Populated only when the request set `include_signed_quotes` and payment + // is required — same semantics as PrepareUploadResponse.signed_quotes. + repeated SignedQuoteEntry signed_quotes = 10; } message FinalizeChunkRequest { diff --git a/antd/proto/antd/v1/common.proto b/antd/proto/antd/v1/common.proto index 1afeb902..8309d57f 100644 --- a/antd/proto/antd/v1/common.proto +++ b/antd/proto/antd/v1/common.proto @@ -35,3 +35,19 @@ message PaymentEntry { // Amount to pay (atto tokens as decimal string). string amount = 3; } + +// One `payments[]` quote in full signed form (V2-854 signed-quote exposure): +// the opaque serialized PaymentQuote plus, for commitment-bound quotes, the +// ADR-0004 commitment sidecar the quote pins. Consumers treat both as opaque +// bytes — only antd (`VerifyService.VerifyQuotes`) parses them. Shared by +// `UploadService` (wave-batch prepares) and `ChunkService.PrepareChunk`. +message SignedQuoteEntry { + // Quote hash (hex with 0x prefix, 32 bytes) — matches the `payments[]` + // entry. + string quote_hash = 1; + // msgpack-serialized signed PaymentQuote. Opaque. + bytes quote = 2; + // msgpack-serialized StorageCommitment the quote's commitment_pin resolves + // to. Empty for baseline quotes. + bytes commitment_sidecar = 3; +} diff --git a/antd/proto/antd/v1/upload.proto b/antd/proto/antd/v1/upload.proto index 2df464fe..fca4a819 100644 --- a/antd/proto/antd/v1/upload.proto +++ b/antd/proto/antd/v1/upload.proto @@ -52,6 +52,12 @@ message PrepareFileUploadRequest { // stored on-network; its address is returned on finalize). Empty string // is treated as "private". string visibility = 2; + // When true, the wave-batch response additionally carries the full signed + // quotes + ADR-0004 commitment sidecars (`signed_quotes`) so a + // hosted-payments gateway can verify the batch offline before paying + // (V2-854). Default false: ~5–6 KB per quote plus up to 8 KB per sidecar, + // and existing consumers see no change. + bool include_signed_quotes = 3; } message PrepareDataUploadRequest { @@ -59,6 +65,8 @@ message PrepareDataUploadRequest { bytes data = 1; // Same semantics as PrepareFileUploadRequest.visibility. string visibility = 2; + // Same semantics as PrepareFileUploadRequest.include_signed_quotes. + bool include_signed_quotes = 3; } // --- Prepare response (shared by both prepares) --- @@ -102,6 +110,12 @@ message PrepareUploadResponse { string payment_token_address = 9; // EVM RPC URL for submitting transactions. string rpc_url = 10; + + // Populated only when the request set `include_signed_quotes` and the + // payment type is wave_batch: one entry per `payments[]` quote for offline + // verification via `VerifyService.VerifyQuotes`. Merkle prepares leave it + // empty (the daemon does not retain merkle candidate commitments). + repeated SignedQuoteEntry signed_quotes = 12; } // One merkle payment batch: everything the external signer needs for a diff --git a/antd/proto/antd/v1/verify.proto b/antd/proto/antd/v1/verify.proto new file mode 100644 index 00000000..5883b5f8 --- /dev/null +++ b/antd/proto/antd/v1/verify.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; + +package antd.v1; + +option csharp_namespace = "Antd.V1"; +option go_package = "github.com/WithAutonomi/ant-sdk/antd-go/proto/antd/v1;v1"; + +// Stateless offline verification of signed payment quotes (hosted payments, +// V2-854). Pure function of the request: no network, no wallet, no session +// state. Run by "the party about to pay" — in hosted mode, the payment +// gateway calls it on its own antd instance (never the customer's) before +// paying a batch. +// +// Checks per entry: quote-hash recomputation, ML-DSA-65 signature, +// paid-fields equality against the request triple, and the ADR-0004 +// resolve-before-pay commitment binding with exact on-curve pricing +// (`price == calculate_price(committed_key_count)`; baseline quotes must +// price exactly `calculate_price(0)`). Policy checks (expiry windows, replay +// ledgers, chunk-set equality, count-plausibility caps) stay caller-side — +// the verdicts carry the extracted fields those policies need. +service VerifyService { + rpc VerifyQuotes(VerifyQuotesRequest) returns (VerifyQuotesResponse); +} + +message VerifyQuotesRequest { + // Max 1024 entries per call (each entry costs one or two ML-DSA-65 + // verifications). + repeated VerifyQuoteEntry entries = 1; +} + +// One entry to verify: the payment triple the caller was asked to pay plus +// the opaque signed artifacts from the prepare response's `signed_quotes`. +message VerifyQuoteEntry { + // Quote hash the payer was asked to pay (hex with 0x prefix, 32 bytes). + string quote_hash = 1; + // Rewards address the payer was asked to pay (hex with 0x prefix). + string rewards_address = 2; + // Amount the payer was asked to pay (atto tokens as decimal string). + string amount = 3; + // msgpack-serialized signed PaymentQuote — from SignedQuoteEntry.quote. + bytes signed_quote = 4; + // msgpack-serialized StorageCommitment sidecar — from + // SignedQuoteEntry.commitment_sidecar. Required when the quote is + // commitment-bound; empty otherwise. + bytes commitment_sidecar = 5; +} + +message VerifyQuotesResponse { + // True only when `entries` is non-empty and every entry verified. + bool valid = 1; + repeated VerifyQuoteVerdict entries = 2; +} + +// Per-entry verdict. The extracted fields are populated as soon as the signed +// quote deserializes — even when a later check fails — so policy layers can +// see what the quote claimed. They are meaningless while `quote_decoded` is +// false. +message VerifyQuoteVerdict { + // Echo of the request entry's quote_hash. + string quote_hash = 1; + // True when every check passed. + bool valid = 2; + // The first failing rule, by name. Empty when valid. + string error = 3; + // True once the signed quote deserialized (the extracted fields below are + // populated). + bool quote_decoded = 4; + // Quote timestamp (unix seconds) — for the caller's expiry policy. + uint64 timestamp_unix_secs = 5; + // The chunk address the quote covers (hex, 32 bytes) — for the caller's + // chunk-set equality policy. + string content = 6; + // The signed price (atto tokens as decimal string). + string price = 7; + // The signed rewards address (hex with 0x prefix). + string rewards_address = 8; + // Claimed ADR-0004 storage-commitment key count (0 = baseline quote) — for + // the caller's count-plausibility cap. + uint32 committed_key_count = 9; + // Whether the quote pins a storage commitment. + bool pinned = 10; +} diff --git a/antd/src/grpc/mod.rs b/antd/src/grpc/mod.rs index 32a57b52..4a7df228 100644 --- a/antd/src/grpc/mod.rs +++ b/antd/src/grpc/mod.rs @@ -12,7 +12,7 @@ use service::pb::{ chunk_service_server::ChunkServiceServer, data_service_server::DataServiceServer, event_service_server::EventServiceServer, file_service_server::FileServiceServer, health_service_server::HealthServiceServer, upload_service_server::UploadServiceServer, - wallet_service_server::WalletServiceServer, + verify_service_server::VerifyServiceServer, wallet_service_server::WalletServiceServer, }; pub async fn serve( @@ -40,6 +40,7 @@ pub async fn serve( let health_svc = HealthServiceServer::new(service::HealthServiceImpl { state: state.clone(), }); + let verify_svc = VerifyServiceServer::new(service::VerifyServiceImpl); let addr = listener.local_addr()?; tracing::info!("gRPC server listening on {addr}"); @@ -52,6 +53,7 @@ pub async fn serve( .add_service(upload_svc) .add_service(event_svc) .add_service(wallet_svc) + .add_service(verify_svc) .serve_with_incoming(TcpListenerStream::new(listener)) .await?; diff --git a/antd/src/grpc/service.rs b/antd/src/grpc/service.rs index b5bf5ce5..95edd54f 100644 --- a/antd/src/grpc/service.rs +++ b/antd/src/grpc/service.rs @@ -43,14 +43,18 @@ fn build_grpc_prepare_response( upload_id: String, prepared: &ant_core::data::PreparedUpload, network: &str, -) -> pb::PrepareUploadResponse { + include_signed_quotes: bool, +) -> Result { let evm_cfg = crate::evm_defaults::resolve(network); let rpc_url = evm_cfg.rpc_url; let payment_token_address = evm_cfg.token_addr; let payment_vault_address = evm_cfg.vault_addr; match &prepared.payment_info { - ant_core::data::ExternalPaymentInfo::WaveBatch { payment_intent, .. } => { + ant_core::data::ExternalPaymentInfo::WaveBatch { + payment_intent, + prepared_chunks, + } => { let payments: Vec = payment_intent .payments .iter() @@ -61,7 +65,18 @@ fn build_grpc_prepare_response( }) .collect(); - pb::PrepareUploadResponse { + // Opt-in signed-quote exposure (V2-854), mirroring the REST arm. + let signed_quotes = if include_signed_quotes { + crate::signed_quotes::entries_for_prepared_chunks(prepared_chunks, payment_intent) + .map_err(AntdError::Internal)? + .iter() + .map(to_pb_signed_quote) + .collect::, AntdError>>()? + } else { + Vec::new() + }; + + Ok(pb::PrepareUploadResponse { upload_id, payment_type: "wave_batch".into(), payments, @@ -73,7 +88,8 @@ fn build_grpc_prepare_response( payment_vault_address, payment_token_address, rpc_url, - } + signed_quotes, + }) } ant_core::data::ExternalPaymentInfo::Merkle { prepared_batches, .. @@ -97,7 +113,7 @@ fn build_grpc_prepare_response( _ => (0, Vec::new(), 0), }; - pb::PrepareUploadResponse { + Ok(pb::PrepareUploadResponse { upload_id, payment_type: "merkle".into(), payments: Vec::new(), @@ -109,11 +125,36 @@ fn build_grpc_prepare_response( payment_vault_address, payment_token_address, rpc_url, - } + // Merkle candidate exposure is blocked upstream (V2-854 open + // question 1) — mirrors the REST arm. + signed_quotes: Vec::new(), + }) } } } +/// Convert a REST-shaped signed-quote entry into its proto twin (base64 +/// strings → raw bytes). Deriving the proto shape from the REST helper keeps +/// the two transports byte-identical. +fn to_pb_signed_quote( + entry: &crate::types::SignedQuoteEntry, +) -> Result { + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine; + Ok(pb::SignedQuoteEntry { + quote_hash: entry.quote_hash.clone(), + quote: BASE64 + .decode(&entry.quote) + .map_err(|e| AntdError::Internal(format!("signed quote round-trip: {e}")))?, + commitment_sidecar: match &entry.commitment_sidecar { + Some(b64) => BASE64 + .decode(b64) + .map_err(|e| AntdError::Internal(format!("sidecar round-trip: {e}")))?, + None => Vec::new(), + }, + }) +} + /// Convert a REST-shaped merkle batch entry into its proto twin. Deriving the /// proto shape from the REST helper keeps the two transports byte-identical. fn to_pb_merkle_batch(entry: &crate::types::MerkleBatchEntry) -> pb::MerkleBatchEntry { @@ -668,7 +709,9 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { &self, request: Request, ) -> Result, Status> { - let content = Bytes::from(request.into_inner().data); + let req = request.into_inner(); + let include_signed_quotes = req.include_signed_quotes; + let content = Bytes::from(req.data); // Compute the content address up-front so the "already stored" // response can still return it without re-quoting (ant-core's prepare @@ -712,6 +755,33 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { .collect(); let total_amount = prepared.payment.total_amount().to_string(); + // Opt-in signed-quote exposure (V2-854), mirroring the REST handler — + // built before the prepared state moves into the session map. + let signed_quotes = if include_signed_quotes { + let paid = prepared + .payment + .quotes + .iter() + .filter(|q| !q.amount.is_zero()) + .map(|q| q.quote_hash) + .collect(); + crate::signed_quotes::entries_for_quotes( + prepared.peer_quotes.iter().map(|(_, q)| q), + &prepared.commitment_sidecars, + &paid, + ) + .map_err(AntdError::Internal) + .and_then(|entries| { + entries + .iter() + .map(to_pb_signed_quote) + .collect::, AntdError>>() + }) + .map_err(tonic::Status::from)? + } else { + Vec::new() + }; + let upload_id = hex::encode(rand::random::<[u8; 16]>()); self.state.pending_chunks.lock().await.insert( upload_id.clone(), @@ -731,6 +801,7 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { payment_vault_address: evm_cfg.vault_addr, payment_token_address: evm_cfg.token_addr, rpc_url: evm_cfg.rpc_url, + signed_quotes, })) } @@ -1049,6 +1120,7 @@ impl pb::upload_service_server::UploadService for UploadServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); + let include_signed_quotes = req.include_signed_quotes; let path = PathBuf::from(&req.path).canonicalize().map_err(|e| { tracing::warn!(path = %req.path, error = %e, "invalid prepare-file-upload path"); Status::invalid_argument("invalid path") @@ -1068,8 +1140,13 @@ impl pb::upload_service_server::UploadService for UploadServiceImpl { .map_err(tonic::Status::from)?; let upload_id = hex::encode(rand::random::<[u8; 16]>()); - let response = - build_grpc_prepare_response(upload_id.clone(), &prepared, &self.state.network); + let response = build_grpc_prepare_response( + upload_id.clone(), + &prepared, + &self.state.network, + include_signed_quotes, + ) + .map_err(tonic::Status::from)?; self.state.pending_uploads.lock().await.insert( upload_id, @@ -1087,6 +1164,7 @@ impl pb::upload_service_server::UploadService for UploadServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); + let include_signed_quotes = req.include_signed_quotes; let visibility = parse_grpc_visibility(&req.visibility).map_err(Status::invalid_argument)?; let data = Bytes::from(req.data); @@ -1103,8 +1181,13 @@ impl pb::upload_service_server::UploadService for UploadServiceImpl { .map_err(tonic::Status::from)?; let upload_id = hex::encode(rand::random::<[u8; 16]>()); - let response = - build_grpc_prepare_response(upload_id.clone(), &prepared, &self.state.network); + let response = build_grpc_prepare_response( + upload_id.clone(), + &prepared, + &self.state.network, + include_signed_quotes, + ) + .map_err(tonic::Status::from)?; self.state.pending_uploads.lock().await.insert( upload_id, @@ -1375,3 +1458,76 @@ impl pb::wallet_service_server::WalletService for WalletServiceImpl { Ok(Response::new(pb::WalletApproveResponse { approved: true })) } } + +// ── Verify service (stateless offline quote verification, V2-854) ── + +pub struct VerifyServiceImpl; + +#[tonic::async_trait] +impl pb::verify_service_server::VerifyService for VerifyServiceImpl { + /// gRPC twin of REST `POST /v1/verify/quotes`. Converts the raw-bytes + /// proto entries into the REST-shaped (base64) entries and runs the same + /// verification core, keeping the two transports byte-identical. + async fn verify_quotes( + &self, + request: Request, + ) -> Result, Status> { + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine; + + let req = request.into_inner(); + if req.entries.len() > crate::signed_quotes::MAX_VERIFY_ENTRIES { + return Err(Status::invalid_argument(format!( + "too many entries: {} (max {})", + req.entries.len(), + crate::signed_quotes::MAX_VERIFY_ENTRIES + ))); + } + + let entries: Vec = req + .entries + .iter() + .map(|e| crate::types::VerifyQuoteEntry { + quote_hash: e.quote_hash.clone(), + rewards_address: e.rewards_address.clone(), + amount: e.amount.clone(), + signed_quote: BASE64.encode(&e.signed_quote), + commitment_sidecar: if e.commitment_sidecar.is_empty() { + None + } else { + Some(BASE64.encode(&e.commitment_sidecar)) + }, + }) + .collect(); + + // CPU-bound (ML-DSA-65 verifications) — keep it off the async reactor. + let verdicts = tokio::task::spawn_blocking(move || { + entries + .iter() + .map(crate::signed_quotes::verify_entry) + .collect::>() + }) + .await + .map_err(|e| Status::internal(format!("verify task failed: {e}")))?; + + let valid = !verdicts.is_empty() && verdicts.iter().all(|v| v.valid); + Ok(Response::new(pb::VerifyQuotesResponse { + valid, + entries: verdicts + .into_iter() + .map(|v| pb::VerifyQuoteVerdict { + quote_hash: v.quote_hash, + valid: v.valid, + error: v.error.unwrap_or_default(), + quote_decoded: v.content.is_some(), + timestamp_unix_secs: v.timestamp_unix_secs.unwrap_or_default(), + content: v.content.unwrap_or_default(), + price: v.price.unwrap_or_default(), + rewards_address: v.rewards_address.unwrap_or_default(), + committed_key_count: v.committed_key_count.unwrap_or_default(), + pinned: v.pinned.unwrap_or_default(), + }) + .collect(), + })) + } +} diff --git a/antd/src/main.rs b/antd/src/main.rs index f56ff40d..de58a5eb 100644 --- a/antd/src/main.rs +++ b/antd/src/main.rs @@ -18,6 +18,7 @@ mod grpc; mod peers; mod port_file; mod rest; +mod signed_quotes; mod state; mod types; diff --git a/antd/src/rest/chunks.rs b/antd/src/rest/chunks.rs index 738c098d..4d2f6d70 100644 --- a/antd/src/rest/chunks.rs +++ b/antd/src/rest/chunks.rs @@ -118,6 +118,7 @@ pub async fn chunk_prepare( payment_vault_address: None, payment_token_address: None, rpc_url: None, + signed_quotes: None, })); }; @@ -139,6 +140,29 @@ pub async fn chunk_prepare( .collect(); let total_amount = prepared.payment.total_amount().to_string(); + // Opt-in signed-quote exposure (V2-854) — built before the prepared state + // moves into the session map. Restricted to the paid quote set (the + // non-zero-amount quotes that populate `payments` above). + let signed_quotes = if req.include_signed_quotes { + let paid = prepared + .payment + .quotes + .iter() + .filter(|q| !q.amount.is_zero()) + .map(|q| q.quote_hash) + .collect(); + Some( + crate::signed_quotes::entries_for_quotes( + prepared.peer_quotes.iter().map(|(_, q)| q), + &prepared.commitment_sidecars, + &paid, + ) + .map_err(AntdError::Internal)?, + ) + } else { + None + }; + let upload_id = hex::encode(rand::random::<[u8; 16]>()); state.pending_chunks.lock().await.insert( upload_id.clone(), @@ -158,6 +182,7 @@ pub async fn chunk_prepare( payment_vault_address: Some(evm_cfg.vault_addr), payment_token_address: Some(evm_cfg.token_addr), rpc_url: Some(evm_cfg.rpc_url), + signed_quotes, })) } diff --git a/antd/src/rest/mod.rs b/antd/src/rest/mod.rs index 08eac2d9..3e0a989d 100644 --- a/antd/src/rest/mod.rs +++ b/antd/src/rest/mod.rs @@ -20,6 +20,7 @@ pub mod data; pub mod events; pub mod files; pub mod upload; +pub mod verify; pub mod wallet; /// Generates a short random hex request ID (8 bytes = 16 hex chars). @@ -112,6 +113,7 @@ pub fn router(state: Arc, cors: &CorsMode) -> Router { .route("/v1/upload/prepare", post(upload::prepare_upload)) .route("/v1/data/prepare", post(upload::prepare_data_upload)) .route("/v1/upload/finalize", post(upload::finalize_upload)) + .route("/v1/verify/quotes", post(verify::verify_quotes)) // Wallet .route("/v1/wallet/address", get(wallet::wallet_address)) .route("/v1/wallet/balance", get(wallet::wallet_balance)) diff --git a/antd/src/rest/upload.rs b/antd/src/rest/upload.rs index 190f3bf8..9bb44c1a 100644 --- a/antd/src/rest/upload.rs +++ b/antd/src/rest/upload.rs @@ -25,6 +25,7 @@ fn build_prepare_response( upload_id: String, prepared: &ant_core::data::PreparedUpload, network: &str, + include_signed_quotes: bool, ) -> Result { let evm_cfg = evm_defaults::resolve(network); let rpc_url = evm_cfg.rpc_url; @@ -38,7 +39,10 @@ fn build_prepare_response( let already_stored_count = prepared.already_stored_addresses.len(); match &prepared.payment_info { - ant_core::data::ExternalPaymentInfo::WaveBatch { payment_intent, .. } => { + ant_core::data::ExternalPaymentInfo::WaveBatch { + payment_intent, + prepared_chunks, + } => { let payments: Vec = payment_intent .payments .iter() @@ -49,6 +53,21 @@ fn build_prepare_response( }) .collect(); + // Opt-in signed-quote exposure (V2-854): the session state already + // holds the full signed quotes + commitment sidecars; serialize + // them out for offline verification via /v1/verify/quotes. + let signed_quotes = if include_signed_quotes { + Some( + crate::signed_quotes::entries_for_prepared_chunks( + prepared_chunks, + payment_intent, + ) + .map_err(AntdError::Internal)?, + ) + } else { + None + }; + Ok(PrepareUploadResponse { upload_id, payment_type: "wave_batch".into(), @@ -63,6 +82,7 @@ fn build_prepare_response( rpc_url, total_chunks, already_stored_count, + signed_quotes, }) } ant_core::data::ExternalPaymentInfo::Merkle { @@ -97,6 +117,10 @@ fn build_prepare_response( rpc_url, total_chunks, already_stored_count, + // Merkle candidate exposure is blocked upstream: ant-core + // keeps candidate pools private and discards resolved + // candidate commitments (V2-854 open question 1). + signed_quotes: None, }) } } @@ -282,7 +306,12 @@ pub async fn prepare_upload( // Generate a unique upload ID and store the prepared state let upload_id = hex::encode(rand::random::<[u8; 16]>()); - let response = build_prepare_response(upload_id.clone(), &prepared, &state.network)?; + let response = build_prepare_response( + upload_id.clone(), + &prepared, + &state.network, + req.include_signed_quotes, + )?; state.pending_uploads.lock().await.insert( upload_id, @@ -323,7 +352,12 @@ pub async fn prepare_data_upload( .map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; let upload_id = hex::encode(rand::random::<[u8; 16]>()); - let response = build_prepare_response(upload_id.clone(), &prepared, &state.network)?; + let response = build_prepare_response( + upload_id.clone(), + &prepared, + &state.network, + req.include_signed_quotes, + )?; state.pending_uploads.lock().await.insert( upload_id, diff --git a/antd/src/rest/verify.rs b/antd/src/rest/verify.rs new file mode 100644 index 00000000..c8b79a2f --- /dev/null +++ b/antd/src/rest/verify.rs @@ -0,0 +1,46 @@ +//! `POST /v1/verify/quotes` — stateless offline verification of signed +//! payment quotes (hosted payments, V2-854 work item 1b). +//! +//! Pure function of the request: no network, no wallet, no session state. +//! Run by "the party about to pay" — in hosted mode, the payment gateway +//! calls it on its own antd instance (never the customer's) before paying a +//! `/pay` batch. Malformed inputs yield per-entry `valid: false` verdicts, +//! not transport errors; only an unparseable request body is a 400. + +use std::sync::Arc; + +use axum::extract::State; +use axum::Json; + +use crate::error::AntdError; +use crate::signed_quotes; +use crate::state::AppState; +use crate::types::{VerifyQuotesRequest, VerifyQuotesResponse}; + +pub async fn verify_quotes( + State(_state): State>, + Json(req): Json, +) -> Result, AntdError> { + if req.entries.len() > signed_quotes::MAX_VERIFY_ENTRIES { + return Err(AntdError::BadRequest(format!( + "too many entries: {} (max {})", + req.entries.len(), + signed_quotes::MAX_VERIFY_ENTRIES + ))); + } + + // CPU-bound (ML-DSA-65 verifications) — keep it off the async reactor. + let verdicts = tokio::task::spawn_blocking(move || { + req.entries + .iter() + .map(signed_quotes::verify_entry) + .collect::>() + }) + .await + .map_err(|e| AntdError::Internal(format!("verify task failed: {e}")))?; + + Ok(Json(VerifyQuotesResponse { + valid: !verdicts.is_empty() && verdicts.iter().all(|v| v.valid), + entries: verdicts, + })) +} diff --git a/antd/src/signed_quotes.rs b/antd/src/signed_quotes.rs new file mode 100644 index 00000000..2847e5c7 --- /dev/null +++ b/antd/src/signed_quotes.rs @@ -0,0 +1,631 @@ +//! Signed-quote exposure + offline verification (hosted payments, V2-854). +//! +//! Two halves: +//! +//! 1. **Exposure** — Prepare* responses can opt in (`include_signed_quotes`) +//! to carrying the full signed [`PaymentQuote`]s and their ADR-0004 +//! commitment sidecars alongside the `payments[]` triples. The wave-batch +//! prepare path already retains both in the pending-upload session state; +//! this module only serializes them out. (Merkle candidate exposure is +//! blocked upstream: `PreparedMerkleBatch` keeps its candidate pools +//! private and ant-core deliberately discards resolved candidate +//! commitments — see V2-854 open question 1.) +//! +//! 2. **Verification** — `/v1/verify/quotes` (and its gRPC twin) verifies a +//! batch offline: quote-hash recomputation, ML-DSA-65 signature, +//! paid-fields equality, and the ADR-0004 resolve-before-pay binding with +//! exact on-curve pricing. This is a port of ant-core's client-side +//! `quote_commitment_binding_is_valid` gate, run by "the party about to +//! pay" — in hosted mode, the payment gateway via its own antd. +//! +//! Quotes and sidecars travel as opaque base64(msgpack) bytes end-to-end: +//! antd emits them at prepare time and antd parses them at verify time — +//! intermediate consumers never decode them. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use evmlib::common::{Address as RewardsAddress, Amount}; +use evmlib::PaymentQuote; +use std::collections::{HashMap, HashSet}; +use std::time::UNIX_EPOCH; + +use ant_protocol::payment::commitment::MAX_COMMITMENT_SIDECAR_BYTES; +use ant_protocol::payment::{ + calculate_price, commitment_hash, verify_commitment_signature, verify_quote_signature, + StorageCommitment, MAX_COMMITMENT_KEY_COUNT, +}; + +use crate::types::{SignedQuoteEntry, VerifyQuoteEntry, VerifyQuoteVerdict}; + +/// Upper bound on a serialized signed quote (ML-DSA-65 pubkey ≈ 2 KB + +/// signature ≈ 3.3 KB + fields). Real quotes are ~5.5 KB; anything larger is +/// rejected before deserialization work. +const MAX_SIGNED_QUOTE_BYTES: usize = 16 * 1024; + +/// Cap on entries per VerifyQuotes call (REST and gRPC): a 256-chunk wave +/// batch (the ADR-0003 merkle threshold region) fits comfortably; anything +/// larger is likely abuse of a CPU-bound endpoint (each entry costs an +/// ML-DSA-65 verification or two). +pub(crate) const MAX_VERIFY_ENTRIES: usize = 1024; + +/// The wave-batch paid amount is the signed quote price times this +/// multiplier: single-quote payments (V2-619) pay only the median quote of +/// the close group, at 3x, keeping per-chunk node economics equivalent to +/// paying the group. Mirrors ant-core's `SINGLE_NODE_PAYMENT_MULTIPLIER` +/// (`pub(crate)`, not importable) and the 3x documented on +/// `ant_protocol::payment::single_node::QuotePaymentInfo`. If the protocol +/// ever changes the multiplier, this verifier versions with the antd release +/// that adopts it. +const SINGLE_NODE_PAYMENT_MULTIPLIER: u64 = 3; + +/// Build the opt-in `signed_quotes` response entries for one prepared quote +/// set, restricted to the quotes that are actually being paid: the network +/// quotes a whole close group per chunk (~7 peers) but the payment intent +/// selects a subset (one quote per chunk since single-quote payments, V2-619), +/// and `payments[]` carries only those. Emitting the unpaid quotes would hand +/// the verifier entries with no payment triple to check against. +/// +/// `paid` is the set of quote hashes appearing in the `payments[]` triples. +/// `sidecars` is ant-core's *compacted* vector — baseline quotes contribute +/// nothing, so it is NOT index-aligned with the quotes. Association is by +/// `commitment_hash(sidecar) == quote.commitment_pin`, mirroring how the +/// sidecars were validated at prepare time. +pub(crate) fn entries_for_quotes<'a>( + quotes: impl IntoIterator, + sidecars: &[Vec], + paid: &HashSet, +) -> Result, String> { + let mut by_pin: HashMap<[u8; 32], String> = HashMap::new(); + for blob in sidecars { + // Sidecars were already validated by ant-core at prepare time; a blob + // that no longer parses would only orphan its quote's pin, which the + // verify side reports as unresolvable — so skip, don't fail. + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + continue; + } + let Ok(commitment) = rmp_serde::from_slice::(blob) else { + continue; + }; + if let Some(pin) = commitment_hash(&commitment) { + by_pin.insert(pin, BASE64.encode(blob)); + } + } + + let mut out = Vec::new(); + for quote in quotes { + if !paid.contains("e.hash()) { + continue; + } + let bytes = + rmp_serde::to_vec(quote).map_err(|e| format!("serializing signed quote: {e}"))?; + out.push(SignedQuoteEntry { + quote_hash: format!("{:#x}", quote.hash()), + quote: BASE64.encode(&bytes), + commitment_sidecar: quote + .commitment_pin + .and_then(|pin| by_pin.get(&pin).cloned()), + }); + } + Ok(out) +} + +/// Build `signed_quotes` entries for a whole wave-batch upload: one entry per +/// `payment_intent.payments` triple, sourced from the prepared chunks' full +/// quote sets (quote hashes are globally unique across chunks). +pub(crate) fn entries_for_prepared_chunks( + chunks: &[ant_core::data::PreparedChunk], + payment_intent: &ant_core::data::PaymentIntent, +) -> Result, String> { + let paid: HashSet = payment_intent + .payments + .iter() + .map(|(quote_hash, _, _)| *quote_hash) + .collect(); + let mut out = Vec::new(); + for chunk in chunks { + out.extend(entries_for_quotes( + chunk.peer_quotes.iter().map(|(_, q)| q), + &chunk.commitment_sidecars, + &paid, + )?); + } + Ok(out) +} + +/// Verify one `/pay`-shaped entry offline. Never panics on untrusted bytes; +/// the verdict carries the first failing rule by name, plus the fields the +/// gateway's policy layer needs (extracted as soon as the quote decodes, even +/// when a later check fails, so callers can see what the quote claimed). +pub(crate) fn verify_entry(entry: &VerifyQuoteEntry) -> VerifyQuoteVerdict { + let mut verdict = VerifyQuoteVerdict { + quote_hash: entry.quote_hash.clone(), + valid: false, + error: None, + timestamp_unix_secs: None, + content: None, + price: None, + rewards_address: None, + committed_key_count: None, + pinned: None, + }; + match verify_inner(entry, &mut verdict) { + Ok(()) => verdict.valid = true, + Err(msg) => verdict.error = Some(msg), + } + verdict +} + +fn verify_inner(entry: &VerifyQuoteEntry, verdict: &mut VerifyQuoteVerdict) -> Result<(), String> { + // Decode the opaque quote. Cap before parsing: bound the deserialize work + // a malicious caller can force. + let bytes = BASE64 + .decode(&entry.signed_quote) + .map_err(|e| format!("signed_quote is not valid base64: {e}"))?; + if bytes.len() > MAX_SIGNED_QUOTE_BYTES { + return Err(format!( + "signed_quote is {} bytes, exceeds {MAX_SIGNED_QUOTE_BYTES}", + bytes.len() + )); + } + let quote: PaymentQuote = rmp_serde::from_slice(&bytes) + .map_err(|e| format!("signed_quote did not deserialize as a PaymentQuote: {e}"))?; + + verdict.timestamp_unix_secs = quote + .timestamp + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()); + verdict.content = Some(hex::encode(quote.content.0)); + verdict.price = Some(quote.price.to_string()); + verdict.rewards_address = Some(format!("{:#x}", quote.rewards_address)); + verdict.committed_key_count = Some(quote.committed_key_count); + verdict.pinned = Some(quote.commitment_pin.is_some()); + + // 1. Hash recomputation — no paying for hashes tied to nothing. + let requested = parse_hash32(&entry.quote_hash).map_err(|e| format!("quote_hash: {e}"))?; + if quote.hash().as_slice() != requested { + return Err("quote_hash does not equal hash(signed_quote) — the payment triple is not tied to this quote".into()); + } + + // 2. ML-DSA-65 signature over the paid fields. + if !verify_quote_signature("e) { + return Err("quote signature failed ML-DSA-65 verification".into()); + } + + // 3. Paid-fields equality — the triple must pay exactly what the quote + // prescribes: the signed price times the single-node payment multiplier + // (the median quote of the close group is paid at 3x, V2-619). + let amount = Amount::from_str_radix(entry.amount.trim(), 10) + .map_err(|e| format!("amount is not a decimal integer: {e}"))?; + let expected_amount = quote + .price + .checked_mul(Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER)) + .ok_or_else(|| format!("signed price {} overflows the 3x multiplier", quote.price))?; + if amount != expected_amount { + return Err(format!( + "amount {amount} does not equal {SINGLE_NODE_PAYMENT_MULTIPLIER}x the signed price {} (expected {expected_amount})", + quote.price + )); + } + let rewards: RewardsAddress = entry + .rewards_address + .trim() + .parse() + .map_err(|e| format!("rewards_address is not a valid address: {e}"))?; + if rewards != quote.rewards_address { + return Err(format!( + "rewards_address {rewards:#x} does not equal the signed address {:#x}", + quote.rewards_address + )); + } + + // 4. ADR-0004 resolve-before-pay binding with exact on-curve pricing. + binding_is_valid("e, entry.commitment_sidecar.as_deref()) +} + +/// Port of ant-core's `quote_commitment_binding_is_valid` (the ADR-0004 +/// client-side gate), with the peer identity derived from the quote's own +/// `pub_key` — offline verification has no independent peer id, and binding +/// the commitment to the quote's signing key is exactly the property the +/// gateway needs (one signer attests both artifacts). +fn binding_is_valid(quote: &PaymentQuote, sidecar_b64: Option<&str>) -> Result<(), String> { + let count = quote.committed_key_count; + let pin = quote.commitment_pin; + match (count, pin.is_some()) { + (0, false) | (1.., true) => {} + (1.., false) => { + return Err(format!( + "committed_key_count={count} > 0 but commitment_pin is None (unauditable count)" + )); + } + (0, true) => { + return Err("committed_key_count=0 with a commitment_pin (incoherent baseline)".into()); + } + } + if count > MAX_COMMITMENT_KEY_COUNT { + return Err(format!( + "committed_key_count={count} exceeds MAX_COMMITMENT_KEY_COUNT={MAX_COMMITMENT_KEY_COUNT}" + )); + } + // Forced price: exact recomputation, never inversion. + let expected = calculate_price(count as usize); + if quote.price != expected { + return Err(format!( + "price {} does not equal calculate_price(committed_key_count={count}) = {expected}", + quote.price + )); + } + + // Baseline `(0, None)` pins nothing — fully resolved by the checks above. + let Some(pin) = pin else { + return Ok(()); + }; + + // Bound quote: the commitment MUST be present and MUST resolve the pin. + let Some(b64) = sidecar_b64 else { + return Err( + "bound quote (commitment_pin set) has no commitment_sidecar; the pin is unresolvable" + .into(), + ); + }; + let blob = BASE64 + .decode(b64) + .map_err(|e| format!("commitment_sidecar is not valid base64: {e}"))?; + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + return Err(format!( + "commitment_sidecar is {} bytes, exceeds MAX_COMMITMENT_SIDECAR_BYTES={MAX_COMMITMENT_SIDECAR_BYTES}", + blob.len() + )); + } + let commitment: StorageCommitment = rmp_serde::from_slice(&blob).map_err(|e| { + format!("commitment_sidecar did not deserialize as a StorageCommitment: {e}") + })?; + + // Key binding: the commitment must belong to the quote's signing key, + // exactly as the storer derives a peer id (`BLAKE3(pub_key)`). + let quote_peer = ant_core::data::compute_address("e.pub_key); + if ant_core::data::compute_address(&commitment.sender_public_key) != quote_peer + || commitment.sender_peer_id != quote_peer + { + return Err("commitment is not bound to the quote's signing key".into()); + } + if !verify_commitment_signature(&commitment) { + return Err("commitment has an invalid ML-DSA-65 signature".into()); + } + if commitment_hash(&commitment) != Some(pin) { + return Err("commitment does not hash to the quote's commitment_pin".into()); + } + if commitment.key_count != count { + return Err(format!( + "commitment attests key_count={} but the quote claims {count}", + commitment.key_count + )); + } + Ok(()) +} + +/// Parse a 32-byte hex string (0x prefix optional). +fn parse_hash32(s: &str) -> Result<[u8; 32], String> { + let stripped = s.trim().strip_prefix("0x").unwrap_or(s.trim()); + let bytes = hex::decode(stripped).map_err(|e| format!("invalid hex: {e}"))?; + bytes + .try_into() + .map_err(|_| "expected 32 bytes".to_string()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use ant_protocol::pqc::api::ml_dsa_65; + use std::time::{Duration, SystemTime}; + use xor_name::XorName; + + /// A genuinely-signed quote: fresh ML-DSA-65 keypair, signature over + /// `bytes_for_sig()` — the same thing a node produces. + fn signed_quote( + committed_key_count: u32, + commitment_pin: Option<[u8; 32]>, + price: Amount, + ) -> ( + PaymentQuote, + Vec, + ant_protocol::pqc::api::MlDsaSecretKey, + ) { + let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); + let pk_bytes = pk.to_bytes(); + let content = XorName([7u8; 32]); + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_756_000_000); + let rewards_address: RewardsAddress = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let bytes = PaymentQuote::bytes_for_signing( + content, + timestamp, + &price, + &rewards_address, + committed_key_count, + &commitment_pin, + ); + let sig = ml_dsa_65().sign(&sk, &bytes).unwrap(); + let quote = PaymentQuote { + content, + timestamp, + price, + rewards_address, + pub_key: pk_bytes.clone(), + signature: sig.to_bytes(), + committed_key_count, + commitment_pin, + }; + (quote, pk_bytes, sk) + } + + /// Replicates ant-protocol's private `commitment_signed_payload` layout so + /// tests can produce a genuinely-signed commitment under the quote's key. + /// If the layout ever drifts, `verify_commitment_signature` fails loudly + /// here. + fn commitment_payload( + root: &[u8; 32], + key_count: u32, + peer_id: &[u8; 32], + pk: &[u8], + ) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(root); + v.extend_from_slice(&key_count.to_le_bytes()); + v.extend_from_slice(peer_id); + v.extend_from_slice(&u32::try_from(pk.len()).unwrap().to_le_bytes()); + v.extend_from_slice(pk); + v + } + + fn signed_commitment( + key_count: u32, + pk_bytes: &[u8], + sk: &ant_protocol::pqc::api::MlDsaSecretKey, + ) -> StorageCommitment { + let root = [3u8; 32]; + let peer_id = ant_core::data::compute_address(pk_bytes); + let payload = commitment_payload(&root, key_count, &peer_id, pk_bytes); + let sig = ml_dsa_65() + .sign_with_context( + sk, + &payload, + ant_protocol::payment::commitment::DOMAIN_COMMITMENT, + ) + .unwrap(); + StorageCommitment { + root, + key_count, + sender_peer_id: peer_id, + sender_public_key: pk_bytes.to_vec(), + signature: sig.to_bytes(), + } + } + + fn entry_for(quote: &PaymentQuote, sidecar: Option<&StorageCommitment>) -> VerifyQuoteEntry { + VerifyQuoteEntry { + quote_hash: format!("{:#x}", quote.hash()), + rewards_address: format!("{:#x}", quote.rewards_address), + amount: (quote.price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER)).to_string(), + signed_quote: BASE64.encode(rmp_serde::to_vec(quote).unwrap()), + commitment_sidecar: sidecar.map(|c| BASE64.encode(rmp_serde::to_vec(c).unwrap())), + } + } + + #[test] + fn baseline_quote_verifies() { + let (quote, _, _) = signed_quote(0, None, calculate_price(0)); + let verdict = verify_entry(&entry_for("e, None)); + assert!(verdict.valid, "error: {:?}", verdict.error); + assert_eq!(verdict.committed_key_count, Some(0)); + assert_eq!(verdict.pinned, Some(false)); + assert_eq!(verdict.price, Some(calculate_price(0).to_string())); + assert_eq!(verdict.timestamp_unix_secs, Some(1_756_000_000)); + } + + #[test] + fn pinned_quote_with_matching_commitment_verifies() { + // Build the commitment first: the quote must pin its hash. + let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); + let pk_bytes = pk.to_bytes(); + let count = 4242u32; + let commitment = { + let root = [3u8; 32]; + let peer_id = ant_core::data::compute_address(&pk_bytes); + let payload = commitment_payload(&root, count, &peer_id, &pk_bytes); + let sig = ml_dsa_65() + .sign_with_context( + &sk, + &payload, + ant_protocol::payment::commitment::DOMAIN_COMMITMENT, + ) + .unwrap(); + StorageCommitment { + root, + key_count: count, + sender_peer_id: peer_id, + sender_public_key: pk_bytes.clone(), + signature: sig.to_bytes(), + } + }; + let pin = commitment_hash(&commitment).unwrap(); + + // Quote signed by the same key, pinning that commitment, priced on-curve. + let content = XorName([7u8; 32]); + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_756_000_000); + let rewards_address: RewardsAddress = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let price = calculate_price(count as usize); + let bytes = PaymentQuote::bytes_for_signing( + content, + timestamp, + &price, + &rewards_address, + count, + &Some(pin), + ); + let sig = ml_dsa_65().sign(&sk, &bytes).unwrap(); + let quote = PaymentQuote { + content, + timestamp, + price, + rewards_address, + pub_key: pk_bytes, + signature: sig.to_bytes(), + committed_key_count: count, + commitment_pin: Some(pin), + }; + + let verdict = verify_entry(&entry_for("e, Some(&commitment))); + assert!(verdict.valid, "error: {:?}", verdict.error); + assert_eq!(verdict.pinned, Some(true)); + assert_eq!(verdict.committed_key_count, Some(count)); + } + + #[test] + fn tampered_amount_is_rejected() { + let (quote, _, _) = signed_quote(0, None, calculate_price(0)); + let mut entry = entry_for("e, None); + entry.amount = (calculate_price(0) * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER) + + Amount::from(1)) + .to_string(); + let verdict = verify_entry(&entry); + assert!(!verdict.valid); + assert!(verdict + .error + .unwrap() + .contains("does not equal 3x the signed price")); + } + + #[test] + fn wrong_quote_hash_is_rejected() { + let (quote, _, _) = signed_quote(0, None, calculate_price(0)); + let mut entry = entry_for("e, None); + entry.quote_hash = format!("0x{}", hex::encode([9u8; 32])); + let verdict = verify_entry(&entry); + assert!(!verdict.valid); + assert!(verdict.error.unwrap().contains("not tied to this quote")); + // Extracted fields still populated so the caller sees the claim. + assert!(verdict.price.is_some()); + } + + #[test] + fn off_curve_price_is_rejected() { + let off = calculate_price(0) + Amount::from(1); + let (quote, _, _) = signed_quote(0, None, off); + let verdict = verify_entry(&entry_for("e, None)); + assert!(!verdict.valid); + assert!(verdict.error.unwrap().contains("calculate_price")); + } + + #[test] + fn pinned_quote_without_sidecar_is_rejected() { + let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); + let commitment = signed_commitment(7, &pk.to_bytes(), &sk); + let pin = commitment_hash(&commitment).unwrap(); + // Quote pins the commitment but the entry ships no sidecar. + let content = XorName([7u8; 32]); + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_756_000_000); + let rewards_address: RewardsAddress = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let price = calculate_price(7); + let bytes = PaymentQuote::bytes_for_signing( + content, + timestamp, + &price, + &rewards_address, + 7, + &Some(pin), + ); + let sig = ml_dsa_65().sign(&sk, &bytes).unwrap(); + let quote = PaymentQuote { + content, + timestamp, + price, + rewards_address, + pub_key: pk.to_bytes(), + signature: sig.to_bytes(), + committed_key_count: 7, + commitment_pin: Some(pin), + }; + let verdict = verify_entry(&entry_for("e, None)); + assert!(!verdict.valid); + assert!(verdict.error.unwrap().contains("unresolvable")); + } + + #[test] + fn forged_signature_is_rejected() { + let (mut quote, _, _) = signed_quote(0, None, calculate_price(0)); + quote.signature[0] ^= 0xff; + let verdict = verify_entry(&entry_for("e, None)); + assert!(!verdict.valid); + assert!(verdict.error.unwrap().contains("ML-DSA-65")); + } + + #[test] + fn entries_builder_emits_only_paid_quotes_and_attaches_sidecar_by_pin() { + let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); + let pk_bytes = pk.to_bytes(); + let count = 12u32; + let commitment = signed_commitment(count, &pk_bytes, &sk); + let pin = commitment_hash(&commitment).unwrap(); + + let content = XorName([7u8; 32]); + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_756_000_000); + let rewards_address: RewardsAddress = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let price = calculate_price(count as usize); + let bytes = PaymentQuote::bytes_for_signing( + content, + timestamp, + &price, + &rewards_address, + count, + &Some(pin), + ); + let sig = ml_dsa_65().sign(&sk, &bytes).unwrap(); + let pinned_quote = PaymentQuote { + content, + timestamp, + price, + rewards_address, + pub_key: pk_bytes, + signature: sig.to_bytes(), + committed_key_count: count, + commitment_pin: Some(pin), + }; + // A validly-priced quote that the payment intent did NOT select (the + // network quotes a whole close group; only a subset is paid). + let (unpaid_quote, _, _) = signed_quote(0, None, calculate_price(0)); + + let sidecar_blob = rmp_serde::to_vec(&commitment).unwrap(); + let paid: HashSet = [pinned_quote.hash()].into_iter().collect(); + let entries = entries_for_quotes( + [&pinned_quote, &unpaid_quote], + std::slice::from_ref(&sidecar_blob), + &paid, + ) + .unwrap(); + assert_eq!(entries.len(), 1, "unpaid quote must be skipped"); + assert_eq!(entries[0].quote_hash, format!("{:#x}", pinned_quote.hash())); + assert_eq!( + entries[0].commitment_sidecar, + Some(BASE64.encode(&sidecar_blob)) + ); + + // Round-trip: the emitted entry verifies. + let verdict = verify_entry(&VerifyQuoteEntry { + quote_hash: entries[0].quote_hash.clone(), + rewards_address: format!("{:#x}", pinned_quote.rewards_address), + amount: (pinned_quote.price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER)).to_string(), + signed_quote: entries[0].quote.clone(), + commitment_sidecar: entries[0].commitment_sidecar.clone(), + }); + assert!(verdict.valid, "error: {:?}", verdict.error); + } +} diff --git a/antd/src/types.rs b/antd/src/types.rs index 5d5bced4..4df7851c 100644 --- a/antd/src/types.rs +++ b/antd/src/types.rs @@ -77,6 +77,9 @@ pub struct PrepareChunkRequest { /// (≤ 4 MiB before self-encryption is irrelevant here — the bytes are /// stored verbatim as one chunk at their BLAKE3 address). pub data: String, + /// Same semantics as `PrepareUploadRequest::include_signed_quotes`. + #[serde(default)] + pub include_signed_quotes: bool, } /// `POST /v1/chunks/prepare` response. Mirrors [`PrepareUploadResponse`]'s @@ -120,6 +123,10 @@ pub struct PrepareChunkResponse { pub payment_token_address: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rpc_url: Option, + /// Same semantics as `PrepareUploadResponse::signed_quotes` — present only + /// when the request set `include_signed_quotes` and payment is required. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_quotes: Option>, } #[derive(Deserialize)] @@ -148,6 +155,13 @@ pub struct PrepareUploadRequest { /// pre-0.6.1 behavior. #[serde(default)] pub visibility: Option, + /// When true, the wave-batch response additionally carries the full + /// signed quotes + ADR-0004 commitment sidecars (`signed_quotes`) so a + /// hosted-payments gateway can verify the batch offline before paying + /// (V2-854). Default false: ~5–6 KB per quote plus up to 8 KB per + /// sidecar, and existing consumers see no change. + #[serde(default)] + pub include_signed_quotes: bool, } #[derive(Deserialize)] @@ -159,6 +173,9 @@ pub struct PrepareDataUploadRequest { /// payment batch and published on-network on finalize. #[serde(default)] pub visibility: Option, + /// Same semantics as [`PrepareUploadRequest::include_signed_quotes`]. + #[serde(default)] + pub include_signed_quotes: bool, } #[derive(Serialize)] @@ -217,6 +234,102 @@ pub struct PrepareUploadResponse { /// self-encryption) and therefore excluded from payment + PUT. The external /// signer is paying for `total_chunks - already_stored_count` chunks. pub already_stored_count: usize, + + // --- Signed-quote exposure (V2-854, antd 0.13.0) --- + /// Present only when the request set `include_signed_quotes` and the + /// payment type is wave_batch: one entry per `payments[]` quote carrying + /// the full signed quote (and its ADR-0004 commitment sidecar when the + /// quote pins one) as opaque bytes for offline verification via + /// `/v1/verify/quotes`. Merkle prepares omit it (candidate exposure is + /// tracked separately — the daemon does not retain merkle candidate + /// commitments). + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_quotes: Option>, +} + +/// One `payments[]` quote in full signed form: the opaque serialized +/// [`PaymentQuote`] plus, for commitment-bound quotes, the commitment sidecar +/// the quote pins. Consumers treat both as opaque bytes — only antd +/// (`/v1/verify/quotes`) parses them. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SignedQuoteEntry { + /// Quote hash (hex with 0x prefix) — matches the `payments[]` entry. + pub quote_hash: String, + /// base64(msgpack-serialized signed PaymentQuote). Opaque. + pub quote: String, + /// base64(msgpack-serialized StorageCommitment) — the ADR-0004 sidecar + /// the quote's `commitment_pin` resolves to. Absent for baseline quotes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commitment_sidecar: Option, +} + +// ── VerifyQuotes (stateless offline verification, V2-854) ── + +#[derive(Deserialize)] +pub struct VerifyQuotesRequest { + pub entries: Vec, +} + +/// One entry to verify: the `/pay`-shaped payment triple plus the opaque +/// signed artifacts from the prepare response's `signed_quotes`. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct VerifyQuoteEntry { + /// Quote hash the payer was asked to pay (hex, 32 bytes). + pub quote_hash: String, + /// Rewards address the payer was asked to pay (hex with 0x prefix). + pub rewards_address: String, + /// Amount the payer was asked to pay (atto tokens, decimal string). + pub amount: String, + /// base64(msgpack) signed PaymentQuote — from `signed_quotes[].quote`. + pub signed_quote: String, + /// base64(msgpack) StorageCommitment sidecar — from + /// `signed_quotes[].commitment_sidecar`. Required when the quote is + /// commitment-bound. + #[serde(default)] + pub commitment_sidecar: Option, +} + +#[derive(Serialize)] +pub struct VerifyQuotesResponse { + /// True only when every entry verified. + pub valid: bool, + pub entries: Vec, +} + +/// Per-entry verification verdict. The extracted fields are populated as soon +/// as the signed quote deserializes — even when a later check fails — so +/// policy layers can see what the quote claimed. +#[derive(Serialize, Clone, Debug)] +pub struct VerifyQuoteVerdict { + /// Echo of the request entry's quote_hash. + pub quote_hash: String, + /// True when every check passed: hash recomputation, ML-DSA-65 signature, + /// paid-fields equality, and the ADR-0004 commitment binding with exact + /// on-curve pricing. + pub valid: bool, + /// The first failing rule, by name. Absent when valid. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Quote timestamp (unix seconds) — for the caller's expiry policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp_unix_secs: Option, + /// The chunk address the quote covers (hex, 32 bytes) — for the caller's + /// chunk-set equality policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// The signed price (atto tokens, decimal string). + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + /// The signed rewards address (hex with 0x prefix). + #[serde(skip_serializing_if = "Option::is_none")] + pub rewards_address: Option, + /// Claimed ADR-0004 storage-commitment key count (0 = baseline quote) — + /// for the caller's count-plausibility cap. + #[serde(skip_serializing_if = "Option::is_none")] + pub committed_key_count: Option, + /// Whether the quote pins a storage commitment. + #[serde(skip_serializing_if = "Option::is_none")] + pub pinned: Option, } /// One merkle payment batch: everything the external signer needs for a @@ -523,6 +636,7 @@ mod tests { rpc_url: "http://localhost:8545".into(), total_chunks: 3, already_stored_count: 1, + signed_quotes: None, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["payment_type"], "wave_batch"); @@ -532,6 +646,8 @@ mod tests { assert!(json.get("depth").is_none()); assert!(json.get("pool_commitments").is_none()); assert!(json.get("merkle_payment_timestamp").is_none()); + // Opt-in signed-quote exposure must be absent when not requested + assert!(json.get("signed_quotes").is_none()); // Preflight fields are always present assert_eq!(json["total_chunks"], 3); assert_eq!(json["already_stored_count"], 1); @@ -569,6 +685,7 @@ mod tests { rpc_url: "http://localhost:8545".into(), total_chunks: 128, already_stored_count: 0, + signed_quotes: None, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["payment_type"], "merkle"); @@ -856,6 +973,7 @@ mod tests { payment_vault_address: Some("0xcc".into()), payment_token_address: Some("0xdd".into()), rpc_url: Some("http://localhost:8545".into()), + signed_quotes: None, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["already_stored"], false); @@ -879,6 +997,7 @@ mod tests { payment_vault_address: None, payment_token_address: None, rpc_url: None, + signed_quotes: None, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["already_stored"], true); diff --git a/docs/external-signer-flow.md b/docs/external-signer-flow.md index ab7900ab..29fc516e 100644 --- a/docs/external-signer-flow.md +++ b/docs/external-signer-flow.md @@ -338,6 +338,82 @@ The flow is: Out of scope for the V2-312 examples (small files only). When merkle examples are added (separate ticket), this section becomes the spec. +## Signed-quote exposure + offline verification (hosted payments, V2-854) + +Added in antd 0.13.0. In hosted-payment mode the party that pays is not the +party that collected the quotes — the payer (a payment gateway) receives +`payments[]` triples from a customer-controlled instance and must not trust +them: a fabricated triple could name any address and any amount. Two additions +close this: + +### Opt-in exposure: `include_signed_quotes` + +All three prepare endpoints (`/v1/upload/prepare`, `/v1/data/prepare`, +`/v1/chunks/prepare`, and their gRPC twins) accept +`"include_signed_quotes": true`. The wave-batch response then carries +`signed_quotes[]` alongside `payments[]`: + +```json +{ + "payments": [{"quote_hash": "0x…", "rewards_address": "0x…", "amount": "…"}], + "signed_quotes": [{"quote_hash": "0x…", "quote": "", "commitment_sidecar": ""}] +} +``` + +- `quote` is the full signed `PaymentQuote` (ML-DSA-65 pubkey + signature over + content/timestamp/price/rewards_address/count/pin) as opaque + base64(msgpack) bytes. +- `commitment_sidecar` is the ADR-0004 `StorageCommitment` the quote pins — + present only for commitment-bound quotes (baseline quotes have none). +- Size: ~5–6 KB per quote plus up to 8 KB per sidecar — hence opt-in. + Default-off; existing consumers see no change. +- Merkle prepares never populate it: ant-core does not retain merkle + candidate commitments (V2-854 open question 1 — hosted merkle is V2-934). + +The signer relays these blobs to its payment gateway **unmodified**; nothing +outside antd ever parses them. + +### Offline verification: `POST /v1/verify/quotes` + +The gateway verifies a batch before paying by calling `VerifyQuotes` on its +**own** antd instance (never the customer's). Stateless, offline, pure: + +```json +{"entries": [{ + "quote_hash": "0x…", "rewards_address": "0x…", "amount": "…", + "signed_quote": "", "commitment_sidecar": "" +}]} +``` + +Per entry, antd checks (the crypto + exact-arithmetic layer): + +1. **Hash recomputation** — `quote_hash == hash(signed_quote)`: the triple is + tied to a concrete quote, not a made-up hash. +2. **ML-DSA-65 signature** over the paid fields. +3. **Paid-fields equality** — the triple's `rewards_address` exactly equals + the signed one, and `amount` exactly equals **3× the signed `price`** (the + single-node payment multiplier: single-quote payments pay only the median + quote of the close group, at 3×, keeping per-chunk economics equivalent — + V2-619). +4. **ADR-0004 resolve-before-pay binding** — commitment sidecar present for + pinned quotes, signed under the quote's own key, `commitment_hash == pin`, + attested `key_count` equals the claimed count, and + `price == calculate_price(key_count)` by exact recomputation (baseline + quotes must price exactly `calculate_price(0)`). An unresolvable pin is + never valid. + +The response carries per-entry verdicts plus the fields caller-side policy +needs (`timestamp_unix_secs`, `content`, `price`, `rewards_address`, +`committed_key_count`, `pinned`). **Policy stays caller-side**: expiry +windows, paid-quote replay ledgers, chunk-set equality vs the declared +upload, count-plausibility caps, and spend ceilings are the gateway's job — +`VerifyQuotes` proves internal consistency, not signer legitimacy (keypairs +are free to mint; only economic caps and spot-check re-quoting bound a +self-consistent fabrication). + +Go client: `PrepareUploadWithOptions(ctx, path, PrepareOptions{IncludeSignedQuotes: true})` +then `VerifyQuotes(ctx, entries)`. + ## References - Daemon surface: PR [#90](https://github.com/WithAutonomi/ant-sdk/pull/90), squash `a3cf4e40` From 940cfcbb281c232d69998eabb03fa75a92c753dc Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 24 Aug 2026 10:49:38 +0100 Subject: [PATCH 2/4] feat(antd-go): signed-quote prepare options + VerifyQuotes client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- antd-go/client.go | 118 ++++++- antd-go/client_test.go | 86 +++++ antd-go/models.go | 52 +++ antd-go/proto/antd/v1/chunks.pb.go | 66 ++-- antd-go/proto/antd/v1/common.pb.go | 92 ++++- antd-go/proto/antd/v1/upload.pb.go | 101 ++++-- antd-go/proto/antd/v1/verify.pb.go | 427 ++++++++++++++++++++++++ antd-go/proto/antd/v1/verify_grpc.pb.go | 149 +++++++++ 8 files changed, 1029 insertions(+), 62 deletions(-) create mode 100644 antd-go/proto/antd/v1/verify.pb.go create mode 100644 antd-go/proto/antd/v1/verify_grpc.pb.go diff --git a/antd-go/client.go b/antd-go/client.go index d30217f3..f462e3c9 100644 --- a/antd-go/client.go +++ b/antd-go/client.go @@ -480,9 +480,17 @@ func (c *Client) ChunkGet(ctx context.Context, address string) ([]byte, error) { // // Requires antd >= 0.7.0. func (c *Client) PrepareChunkUpload(ctx context.Context, content []byte) (*PrepareChunkResult, error) { - j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/chunks/prepare", map[string]any{ - "data": b64Encode(content), - }) + return c.PrepareChunkUploadWithOptions(ctx, content, PrepareOptions{}) +} + +// PrepareChunkUploadWithOptions is PrepareChunkUpload with explicit options. +// Visibility is not applicable to single-chunk publishes and is ignored. +func (c *Client) PrepareChunkUploadWithOptions(ctx context.Context, content []byte, opts PrepareOptions) (*PrepareChunkResult, error) { + body := map[string]any{"data": b64Encode(content)} + if opts.IncludeSignedQuotes { + body["include_signed_quotes"] = true + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/chunks/prepare", body) if err != nil { return nil, err } @@ -510,6 +518,7 @@ func (c *Client) PrepareChunkUpload(ctx context.Context, content []byte) (*Prepa }) } } + r.SignedQuotes = parseSignedQuotes(arrAt(j, "signed_quotes")) return r, nil } @@ -678,6 +687,10 @@ func parsePrepareResponse(j map[string]any) *PrepareUploadResult { } } + // Signed-quote exposure (antd >= 0.13.0) — present only when the prepare + // requested IncludeSignedQuotes. + result.SignedQuotes = parseSignedQuotes(arrAt(j, "signed_quotes")) + // Parse merkle fields if result.PaymentType == "merkle" { result.Depth = int(num64(j, "depth")) @@ -735,6 +748,105 @@ func (c *Client) PrepareUpload(ctx context.Context, path string) (*PrepareUpload return parsePrepareResponse(j), nil } +// PrepareOptions selects optional behaviour for the prepare endpoints. +type PrepareOptions struct { + // Visibility is "private" (default when empty) or "public" — see + // PrepareUploadPublic for what "public" changes. + Visibility string + // IncludeSignedQuotes asks the daemon to carry the full signed quotes + + // ADR-0004 commitment sidecars in the response (wave-batch only), for + // offline verification via VerifyQuotes. Requires antd >= 0.13.0; older + // daemons ignore the flag and SignedQuotes stays empty. + IncludeSignedQuotes bool +} + +// PrepareUploadWithOptions is PrepareUpload with explicit options. +func (c *Client) PrepareUploadWithOptions(ctx context.Context, path string, opts PrepareOptions) (*PrepareUploadResult, error) { + body := map[string]any{"path": path} + if opts.Visibility != "" { + body["visibility"] = opts.Visibility + } + if opts.IncludeSignedQuotes { + body["include_signed_quotes"] = true + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/upload/prepare", body) + if err != nil { + return nil, err + } + return parsePrepareResponse(j), nil +} + +// PrepareDataUploadWithOptions is PrepareDataUpload with explicit options. +// Note visibility:"public" is not yet supported by the data endpoint (the +// daemon returns 501) — see PrepareDataUpload. +func (c *Client) PrepareDataUploadWithOptions(ctx context.Context, data []byte, opts PrepareOptions) (*PrepareUploadResult, error) { + body := map[string]any{"data": b64Encode(data)} + if opts.Visibility != "" { + body["visibility"] = opts.Visibility + } + if opts.IncludeSignedQuotes { + body["include_signed_quotes"] = true + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/prepare", body) + if err != nil { + return nil, err + } + return parsePrepareResponse(j), nil +} + +// VerifyQuotes verifies a batch of signed quotes offline via +// POST /v1/verify/quotes: quote-hash recomputation, ML-DSA-65 signature, +// paid-fields equality against each entry's triple, and the ADR-0004 +// commitment binding with exact on-curve pricing. Stateless and offline — +// call it on a daemon you trust (your own), never the counterparty's. +// Policy checks (expiry windows, replay ledgers, chunk-set equality, +// count-plausibility caps) remain the caller's job; the verdicts carry the +// extracted fields those policies need. +// +// Requires antd >= 0.13.0. +func (c *Client) VerifyQuotes(ctx context.Context, entries []VerifyQuoteEntry) (*VerifyQuotesResult, error) { + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/verify/quotes", map[string]any{ + "entries": entries, + }) + if err != nil { + return nil, err + } + result := &VerifyQuotesResult{Valid: boolField(j, "valid")} + for _, e := range arrAt(j, "entries") { + em, ok := e.(map[string]any) + if !ok { + continue + } + result.Entries = append(result.Entries, VerifyQuoteVerdict{ + QuoteHash: str(em, "quote_hash"), + Valid: boolField(em, "valid"), + Error: str(em, "error"), + TimestampUnixSecs: uint64(num64(em, "timestamp_unix_secs")), + Content: str(em, "content"), + Price: str(em, "price"), + RewardsAddress: str(em, "rewards_address"), + CommittedKeyCount: uint32(num64(em, "committed_key_count")), + Pinned: boolField(em, "pinned"), + }) + } + return result, nil +} + +// parseSignedQuotes maps a JSON signed_quotes array into typed entries. +func parseSignedQuotes(raw []any) []SignedQuoteEntry { + var out []SignedQuoteEntry + for _, s := range raw { + if sm, ok := s.(map[string]any); ok { + out = append(out, SignedQuoteEntry{ + QuoteHash: str(sm, "quote_hash"), + Quote: str(sm, "quote"), + CommitmentSidecar: str(sm, "commitment_sidecar"), + }) + } + } + return out +} + // PrepareUploadPublic prepares a public file upload for external signing. // In addition to the data chunks, the daemon bundles the serialized DataMap // chunk into the same payment batch — so the external signer signs ONE EVM diff --git a/antd-go/client_test.go b/antd-go/client_test.go index c9fbec8b..b81601d1 100644 --- a/antd-go/client_test.go +++ b/antd-go/client_test.go @@ -1137,3 +1137,89 @@ func TestPlain502StillMapsToNetworkError(t *testing.T) { t.Fatalf("expected *NetworkError for plain 502, got %T: %v", err, err) } } + +func TestPrepareUploadWithOptionsSendsFlagAndParsesSignedQuotes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/upload/prepare" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["include_signed_quotes"] != true { + t.Fatalf("include_signed_quotes not sent: %+v", body) + } + _, _ = io.WriteString(w, `{ + "upload_id": "up9", "payment_type": "wave_batch", + "payments": [{"quote_hash": "qh9", "rewards_address": "ra9", "amount": "5"}], + "signed_quotes": [{"quote_hash": "qh9", "quote": "b3BhcXVl", "commitment_sidecar": "c2lkZQ=="}], + "total_amount": "5", "payment_vault_address": "dp", "payment_token_address": "pt", + "rpc_url": "http://localhost:1" + }`) + })) + defer srv.Close() + c := NewClient(srv.URL) + res, err := c.PrepareUploadWithOptions(context.Background(), "/tmp/x", PrepareOptions{IncludeSignedQuotes: true}) + if err != nil { + t.Fatal(err) + } + if len(res.SignedQuotes) != 1 { + t.Fatalf("unexpected signed_quotes: %+v", res.SignedQuotes) + } + sq := res.SignedQuotes[0] + if sq.QuoteHash != "qh9" || sq.Quote != "b3BhcXVl" || sq.CommitmentSidecar != "c2lkZQ==" { + t.Fatalf("unexpected entry: %+v", sq) + } +} + +func TestVerifyQuotes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/verify/quotes" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var body struct { + Entries []VerifyQuoteEntry `json:"entries"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Entries) != 2 || body.Entries[0].SignedQuote != "b3BhcXVl" { + t.Fatalf("unexpected entries: %+v", body.Entries) + } + if body.Entries[1].CommitmentSidecar != "" { + t.Fatalf("baseline entry should have no sidecar: %+v", body.Entries[1]) + } + _, _ = io.WriteString(w, `{ + "valid": false, + "entries": [ + {"quote_hash": "qh1", "valid": true, "timestamp_unix_secs": 1756000000, + "content": "aa", "price": "5", "rewards_address": "ra1", + "committed_key_count": 42, "pinned": true}, + {"quote_hash": "qh2", "valid": false, "error": "price 6 does not equal calculate_price(committed_key_count=0)"} + ] + }`) + })) + defer srv.Close() + c := NewClient(srv.URL) + res, err := c.VerifyQuotes(context.Background(), []VerifyQuoteEntry{ + {QuoteHash: "qh1", RewardsAddress: "ra1", Amount: "5", SignedQuote: "b3BhcXVl", CommitmentSidecar: "c2lkZQ=="}, + {QuoteHash: "qh2", RewardsAddress: "ra2", Amount: "6", SignedQuote: "b3BhcXVlMg=="}, + }) + if err != nil { + t.Fatal(err) + } + if res.Valid { + t.Fatal("expected overall valid=false") + } + if len(res.Entries) != 2 { + t.Fatalf("unexpected entries: %+v", res.Entries) + } + if !res.Entries[0].Valid || res.Entries[0].CommittedKeyCount != 42 || !res.Entries[0].Pinned || + res.Entries[0].TimestampUnixSecs != 1756000000 { + t.Fatalf("unexpected first verdict: %+v", res.Entries[0]) + } + if res.Entries[1].Valid || res.Entries[1].Error == "" { + t.Fatalf("unexpected second verdict: %+v", res.Entries[1]) + } +} diff --git a/antd-go/models.go b/antd-go/models.go index 6f6bfc4f..55d7ddee 100644 --- a/antd-go/models.go +++ b/antd-go/models.go @@ -111,6 +111,22 @@ type PrepareUploadResult struct { // external signer pays for (TotalChunks - AlreadyStoredCount) chunks. TotalChunks int `json:"total_chunks,omitempty"` // total chunks incl. already-stored AlreadyStoredCount int `json:"already_stored_count,omitempty"` // chunks skipped (already on-network) + + // Signed-quote exposure (antd >= 0.13.0, V2-854). Populated only when the + // prepare was made with IncludeSignedQuotes and PaymentType is wave_batch: + // one entry per Payments quote carrying the full signed quote (and its + // ADR-0004 commitment sidecar when pinned) as opaque bytes for offline + // verification via VerifyQuotes. + SignedQuotes []SignedQuoteEntry `json:"signed_quotes,omitempty"` +} + +// SignedQuoteEntry is one Payments quote in full signed form. Quote and +// CommitmentSidecar are opaque base64 blobs — pass them to VerifyQuotes +// unchanged; only antd parses them. +type SignedQuoteEntry struct { + QuoteHash string `json:"quote_hash"` // hex with 0x prefix — matches Payments + Quote string `json:"quote"` // base64(msgpack signed PaymentQuote), opaque + CommitmentSidecar string `json:"commitment_sidecar,omitempty"` // base64(msgpack StorageCommitment), empty for baseline quotes } // MerkleBatchEntry describes one merkle payment batch: everything the @@ -172,6 +188,42 @@ type PrepareChunkResult struct { PaymentTokenAddress string `json:"payment_token_address,omitempty"` // EVM RPC URL for submitting transactions. RPCUrl string `json:"rpc_url,omitempty"` + // Same semantics as PrepareUploadResult.SignedQuotes (antd >= 0.13.0). + SignedQuotes []SignedQuoteEntry `json:"signed_quotes,omitempty"` +} + +// VerifyQuoteEntry is one entry for VerifyQuotes: the payment triple the +// caller was asked to pay plus the opaque signed artifacts from the prepare +// response's SignedQuotes. +type VerifyQuoteEntry struct { + QuoteHash string `json:"quote_hash"` // hex, 32 bytes + RewardsAddress string `json:"rewards_address"` // hex with 0x prefix + Amount string `json:"amount"` // atto tokens, decimal string + SignedQuote string `json:"signed_quote"` // SignedQuoteEntry.Quote, opaque + CommitmentSidecar string `json:"commitment_sidecar,omitempty"` // SignedQuoteEntry.CommitmentSidecar, opaque +} + +// VerifyQuoteVerdict is the per-entry result of VerifyQuotes. The extracted +// fields (Timestamp, Content, …) are populated as soon as the signed quote +// deserializes — even when a later check fails — so policy layers can see +// what the quote claimed. +type VerifyQuoteVerdict struct { + QuoteHash string `json:"quote_hash"` // echo of the request entry + Valid bool `json:"valid"` // every check passed + Error string `json:"error,omitempty"` // first failing rule, by name + TimestampUnixSecs uint64 `json:"timestamp_unix_secs,omitempty"` // for expiry policy + Content string `json:"content,omitempty"` // chunk address (hex, 32 bytes) + Price string `json:"price,omitempty"` // signed price (atto tokens) + RewardsAddress string `json:"rewards_address,omitempty"` // signed rewards address + CommittedKeyCount uint32 `json:"committed_key_count,omitempty"` // for count-plausibility caps (0 = baseline) + Pinned bool `json:"pinned,omitempty"` // quote pins a storage commitment +} + +// VerifyQuotesResult is the result of VerifyQuotes. +type VerifyQuotesResult struct { + // Valid is true only when Entries is non-empty and every entry verified. + Valid bool `json:"valid"` + Entries []VerifyQuoteVerdict `json:"entries"` } // UploadCostEstimate is the result of an estimate (EstimateDataCost / EstimateFileCost). diff --git a/antd-go/proto/antd/v1/chunks.pb.go b/antd-go/proto/antd/v1/chunks.pb.go index 63087c5a..67882e19 100644 --- a/antd-go/proto/antd/v1/chunks.pb.go +++ b/antd-go/proto/antd/v1/chunks.pb.go @@ -208,9 +208,11 @@ func (x *PutChunkResponse) GetAddress() string { type PrepareChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw chunk bytes — at most one ant-protocol chunk. - Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + // Same semantics as PrepareFileUploadRequest.include_signed_quotes. + IncludeSignedQuotes bool `protobuf:"varint,2,opt,name=include_signed_quotes,json=includeSignedQuotes,proto3" json:"include_signed_quotes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PrepareChunkRequest) Reset() { @@ -250,6 +252,13 @@ func (x *PrepareChunkRequest) GetData() []byte { return nil } +func (x *PrepareChunkRequest) GetIncludeSignedQuotes() bool { + if x != nil { + return x.IncludeSignedQuotes + } + return false +} + // Mirrors REST `PrepareChunkResponse`. Single-chunk publishes are always // wave-batch, so there are no merkle fields. When `already_stored = true` // the payment fields are empty / zero and the caller can skip FinalizeChunk @@ -282,7 +291,10 @@ type PrepareChunkResponse struct { PaymentTokenAddress string `protobuf:"bytes,8,opt,name=payment_token_address,json=paymentTokenAddress,proto3" json:"payment_token_address,omitempty"` // EVM RPC URL for submitting transactions. Empty when // `already_stored == true`. - RpcUrl string `protobuf:"bytes,9,opt,name=rpc_url,json=rpcUrl,proto3" json:"rpc_url,omitempty"` + RpcUrl string `protobuf:"bytes,9,opt,name=rpc_url,json=rpcUrl,proto3" json:"rpc_url,omitempty"` + // Populated only when the request set `include_signed_quotes` and payment + // is required — same semantics as PrepareUploadResponse.signed_quotes. + SignedQuotes []*SignedQuoteEntry `protobuf:"bytes,10,rep,name=signed_quotes,json=signedQuotes,proto3" json:"signed_quotes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -380,6 +392,13 @@ func (x *PrepareChunkResponse) GetRpcUrl() string { return "" } +func (x *PrepareChunkResponse) GetSignedQuotes() []*SignedQuoteEntry { + if x != nil { + return x.SignedQuotes + } + return nil +} + type FinalizeChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The upload_id returned from PrepareChunk. @@ -492,9 +511,10 @@ const file_antd_v1_chunks_proto_rawDesc = "" + "\x04data\x18\x01 \x01(\fR\x04data\"O\n" + "\x10PutChunkResponse\x12!\n" + "\x04cost\x18\x01 \x01(\v2\r.antd.v1.CostR\x04cost\x12\x18\n" + - "\aaddress\x18\x02 \x01(\tR\aaddress\")\n" + + "\aaddress\x18\x02 \x01(\tR\aaddress\"]\n" + "\x13PrepareChunkRequest\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\"\xee\x02\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x122\n" + + "\x15include_signed_quotes\x18\x02 \x01(\bR\x13includeSignedQuotes\"\xae\x03\n" + "\x14PrepareChunkResponse\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\x12%\n" + "\x0ealready_stored\x18\x02 \x01(\bR\ralreadyStored\x12\x1b\n" + @@ -504,7 +524,9 @@ const file_antd_v1_chunks_proto_rawDesc = "" + "\ftotal_amount\x18\x06 \x01(\tR\vtotalAmount\x122\n" + "\x15payment_vault_address\x18\a \x01(\tR\x13paymentVaultAddress\x122\n" + "\x15payment_token_address\x18\b \x01(\tR\x13paymentTokenAddress\x12\x17\n" + - "\arpc_url\x18\t \x01(\tR\x06rpcUrl\"\xba\x01\n" + + "\arpc_url\x18\t \x01(\tR\x06rpcUrl\x12>\n" + + "\rsigned_quotes\x18\n" + + " \x03(\v2\x19.antd.v1.SignedQuoteEntryR\fsignedQuotes\"\xba\x01\n" + "\x14FinalizeChunkRequest\x12\x1b\n" + "\tupload_id\x18\x01 \x01(\tR\buploadId\x12H\n" + "\ttx_hashes\x18\x02 \x03(\v2+.antd.v1.FinalizeChunkRequest.TxHashesEntryR\btxHashes\x1a;\n" + @@ -544,24 +566,26 @@ var file_antd_v1_chunks_proto_goTypes = []any{ nil, // 8: antd.v1.FinalizeChunkRequest.TxHashesEntry (*Cost)(nil), // 9: antd.v1.Cost (*PaymentEntry)(nil), // 10: antd.v1.PaymentEntry + (*SignedQuoteEntry)(nil), // 11: antd.v1.SignedQuoteEntry } var file_antd_v1_chunks_proto_depIdxs = []int32{ 9, // 0: antd.v1.PutChunkResponse.cost:type_name -> antd.v1.Cost 10, // 1: antd.v1.PrepareChunkResponse.payments:type_name -> antd.v1.PaymentEntry - 8, // 2: antd.v1.FinalizeChunkRequest.tx_hashes:type_name -> antd.v1.FinalizeChunkRequest.TxHashesEntry - 0, // 3: antd.v1.ChunkService.Get:input_type -> antd.v1.GetChunkRequest - 2, // 4: antd.v1.ChunkService.Put:input_type -> antd.v1.PutChunkRequest - 4, // 5: antd.v1.ChunkService.PrepareChunk:input_type -> antd.v1.PrepareChunkRequest - 6, // 6: antd.v1.ChunkService.FinalizeChunk:input_type -> antd.v1.FinalizeChunkRequest - 1, // 7: antd.v1.ChunkService.Get:output_type -> antd.v1.GetChunkResponse - 3, // 8: antd.v1.ChunkService.Put:output_type -> antd.v1.PutChunkResponse - 5, // 9: antd.v1.ChunkService.PrepareChunk:output_type -> antd.v1.PrepareChunkResponse - 7, // 10: antd.v1.ChunkService.FinalizeChunk:output_type -> antd.v1.FinalizeChunkResponse - 7, // [7:11] is the sub-list for method output_type - 3, // [3:7] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 11, // 2: antd.v1.PrepareChunkResponse.signed_quotes:type_name -> antd.v1.SignedQuoteEntry + 8, // 3: antd.v1.FinalizeChunkRequest.tx_hashes:type_name -> antd.v1.FinalizeChunkRequest.TxHashesEntry + 0, // 4: antd.v1.ChunkService.Get:input_type -> antd.v1.GetChunkRequest + 2, // 5: antd.v1.ChunkService.Put:input_type -> antd.v1.PutChunkRequest + 4, // 6: antd.v1.ChunkService.PrepareChunk:input_type -> antd.v1.PrepareChunkRequest + 6, // 7: antd.v1.ChunkService.FinalizeChunk:input_type -> antd.v1.FinalizeChunkRequest + 1, // 8: antd.v1.ChunkService.Get:output_type -> antd.v1.GetChunkResponse + 3, // 9: antd.v1.ChunkService.Put:output_type -> antd.v1.PutChunkResponse + 5, // 10: antd.v1.ChunkService.PrepareChunk:output_type -> antd.v1.PrepareChunkResponse + 7, // 11: antd.v1.ChunkService.FinalizeChunk:output_type -> antd.v1.FinalizeChunkResponse + 8, // [8:12] is the sub-list for method output_type + 4, // [4:8] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_antd_v1_chunks_proto_init() } diff --git a/antd-go/proto/antd/v1/common.pb.go b/antd-go/proto/antd/v1/common.pb.go index 84a45982..04386630 100644 --- a/antd-go/proto/antd/v1/common.pb.go +++ b/antd-go/proto/antd/v1/common.pb.go @@ -294,6 +294,76 @@ func (x *PaymentEntry) GetAmount() string { return "" } +// One `payments[]` quote in full signed form (V2-854 signed-quote exposure): +// the opaque serialized PaymentQuote plus, for commitment-bound quotes, the +// ADR-0004 commitment sidecar the quote pins. Consumers treat both as opaque +// bytes — only antd (`VerifyService.VerifyQuotes`) parses them. Shared by +// `UploadService` (wave-batch prepares) and `ChunkService.PrepareChunk`. +type SignedQuoteEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Quote hash (hex with 0x prefix, 32 bytes) — matches the `payments[]` + // entry. + QuoteHash string `protobuf:"bytes,1,opt,name=quote_hash,json=quoteHash,proto3" json:"quote_hash,omitempty"` + // msgpack-serialized signed PaymentQuote. Opaque. + Quote []byte `protobuf:"bytes,2,opt,name=quote,proto3" json:"quote,omitempty"` + // msgpack-serialized StorageCommitment the quote's commitment_pin resolves + // to. Empty for baseline quotes. + CommitmentSidecar []byte `protobuf:"bytes,3,opt,name=commitment_sidecar,json=commitmentSidecar,proto3" json:"commitment_sidecar,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedQuoteEntry) Reset() { + *x = SignedQuoteEntry{} + mi := &file_antd_v1_common_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedQuoteEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedQuoteEntry) ProtoMessage() {} + +func (x *SignedQuoteEntry) ProtoReflect() protoreflect.Message { + mi := &file_antd_v1_common_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedQuoteEntry.ProtoReflect.Descriptor instead. +func (*SignedQuoteEntry) Descriptor() ([]byte, []int) { + return file_antd_v1_common_proto_rawDescGZIP(), []int{5} +} + +func (x *SignedQuoteEntry) GetQuoteHash() string { + if x != nil { + return x.QuoteHash + } + return "" +} + +func (x *SignedQuoteEntry) GetQuote() []byte { + if x != nil { + return x.Quote + } + return nil +} + +func (x *SignedQuoteEntry) GetCommitmentSidecar() []byte { + if x != nil { + return x.CommitmentSidecar + } + return nil +} + var File_antd_v1_common_proto protoreflect.FileDescriptor const file_antd_v1_common_proto_rawDesc = "" + @@ -317,7 +387,12 @@ const file_antd_v1_common_proto_rawDesc = "" + "\n" + "quote_hash\x18\x01 \x01(\tR\tquoteHash\x12'\n" + "\x0frewards_address\x18\x02 \x01(\tR\x0erewardsAddress\x12\x16\n" + - "\x06amount\x18\x03 \x01(\tR\x06amountBDZ8github.com/WithAutonomi/ant-sdk/antd-go/proto/antd/v1;v1\xaa\x02\aAntd.V1b\x06proto3" + "\x06amount\x18\x03 \x01(\tR\x06amount\"v\n" + + "\x10SignedQuoteEntry\x12\x1d\n" + + "\n" + + "quote_hash\x18\x01 \x01(\tR\tquoteHash\x12\x14\n" + + "\x05quote\x18\x02 \x01(\fR\x05quote\x12-\n" + + "\x12commitment_sidecar\x18\x03 \x01(\fR\x11commitmentSidecarBDZ8github.com/WithAutonomi/ant-sdk/antd-go/proto/antd/v1;v1\xaa\x02\aAntd.V1b\x06proto3" var ( file_antd_v1_common_proto_rawDescOnce sync.Once @@ -331,13 +406,14 @@ func file_antd_v1_common_proto_rawDescGZIP() []byte { return file_antd_v1_common_proto_rawDescData } -var file_antd_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_antd_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_antd_v1_common_proto_goTypes = []any{ - (*Cost)(nil), // 0: antd.v1.Cost - (*Address)(nil), // 1: antd.v1.Address - (*PublicKeyProto)(nil), // 2: antd.v1.PublicKeyProto - (*SecretKeyProto)(nil), // 3: antd.v1.SecretKeyProto - (*PaymentEntry)(nil), // 4: antd.v1.PaymentEntry + (*Cost)(nil), // 0: antd.v1.Cost + (*Address)(nil), // 1: antd.v1.Address + (*PublicKeyProto)(nil), // 2: antd.v1.PublicKeyProto + (*SecretKeyProto)(nil), // 3: antd.v1.SecretKeyProto + (*PaymentEntry)(nil), // 4: antd.v1.PaymentEntry + (*SignedQuoteEntry)(nil), // 5: antd.v1.SignedQuoteEntry } var file_antd_v1_common_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type @@ -358,7 +434,7 @@ func file_antd_v1_common_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_antd_v1_common_proto_rawDesc), len(file_antd_v1_common_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/antd-go/proto/antd/v1/upload.pb.go b/antd-go/proto/antd/v1/upload.pb.go index 0567c293..7f255e2c 100644 --- a/antd-go/proto/antd/v1/upload.pb.go +++ b/antd-go/proto/antd/v1/upload.pb.go @@ -29,9 +29,15 @@ type PrepareFileUploadRequest struct { // or "public" (DataMap chunk bundled into the same payment batch and // stored on-network; its address is returned on finalize). Empty string // is treated as "private". - Visibility string `protobuf:"bytes,2,opt,name=visibility,proto3" json:"visibility,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Visibility string `protobuf:"bytes,2,opt,name=visibility,proto3" json:"visibility,omitempty"` + // When true, the wave-batch response additionally carries the full signed + // quotes + ADR-0004 commitment sidecars (`signed_quotes`) so a + // hosted-payments gateway can verify the batch offline before paying + // (V2-854). Default false: ~5–6 KB per quote plus up to 8 KB per sidecar, + // and existing consumers see no change. + IncludeSignedQuotes bool `protobuf:"varint,3,opt,name=include_signed_quotes,json=includeSignedQuotes,proto3" json:"include_signed_quotes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PrepareFileUploadRequest) Reset() { @@ -78,14 +84,23 @@ func (x *PrepareFileUploadRequest) GetVisibility() string { return "" } +func (x *PrepareFileUploadRequest) GetIncludeSignedQuotes() bool { + if x != nil { + return x.IncludeSignedQuotes + } + return false +} + type PrepareDataUploadRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw bytes to upload. Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` // Same semantics as PrepareFileUploadRequest.visibility. - Visibility string `protobuf:"bytes,2,opt,name=visibility,proto3" json:"visibility,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Visibility string `protobuf:"bytes,2,opt,name=visibility,proto3" json:"visibility,omitempty"` + // Same semantics as PrepareFileUploadRequest.include_signed_quotes. + IncludeSignedQuotes bool `protobuf:"varint,3,opt,name=include_signed_quotes,json=includeSignedQuotes,proto3" json:"include_signed_quotes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PrepareDataUploadRequest) Reset() { @@ -132,6 +147,13 @@ func (x *PrepareDataUploadRequest) GetVisibility() string { return "" } +func (x *PrepareDataUploadRequest) GetIncludeSignedQuotes() bool { + if x != nil { + return x.IncludeSignedQuotes + } + return false +} + type PrepareUploadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Opaque token to pass back to FinalizeUpload. @@ -168,7 +190,12 @@ type PrepareUploadResponse struct { // Payment token contract address (hex with 0x prefix). PaymentTokenAddress string `protobuf:"bytes,9,opt,name=payment_token_address,json=paymentTokenAddress,proto3" json:"payment_token_address,omitempty"` // EVM RPC URL for submitting transactions. - RpcUrl string `protobuf:"bytes,10,opt,name=rpc_url,json=rpcUrl,proto3" json:"rpc_url,omitempty"` + RpcUrl string `protobuf:"bytes,10,opt,name=rpc_url,json=rpcUrl,proto3" json:"rpc_url,omitempty"` + // Populated only when the request set `include_signed_quotes` and the + // payment type is wave_batch: one entry per `payments[]` quote for offline + // verification via `VerifyService.VerifyQuotes`. Merkle prepares leave it + // empty (the daemon does not retain merkle candidate commitments). + SignedQuotes []*SignedQuoteEntry `protobuf:"bytes,12,rep,name=signed_quotes,json=signedQuotes,proto3" json:"signed_quotes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -280,6 +307,13 @@ func (x *PrepareUploadResponse) GetRpcUrl() string { return "" } +func (x *PrepareUploadResponse) GetSignedQuotes() []*SignedQuoteEntry { + if x != nil { + return x.SignedQuotes + } + return nil +} + // One merkle payment batch: everything the external signer needs for a // single `payForMerkleTree2()` call. type MerkleBatchEntry struct { @@ -460,8 +494,10 @@ type FinalizeUploadRequest struct { // The upload_id returned from a Prepare* call. UploadId string `protobuf:"bytes,1,opt,name=upload_id,json=uploadId,proto3" json:"upload_id,omitempty"` // Wave-batch: map of quote_hash (hex) → tx_hash (hex) from the on-chain - // payment. Required when the prepared upload was wave-batch, must be - // empty otherwise. + // payment. Required when the prepared upload was wave-batch and prepare + // reported payments; may be empty when prepare reported none (every chunk + // already stored — no on-chain payment is needed). Must be empty for + // merkle uploads. TxHashes map[string]string `protobuf:"bytes,2,rep,name=tx_hashes,json=txHashes,proto3" json:"tx_hashes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Merkle, LEGACY single-batch: winner pool hash (hex with 0x prefix, // 32 bytes) from the `MerklePaymentMade` event. Accepted only when the @@ -630,17 +666,19 @@ var File_antd_v1_upload_proto protoreflect.FileDescriptor const file_antd_v1_upload_proto_rawDesc = "" + "\n" + - "\x14antd/v1/upload.proto\x12\aantd.v1\x1a\x14antd/v1/common.proto\"N\n" + + "\x14antd/v1/upload.proto\x12\aantd.v1\x1a\x14antd/v1/common.proto\"\x82\x01\n" + "\x18PrepareFileUploadRequest\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1e\n" + "\n" + "visibility\x18\x02 \x01(\tR\n" + - "visibility\"N\n" + + "visibility\x122\n" + + "\x15include_signed_quotes\x18\x03 \x01(\bR\x13includeSignedQuotes\"\x82\x01\n" + "\x18PrepareDataUploadRequest\x12\x12\n" + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1e\n" + "\n" + "visibility\x18\x02 \x01(\tR\n" + - "visibility\"\x89\x04\n" + + "visibility\x122\n" + + "\x15include_signed_quotes\x18\x03 \x01(\bR\x13includeSignedQuotes\"\xc9\x04\n" + "\x15PrepareUploadResponse\x12\x1b\n" + "\tupload_id\x18\x01 \x01(\tR\buploadId\x12!\n" + "\fpayment_type\x18\x02 \x01(\tR\vpaymentType\x121\n" + @@ -653,7 +691,8 @@ const file_antd_v1_upload_proto_rawDesc = "" + "\x15payment_vault_address\x18\b \x01(\tR\x13paymentVaultAddress\x122\n" + "\x15payment_token_address\x18\t \x01(\tR\x13paymentTokenAddress\x12\x17\n" + "\arpc_url\x18\n" + - " \x01(\tR\x06rpcUrl\"\xab\x01\n" + + " \x01(\tR\x06rpcUrl\x12>\n" + + "\rsigned_quotes\x18\f \x03(\v2\x19.antd.v1.SignedQuoteEntryR\fsignedQuotes\"\xab\x01\n" + "\x10MerkleBatchEntry\x12\x14\n" + "\x05depth\x18\x01 \x01(\rR\x05depth\x12G\n" + "\x10pool_commitments\x18\x02 \x03(\v2\x1c.antd.v1.PoolCommitmentEntryR\x0fpoolCommitments\x128\n" + @@ -709,25 +748,27 @@ var file_antd_v1_upload_proto_goTypes = []any{ (*FinalizeUploadResponse)(nil), // 7: antd.v1.FinalizeUploadResponse nil, // 8: antd.v1.FinalizeUploadRequest.TxHashesEntry (*PaymentEntry)(nil), // 9: antd.v1.PaymentEntry + (*SignedQuoteEntry)(nil), // 10: antd.v1.SignedQuoteEntry } var file_antd_v1_upload_proto_depIdxs = []int32{ - 9, // 0: antd.v1.PrepareUploadResponse.payments:type_name -> antd.v1.PaymentEntry - 4, // 1: antd.v1.PrepareUploadResponse.pool_commitments:type_name -> antd.v1.PoolCommitmentEntry - 3, // 2: antd.v1.PrepareUploadResponse.merkle_batches:type_name -> antd.v1.MerkleBatchEntry - 4, // 3: antd.v1.MerkleBatchEntry.pool_commitments:type_name -> antd.v1.PoolCommitmentEntry - 5, // 4: antd.v1.PoolCommitmentEntry.candidates:type_name -> antd.v1.CandidateNodeEntry - 8, // 5: antd.v1.FinalizeUploadRequest.tx_hashes:type_name -> antd.v1.FinalizeUploadRequest.TxHashesEntry - 0, // 6: antd.v1.UploadService.PrepareFileUpload:input_type -> antd.v1.PrepareFileUploadRequest - 1, // 7: antd.v1.UploadService.PrepareDataUpload:input_type -> antd.v1.PrepareDataUploadRequest - 6, // 8: antd.v1.UploadService.FinalizeUpload:input_type -> antd.v1.FinalizeUploadRequest - 2, // 9: antd.v1.UploadService.PrepareFileUpload:output_type -> antd.v1.PrepareUploadResponse - 2, // 10: antd.v1.UploadService.PrepareDataUpload:output_type -> antd.v1.PrepareUploadResponse - 7, // 11: antd.v1.UploadService.FinalizeUpload:output_type -> antd.v1.FinalizeUploadResponse - 9, // [9:12] is the sub-list for method output_type - 6, // [6:9] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 9, // 0: antd.v1.PrepareUploadResponse.payments:type_name -> antd.v1.PaymentEntry + 4, // 1: antd.v1.PrepareUploadResponse.pool_commitments:type_name -> antd.v1.PoolCommitmentEntry + 3, // 2: antd.v1.PrepareUploadResponse.merkle_batches:type_name -> antd.v1.MerkleBatchEntry + 10, // 3: antd.v1.PrepareUploadResponse.signed_quotes:type_name -> antd.v1.SignedQuoteEntry + 4, // 4: antd.v1.MerkleBatchEntry.pool_commitments:type_name -> antd.v1.PoolCommitmentEntry + 5, // 5: antd.v1.PoolCommitmentEntry.candidates:type_name -> antd.v1.CandidateNodeEntry + 8, // 6: antd.v1.FinalizeUploadRequest.tx_hashes:type_name -> antd.v1.FinalizeUploadRequest.TxHashesEntry + 0, // 7: antd.v1.UploadService.PrepareFileUpload:input_type -> antd.v1.PrepareFileUploadRequest + 1, // 8: antd.v1.UploadService.PrepareDataUpload:input_type -> antd.v1.PrepareDataUploadRequest + 6, // 9: antd.v1.UploadService.FinalizeUpload:input_type -> antd.v1.FinalizeUploadRequest + 2, // 10: antd.v1.UploadService.PrepareFileUpload:output_type -> antd.v1.PrepareUploadResponse + 2, // 11: antd.v1.UploadService.PrepareDataUpload:output_type -> antd.v1.PrepareUploadResponse + 7, // 12: antd.v1.UploadService.FinalizeUpload:output_type -> antd.v1.FinalizeUploadResponse + 10, // [10:13] is the sub-list for method output_type + 7, // [7:10] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_antd_v1_upload_proto_init() } diff --git a/antd-go/proto/antd/v1/verify.pb.go b/antd-go/proto/antd/v1/verify.pb.go new file mode 100644 index 00000000..e3bc789f --- /dev/null +++ b/antd-go/proto/antd/v1/verify.pb.go @@ -0,0 +1,427 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.34.1 +// source: antd/v1/verify.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type VerifyQuotesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Max 1024 entries per call (each entry costs one or two ML-DSA-65 + // verifications). + Entries []*VerifyQuoteEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyQuotesRequest) Reset() { + *x = VerifyQuotesRequest{} + mi := &file_antd_v1_verify_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyQuotesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyQuotesRequest) ProtoMessage() {} + +func (x *VerifyQuotesRequest) ProtoReflect() protoreflect.Message { + mi := &file_antd_v1_verify_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyQuotesRequest.ProtoReflect.Descriptor instead. +func (*VerifyQuotesRequest) Descriptor() ([]byte, []int) { + return file_antd_v1_verify_proto_rawDescGZIP(), []int{0} +} + +func (x *VerifyQuotesRequest) GetEntries() []*VerifyQuoteEntry { + if x != nil { + return x.Entries + } + return nil +} + +// One entry to verify: the payment triple the caller was asked to pay plus +// the opaque signed artifacts from the prepare response's `signed_quotes`. +type VerifyQuoteEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Quote hash the payer was asked to pay (hex with 0x prefix, 32 bytes). + QuoteHash string `protobuf:"bytes,1,opt,name=quote_hash,json=quoteHash,proto3" json:"quote_hash,omitempty"` + // Rewards address the payer was asked to pay (hex with 0x prefix). + RewardsAddress string `protobuf:"bytes,2,opt,name=rewards_address,json=rewardsAddress,proto3" json:"rewards_address,omitempty"` + // Amount the payer was asked to pay (atto tokens as decimal string). + Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + // msgpack-serialized signed PaymentQuote — from SignedQuoteEntry.quote. + SignedQuote []byte `protobuf:"bytes,4,opt,name=signed_quote,json=signedQuote,proto3" json:"signed_quote,omitempty"` + // msgpack-serialized StorageCommitment sidecar — from + // SignedQuoteEntry.commitment_sidecar. Required when the quote is + // commitment-bound; empty otherwise. + CommitmentSidecar []byte `protobuf:"bytes,5,opt,name=commitment_sidecar,json=commitmentSidecar,proto3" json:"commitment_sidecar,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyQuoteEntry) Reset() { + *x = VerifyQuoteEntry{} + mi := &file_antd_v1_verify_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyQuoteEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyQuoteEntry) ProtoMessage() {} + +func (x *VerifyQuoteEntry) ProtoReflect() protoreflect.Message { + mi := &file_antd_v1_verify_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyQuoteEntry.ProtoReflect.Descriptor instead. +func (*VerifyQuoteEntry) Descriptor() ([]byte, []int) { + return file_antd_v1_verify_proto_rawDescGZIP(), []int{1} +} + +func (x *VerifyQuoteEntry) GetQuoteHash() string { + if x != nil { + return x.QuoteHash + } + return "" +} + +func (x *VerifyQuoteEntry) GetRewardsAddress() string { + if x != nil { + return x.RewardsAddress + } + return "" +} + +func (x *VerifyQuoteEntry) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *VerifyQuoteEntry) GetSignedQuote() []byte { + if x != nil { + return x.SignedQuote + } + return nil +} + +func (x *VerifyQuoteEntry) GetCommitmentSidecar() []byte { + if x != nil { + return x.CommitmentSidecar + } + return nil +} + +type VerifyQuotesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True only when `entries` is non-empty and every entry verified. + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + Entries []*VerifyQuoteVerdict `protobuf:"bytes,2,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyQuotesResponse) Reset() { + *x = VerifyQuotesResponse{} + mi := &file_antd_v1_verify_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyQuotesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyQuotesResponse) ProtoMessage() {} + +func (x *VerifyQuotesResponse) ProtoReflect() protoreflect.Message { + mi := &file_antd_v1_verify_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyQuotesResponse.ProtoReflect.Descriptor instead. +func (*VerifyQuotesResponse) Descriptor() ([]byte, []int) { + return file_antd_v1_verify_proto_rawDescGZIP(), []int{2} +} + +func (x *VerifyQuotesResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *VerifyQuotesResponse) GetEntries() []*VerifyQuoteVerdict { + if x != nil { + return x.Entries + } + return nil +} + +// Per-entry verdict. The extracted fields are populated as soon as the signed +// quote deserializes — even when a later check fails — so policy layers can +// see what the quote claimed. They are meaningless while `quote_decoded` is +// false. +type VerifyQuoteVerdict struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Echo of the request entry's quote_hash. + QuoteHash string `protobuf:"bytes,1,opt,name=quote_hash,json=quoteHash,proto3" json:"quote_hash,omitempty"` + // True when every check passed. + Valid bool `protobuf:"varint,2,opt,name=valid,proto3" json:"valid,omitempty"` + // The first failing rule, by name. Empty when valid. + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + // True once the signed quote deserialized (the extracted fields below are + // populated). + QuoteDecoded bool `protobuf:"varint,4,opt,name=quote_decoded,json=quoteDecoded,proto3" json:"quote_decoded,omitempty"` + // Quote timestamp (unix seconds) — for the caller's expiry policy. + TimestampUnixSecs uint64 `protobuf:"varint,5,opt,name=timestamp_unix_secs,json=timestampUnixSecs,proto3" json:"timestamp_unix_secs,omitempty"` + // The chunk address the quote covers (hex, 32 bytes) — for the caller's + // chunk-set equality policy. + Content string `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"` + // The signed price (atto tokens as decimal string). + Price string `protobuf:"bytes,7,opt,name=price,proto3" json:"price,omitempty"` + // The signed rewards address (hex with 0x prefix). + RewardsAddress string `protobuf:"bytes,8,opt,name=rewards_address,json=rewardsAddress,proto3" json:"rewards_address,omitempty"` + // Claimed ADR-0004 storage-commitment key count (0 = baseline quote) — for + // the caller's count-plausibility cap. + CommittedKeyCount uint32 `protobuf:"varint,9,opt,name=committed_key_count,json=committedKeyCount,proto3" json:"committed_key_count,omitempty"` + // Whether the quote pins a storage commitment. + Pinned bool `protobuf:"varint,10,opt,name=pinned,proto3" json:"pinned,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyQuoteVerdict) Reset() { + *x = VerifyQuoteVerdict{} + mi := &file_antd_v1_verify_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyQuoteVerdict) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyQuoteVerdict) ProtoMessage() {} + +func (x *VerifyQuoteVerdict) ProtoReflect() protoreflect.Message { + mi := &file_antd_v1_verify_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyQuoteVerdict.ProtoReflect.Descriptor instead. +func (*VerifyQuoteVerdict) Descriptor() ([]byte, []int) { + return file_antd_v1_verify_proto_rawDescGZIP(), []int{3} +} + +func (x *VerifyQuoteVerdict) GetQuoteHash() string { + if x != nil { + return x.QuoteHash + } + return "" +} + +func (x *VerifyQuoteVerdict) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *VerifyQuoteVerdict) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *VerifyQuoteVerdict) GetQuoteDecoded() bool { + if x != nil { + return x.QuoteDecoded + } + return false +} + +func (x *VerifyQuoteVerdict) GetTimestampUnixSecs() uint64 { + if x != nil { + return x.TimestampUnixSecs + } + return 0 +} + +func (x *VerifyQuoteVerdict) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *VerifyQuoteVerdict) GetPrice() string { + if x != nil { + return x.Price + } + return "" +} + +func (x *VerifyQuoteVerdict) GetRewardsAddress() string { + if x != nil { + return x.RewardsAddress + } + return "" +} + +func (x *VerifyQuoteVerdict) GetCommittedKeyCount() uint32 { + if x != nil { + return x.CommittedKeyCount + } + return 0 +} + +func (x *VerifyQuoteVerdict) GetPinned() bool { + if x != nil { + return x.Pinned + } + return false +} + +var File_antd_v1_verify_proto protoreflect.FileDescriptor + +const file_antd_v1_verify_proto_rawDesc = "" + + "\n" + + "\x14antd/v1/verify.proto\x12\aantd.v1\"J\n" + + "\x13VerifyQuotesRequest\x123\n" + + "\aentries\x18\x01 \x03(\v2\x19.antd.v1.VerifyQuoteEntryR\aentries\"\xc4\x01\n" + + "\x10VerifyQuoteEntry\x12\x1d\n" + + "\n" + + "quote_hash\x18\x01 \x01(\tR\tquoteHash\x12'\n" + + "\x0frewards_address\x18\x02 \x01(\tR\x0erewardsAddress\x12\x16\n" + + "\x06amount\x18\x03 \x01(\tR\x06amount\x12!\n" + + "\fsigned_quote\x18\x04 \x01(\fR\vsignedQuote\x12-\n" + + "\x12commitment_sidecar\x18\x05 \x01(\fR\x11commitmentSidecar\"c\n" + + "\x14VerifyQuotesResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x125\n" + + "\aentries\x18\x02 \x03(\v2\x1b.antd.v1.VerifyQuoteVerdictR\aentries\"\xd5\x02\n" + + "\x12VerifyQuoteVerdict\x12\x1d\n" + + "\n" + + "quote_hash\x18\x01 \x01(\tR\tquoteHash\x12\x14\n" + + "\x05valid\x18\x02 \x01(\bR\x05valid\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12#\n" + + "\rquote_decoded\x18\x04 \x01(\bR\fquoteDecoded\x12.\n" + + "\x13timestamp_unix_secs\x18\x05 \x01(\x04R\x11timestampUnixSecs\x12\x18\n" + + "\acontent\x18\x06 \x01(\tR\acontent\x12\x14\n" + + "\x05price\x18\a \x01(\tR\x05price\x12'\n" + + "\x0frewards_address\x18\b \x01(\tR\x0erewardsAddress\x12.\n" + + "\x13committed_key_count\x18\t \x01(\rR\x11committedKeyCount\x12\x16\n" + + "\x06pinned\x18\n" + + " \x01(\bR\x06pinned2\\\n" + + "\rVerifyService\x12K\n" + + "\fVerifyQuotes\x12\x1c.antd.v1.VerifyQuotesRequest\x1a\x1d.antd.v1.VerifyQuotesResponseBDZ8github.com/WithAutonomi/ant-sdk/antd-go/proto/antd/v1;v1\xaa\x02\aAntd.V1b\x06proto3" + +var ( + file_antd_v1_verify_proto_rawDescOnce sync.Once + file_antd_v1_verify_proto_rawDescData []byte +) + +func file_antd_v1_verify_proto_rawDescGZIP() []byte { + file_antd_v1_verify_proto_rawDescOnce.Do(func() { + file_antd_v1_verify_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_antd_v1_verify_proto_rawDesc), len(file_antd_v1_verify_proto_rawDesc))) + }) + return file_antd_v1_verify_proto_rawDescData +} + +var file_antd_v1_verify_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_antd_v1_verify_proto_goTypes = []any{ + (*VerifyQuotesRequest)(nil), // 0: antd.v1.VerifyQuotesRequest + (*VerifyQuoteEntry)(nil), // 1: antd.v1.VerifyQuoteEntry + (*VerifyQuotesResponse)(nil), // 2: antd.v1.VerifyQuotesResponse + (*VerifyQuoteVerdict)(nil), // 3: antd.v1.VerifyQuoteVerdict +} +var file_antd_v1_verify_proto_depIdxs = []int32{ + 1, // 0: antd.v1.VerifyQuotesRequest.entries:type_name -> antd.v1.VerifyQuoteEntry + 3, // 1: antd.v1.VerifyQuotesResponse.entries:type_name -> antd.v1.VerifyQuoteVerdict + 0, // 2: antd.v1.VerifyService.VerifyQuotes:input_type -> antd.v1.VerifyQuotesRequest + 2, // 3: antd.v1.VerifyService.VerifyQuotes:output_type -> antd.v1.VerifyQuotesResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_antd_v1_verify_proto_init() } +func file_antd_v1_verify_proto_init() { + if File_antd_v1_verify_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_antd_v1_verify_proto_rawDesc), len(file_antd_v1_verify_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_antd_v1_verify_proto_goTypes, + DependencyIndexes: file_antd_v1_verify_proto_depIdxs, + MessageInfos: file_antd_v1_verify_proto_msgTypes, + }.Build() + File_antd_v1_verify_proto = out.File + file_antd_v1_verify_proto_goTypes = nil + file_antd_v1_verify_proto_depIdxs = nil +} diff --git a/antd-go/proto/antd/v1/verify_grpc.pb.go b/antd-go/proto/antd/v1/verify_grpc.pb.go new file mode 100644 index 00000000..ec5c6d48 --- /dev/null +++ b/antd-go/proto/antd/v1/verify_grpc.pb.go @@ -0,0 +1,149 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.34.1 +// source: antd/v1/verify.proto + +package v1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + VerifyService_VerifyQuotes_FullMethodName = "/antd.v1.VerifyService/VerifyQuotes" +) + +// VerifyServiceClient is the client API for VerifyService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Stateless offline verification of signed payment quotes (hosted payments, +// V2-854). Pure function of the request: no network, no wallet, no session +// state. Run by "the party about to pay" — in hosted mode, the payment +// gateway calls it on its own antd instance (never the customer's) before +// paying a batch. +// +// Checks per entry: quote-hash recomputation, ML-DSA-65 signature, +// paid-fields equality against the request triple, and the ADR-0004 +// resolve-before-pay commitment binding with exact on-curve pricing +// (`price == calculate_price(committed_key_count)`; baseline quotes must +// price exactly `calculate_price(0)`). Policy checks (expiry windows, replay +// ledgers, chunk-set equality, count-plausibility caps) stay caller-side — +// the verdicts carry the extracted fields those policies need. +type VerifyServiceClient interface { + VerifyQuotes(ctx context.Context, in *VerifyQuotesRequest, opts ...grpc.CallOption) (*VerifyQuotesResponse, error) +} + +type verifyServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewVerifyServiceClient(cc grpc.ClientConnInterface) VerifyServiceClient { + return &verifyServiceClient{cc} +} + +func (c *verifyServiceClient) VerifyQuotes(ctx context.Context, in *VerifyQuotesRequest, opts ...grpc.CallOption) (*VerifyQuotesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VerifyQuotesResponse) + err := c.cc.Invoke(ctx, VerifyService_VerifyQuotes_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VerifyServiceServer is the server API for VerifyService service. +// All implementations must embed UnimplementedVerifyServiceServer +// for forward compatibility. +// +// Stateless offline verification of signed payment quotes (hosted payments, +// V2-854). Pure function of the request: no network, no wallet, no session +// state. Run by "the party about to pay" — in hosted mode, the payment +// gateway calls it on its own antd instance (never the customer's) before +// paying a batch. +// +// Checks per entry: quote-hash recomputation, ML-DSA-65 signature, +// paid-fields equality against the request triple, and the ADR-0004 +// resolve-before-pay commitment binding with exact on-curve pricing +// (`price == calculate_price(committed_key_count)`; baseline quotes must +// price exactly `calculate_price(0)`). Policy checks (expiry windows, replay +// ledgers, chunk-set equality, count-plausibility caps) stay caller-side — +// the verdicts carry the extracted fields those policies need. +type VerifyServiceServer interface { + VerifyQuotes(context.Context, *VerifyQuotesRequest) (*VerifyQuotesResponse, error) + mustEmbedUnimplementedVerifyServiceServer() +} + +// UnimplementedVerifyServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedVerifyServiceServer struct{} + +func (UnimplementedVerifyServiceServer) VerifyQuotes(context.Context, *VerifyQuotesRequest) (*VerifyQuotesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method VerifyQuotes not implemented") +} +func (UnimplementedVerifyServiceServer) mustEmbedUnimplementedVerifyServiceServer() {} +func (UnimplementedVerifyServiceServer) testEmbeddedByValue() {} + +// UnsafeVerifyServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to VerifyServiceServer will +// result in compilation errors. +type UnsafeVerifyServiceServer interface { + mustEmbedUnimplementedVerifyServiceServer() +} + +func RegisterVerifyServiceServer(s grpc.ServiceRegistrar, srv VerifyServiceServer) { + // If the following call panics, it indicates UnimplementedVerifyServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&VerifyService_ServiceDesc, srv) +} + +func _VerifyService_VerifyQuotes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(VerifyQuotesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VerifyServiceServer).VerifyQuotes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VerifyService_VerifyQuotes_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VerifyServiceServer).VerifyQuotes(ctx, req.(*VerifyQuotesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// VerifyService_ServiceDesc is the grpc.ServiceDesc for VerifyService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var VerifyService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "antd.v1.VerifyService", + HandlerType: (*VerifyServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "VerifyQuotes", + Handler: _VerifyService_VerifyQuotes_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "antd/v1/verify.proto", +} From 9e9b505d9e84f76dbdb148b8d684c24184d2ed49 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 24 Aug 2026 11:23:15 +0100 Subject: [PATCH 3/4] fix(antd-rust): carry new signed-quote proto fields in initializers 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 --- antd-rust/src/grpc_client.rs | 3 +++ antd-rust/src/grpc_tests.rs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/antd-rust/src/grpc_client.rs b/antd-rust/src/grpc_client.rs index 0dbe5a33..38437f92 100644 --- a/antd-rust/src/grpc_client.rs +++ b/antd-rust/src/grpc_client.rs @@ -400,6 +400,7 @@ impl GrpcClient { .clone() .prepare_chunk(proto::antd::v1::PrepareChunkRequest { data: data.to_vec(), + include_signed_quotes: false, }) .await? .into_inner(); @@ -570,6 +571,7 @@ impl GrpcClient { .prepare_file_upload(proto::antd::v1::PrepareFileUploadRequest { path: path.to_string(), visibility: visibility.unwrap_or("").to_string(), + include_signed_quotes: false, }) .await? .into_inner(); @@ -606,6 +608,7 @@ impl GrpcClient { .prepare_data_upload(proto::antd::v1::PrepareDataUploadRequest { data: data.to_vec(), visibility: visibility.unwrap_or("").to_string(), + include_signed_quotes: false, }) .await? .into_inner(); diff --git a/antd-rust/src/grpc_tests.rs b/antd-rust/src/grpc_tests.rs index 37611a17..2b6a6e2c 100644 --- a/antd-rust/src/grpc_tests.rs +++ b/antd-rust/src/grpc_tests.rs @@ -218,6 +218,7 @@ impl v1::chunk_service_server::ChunkService for MockChunkService { payment_vault_address: "0xvault".to_string(), payment_token_address: "0xtoken".to_string(), rpc_url: "http://localhost:8545".to_string(), + signed_quotes: Vec::new(), })) } @@ -291,6 +292,7 @@ impl v1::upload_service_server::UploadService for MockUploadService { payment_vault_address: "0xvault".to_string(), payment_token_address: "0xtoken".to_string(), rpc_url: "http://localhost:8545".to_string(), + signed_quotes: Vec::new(), })); } Ok(Response::new(v1::PrepareUploadResponse { From 468921811bf3fa83c9191fda1b5293bee19ffc6d Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 16 Sep 2026 10:54:46 +0100 Subject: [PATCH 4/4] chore(antd-rust): regenerate committed gRPC code for the signed-quote protos; add verify.proto to the drift list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- antd-rust/src/generated/antd.v1.rs | 442 +++++++++++++++++++++++++++++ antd-rust/tests/proto_drift.rs | 1 + 2 files changed, 443 insertions(+) diff --git a/antd-rust/src/generated/antd.v1.rs b/antd-rust/src/generated/antd.v1.rs index 019a899d..c6d33ffa 100644 --- a/antd-rust/src/generated/antd.v1.rs +++ b/antd-rust/src/generated/antd.v1.rs @@ -46,6 +46,25 @@ pub struct PaymentEntry { #[prost(string, tag = "3")] pub amount: ::prost::alloc::string::String, } +/// One `payments\[\]` quote in full signed form (V2-854 signed-quote exposure): +/// the opaque serialized PaymentQuote plus, for commitment-bound quotes, the +/// ADR-0004 commitment sidecar the quote pins. Consumers treat both as opaque +/// bytes — only antd (`VerifyService.VerifyQuotes`) parses them. Shared by +/// `UploadService` (wave-batch prepares) and `ChunkService.PrepareChunk`. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SignedQuoteEntry { + /// Quote hash (hex with 0x prefix, 32 bytes) — matches the `payments\[\]` + /// entry. + #[prost(string, tag = "1")] + pub quote_hash: ::prost::alloc::string::String, + /// msgpack-serialized signed PaymentQuote. Opaque. + #[prost(bytes = "vec", tag = "2")] + pub quote: ::prost::alloc::vec::Vec, + /// msgpack-serialized StorageCommitment the quote's commitment_pin resolves + /// to. Empty for baseline quotes. + #[prost(bytes = "vec", tag = "3")] + pub commitment_sidecar: ::prost::alloc::vec::Vec, +} #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct HealthCheckRequest {} #[derive(Clone, PartialEq, ::prost::Message)] @@ -1310,6 +1329,9 @@ pub struct PrepareChunkRequest { /// Raw chunk bytes — at most one ant-protocol chunk. #[prost(bytes = "vec", tag = "1")] pub data: ::prost::alloc::vec::Vec, + /// Same semantics as PrepareFileUploadRequest.include_signed_quotes. + #[prost(bool, tag = "2")] + pub include_signed_quotes: bool, } /// Mirrors REST `PrepareChunkResponse`. Single-chunk publishes are always /// wave-batch, so there are no merkle fields. When `already_stored = true` @@ -1353,6 +1375,10 @@ pub struct PrepareChunkResponse { /// `already_stored == true`. #[prost(string, tag = "9")] pub rpc_url: ::prost::alloc::string::String, + /// Populated only when the request set `include_signed_quotes` and payment + /// is required — same semantics as PrepareUploadResponse.signed_quotes. + #[prost(message, repeated, tag = "10")] + pub signed_quotes: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct FinalizeChunkRequest { @@ -2572,6 +2598,13 @@ pub struct PrepareFileUploadRequest { /// is treated as "private". #[prost(string, tag = "2")] pub visibility: ::prost::alloc::string::String, + /// When true, the wave-batch response additionally carries the full signed + /// quotes + ADR-0004 commitment sidecars (`signed_quotes`) so a + /// hosted-payments gateway can verify the batch offline before paying + /// (V2-854). Default false: ~5–6 KB per quote plus up to 8 KB per sidecar, + /// and existing consumers see no change. + #[prost(bool, tag = "3")] + pub include_signed_quotes: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PrepareDataUploadRequest { @@ -2581,6 +2614,9 @@ pub struct PrepareDataUploadRequest { /// Same semantics as PrepareFileUploadRequest.visibility. #[prost(string, tag = "2")] pub visibility: ::prost::alloc::string::String, + /// Same semantics as PrepareFileUploadRequest.include_signed_quotes. + #[prost(bool, tag = "3")] + pub include_signed_quotes: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PrepareUploadResponse { @@ -2630,6 +2666,12 @@ pub struct PrepareUploadResponse { /// EVM RPC URL for submitting transactions. #[prost(string, tag = "10")] pub rpc_url: ::prost::alloc::string::String, + /// Populated only when the request set `include_signed_quotes` and the + /// payment type is wave_batch: one entry per `payments\[\]` quote for offline + /// verification via `VerifyService.VerifyQuotes`. Merkle prepares leave it + /// empty (the daemon does not retain merkle candidate commitments). + #[prost(message, repeated, tag = "12")] + pub signed_quotes: ::prost::alloc::vec::Vec, } /// One merkle payment batch: everything the external signer needs for a /// single `payForMerkleTree2()` call. @@ -4048,3 +4090,403 @@ pub mod wallet_service_server { const NAME: &'static str = SERVICE_NAME; } } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct VerifyQuotesRequest { + /// Max 1024 entries per call (each entry costs one or two ML-DSA-65 + /// verifications). + #[prost(message, repeated, tag = "1")] + pub entries: ::prost::alloc::vec::Vec, +} +/// One entry to verify: the payment triple the caller was asked to pay plus +/// the opaque signed artifacts from the prepare response's `signed_quotes`. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct VerifyQuoteEntry { + /// Quote hash the payer was asked to pay (hex with 0x prefix, 32 bytes). + #[prost(string, tag = "1")] + pub quote_hash: ::prost::alloc::string::String, + /// Rewards address the payer was asked to pay (hex with 0x prefix). + #[prost(string, tag = "2")] + pub rewards_address: ::prost::alloc::string::String, + /// Amount the payer was asked to pay (atto tokens as decimal string). + #[prost(string, tag = "3")] + pub amount: ::prost::alloc::string::String, + /// msgpack-serialized signed PaymentQuote — from SignedQuoteEntry.quote. + #[prost(bytes = "vec", tag = "4")] + pub signed_quote: ::prost::alloc::vec::Vec, + /// msgpack-serialized StorageCommitment sidecar — from + /// SignedQuoteEntry.commitment_sidecar. Required when the quote is + /// commitment-bound; empty otherwise. + #[prost(bytes = "vec", tag = "5")] + pub commitment_sidecar: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct VerifyQuotesResponse { + /// True only when `entries` is non-empty and every entry verified. + #[prost(bool, tag = "1")] + pub valid: bool, + #[prost(message, repeated, tag = "2")] + pub entries: ::prost::alloc::vec::Vec, +} +/// Per-entry verdict. The extracted fields are populated as soon as the signed +/// quote deserializes — even when a later check fails — so policy layers can +/// see what the quote claimed. They are meaningless while `quote_decoded` is +/// false. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct VerifyQuoteVerdict { + /// Echo of the request entry's quote_hash. + #[prost(string, tag = "1")] + pub quote_hash: ::prost::alloc::string::String, + /// True when every check passed. + #[prost(bool, tag = "2")] + pub valid: bool, + /// The first failing rule, by name. Empty when valid. + #[prost(string, tag = "3")] + pub error: ::prost::alloc::string::String, + /// True once the signed quote deserialized (the extracted fields below are + /// populated). + #[prost(bool, tag = "4")] + pub quote_decoded: bool, + /// Quote timestamp (unix seconds) — for the caller's expiry policy. + #[prost(uint64, tag = "5")] + pub timestamp_unix_secs: u64, + /// The chunk address the quote covers (hex, 32 bytes) — for the caller's + /// chunk-set equality policy. + #[prost(string, tag = "6")] + pub content: ::prost::alloc::string::String, + /// The signed price (atto tokens as decimal string). + #[prost(string, tag = "7")] + pub price: ::prost::alloc::string::String, + /// The signed rewards address (hex with 0x prefix). + #[prost(string, tag = "8")] + pub rewards_address: ::prost::alloc::string::String, + /// Claimed ADR-0004 storage-commitment key count (0 = baseline quote) — for + /// the caller's count-plausibility cap. + #[prost(uint32, tag = "9")] + pub committed_key_count: u32, + /// Whether the quote pins a storage commitment. + #[prost(bool, tag = "10")] + pub pinned: bool, +} +/// Generated client implementations. +pub mod verify_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + /// Stateless offline verification of signed payment quotes (hosted payments, + /// V2-854). Pure function of the request: no network, no wallet, no session + /// state. Run by "the party about to pay" — in hosted mode, the payment + /// gateway calls it on its own antd instance (never the customer's) before + /// paying a batch. + /// + /// Checks per entry: quote-hash recomputation, ML-DSA-65 signature, + /// paid-fields equality against the request triple, and the ADR-0004 + /// resolve-before-pay commitment binding with exact on-curve pricing + /// (`price == calculate_price(committed_key_count)`; baseline quotes must + /// price exactly `calculate_price(0)`). Policy checks (expiry windows, replay + /// ledgers, chunk-set equality, count-plausibility caps) stay caller-side — + /// the verdicts carry the extracted fields those policies need. + #[derive(Debug, Clone)] + pub struct VerifyServiceClient { + inner: tonic::client::Grpc, + } + impl VerifyServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl VerifyServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> VerifyServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + VerifyServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn verify_quotes( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/antd.v1.VerifyService/VerifyQuotes", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("antd.v1.VerifyService", "VerifyQuotes")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod verify_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with VerifyServiceServer. + #[async_trait] + pub trait VerifyService: std::marker::Send + std::marker::Sync + 'static { + async fn verify_quotes( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + /// Stateless offline verification of signed payment quotes (hosted payments, + /// V2-854). Pure function of the request: no network, no wallet, no session + /// state. Run by "the party about to pay" — in hosted mode, the payment + /// gateway calls it on its own antd instance (never the customer's) before + /// paying a batch. + /// + /// Checks per entry: quote-hash recomputation, ML-DSA-65 signature, + /// paid-fields equality against the request triple, and the ADR-0004 + /// resolve-before-pay commitment binding with exact on-curve pricing + /// (`price == calculate_price(committed_key_count)`; baseline quotes must + /// price exactly `calculate_price(0)`). Policy checks (expiry windows, replay + /// ledgers, chunk-set equality, count-plausibility caps) stay caller-side — + /// the verdicts carry the extracted fields those policies need. + #[derive(Debug)] + pub struct VerifyServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl VerifyServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for VerifyServiceServer + where + T: VerifyService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/antd.v1.VerifyService/VerifyQuotes" => { + #[allow(non_camel_case_types)] + struct VerifyQuotesSvc(pub Arc); + impl< + T: VerifyService, + > tonic::server::UnaryService + for VerifyQuotesSvc { + type Response = super::VerifyQuotesResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::verify_quotes(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = VerifyQuotesSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new(empty_body()); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for VerifyServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "antd.v1.VerifyService"; + impl tonic::server::NamedService for VerifyServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/antd-rust/tests/proto_drift.rs b/antd-rust/tests/proto_drift.rs index d4ffa6af..6bad7940 100644 --- a/antd-rust/tests/proto_drift.rs +++ b/antd-rust/tests/proto_drift.rs @@ -23,6 +23,7 @@ const PROTOS: &[&str] = &[ "antd/v1/upload.proto", "antd/v1/events.proto", "antd/v1/wallet.proto", + "antd/v1/verify.proto", ]; fn normalize(s: &str) -> String {