tenzro_network/message.rs
1//! Network message types for Tenzro Network
2
3use bytes::Bytes;
4use serde::{Deserialize, Serialize};
5use tenzro_types::{
6 Block, SignedTransaction, Hash,
7 ModelClass, ArtifactCompleteness, ArtifactMetadata, ModelTopology, ExecutionSupport,
8 RuntimeSupport, NodeNetworkProfile, TrustProfile, WorkerRole,
9 HardwareCapabilities,
10};
11
12/// Network message envelope
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NetworkMessage {
15 /// Message type and payload
16 pub payload: MessagePayload,
17 /// Message ID for deduplication
18 pub message_id: String,
19 /// Timestamp when message was created
20 pub timestamp: i64,
21}
22
23impl NetworkMessage {
24 /// Creates a new network message
25 pub fn new(payload: MessagePayload) -> Self {
26 Self {
27 payload,
28 message_id: uuid::Uuid::new_v4().to_string(),
29 timestamp: chrono::Utc::now().timestamp_millis(),
30 }
31 }
32
33 /// Serializes the message to bytes using bincode.
34 ///
35 /// Wire format: bincode (length-prefixed varint sequences). Bincode is used
36 /// rather than `serde_json` because consensus payloads embed `Block` and
37 /// `Transaction` types that carry `u128` fields (token amounts, gas), and
38 /// `serde_json` does not support `u128` without `arbitrary_precision`.
39 /// Mismatched encoders/decoders previously caused honest peers to silently
40 /// drop each other's votes, stalling consensus.
41 pub fn to_bytes(&self) -> Result<Bytes, bincode::Error> {
42 let buf = bincode::serialize(self)?;
43 Ok(Bytes::from(buf))
44 }
45
46 /// Deserializes a message from bincode bytes.
47 pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::Error> {
48 bincode::deserialize(bytes)
49 }
50
51 /// Returns the message topic
52 pub fn topic(&self) -> &str {
53 self.payload.topic()
54 }
55}
56
57/// Message payload types.
58///
59/// Uses serde's default externally-tagged enum representation
60/// (`{"Variant": payload}` in JSON, `u32` discriminant + payload in bincode).
61/// Adjacently/internally tagged forms (`#[serde(tag = "...", content = "...")]`)
62/// route through `serialize_struct`/`deserialize_identifier`, which bincode 1.x
63/// does not support — receivers reject every gossip message with
64/// "Bincode does not support Deserializer::deserialize_identifier", stalling
65/// consensus. See bincode-org/bincode#272 and #548.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub enum MessagePayload {
68 /// New block announcement
69 Block(Block),
70
71 /// Block request by hash
72 BlockRequest(Hash),
73
74 /// Block response
75 BlockResponse(Option<Block>),
76
77 /// Transaction broadcast
78 Transaction(SignedTransaction),
79
80 /// Transaction request
81 TransactionRequest(Hash),
82
83 /// Transaction response
84 TransactionResponse(Option<SignedTransaction>),
85
86 /// TEE attestation
87 Attestation(AttestationMessage),
88
89 /// Model inference request
90 InferenceRequest(InferenceRequestMessage),
91
92 /// Model inference response
93 InferenceResponse(InferenceResponseMessage),
94
95 /// Model registration
96 ModelRegistration(ModelRegistrationMessage),
97
98 /// Agent announcement — broadcast by nodes that have registered agents
99 /// so all peers can populate their network_agents cache.
100 AgentAnnouncement(AgentAnnouncementMessage),
101
102 /// Provider announcement — broadcast by nodes serving models/TEE so all
103 /// peers can populate their network_providers cache.
104 ProviderAnnouncement(ProviderAnnouncementMessage),
105
106 /// Peer status update
107 Status(StatusMessage),
108
109 /// Ping message
110 Ping,
111
112 /// Pong response
113 Pong,
114
115 /// Custom application message
116 Custom { topic: String, data: Vec<u8> },
117}
118
119impl MessagePayload {
120 /// Returns the topic for this message type
121 pub fn topic(&self) -> &str {
122 match self {
123 Self::Block(_) | Self::BlockRequest(_) | Self::BlockResponse(_) => "tenzro/blocks",
124 Self::Transaction(_) | Self::TransactionRequest(_) | Self::TransactionResponse(_) => {
125 "tenzro/transactions"
126 }
127 Self::Attestation(_) => "tenzro/attestations",
128 Self::InferenceRequest(_) | Self::InferenceResponse(_) => "tenzro/inference",
129 Self::ModelRegistration(_) => "tenzro/models",
130 Self::AgentAnnouncement(_) => "tenzro/agents",
131 Self::ProviderAnnouncement(_) => "tenzro/providers",
132 Self::Status(_) | Self::Ping | Self::Pong => "tenzro/status",
133 Self::Custom { topic, .. } => topic,
134 }
135 }
136}
137
138/// Consensus message types.
139///
140/// Uses serde's default externally-tagged enum representation. See
141/// `MessagePayload` above for the rationale — `#[serde(tag = "...")]` is
142/// internally-tagged and incompatible with bincode 1.x.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum ConsensusMessage {
145 /// Block proposal.
146 ///
147 /// `timeout_certificate` is `Some(_)` only when the leader is recovering
148 /// from a view timeout — it carries the bincode-serialized
149 /// `tenzro_consensus::timeout::TimeoutCertificate` (2f+1 timeout signatures
150 /// from the previous view) so peers can verify the new view was
151 /// legitimately abandoned (Jolteon `safe_to_extend`, DiemBFT v4 §3.5).
152 ///
153 /// `high_qc_view` is the proposer's local highest-Prepare-QC view at the
154 /// moment of proposing (#171, Aptos SyncInfo pattern). Receivers adopt it
155 /// if higher than their own to fast-forward the lagging-replica case.
156 /// Must satisfy `high_qc_view < block.header.view`.
157 Proposal {
158 block: Box<Block>,
159 proposer: String,
160 round: u64,
161 high_qc_view: u64,
162 /// bincode-serialized `tenzro_consensus::timeout::TimeoutCertificate`,
163 /// or `None` for the steady-state happy path.
164 timeout_certificate: Option<Vec<u8>>,
165 /// bincode-serialized
166 /// `tenzro_consensus::timeout::NoEndorsementCertificate`. Carries f+1
167 /// no-endorsement signatures attesting that no Prepare-QC formed at
168 /// the timed-out view (MonadBFT, arXiv:2502.20692). `Some(_)` is
169 /// required when the leader is proposing a fresh block after a TC
170 /// — receivers reject an unaccompanied fresh block. `None` for the
171 /// steady-state happy path AND when the leader is reproposing the
172 /// existing high-tip block (the parent-hash match suffices).
173 no_endorsement_certificate: Option<Vec<u8>>,
174 },
175 /// Vote on a proposal
176 ///
177 /// Carries a hybrid (Ed25519 + ML-DSA-65) signature and the voter's
178 /// composite public key so peers can verify both legs without an
179 /// out-of-band registry lookup. The two opaque blobs are bincode-
180 /// serialized `CompositeSignature` / `CompositePublicKey` from
181 /// `tenzro_crypto::composite`.
182 ///
183 /// `high_qc_view` is the voter's local highest-Prepare-QC view at the
184 /// moment of voting (#171, Aptos SyncInfo). Bound into the vote's signing
185 /// payload — must match the bound on the inner `Vote` or signature
186 /// verification fails.
187 Vote {
188 block_hash: Hash,
189 voter: String,
190 vote_type: VoteType,
191 round: u64,
192 height: u64,
193 high_qc_view: u64,
194 /// bincode-serialized `tenzro_crypto::composite::CompositeSignature`
195 signature: Vec<u8>,
196 /// bincode-serialized `tenzro_crypto::composite::CompositePublicKey`
197 public_key: Vec<u8>,
198 /// Raw 96-byte BLS12-381 G2 signature over the canonical QC payload
199 /// (`TENZRO_QC_BLS:` || vote_format_version || view || height || block_hash
200 /// || vote_type`). Aggregated by `VoteCollector` into the QC's
201 /// `bls_aggregate`.
202 bls_signature: Vec<u8>,
203 },
204 /// Commit message
205 Commit {
206 block_hash: Hash,
207 signatures: Vec<Vec<u8>>,
208 },
209 /// Pacemaker timeout broadcast (DiemBFT v4 §3.5).
210 ///
211 /// Sent on local view-timer expiry. Receivers at a strictly lower view
212 /// adopt `view` after verifying the sender's hybrid signature — the
213 /// signature is the cryptographic gate (DiemBFT v4 §3.5
214 /// `process_remote_timeout`); no numeric jump cap is applied, since
215 /// stuck replicas may legitimately need to sync forward by many
216 /// thousands of views. This is the backward-sync channel that
217 /// prevents two honest replicas from drifting apart under partial
218 /// synchrony.
219 ///
220 /// The two opaque blobs are bincode-serialized `CompositeSignature` /
221 /// `CompositePublicKey` from `tenzro_crypto::composite`, mirroring the
222 /// Vote variant. Format version is pinned by
223 /// `tenzro_consensus::TIMEOUT_MSG_FORMAT_VERSION`.
224 Timeout {
225 format_version: u8,
226 view: u64,
227 /// Highest Prepare-QC view this voter has observed (≤ `view - 1`).
228 /// Aggregated by the receiver into the TC's `max_high_qc_view()` so
229 /// the next leader can compute the Jolteon `safe_to_extend` predicate.
230 high_qc_view: u64,
231 /// The sender's highest finalized block height. Part of the signed
232 /// payload. Receivers behind this height engage block-sync — the heal
233 /// path for single-block finalization skew (one replica finalized via
234 /// a Commit QC the others never received).
235 finalized_height: u64,
236 voter: tenzro_types::primitives::Address,
237 /// bincode-serialized `tenzro_crypto::composite::CompositeSignature`
238 signature: Vec<u8>,
239 /// bincode-serialized `tenzro_crypto::composite::CompositePublicKey`
240 public_key: Vec<u8>,
241 },
242 /// MonadBFT no-endorsement attestation broadcast (arXiv:2502.20692).
243 ///
244 /// Sent on local view-timer expiry alongside the Timeout broadcast.
245 /// Aggregated by the receiver into a `NoEndorsementCertificate` (f+1
246 /// signatures) which the next leader attaches to a fresh block proposal
247 /// after the timed-out view. The f+1 threshold is the smallest set that
248 /// guarantees at least one honest signer — and any honest signer would
249 /// refuse to sign if it had observed a Prepare-QC at the timed-out view,
250 /// so the NEC is unforgeable evidence that no QC formed.
251 ///
252 /// The two opaque blobs mirror the Vote / Timeout variants — bincode-
253 /// serialized `CompositeSignature` / `CompositePublicKey`. Format version
254 /// is pinned by `tenzro_consensus::NO_ENDORSEMENT_MSG_FORMAT_VERSION`.
255 NoEndorsement {
256 format_version: u8,
257 view: u64,
258 voter: tenzro_types::primitives::Address,
259 /// bincode-serialized `tenzro_crypto::composite::CompositeSignature`
260 signature: Vec<u8>,
261 /// bincode-serialized `tenzro_crypto::composite::CompositePublicKey`
262 public_key: Vec<u8>,
263 },
264}
265
266/// Vote types for consensus
267#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
268pub enum VoteType {
269 /// Prevote
270 Prevote,
271 /// Precommit
272 Precommit,
273}
274
275/// TEE attestation message
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct AttestationMessage {
278 /// Provider identifier
279 pub provider_id: String,
280 /// Attestation report (vendor-specific)
281 pub report: Vec<u8>,
282 /// Report signature
283 pub signature: Vec<u8>,
284 /// Timestamp
285 pub timestamp: i64,
286}
287
288/// Inference request message
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct InferenceRequestMessage {
291 /// Request ID
292 pub request_id: String,
293 /// Model identifier
294 pub model_id: String,
295 /// Input data
296 pub input: Vec<u8>,
297 /// Requester address
298 pub requester: String,
299 /// Payment details
300 pub payment: PaymentDetails,
301}
302
303/// Inference response message
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct InferenceResponseMessage {
306 /// Request ID this responds to
307 pub request_id: String,
308 /// Provider identifier
309 pub provider_id: String,
310 /// Output data
311 pub output: Vec<u8>,
312 /// Computation proof (optional)
313 pub proof: Option<Vec<u8>>,
314}
315
316/// Model registration message — broadcast over gossipsub when a provider
317/// starts serving a model to the network.
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct ModelRegistrationMessage {
320 /// Model identifier
321 pub model_id: String,
322 /// Model name
323 pub name: String,
324 /// Model description
325 pub description: String,
326 /// Model modality
327 pub modality: String,
328 /// Model category (e.g. "chat", "completion", "embedding")
329 #[serde(default)]
330 pub category: String,
331 /// Model parameters (e.g. "3B", "0.8B")
332 #[serde(default)]
333 pub parameters: String,
334 /// Model context length
335 #[serde(default)]
336 pub context_length: u32,
337 /// Provider address
338 pub provider: String,
339 /// Provider's libp2p peer ID (for direct routing)
340 #[serde(default)]
341 pub peer_id: String,
342 /// Pricing information
343 pub pricing: PricingInfo,
344 /// Serving schedule (when this model is available)
345 #[serde(default)]
346 pub schedule: Option<ModelSchedule>,
347 /// Visibility: "network" (gossipsub-discoverable) or "local" (this node only)
348 #[serde(default = "default_visibility")]
349 pub visibility: String,
350 /// TTL in seconds — entries expire if not refreshed (default 120s)
351 #[serde(default = "default_ttl")]
352 pub ttl_secs: u64,
353 /// Whether this is a withdrawal (model stopped serving)
354 #[serde(default)]
355 pub withdrawn: bool,
356 /// RPC endpoint for inference requests (e.g. "http://10.128.0.3:8545")
357 #[serde(default)]
358 pub rpc_endpoint: String,
359 /// RFC-0007: High-level model class classification
360 #[serde(default)]
361 pub model_class: ModelClass,
362 /// RFC-0007: Which artifact types are present, determining supported execution modes
363 #[serde(default)]
364 pub artifact_completeness: ArtifactCompleteness,
365 /// RFC-0007: Downloadable artifact descriptors (weights, shards, tokenizers)
366 #[serde(default)]
367 pub artifacts: Vec<ArtifactMetadata>,
368 /// RFC-0007: Internal topology metadata for MoE and large-scale models
369 #[serde(default)]
370 pub topology: ModelTopology,
371 /// RFC-0007: Execution modes this provider can serve for this model
372 #[serde(default)]
373 pub execution_support: ExecutionSupport,
374}
375
376fn default_visibility() -> String {
377 "network".to_string()
378}
379
380fn default_ttl() -> u64 {
381 120
382}
383
384fn default_agent_ttl() -> u64 {
385 180
386}
387
388fn default_provider_ttl() -> u64 {
389 120
390}
391
392/// Agent announcement message — broadcast over gossipsub topic "tenzro/agents"
393/// every 60s by nodes that have registered agents. All peers merge incoming
394/// announcements into their `network_agents` DashMap so any node can discover
395/// every agent in the network without a central registry.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct AgentAnnouncementMessage {
398 /// Agent identifier
399 pub agent_id: String,
400 /// Human-readable agent name
401 pub name: String,
402 /// Agent type (e.g. "tenzroclaw", "custom")
403 #[serde(default)]
404 pub agent_type: String,
405 /// Capability names this agent exposes
406 #[serde(default)]
407 pub capabilities: Vec<String>,
408 /// Lifecycle status (e.g. "active", "suspended")
409 #[serde(default)]
410 pub status: String,
411 /// libp2p peer ID of the originating node
412 #[serde(default)]
413 pub origin_peer_id: String,
414 /// RPC endpoint of the originating node (for direct routing)
415 #[serde(default)]
416 pub rpc_endpoint: String,
417 /// Unix timestamp (ms) when this announcement was created
418 pub timestamp: i64,
419 /// TTL in seconds — entries expire if not refreshed (default 180s)
420 #[serde(default = "default_agent_ttl")]
421 pub ttl_secs: u64,
422}
423
424/// Provider announcement message — broadcast over gossipsub topic "tenzro/providers"
425/// every 60s by nodes serving models or TEE services. All peers merge incoming
426/// announcements into their `network_providers` DashMap so any node can discover
427/// every provider in the network without a central registry.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct ProviderAnnouncementMessage {
430 /// libp2p peer ID of the announcing node
431 pub peer_id: String,
432 /// Wallet/account address of the provider
433 pub provider_address: String,
434 /// Provider type (e.g. "llm", "tee", "general")
435 #[serde(default)]
436 pub provider_type: String,
437 /// Model IDs currently being served by this node
438 #[serde(default)]
439 pub served_models: Vec<String>,
440 /// Capability labels (e.g. "inference", "tee-attestation")
441 #[serde(default)]
442 pub capabilities: Vec<String>,
443 /// HTTP RPC endpoint for direct inference routing (e.g. "http://10.128.0.5:8545")
444 #[serde(default)]
445 pub rpc_endpoint: String,
446 /// Lifecycle status (e.g. "active", "draining")
447 #[serde(default)]
448 pub status: String,
449 /// Unix timestamp (ms) when this announcement was created
450 pub timestamp: i64,
451 /// TTL in seconds — entries expire if not refreshed (default 120s)
452 #[serde(default = "default_provider_ttl")]
453 pub ttl_secs: u64,
454 /// RFC-0007: Runtime capabilities of this provider node
455 #[serde(default)]
456 pub runtime_support: RuntimeSupport,
457 /// RFC-0007: Network topology profile of this node
458 #[serde(default)]
459 pub network_profile: NodeNetworkProfile,
460 /// RFC-0007: TEE trust provenance for this provider
461 #[serde(default)]
462 pub trust_profile: TrustProfile,
463 /// RFC-0007: Worker roles this node can fulfil in distributed inference
464 #[serde(default)]
465 pub worker_roles: Vec<WorkerRole>,
466 /// Hardware envelope of this provider node — RAM, VRAM, disk, CPU, TEE
467 /// availability. Populated at announcement-build time from the local
468 /// `HardwareCapabilities::detect()` result so consumers can route by
469 /// memory / GPU / TEE class without an extra RPC round-trip.
470 #[serde(default)]
471 pub hardware: HardwareCapabilities,
472 /// Geographic locality declared by the operator (free-form identifier
473 /// such as `us-central1-a`, `eu-west`, `ap-southeast-1`). `None` means
474 /// the provider declined to declare a region; consumers must treat
475 /// `None` as "unknown geography", not as a wildcard match.
476 #[serde(default)]
477 pub geography: Option<String>,
478}
479
480/// Schedule for when a model is available for serving
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct ModelSchedule {
483 /// Whether scheduling is enabled (if false, model is always available)
484 pub enabled: bool,
485 /// Start hour (0-23) in the provider's timezone
486 #[serde(default)]
487 pub start_hour: u8,
488 /// End hour (0-23) in the provider's timezone
489 #[serde(default = "default_end_hour")]
490 pub end_hour: u8,
491 /// Timezone (e.g. "UTC", "America/New_York")
492 #[serde(default = "default_timezone")]
493 pub timezone: String,
494 /// Days of the week the model is available (0=Sun, 1=Mon, ..., 6=Sat)
495 #[serde(default = "default_days")]
496 pub days_of_week: Vec<u8>,
497}
498
499fn default_end_hour() -> u8 {
500 23
501}
502
503fn default_timezone() -> String {
504 "UTC".to_string()
505}
506
507fn default_days() -> Vec<u8> {
508 vec![0, 1, 2, 3, 4, 5, 6]
509}
510
511/// Payment details for inference requests
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct PaymentDetails {
514 /// Amount in TNZO
515 pub amount: u64,
516 /// Payment transaction hash
517 pub tx_hash: Option<Hash>,
518}
519
520/// Pricing information for models
521#[derive(Debug, Clone, Default, Serialize, Deserialize)]
522pub struct PricingInfo {
523 /// Price per request in TNZO
524 pub per_request: u64,
525 /// Price per token (for LLMs)
526 pub per_token: Option<u64>,
527}
528
529/// Status message for peer synchronization.
530///
531/// Broadcast on `tenzro/status` every 10s by every node and consumed
532/// by `PeerStatusTracker` to compute a network-tip estimate for `eth_syncing`
533/// and to discover TEE-capable peers for confidential-compute routing.
534/// `peer_id` is embedded so subscribers can attribute the message to a sender —
535/// gossipsub does not surface the originating PeerId to topic subscribers,
536/// only to the swarm event handler.
537///
538/// # TEE capability advertisement
539///
540/// Every node advertises its TEE capability here so peers can route
541/// confidential-compute and custodial-key workloads to TEE-equipped nodes
542/// without requiring out-of-band discovery. All nodes participate in
543/// consensus regardless of `tee_capable`; the field is purely a routing
544/// hint for TEE-gated workloads (confidential AI inference, custodial
545/// key management, attestation issuance).
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct StatusMessage {
548 /// libp2p PeerId of the sender, base58-encoded. Subscribers parse this
549 /// back into a `PeerId` via `PeerId::from_str`. Required so the
550 /// `PeerStatusTracker` can key entries by peer.
551 pub peer_id: String,
552 /// Current best block hash
553 pub best_block: Hash,
554 /// Current block height
555 pub height: u64,
556 /// Chain ID
557 pub chain_id: u64,
558 /// Protocol version
559 pub protocol_version: String,
560 /// Whether this node has a TEE provider available and can serve
561 /// confidential-compute / custodial workloads on behalf of peers.
562 pub tee_capable: bool,
563 /// TEE vendor for this node, if any (`None` on commodity hardware).
564 /// Peers consult this when selecting a TEE provider for a specific
565 /// vendor requirement (e.g. SEV-SNP-only workloads).
566 pub tee_vendor: Option<tenzro_types::tee::TeeVendor>,
567}
568
569/// Message validation
570pub fn validate_message(msg: &NetworkMessage) -> crate::error::Result<()> {
571 // Check timestamp is not too far in the future (allow 5 minute clock skew)
572 let now = chrono::Utc::now().timestamp_millis();
573 if msg.timestamp > now + 300_000 {
574 return Err(crate::error::NetworkError::InvalidMessage("Message timestamp is too far in the future".to_string()));
575 }
576
577 // Check message is not too old (reject messages older than 1 hour)
578 if now - msg.timestamp > 3_600_000 {
579 return Err(crate::error::NetworkError::InvalidMessage("Message is too old".to_string()));
580 }
581
582 // Additional payload-specific validation
583 match &msg.payload {
584 MessagePayload::Block(block) => {
585 if block.header.height.0 == 0 && block.header.prev_hash != Hash::zero() {
586 return Err(crate::error::NetworkError::InvalidMessage("Genesis block must have zero prev_hash".to_string()));
587 }
588 }
589 MessagePayload::InferenceRequest(req) => {
590 if req.request_id.is_empty() {
591 return Err(crate::error::NetworkError::InvalidMessage("Inference request must have a request ID".to_string()));
592 }
593 if req.model_id.is_empty() {
594 return Err(crate::error::NetworkError::InvalidMessage("Inference request must specify a model ID".to_string()));
595 }
596 }
597 _ => {}
598 }
599
600 Ok(())
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn test_message_serialization() {
609 let msg = NetworkMessage::new(MessagePayload::Ping);
610 let bytes = msg.to_bytes().unwrap();
611 let decoded = NetworkMessage::from_bytes(&bytes).unwrap();
612
613 assert_eq!(msg.message_id, decoded.message_id);
614 assert_eq!(msg.timestamp, decoded.timestamp);
615 }
616
617 #[test]
618 fn test_message_topics() {
619 assert_eq!(MessagePayload::Ping.topic(), "tenzro/status");
620 assert_eq!(
621 MessagePayload::Custom {
622 topic: "test/topic".to_string(),
623 data: vec![]
624 }
625 .topic(),
626 "test/topic"
627 );
628 }
629}