Skip to main content

nectar_postage/
stamp.rs

1//! Postage stamp types.
2
3use alloy_primitives::{Address, B256, Signature, eip191_hash_message};
4use alloy_signer::k256::ecdsa::VerifyingKey;
5use byteorder::{BigEndian, ByteOrder};
6use nectar_primitives::SwarmAddress;
7
8use crate::{BatchId, StampError};
9
10/// The size of a serialized stamp in bytes.
11///
12/// Layout: batch_id (32) + bucket (4) + index (4) + timestamp (8) + signature (65) = 113 bytes
13pub const STAMP_SIZE: usize = 113;
14
15/// A serialized postage stamp as a fixed-size byte array.
16pub type StampBytes = [u8; STAMP_SIZE];
17
18/// A stamp index representing the position of a chunk within a batch.
19///
20/// The stamp index consists of two components:
21/// - `bucket`: The collision bucket determined by the chunk's address (also called "x")
22/// - `index`: The position within that bucket (also called "y")
23///
24/// # Implementation Note
25///
26/// The exact encoding of the stamp index into a single value is **implementation-specific**
27/// and **not defined by the Swarm specifications**. This implementation encodes the index
28/// as a 64-bit value by concatenating the bucket (high 32 bits) and position (low 32 bits)
29/// in big-endian format. Other implementations may use different encodings.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct StampIndex {
33    /// The collision bucket (x coordinate).
34    ///
35    /// Determined by the leading bits of the chunk address, specifically
36    /// the first `bucket_depth` bits interpreted as a big-endian integer.
37    bucket: u32,
38    /// The position within the bucket (y coordinate).
39    ///
40    /// Assigned sequentially as chunks are added to the bucket, starting from 0.
41    index: u32,
42}
43
44impl StampIndex {
45    /// Creates a new stamp index.
46    #[inline]
47    pub const fn new(bucket: u32, index: u32) -> Self {
48        Self { bucket, index }
49    }
50
51    /// Returns the collision bucket (x).
52    #[inline]
53    pub const fn bucket(&self) -> u32 {
54        self.bucket
55    }
56
57    /// Returns the position within the bucket (y).
58    #[inline]
59    pub const fn index(&self) -> u32 {
60        self.index
61    }
62
63    /// Encodes the stamp index as a 64-bit value for use in stamp digest calculation.
64    ///
65    /// # Encoding Format
66    ///
67    /// The encoding concatenates bucket (4 bytes BE) and index (4 bytes BE):
68    /// ```text
69    /// | bucket (32 bits) | index (32 bits) |
70    /// |   high 32 bits   |   low 32 bits   |
71    /// ```
72    ///
73    /// # Implementation Note
74    ///
75    /// This encoding is **implementation-specific** and not defined by the Swarm
76    /// specifications. The Swarm protocol only specifies that the stamp contains
77    /// bucket and index values; the exact wire format for the combined index
78    /// used in signature computation is left to implementations.
79    #[inline]
80    pub const fn encode(&self) -> u64 {
81        ((self.bucket as u64) << 32) | (self.index as u64)
82    }
83
84    /// Decodes a stamp index from a 64-bit encoded value.
85    ///
86    /// See [`encode`](Self::encode) for the encoding format.
87    #[inline]
88    pub const fn decode(encoded: u64) -> Self {
89        Self {
90            bucket: (encoded >> 32) as u32,
91            index: encoded as u32,
92        }
93    }
94
95    /// Converts the index to big-endian bytes (8 bytes total).
96    #[inline]
97    pub const fn to_be_bytes(&self) -> [u8; 8] {
98        self.encode().to_be_bytes()
99    }
100
101    /// Creates a stamp index from big-endian bytes.
102    #[inline]
103    pub const fn from_be_bytes(bytes: [u8; 8]) -> Self {
104        Self::decode(u64::from_be_bytes(bytes))
105    }
106}
107
108impl From<(u32, u32)> for StampIndex {
109    fn from((bucket, index): (u32, u32)) -> Self {
110        Self::new(bucket, index)
111    }
112}
113
114impl From<StampIndex> for (u32, u32) {
115    fn from(idx: StampIndex) -> Self {
116        (idx.bucket, idx.index)
117    }
118}
119
120/// A postage stamp represents proof of payment for storing a chunk.
121///
122/// Stamps are created by signing a message containing the chunk address,
123/// batch ID, stamp index, and timestamp with the batch owner's private key.
124///
125/// # Wire Format
126///
127/// A serialized stamp is 113 bytes:
128/// - Batch ID: 32 bytes
129/// - Bucket (x): 4 bytes, big-endian
130/// - Index (y): 4 bytes, big-endian
131/// - Timestamp: 8 bytes, big-endian
132/// - Signature: 65 bytes (r || s || v)
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct Stamp {
136    /// The batch ID this stamp belongs to.
137    batch: BatchId,
138    /// The stamp index (bucket and position).
139    index: StampIndex,
140    /// Timestamp when the stamp was created (nanoseconds since epoch).
141    timestamp: u64,
142    /// The signature proving ownership.
143    sig: Signature,
144}
145
146impl Stamp {
147    /// Creates a new stamp with the given parameters.
148    #[inline]
149    pub const fn new(
150        batch: BatchId,
151        bucket: u32,
152        index: u32,
153        timestamp: u64,
154        sig: Signature,
155    ) -> Self {
156        Self {
157            batch,
158            index: StampIndex::new(bucket, index),
159            timestamp,
160            sig,
161        }
162    }
163
164    /// Creates a new stamp from a stamp index.
165    #[inline]
166    pub const fn with_index(
167        batch: BatchId,
168        index: StampIndex,
169        timestamp: u64,
170        sig: Signature,
171    ) -> Self {
172        Self {
173            batch,
174            index,
175            timestamp,
176            sig,
177        }
178    }
179
180    /// Returns the batch ID.
181    #[inline]
182    pub const fn batch(&self) -> BatchId {
183        self.batch
184    }
185
186    /// Returns the stamp index.
187    #[inline]
188    pub const fn stamp_index(&self) -> StampIndex {
189        self.index
190    }
191
192    /// Returns the collision bucket.
193    #[inline]
194    pub const fn bucket(&self) -> u32 {
195        self.index.bucket()
196    }
197
198    /// Returns the position within the bucket.
199    #[inline]
200    pub const fn index(&self) -> u32 {
201        self.index.index()
202    }
203
204    /// Returns the timestamp.
205    #[inline]
206    pub const fn timestamp(&self) -> u64 {
207        self.timestamp
208    }
209
210    /// Returns the signature.
211    #[inline]
212    pub const fn signature(&self) -> &Signature {
213        &self.sig
214    }
215
216    /// Serializes the stamp to a 113-byte array.
217    #[inline]
218    pub fn to_bytes(&self) -> StampBytes {
219        let mut bytes = [0u8; STAMP_SIZE];
220        bytes[..32].copy_from_slice(self.batch.as_slice());
221        BigEndian::write_u32(&mut bytes[32..36], self.index.bucket());
222        BigEndian::write_u32(&mut bytes[36..40], self.index.index());
223        BigEndian::write_u64(&mut bytes[40..48], self.timestamp);
224        bytes[48..STAMP_SIZE].copy_from_slice(&self.sig.as_bytes());
225        bytes
226    }
227
228    /// Deserializes a stamp from a 113-byte array.
229    ///
230    /// Returns an error if the signature bytes are invalid.
231    #[inline]
232    pub fn from_bytes(bytes: &StampBytes) -> Result<Self, StampError> {
233        let batch = B256::from_slice(&bytes[..32]);
234        let bucket = BigEndian::read_u32(&bytes[32..36]);
235        let index = BigEndian::read_u32(&bytes[36..40]);
236        let timestamp = BigEndian::read_u64(&bytes[40..48]);
237
238        let sig = Signature::from_raw(&bytes[48..STAMP_SIZE])
239            .map_err(|_| StampError::InvalidSignature)?;
240
241        Ok(Self {
242            batch,
243            index: StampIndex::new(bucket, index),
244            timestamp,
245            sig,
246        })
247    }
248
249    /// Attempts to deserialize a stamp from a byte slice.
250    ///
251    /// Returns an error if the slice is not exactly 113 bytes or if the signature is invalid.
252    #[inline]
253    pub fn try_from_slice(bytes: &[u8]) -> Result<Self, StampError> {
254        if bytes.len() != STAMP_SIZE {
255            return Err(StampError::InvalidData("stamp must be exactly 113 bytes"));
256        }
257
258        // Safety: we verified the length above
259        let mut stamp_bytes = [0u8; STAMP_SIZE];
260        stamp_bytes.copy_from_slice(bytes);
261        Self::from_bytes(&stamp_bytes)
262    }
263
264    /// Recovers the signer address from this stamp using EIP-191 message recovery.
265    ///
266    /// This computes the stamp digest from the chunk address and stamp fields,
267    /// then recovers the Ethereum address that signed it.
268    ///
269    /// # Arguments
270    ///
271    /// * `chunk_address` - The address of the chunk this stamp is for
272    ///
273    /// # Returns
274    ///
275    /// The Ethereum address of the signer, or an error if recovery fails.
276    ///
277    /// # Example
278    ///
279    /// ```ignore
280    /// let stamp = Stamp::try_from_slice(&bytes)?;
281    /// let signer = stamp.recover_signer(&chunk_address)?;
282    /// println!("Stamp signed by: {}", signer);
283    /// ```
284    pub fn recover_signer(&self, chunk_address: &SwarmAddress) -> Result<Address, StampError> {
285        let digest = StampDigest::new(*chunk_address, self.batch, self.index, self.timestamp);
286        let prehash = digest.to_prehash();
287
288        // Use recover_address_from_msg for EIP-191 compatibility
289        self.sig
290            .recover_address_from_msg(prehash.as_slice())
291            .map_err(|_| StampError::InvalidSignature)
292    }
293
294    /// Verifies this stamp was signed by the expected owner.
295    ///
296    /// This is a convenience method that calls [`recover_signer`](Self::recover_signer)
297    /// and compares the result to the expected owner address.
298    ///
299    /// # Arguments
300    ///
301    /// * `chunk_address` - The address of the chunk this stamp is for
302    /// * `owner` - The expected owner/signer address
303    ///
304    /// # Returns
305    ///
306    /// `Ok(())` if the stamp was signed by the expected owner,
307    /// or an error if signature recovery fails or the signer doesn't match.
308    ///
309    /// # Example
310    ///
311    /// ```ignore
312    /// let stamp = Stamp::try_from_slice(&bytes)?;
313    /// stamp.verify(&chunk_address, batch.owner())?;
314    /// ```
315    pub fn verify(&self, chunk_address: &SwarmAddress, owner: Address) -> Result<(), StampError> {
316        let recovered = self.recover_signer(chunk_address)?;
317        if recovered != owner {
318            return Err(StampError::OwnerMismatch {
319                expected: owner,
320                actual: recovered,
321            });
322        }
323        Ok(())
324    }
325
326    /// Recovers the public key from this stamp.
327    ///
328    /// This is useful for caching the public key after the first verification
329    /// of a batch. Subsequent stamps from the same batch can then use
330    /// [`verify_with_pubkey`](Self::verify_with_pubkey) which is approximately
331    /// 10x faster than full signature recovery.
332    ///
333    /// This pair (`recover_pubkey` / `verify_with_pubkey`) is the primitive for a
334    /// future in-memory, never-persisted per-batch pubkey memoization; it is kept
335    /// deliberately even though no cache is wired up yet.
336    ///
337    /// # Arguments
338    ///
339    /// * `chunk_address` - The address of the chunk this stamp is for
340    ///
341    /// # Returns
342    ///
343    /// The public key of the signer, or an error if recovery fails.
344    ///
345    /// # Example
346    ///
347    /// ```ignore
348    /// // First stamp: recover public key and cache it
349    /// let pubkey = first_stamp.recover_pubkey(&first_chunk_address)?;
350    ///
351    /// // Subsequent stamps: fast verification with cached pubkey
352    /// for (stamp, addr) in remaining_stamps {
353    ///     stamp.verify_with_pubkey(&addr, &pubkey)?;
354    /// }
355    /// ```
356    pub fn recover_pubkey(&self, chunk_address: &SwarmAddress) -> Result<VerifyingKey, StampError> {
357        let digest = StampDigest::new(*chunk_address, self.batch, self.index, self.timestamp);
358        let prehash = digest.to_prehash();
359
360        // Compute EIP-191 message hash
361        let msg_hash = eip191_hash_message(prehash.as_slice());
362
363        // Convert to k256 signature (64-byte r||s)
364        let k256_sig = self
365            .sig
366            .to_k256()
367            .map_err(|_| StampError::InvalidSignature)?;
368
369        // Get recovery id from signature
370        let recovery_id = self.sig.recid();
371
372        // Recover the public key
373        VerifyingKey::recover_from_prehash(msg_hash.as_slice(), &k256_sig, recovery_id)
374            .map_err(|_| StampError::InvalidSignature)
375    }
376
377    /// Verifies this stamp using a known public key.
378    ///
379    /// This is approximately 10x faster than [`verify`](Self::verify) or
380    /// [`recover_signer`](Self::recover_signer) because it avoids the expensive
381    /// ECDSA public key recovery operation.
382    ///
383    /// Use this when you've already recovered the owner's public key from a
384    /// previous stamp in the same batch (via [`recover_pubkey`](Self::recover_pubkey)).
385    ///
386    /// # Arguments
387    ///
388    /// * `chunk_address` - The address of the chunk this stamp is for
389    /// * `pubkey` - The expected signer's public key (cached from previous recovery)
390    ///
391    /// # Returns
392    ///
393    /// `Ok(())` if the signature is valid for the given public key,
394    /// or an error if verification fails.
395    ///
396    /// # Example
397    ///
398    /// ```ignore
399    /// // First stamp: recover and cache the public key
400    /// let pubkey = first_stamp.recover_pubkey(&first_address)?;
401    /// let owner = alloy_signer::utils::public_key_to_address(&pubkey);
402    ///
403    /// // Fast verification for remaining stamps in the same batch
404    /// second_stamp.verify_with_pubkey(&second_address, &pubkey)?;
405    /// third_stamp.verify_with_pubkey(&third_address, &pubkey)?;
406    /// ```
407    pub fn verify_with_pubkey(
408        &self,
409        chunk_address: &SwarmAddress,
410        pubkey: &VerifyingKey,
411    ) -> Result<(), StampError> {
412        use alloy_signer::k256::ecdsa::signature::hazmat::PrehashVerifier;
413
414        let digest = StampDigest::new(*chunk_address, self.batch, self.index, self.timestamp);
415        let prehash = digest.to_prehash();
416
417        // Compute EIP-191 message hash
418        let msg_hash = eip191_hash_message(prehash.as_slice());
419
420        // Convert to k256 signature (64-byte r||s)
421        let k256_sig = self
422            .sig
423            .to_k256()
424            .map_err(|_| StampError::InvalidSignature)?;
425
426        // Verify the signature using prehash
427        pubkey
428            .verify_prehash(msg_hash.as_slice(), &k256_sig)
429            .map_err(|_| StampError::InvalidSignature)
430    }
431}
432
433/// The digest that must be signed to create a valid stamp.
434///
435/// The digest is computed as: `keccak256(chunk_address || batch_id || index || timestamp)`
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct StampDigest {
438    /// The chunk address being stamped.
439    pub chunk_address: SwarmAddress,
440    /// The batch ID.
441    pub batch_id: BatchId,
442    /// The stamp index (bucket and position).
443    pub index: StampIndex,
444    /// The timestamp.
445    pub timestamp: u64,
446}
447
448impl StampDigest {
449    /// Creates a new stamp digest.
450    #[inline]
451    pub const fn new(
452        chunk_address: SwarmAddress,
453        batch_id: BatchId,
454        index: StampIndex,
455        timestamp: u64,
456    ) -> Self {
457        Self {
458            chunk_address,
459            batch_id,
460            index,
461            timestamp,
462        }
463    }
464
465    /// Computes the 32-byte hash that must be signed.
466    ///
467    /// Format: `keccak256(chunk_address || batch_id || index_bytes || timestamp_bytes)`
468    pub fn to_prehash(&self) -> B256 {
469        use alloy_primitives::keccak256;
470
471        let mut data = [0u8; 32 + 32 + 8 + 8]; // 80 bytes
472        data[..32].copy_from_slice(self.chunk_address.as_bytes());
473        data[32..64].copy_from_slice(self.batch_id.as_slice());
474        data[64..72].copy_from_slice(&self.index.to_be_bytes());
475        data[72..80].copy_from_slice(&self.timestamp.to_be_bytes());
476
477        keccak256(data)
478    }
479}
480
481impl From<Stamp> for StampBytes {
482    #[inline]
483    fn from(stamp: Stamp) -> Self {
484        stamp.to_bytes()
485    }
486}
487
488impl TryFrom<StampBytes> for Stamp {
489    type Error = StampError;
490
491    #[inline]
492    fn try_from(bytes: StampBytes) -> Result<Self, Self::Error> {
493        Self::from_bytes(&bytes)
494    }
495}
496
497// Arbitrary implementations for property-based testing
498
499#[cfg(feature = "arbitrary")]
500impl<'a> arbitrary::Arbitrary<'a> for StampIndex {
501    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
502        Ok(Self::new(u.arbitrary()?, u.arbitrary()?))
503    }
504}
505
506#[cfg(feature = "arbitrary")]
507impl<'a> arbitrary::Arbitrary<'a> for Stamp {
508    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
509        use alloy_primitives::U256;
510
511        let batch: B256 = u.arbitrary()?;
512        let index = StampIndex::arbitrary(u)?;
513        let timestamp: u64 = u.arbitrary()?;
514
515        // Generate a valid signature (r, s must be non-zero for a valid ECDSA signature)
516        let r = U256::from_be_bytes(u.arbitrary::<[u8; 32]>()?);
517        let s = U256::from_be_bytes(u.arbitrary::<[u8; 32]>()?);
518        let v: bool = u.arbitrary()?;
519        let sig = Signature::new(r, s, v);
520
521        Ok(Self::with_index(batch, index, timestamp, sig))
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use alloy_primitives::hex;
529
530    const TEST_BATCH_ID: &str = "c3387832bb1b88acbcd0ffdb65a08ef077d98c08d4bee576a72dbe3d36761369";
531    const TEST_STAMP: &str = "c3387832bb1b88acbcd0ffdb65a08ef077d98c08d4bee576a72dbe3d367613690000cbe5000000000000018921ff0dbb29169df9e6364e26c6ca6b17745c10b9d6a36ea38e204f2e3cc64a8373c0661f5bb0a347c61d8d1689b0dcf8354117686a6a18d08cff927f526de5fc61b2b7491b";
532
533    #[test]
534    fn test_stamp_index_encode_decode() {
535        let idx = StampIndex::new(0x1234, 0x5678);
536        assert_eq!(idx.encode(), 0x0000123400005678);
537
538        let decoded = StampIndex::decode(0x0000123400005678);
539        assert_eq!(decoded, idx);
540    }
541
542    #[test]
543    fn test_stamp_index_bytes() {
544        let idx = StampIndex::new(0x1234, 0x5678);
545        let bytes = idx.to_be_bytes();
546        let restored = StampIndex::from_be_bytes(bytes);
547        assert_eq!(idx, restored);
548    }
549
550    #[test]
551    fn test_stamp_index_conversions() {
552        let idx = StampIndex::new(100, 50);
553        let tuple: (u32, u32) = idx.into();
554        assert_eq!(tuple, (100, 50));
555
556        let back: StampIndex = tuple.into();
557        assert_eq!(back, idx);
558    }
559
560    #[test]
561    fn test_stamp_roundtrip() {
562        let batch = B256::ZERO;
563        let sig = Signature::test_signature();
564        let stamp = Stamp::new(batch, 100, 50, 1234567890, sig);
565
566        let bytes = stamp.to_bytes();
567        let restored = Stamp::from_bytes(&bytes).unwrap();
568
569        assert_eq!(stamp, restored);
570    }
571
572    #[test]
573    fn test_stamp_from_known_data() {
574        let bytes = hex::decode(TEST_STAMP).unwrap();
575        let stamp = Stamp::try_from_slice(&bytes).unwrap();
576
577        let expected_batch = B256::from_slice(&hex::decode(TEST_BATCH_ID).unwrap());
578        assert_eq!(stamp.batch(), expected_batch);
579        assert_eq!(stamp.bucket(), 52197); // 0x0000cbe5
580        assert_eq!(stamp.index(), 0);
581        assert_eq!(stamp.timestamp(), 1688492510651);
582    }
583
584    #[test]
585    fn test_stamp_with_index() {
586        let batch = B256::ZERO;
587        let idx = StampIndex::new(100, 50);
588        let sig = Signature::test_signature();
589        let stamp = Stamp::with_index(batch, idx, 1234567890, sig);
590
591        assert_eq!(stamp.stamp_index(), idx);
592        assert_eq!(stamp.bucket(), 100);
593        assert_eq!(stamp.index(), 50);
594    }
595
596    #[test]
597    fn test_stamp_size() {
598        assert_eq!(STAMP_SIZE, 113);
599    }
600
601    #[test]
602    fn test_invalid_slice_size() {
603        let bytes = [0u8; 100];
604        let result = Stamp::try_from_slice(&bytes);
605        assert!(matches!(result, Err(StampError::InvalidData(_))));
606    }
607
608    #[test]
609    fn test_from_conversions() {
610        let sig = Signature::test_signature();
611        let stamp = Stamp::new(B256::ZERO, 1, 2, 3, sig);
612
613        // From<Stamp> for StampBytes
614        let bytes: StampBytes = stamp.clone().into();
615        // TryFrom<StampBytes> for Stamp
616        let back: Stamp = bytes.try_into().unwrap();
617        assert_eq!(stamp, back);
618    }
619
620    /// Test recover_signer using the Go interop test vector.
621    ///
622    /// This uses the same test data as stamper::tests::test_verify_go_created_stamp
623    /// to ensure the Stamp::recover_signer method works correctly.
624    #[test]
625    fn test_recover_signer() {
626        // Test vector from Go's TestGenerateInteropStamp
627        let chunk_addr_bytes =
628            hex::decode("0000000000000000000000000000000000000000000000000000000000000002")
629                .unwrap();
630        let full_stamp_bytes = hex::decode(
631            "000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000003496cb9ac06221d39c3f6a7dd3b9c2301c1f923162b90d5443e42023f34ff908945b0da1c297190f111b7c6ebc828648ead8f7fce06c0364cb5a833410230c5c01c"
632        ).unwrap();
633        let expected_owner: Address = "8d3766440f0d7b949a5e32995d09619a7f86e632".parse().unwrap();
634
635        let chunk_address = SwarmAddress::new(chunk_addr_bytes.try_into().unwrap());
636        let stamp = Stamp::try_from_slice(&full_stamp_bytes).unwrap();
637
638        // Test recover_signer
639        let recovered = stamp.recover_signer(&chunk_address).unwrap();
640        assert_eq!(recovered, expected_owner);
641    }
642
643    /// Test verify method using the Go interop test vector.
644    #[test]
645    fn test_verify() {
646        // Test vector from Go's TestGenerateInteropStamp
647        let chunk_addr_bytes =
648            hex::decode("0000000000000000000000000000000000000000000000000000000000000002")
649                .unwrap();
650        let full_stamp_bytes = hex::decode(
651            "000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000003496cb9ac06221d39c3f6a7dd3b9c2301c1f923162b90d5443e42023f34ff908945b0da1c297190f111b7c6ebc828648ead8f7fce06c0364cb5a833410230c5c01c"
652        ).unwrap();
653        let expected_owner: Address = "8d3766440f0d7b949a5e32995d09619a7f86e632".parse().unwrap();
654        let wrong_owner: Address = "0000000000000000000000000000000000000001".parse().unwrap();
655
656        let chunk_address = SwarmAddress::new(chunk_addr_bytes.try_into().unwrap());
657        let stamp = Stamp::try_from_slice(&full_stamp_bytes).unwrap();
658
659        // Verify with correct owner should succeed
660        assert!(stamp.verify(&chunk_address, expected_owner).is_ok());
661
662        // Verify with wrong owner should fail
663        let result = stamp.verify(&chunk_address, wrong_owner);
664        assert!(matches!(result, Err(StampError::OwnerMismatch { .. })));
665    }
666
667    /// Test recover_pubkey using the Go interop test vector.
668    #[test]
669    fn test_recover_pubkey() {
670        use alloy_signer::utils::public_key_to_address;
671
672        // Test vector from Go's TestGenerateInteropStamp
673        let chunk_addr_bytes =
674            hex::decode("0000000000000000000000000000000000000000000000000000000000000002")
675                .unwrap();
676        let full_stamp_bytes = hex::decode(
677            "000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000003496cb9ac06221d39c3f6a7dd3b9c2301c1f923162b90d5443e42023f34ff908945b0da1c297190f111b7c6ebc828648ead8f7fce06c0364cb5a833410230c5c01c"
678        ).unwrap();
679        let expected_owner: Address = "8d3766440f0d7b949a5e32995d09619a7f86e632".parse().unwrap();
680
681        let chunk_address = SwarmAddress::new(chunk_addr_bytes.try_into().unwrap());
682        let stamp = Stamp::try_from_slice(&full_stamp_bytes).unwrap();
683
684        // Test recover_pubkey
685        let pubkey = stamp.recover_pubkey(&chunk_address).unwrap();
686
687        // Convert to address and verify
688        let recovered_addr = public_key_to_address(&pubkey);
689        assert_eq!(recovered_addr, expected_owner);
690    }
691
692    /// Test verify_with_pubkey using the Go interop test vector.
693    #[test]
694    fn test_verify_with_pubkey() {
695        // Test vector from Go's TestGenerateInteropStamp
696        let chunk_addr_bytes =
697            hex::decode("0000000000000000000000000000000000000000000000000000000000000002")
698                .unwrap();
699        let full_stamp_bytes = hex::decode(
700            "000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000003496cb9ac06221d39c3f6a7dd3b9c2301c1f923162b90d5443e42023f34ff908945b0da1c297190f111b7c6ebc828648ead8f7fce06c0364cb5a833410230c5c01c"
701        ).unwrap();
702
703        let chunk_address = SwarmAddress::new(chunk_addr_bytes.try_into().unwrap());
704        let stamp = Stamp::try_from_slice(&full_stamp_bytes).unwrap();
705
706        // First recover the public key
707        let pubkey = stamp.recover_pubkey(&chunk_address).unwrap();
708
709        // Now verify using the cached pubkey
710        let result = stamp.verify_with_pubkey(&chunk_address, &pubkey);
711        assert!(result.is_ok());
712    }
713
714    /// Test that verify_with_pubkey fails with wrong pubkey.
715    #[test]
716    fn test_verify_with_wrong_pubkey() {
717        use alloy_signer::SignerSync;
718        use alloy_signer_local::PrivateKeySigner;
719
720        // Create a stamp with one signer
721        let signer = PrivateKeySigner::random();
722        let chunk_address = SwarmAddress::new([0xAB; 32]);
723        let batch_id = B256::ZERO;
724        let index = StampIndex::new(0, 0);
725        let timestamp = 12345u64;
726
727        let digest = StampDigest::new(chunk_address, batch_id, index, timestamp);
728        let prehash = digest.to_prehash();
729
730        // sign_message_sync returns alloy_primitives::Signature directly
731        let sig = signer.sign_message_sync(prehash.as_slice()).unwrap();
732        let stamp = Stamp::with_index(batch_id, index, timestamp, sig);
733
734        // Get the correct pubkey
735        let correct_pubkey = stamp.recover_pubkey(&chunk_address).unwrap();
736
737        // Create a different signer for wrong pubkey
738        let wrong_signer = PrivateKeySigner::random();
739        let wrong_pubkey = wrong_signer.credential().verifying_key();
740
741        // Verify with correct pubkey should succeed
742        assert!(
743            stamp
744                .verify_with_pubkey(&chunk_address, &correct_pubkey)
745                .is_ok()
746        );
747
748        // Verify with wrong pubkey should fail
749        assert!(
750            stamp
751                .verify_with_pubkey(&chunk_address, wrong_pubkey)
752                .is_err()
753        );
754    }
755}