Skip to main content

ark/
attestations.rs

1use std::io::{self, Write as _};
2
3use bitcoin::consensus::WriteExt;
4use bitcoin::hashes::{sha256, Hash, HashEngine};
5use bitcoin::key::Keypair;
6use bitcoin::secp256k1::{self, schnorr, Message};
7
8use crate::{SignedVtxoRequest, Vtxo, VtxoId, VtxoRequest, SECP};
9use crate::arkoor::ArkoorDestination;
10use crate::encode::{ProtocolEncoding, ProtocolDecodingError};
11use crate::lightning::PaymentHash;
12use crate::offboard::OffboardRequest;
13
14/// Random 32-byte challenge for challenge-response protocols
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Challenge([u8; 32]);
17
18impl Challenge {
19	pub fn new(value: [u8; 32]) -> Self {
20		Self(value)
21	}
22
23	pub fn generate() -> Self {
24		Self(rand::random())
25	}
26
27	pub fn inner(&self) -> [u8; 32] {
28		self.0
29	}
30}
31
32impl AsRef<[u8]> for Challenge {
33	fn as_ref(&self) -> &[u8] {
34	    &self.0[..]
35	}
36}
37
38/// Attestation for self-signed round participation
39///
40/// Contains a signature proving ownership of a VTXO for participation
41/// in a specific round attempt with a given challenge.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
43pub struct RoundAttemptAttestation {
44	signature: schnorr::Signature,
45}
46
47impl RoundAttemptAttestation {
48	// Note: Keeping "challenge" in prefix for backward compatibility with existing signatures
49	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Ark round input ownership proof ";
50
51	/// Create a new attestation by signing with the provided keypair
52	pub fn new(
53		challenge: Challenge,
54		vtxo_id: VtxoId,
55		vtxo_reqs: &[SignedVtxoRequest],
56		vtxo_keypair: &Keypair,
57	) -> Self {
58		let msg = Self::compute_message(challenge, vtxo_id, vtxo_reqs);
59		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
60		Self { signature }
61	}
62
63	/// Get the signature
64	pub fn signature(&self) -> &schnorr::Signature {
65		&self.signature
66	}
67
68	/// Verify the attestation against a VTXO
69	pub fn verify(
70		&self,
71		challenge: Challenge,
72		vtxo: &Vtxo,
73		vtxo_reqs: &[SignedVtxoRequest],
74	) -> Result<(), secp256k1::Error> {
75		let msg = Self::compute_message(challenge, vtxo.id(), vtxo_reqs);
76		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
77	}
78
79	fn compute_message(
80		challenge: Challenge,
81		vtxo_id: VtxoId,
82		vtxo_reqs: &[SignedVtxoRequest],
83	) -> Message {
84		let mut engine = sha256::Hash::engine();
85		engine.write_all(Self::CHALLENGE_MESSAGE_PREFIX).unwrap();
86		engine.write_all(&challenge.inner()).unwrap();
87		engine.write_all(&vtxo_id.to_bytes()).unwrap();
88
89		engine.write_all(&(vtxo_reqs.len() as u64).to_be_bytes()).unwrap();
90		for req in vtxo_reqs {
91			engine.write_all(&req.vtxo.amount.to_sat().to_be_bytes()).unwrap();
92			req.vtxo.policy.encode(&mut engine).unwrap();
93			req.cosign_pubkey.encode(&mut engine).unwrap();
94		}
95
96		let hash = sha256::Hash::from_engine(engine).to_byte_array();
97		Message::from_digest(hash)
98	}
99}
100
101impl ProtocolEncoding for RoundAttemptAttestation {
102	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
103		self.signature.encode(writer)
104	}
105
106	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
107		let signature = schnorr::Signature::decode(reader)?;
108		Ok(Self { signature })
109	}
110}
111
112/// Attestation for delegated round participation
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
114pub struct DelegatedRoundParticipationAttestation {
115	signature: schnorr::Signature,
116}
117
118impl DelegatedRoundParticipationAttestation {
119	// Note: Keeping "challenge" in prefix for backward compatibility with existing signatures
120	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"hArk round join ownership proof ";
121
122	/// Create a new attestation by signing with the provided keypair
123	pub fn new(
124		vtxo_id: VtxoId,
125		vtxo_reqs: &[VtxoRequest],
126		vtxo_keypair: &Keypair,
127	) -> Self {
128		let msg = Self::compute_message(vtxo_id, vtxo_reqs);
129		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
130		Self { signature }
131	}
132
133	/// Get the signature
134	pub fn signature(&self) -> &schnorr::Signature {
135		&self.signature
136	}
137
138	/// Verify the attestation against a VTXO
139	pub fn verify(
140		&self,
141		vtxo: &Vtxo,
142		vtxo_reqs: &[VtxoRequest],
143	) -> Result<(), secp256k1::Error> {
144		let msg = Self::compute_message(vtxo.id(), vtxo_reqs);
145		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
146	}
147
148	fn compute_message(
149		vtxo_id: VtxoId,
150		vtxo_reqs: &[VtxoRequest],
151	) -> Message {
152		let mut engine = sha256::Hash::engine();
153		engine.write_all(Self::CHALLENGE_MESSAGE_PREFIX).unwrap();
154		engine.write_all(&vtxo_id.to_bytes()).unwrap();
155
156		engine.write_all(&(vtxo_reqs.len() as u64).to_be_bytes()).unwrap();
157		for req in vtxo_reqs {
158			engine.write_all(&req.amount.to_sat().to_be_bytes()).unwrap();
159			req.policy.encode(&mut engine).unwrap();
160		}
161
162		let hash = sha256::Hash::from_engine(engine).to_byte_array();
163		Message::from_digest(hash)
164	}
165}
166
167impl ProtocolEncoding for DelegatedRoundParticipationAttestation {
168	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
169		self.signature.encode(writer)
170	}
171
172	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
173		let signature = schnorr::Signature::decode(reader)?;
174		Ok(Self { signature })
175	}
176}
177
178/// Attestation for proving ownership of a VTXO when claiming a Lightning receive.
179///
180/// This attestation commits to a payment hash and the input VTXO ID to create
181/// a unique signature proving the user controls the input VTXO and is authorised
182/// as a mitigation against liquidity denial-of-service attacks.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
184pub struct LightningReceiveAttestation {
185	signature: schnorr::Signature,
186}
187
188impl LightningReceiveAttestation {
189	// Note: Keeping "challenge" in prefix for backward compatibility with existing signatures
190	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Lightning receive VTXO challenge";
191
192	/// Create a new attestation by signing with the provided keypair
193	pub fn new(
194		payment_hash: PaymentHash,
195		vtxo_id: VtxoId,
196		vtxo_keypair: &Keypair,
197	) -> Self {
198		let msg = Self::compute_message(payment_hash, vtxo_id);
199		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
200		Self { signature }
201	}
202
203	/// Get the signature
204	pub fn signature(&self) -> &schnorr::Signature {
205		&self.signature
206	}
207
208	/// Verify the attestation against a VTXO
209	pub fn verify(&self, payment_hash: PaymentHash, vtxo: &Vtxo) -> Result<(), secp256k1::Error> {
210		let msg = Self::compute_message(payment_hash, vtxo.id());
211		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
212	}
213
214	fn compute_message(payment_hash: PaymentHash, vtxo_id: VtxoId) -> Message {
215		let mut engine = sha256::Hash::engine();
216		engine.write_all(Self::CHALLENGE_MESSAGE_PREFIX).unwrap();
217		engine.write_all(&payment_hash.to_byte_array()).unwrap();
218		engine.write_all(&vtxo_id.to_bytes()).unwrap();
219
220		let hash = sha256::Hash::from_engine(engine).to_byte_array();
221		Message::from_digest(hash)
222	}
223}
224
225impl ProtocolEncoding for LightningReceiveAttestation {
226	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
227		self.signature.encode(writer)
228	}
229
230	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
231		let signature = schnorr::Signature::decode(reader)?;
232		Ok(Self { signature })
233	}
234}
235
236/// Attestation for proving ownership of a VTXO when querying its status.
237///
238/// This is the simplest attestation - it only commits to the VTXO ID itself,
239/// with no additional challenge data or context. It proves the user controls
240/// the VTXO and is authorised to query its status.
241///
242/// No additional unique or random challenge data is necessary here.
243/// We're not concerned with guarding against "replay" attacks as this attestation
244/// is for informational purposes and knowledge of this proof by a third party
245/// would indicate some kind of prior privacy leak for the user.
246///
247/// A malicious third party that can access this signed message would only be able
248/// to query the status of this specific VTXO.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
250pub struct VtxoStatusAttestation {
251	signature: schnorr::Signature,
252}
253
254impl VtxoStatusAttestation {
255	// Note: Keeping "challenge" in prefix for backward compatibility with existing signatures
256	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Ark VTXO status query challenge ";
257
258	/// Create a new attestation by signing with the provided keypair
259	pub fn new(vtxo_id: VtxoId, vtxo_keypair: &Keypair) -> Self {
260		let msg = Self::compute_message(vtxo_id);
261		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
262		Self { signature }
263	}
264
265	/// Get the signature
266	pub fn signature(&self) -> &schnorr::Signature {
267		&self.signature
268	}
269
270	/// Verify the attestation against a VTXO
271	pub fn verify(&self, vtxo: &Vtxo) -> Result<(), secp256k1::Error> {
272		let msg = Self::compute_message(vtxo.id());
273		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
274	}
275
276	fn compute_message(vtxo_id: VtxoId) -> Message {
277		let mut engine = sha256::Hash::engine();
278		engine.write_all(Self::CHALLENGE_MESSAGE_PREFIX).unwrap();
279		engine.write_all(&vtxo_id.to_bytes()).unwrap();
280
281		let hash = sha256::Hash::from_engine(engine).to_byte_array();
282		Message::from_digest(hash)
283	}
284}
285
286impl ProtocolEncoding for VtxoStatusAttestation {
287	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
288		self.signature.encode(writer)
289	}
290
291	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
292		let signature = schnorr::Signature::decode(reader)?;
293		Ok(Self { signature })
294	}
295}
296
297/// Attestation for proving ownership of a VTXO when requesting an offboard
298///
299/// It commits to the offboard request and all input vtxos.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
301pub struct OffboardRequestAttestation {
302	signature: schnorr::Signature,
303}
304
305impl OffboardRequestAttestation {
306	// Note: Keeping "challenge" in prefix for backward compatibility with existing signatures
307	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Ark offboard request challenge  ";
308
309	/// Create a new attestation by signing with the provided keypair
310	pub fn new(
311		req: &OffboardRequest,
312		inputs: &[VtxoId],
313		vtxo_keypair: &Keypair,
314	) -> Self {
315		let msg = Self::compute_message(req, inputs);
316		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
317		Self { signature }
318	}
319
320	/// Get the signature
321	pub fn signature(&self) -> &schnorr::Signature {
322		&self.signature
323	}
324
325	/// Verify the attestation against a VTXO
326	pub fn verify(
327		&self,
328		req: &OffboardRequest,
329		inputs: &[VtxoId],
330		vtxo: &Vtxo,
331	) -> Result<(), secp256k1::Error> {
332		let msg = Self::compute_message(req, inputs);
333		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
334	}
335
336	fn compute_message(req: &OffboardRequest, inputs: &[VtxoId]) -> Message {
337		let mut eng = sha256::Hash::engine();
338		eng.input(Self::CHALLENGE_MESSAGE_PREFIX);
339		req.to_txout().encode(&mut eng).unwrap();
340		eng.emit_u32(inputs.len() as u32).unwrap();
341		for vtxo in inputs {
342			eng.input(&vtxo.to_bytes());
343		}
344		Message::from_digest(sha256::Hash::from_engine(eng).to_byte_array())
345	}
346}
347
348impl ProtocolEncoding for OffboardRequestAttestation {
349	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
350		self.signature.encode(writer)
351	}
352
353	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
354		let signature = schnorr::Signature::decode(reader)?;
355		Ok(Self { signature })
356	}
357}
358
359/// Attestation for proving ownership of a VTXO when requesting an arkoor cosign.
360///
361/// Commits to the input VTXO ID and all output destinations, binding the
362/// attestation to the specific transaction the user intends. One
363/// attestation is created per input VTXO / part.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
365pub struct ArkoorCosignAttestation {
366	signature: schnorr::Signature,
367}
368
369impl ArkoorCosignAttestation {
370	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"arkoor cosign attestation       ";
371
372	pub fn new(
373		vtxo_id: VtxoId,
374		outputs: &[&ArkoorDestination],
375		vtxo_keypair: &Keypair,
376	) -> Self {
377		let msg = Self::compute_message(vtxo_id, outputs);
378		let signature = SECP.sign_schnorr_with_aux_rand(&msg, vtxo_keypair, &rand::random());
379		Self { signature }
380	}
381
382	/// Verify the attestation against a VTXO and outputs
383	pub fn verify(&self, vtxo: &Vtxo, outputs: &[&ArkoorDestination]) -> Result<(), secp256k1::Error> {
384		let msg = Self::compute_message(vtxo.id(), outputs);
385		SECP.verify_schnorr(&self.signature, &msg, &vtxo.user_pubkey().x_only_public_key().0)
386	}
387
388	fn compute_message(vtxo_id: VtxoId, outputs: &[&ArkoorDestination]) -> Message {
389		let mut eng = sha256::Hash::engine();
390		eng.input(Self::CHALLENGE_MESSAGE_PREFIX);
391		eng.input(&vtxo_id.to_bytes());
392
393		eng.emit_u32(outputs.len() as u32).unwrap();
394		for output in outputs {
395			eng.emit_u64(output.total_amount.to_sat()).unwrap();
396			output.policy.encode(&mut eng).unwrap();
397		}
398		Message::from_digest(sha256::Hash::from_engine(eng).to_byte_array())
399	}
400}
401
402impl ProtocolEncoding for ArkoorCosignAttestation {
403	fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<(), io::Error> {
404		self.signature.encode(writer)
405	}
406
407	fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, ProtocolDecodingError> {
408		let signature = schnorr::Signature::decode(reader)?;
409		Ok(Self { signature })
410	}
411}
412
413#[cfg(test)]
414mod tests {
415	use super::*;
416	use bitcoin::{Amount, PublicKey, ScriptBuf};
417	use bitcoin::hashes::Hash;
418	use bitcoin::hex::DisplayHex;
419	use std::str::FromStr;
420	use crate::FeeRate;
421	use crate::test_util::dummy::{DummyTestVtxoSpec, DUMMY_USER_KEY};
422	use crate::test_util::encoding_roundtrip;
423	use crate::vtxo::policy::{PubkeyVtxoPolicy, VtxoPolicy};
424	use crate::musig;
425
426	lazy_static! {
427		static ref TEST_CHALLENGE: Challenge = Challenge::new([0x42; 32]);
428	}
429
430	#[test]
431	fn test_round_attempt_attestation() {
432		let spec = DummyTestVtxoSpec::default();
433		let (_tx, vtxo) = spec.build();
434
435		let challenge = *TEST_CHALLENGE;
436		let vtxo_id = vtxo.id();
437
438		let vtxo_req = VtxoRequest {
439			amount: Amount::from_sat(100_000),
440			policy: VtxoPolicy::Pubkey(PubkeyVtxoPolicy {
441				user_pubkey: DUMMY_USER_KEY.public_key(),
442			}),
443		};
444		let (_, pub_nonce) = musig::nonce_pair(&DUMMY_USER_KEY);
445		let signed_vtxo_req = SignedVtxoRequest {
446			vtxo: vtxo_req,
447			cosign_pubkey: DUMMY_USER_KEY.public_key(),
448			nonces: vec![pub_nonce],
449		};
450		let vtxo_reqs = vec![signed_vtxo_req];
451
452		let attestation = RoundAttemptAttestation::new(
453			challenge,
454			vtxo_id,
455			&vtxo_reqs,
456			&DUMMY_USER_KEY,
457		);
458
459		println!("RoundAttemptAttestation hex: {}", attestation.serialize().as_hex());
460		encoding_roundtrip(&attestation);
461		attestation.verify(challenge, &vtxo, &vtxo_reqs).expect("verification failed");
462
463		// Hard-coded attestation test
464		let vector = "ddbf11d8022ec8bcc85704e0ee0c27f1c26d024c43692653d060284ad5cf32ebf89d849b6943d7b1cb9e9c108251c23f067e95bf585fabcf4be5baab8a53897a";
465		let hardcoded = RoundAttemptAttestation::deserialize_hex(&vector)
466			.expect("valid attestation");
467		hardcoded.verify(challenge, &vtxo, &vtxo_reqs).expect("hardcoded verification failed");
468	}
469
470	#[test]
471	fn test_delegated_round_participation_attestation() {
472		let spec = DummyTestVtxoSpec::default();
473		let (_tx, vtxo) = spec.build();
474
475		let vtxo_id = vtxo.id();
476
477		let vtxo_req = VtxoRequest {
478			amount: Amount::from_sat(100_000),
479			policy: VtxoPolicy::Pubkey(PubkeyVtxoPolicy {
480				user_pubkey: DUMMY_USER_KEY.public_key(),
481			}),
482		};
483		let vtxo_reqs = vec![vtxo_req];
484
485		let attestation = DelegatedRoundParticipationAttestation::new(
486			vtxo_id,
487			&vtxo_reqs,
488			&DUMMY_USER_KEY,
489		);
490		println!("DelegatedRoundParticipationAttestation hex: {}", attestation.serialize().as_hex());
491		encoding_roundtrip(&attestation);
492		attestation.verify(&vtxo, &vtxo_reqs).expect("verification failed");
493
494		// Hard-coded attestation test
495		let vector = "fc482a0ef7b86427416865657986032bb49d458c3649097a389670aabee26bd7789218f18b2b58cc68524cb7ae4f224b19fcb6d905e50e63d32dacc04d78cf32";
496		let hardcoded = DelegatedRoundParticipationAttestation::deserialize_hex(&vector)
497			.expect("valid attestation");
498		hardcoded.verify(&vtxo, &vtxo_reqs).expect("hardcoded verification failed");
499	}
500
501	#[test]
502	fn test_lightning_receive_attestation() {
503		let spec = DummyTestVtxoSpec::default();
504		let (_tx, vtxo) = spec.build();
505
506		let payment_hash = PaymentHash::from(sha256::Hash::hash(&[0x42; 32]));
507		let vtxo_id = vtxo.id();
508
509		let attestation = LightningReceiveAttestation::new(
510			payment_hash,
511			vtxo_id,
512			&DUMMY_USER_KEY,
513		);
514		println!("LightningReceiveAttestation hex: {}", attestation.serialize().as_hex());
515		encoding_roundtrip(&attestation);
516		attestation.verify(payment_hash, &vtxo).expect("verification failed");
517
518		// Hard-coded attestation test
519		let vector = "a1240a572d9298c08a75102fb872b8d30bae521228083ec717f220d32de3b4446e7214e6e9e1c586c797fdff8b67e26d6f81a497dee5d584bdc80851e06c7fd5";
520		let hardcoded = LightningReceiveAttestation::deserialize_hex(&vector)
521			.expect("valid attestation");
522		hardcoded.verify(payment_hash, &vtxo).expect("hardcoded verification failed");
523	}
524
525	#[test]
526	fn test_vtxo_status_attestation() {
527		let spec = DummyTestVtxoSpec::default();
528		let (_tx, vtxo) = spec.build();
529
530		let vtxo_id = vtxo.id();
531
532		let attestation = VtxoStatusAttestation::new(vtxo_id, &DUMMY_USER_KEY);
533		println!("VtxoStatusAttestation hex: {}", attestation.serialize().as_hex());
534		encoding_roundtrip(&attestation);
535		attestation.verify(&vtxo).expect("verification failed");
536
537		// Hard-coded attestation test
538		let vector = "ae3bb779ec3f700ccef8031f6589797ec7ac56443b01ed495bef210c8ca12a603c26ffaa2d30502a911c635b5926f1c898cad343b7cd31105aaa22c4c3688f54";
539		let hardcoded = VtxoStatusAttestation::deserialize_hex(&vector)
540			.expect("valid attestation");
541		hardcoded.verify(&vtxo).expect("hardcoded verification failed");
542	}
543
544	#[test]
545	fn test_arkoor_cosign_attestation() {
546		let spec = DummyTestVtxoSpec::default();
547		let (_tx, vtxo) = spec.build();
548
549		let vtxo_id = vtxo.id();
550
551		let dest = ArkoorDestination {
552			total_amount: Amount::from_sat(50_000),
553			policy: VtxoPolicy::Pubkey(PubkeyVtxoPolicy {
554				user_pubkey: DUMMY_USER_KEY.public_key(),
555			}),
556		};
557		let outputs = vec![&dest];
558
559		let attestation = ArkoorCosignAttestation::new(
560			vtxo_id,
561			&outputs,
562			&DUMMY_USER_KEY,
563		);
564		println!("ArkoorCosignAttestation hex: {}", attestation.serialize().as_hex());
565		encoding_roundtrip(&attestation);
566		attestation.verify(&vtxo, &outputs).expect("verification failed");
567	}
568
569	#[test]
570	fn test_offboard_request_attestation() {
571		let spec = DummyTestVtxoSpec::default();
572		let (_tx, vtxo) = spec.build();
573
574		let req_pk = PublicKey::from_str(
575			"02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
576		).unwrap();
577		let xonly_pk = req_pk.inner.x_only_public_key().0;
578		let offboard_req = OffboardRequest {
579			script_pubkey: ScriptBuf::new_p2tr(&*SECP, xonly_pk, None),
580			net_amount: Amount::from_sat(50_000),
581			deduct_fees_from_gross_amount: false,
582			fee_rate: FeeRate::from_sat_per_vb(10).unwrap(),
583		};
584		let inputs = vec![vtxo.id()];
585
586		let attestation = OffboardRequestAttestation::new(
587			&offboard_req,
588			&inputs,
589			&DUMMY_USER_KEY,
590		);
591		println!("OffboardRequestAttestation hex: {}", attestation.serialize().as_hex());
592		encoding_roundtrip(&attestation);
593		attestation.verify(&offboard_req, &inputs, &vtxo).expect("verification failed");
594
595		// Hard-coded attestation test
596		let vector = "d6c85934b715c086164294645fadbe6a08fe6263609f38b44c23239913935c2e30fe5e9ad099941e751facb039797b6c920a90095b5e1cf26cc8aa274082aead";
597		let hardcoded = OffboardRequestAttestation::deserialize_hex(&vector)
598			.expect("valid attestation");
599		hardcoded.verify(&offboard_req, &inputs, &vtxo).expect("hardcoded verification failed");
600	}
601}