1use crate::types::GASPNode;
16use serde::{Deserialize, Serialize};
17
18pub const NODE_BATCH_MAX_OUTPOINTS: usize = 100;
27
28pub const NODE_BATCH_MAX_BYTES: usize = 4 * 1024 * 1024;
42
43pub mod refusal {
45 pub const NOT_FOUND: &str = "not_found";
48 pub const OVER_BUDGET: &str = "over_budget";
50 pub const ERROR: &str = "error";
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub struct BatchOutpoint {
57 pub txid: String,
58 pub vout: u32,
59}
60
61impl BatchOutpoint {
62 pub fn key(&self) -> String {
64 format!("{}.{}", self.txid, self.vout)
65 }
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct NodeBatchRequest {
72 pub outpoints: Vec<BatchOutpoint>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct NodeBatchEntry {
78 pub txid: String,
79 pub vout: u32,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub node: Option<GASPNode>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub refused: Option<String>,
84}
85
86impl NodeBatchEntry {
87 pub fn key(&self) -> String {
88 format!("{}.{}", self.txid, self.vout)
89 }
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct NodeBatchAnswer {
96 pub nodes: Vec<NodeBatchEntry>,
97}
98
99#[derive(Debug, Clone, Default)]
102pub struct NodeBatch {
103 pub answer: NodeBatchAnswer,
104 pub bytes: usize,
105}
106
107const ENVELOPE_BYTES: usize = 12;
109
110pub(crate) struct Packer {
116 budget: usize,
117 used: usize,
118 pub(crate) answer: NodeBatchAnswer,
119}
120
121impl Packer {
122 pub(crate) fn new(budget: usize) -> Self {
123 Self {
124 budget,
125 used: ENVELOPE_BYTES,
126 answer: NodeBatchAnswer::default(),
127 }
128 }
129
130 fn size(entry: &NodeBatchEntry) -> usize {
131 serde_json::to_string(entry).map_or(usize::MAX, |s| s.len())
132 }
133
134 pub(crate) fn push(&mut self, entry: NodeBatchEntry) -> bool {
136 let comma = usize::from(!self.answer.nodes.is_empty());
137 let mut entry = entry;
138 let mut size = Self::size(&entry);
139 if ENVELOPE_BYTES.saturating_add(size) > self.budget {
140 entry = NodeBatchEntry {
141 txid: entry.txid,
142 vout: entry.vout,
143 node: None,
144 refused: Some(refusal::OVER_BUDGET.into()),
145 };
146 size = Self::size(&entry);
147 }
148 if self.used.saturating_add(comma).saturating_add(size) > self.budget {
149 return false;
150 }
151 self.used += comma + size;
152 self.answer.nodes.push(entry);
153 true
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn node(hex_len: usize) -> GASPNode {
162 GASPNode {
163 graph_id: "aa.0".into(),
164 raw_tx: "0".repeat(hex_len),
165 output_index: 0,
166 proof: None,
167 tx_metadata: None,
168 output_metadata: None,
169 inputs: None,
170 }
171 }
172
173 fn entry(vout: u32, hex_len: usize) -> NodeBatchEntry {
174 NodeBatchEntry {
175 txid: "aa".into(),
176 vout,
177 node: Some(node(hex_len)),
178 refused: None,
179 }
180 }
181
182 #[test]
183 fn batch_021_the_packer_never_crosses_its_budget() {
184 let budget = 1_000;
185 let mut packer = Packer::new(budget);
186 let mut count = 0;
187 while packer.push(entry(count, 200)) {
188 count += 1;
189 }
190 let body = serde_json::to_string(&packer.answer).unwrap();
191 assert_eq!(body.len(), packer.used);
192 assert!(body.len() <= budget, "{}", body.len());
193 assert_eq!(count as usize, packer.answer.nodes.len());
194 assert!(count >= 3, "{count}");
195 }
196
197 #[test]
198 fn batch_021_a_node_over_the_whole_budget_is_refused_by_name() {
199 let mut packer = Packer::new(1_000);
200 assert!(packer.push(entry(0, 5_000)));
201 assert!(packer.push(entry(1, 10)));
202 let alone = &packer.answer.nodes[0];
203 assert!(alone.node.is_none());
204 assert_eq!(alone.refused.as_deref(), Some(refusal::OVER_BUDGET));
205 assert!(packer.answer.nodes[1].node.is_some());
206 let body = serde_json::to_string(&packer.answer).unwrap();
207 assert_eq!(body.len(), packer.used);
208 }
209
210 #[test]
211 fn batch_021_the_wire_shape() {
212 let request: NodeBatchRequest =
213 serde_json::from_str(r#"{"outpoints":[{"txid":"aa","vout":1}]}"#).unwrap();
214 assert_eq!(request.outpoints[0].key(), "aa.1");
215 assert!(serde_json::from_str::<NodeBatchRequest>(r#"{"outpoints":[],"x":1}"#).is_err());
216 let refused = NodeBatchEntry {
217 txid: "aa".into(),
218 vout: 1,
219 node: None,
220 refused: Some(refusal::NOT_FOUND.into()),
221 };
222 assert_eq!(
223 serde_json::to_string(&refused).unwrap(),
224 r#"{"txid":"aa","vout":1,"refused":"not_found"}"#
225 );
226 let served = serde_json::to_value(entry(0, 2)).unwrap();
227 assert_eq!(served["node"]["graphID"], "aa.0");
228 assert_eq!(served["node"]["rawTx"], "00");
229 }
230
231 #[test]
234 fn batch_021_a_full_request_of_proven_uhrp_nodes_fits_the_budget() {
235 let mut node = node(483 * 2);
236 node.proof = Some("0".repeat(crate::beef_limits::PEER_PROOF_MAX_BYTES * 2));
237 let mut packer = Packer::new(NODE_BATCH_MAX_BYTES);
238 for vout in 0..NODE_BATCH_MAX_OUTPOINTS as u32 {
239 assert!(packer.push(NodeBatchEntry {
240 txid: "ab".repeat(32),
241 vout,
242 node: Some(node.clone()),
243 refused: None,
244 }));
245 }
246 let body = serde_json::to_string(&packer.answer).unwrap().len();
247 assert!(body < 1_000_000, "{body}");
248 }
249}