Skip to main content

ckb_transaction_firewall_sdk/
lib.rs

1//! # ckb-transaction-firewall-sdk
2//!
3//! Off-chain pre-flight blacklist check for CKB transactions.
4//!
5//! Before broadcasting a transaction, call [`check_transaction`] to verify
6//! that no output's lock or type args appear in the on-chain BLKL v2 blacklist
7//! registry. The check mirrors what the on-chain firewall-lock contract enforces
8//! at execution time, letting wallets and dApps reject blacklisted transactions
9//! early without spending UTXOs.
10//!
11//! ## Quick start
12//!
13//! ```rust
14//! use ckb_transaction_firewall_sdk::{
15//!     check_transaction, FirewallConfig, RegistrySpec, HashType,
16//!     CellDepLike, ScriptLike, TxOutputLike, UnsignedTxLike,
17//! };
18//!
19//! // Identify the registry cell dep by its type_id_value (bytes 34-66 of
20//! // the registry type-script args). This is stable across governance upgrades.
21//! let spec = RegistrySpec {
22//!     code_hash: [0u8; 32],   // replace with actual code hash
23//!     hash_type: HashType::Type,
24//!     type_id_value: [0u8; 32], // replace with actual type id value
25//!     required: true,
26//! };
27//!
28//! let cfg = FirewallConfig { registries: vec![spec] };
29//!
30//! // Build the transaction (with the live registry cell as a cell dep)
31//! let tx = UnsignedTxLike {
32//!     cell_deps: vec![/* ... live registry cell dep ... */],
33//!     outputs: vec![TxOutputLike {
34//!         lock_args: vec![0xde, 0xad],
35//!         type_args: None,
36//!     }],
37//! };
38//!
39//! let now_secs = std::time::SystemTime::now()
40//!     .duration_since(std::time::UNIX_EPOCH)
41//!     .unwrap()
42//!     .as_secs();
43//!
44//! match check_transaction(&cfg, &tx, now_secs) {
45//!     Ok(()) => println!("transaction is clean"),
46//!     Err(e) => println!("blocked: {} (code {})", e, e.code()),
47//! }
48//! ```
49//!
50//! ## Feature flags
51//!
52//! - **`serde`** — derives `Serialize` / `Deserialize` on all public types.
53//! - **`testnet`** — exposes the [`testnet`] module with testnet RPC URL,
54//!   governance constants, and deployed contract outpoints.
55
56pub mod builder;
57pub mod errors;
58pub mod firewall;
59pub mod registry;
60pub mod types;
61
62#[cfg(feature = "testnet")]
63pub mod testnet;
64
65// ── flat re-exports for ergonomic `use ckb_transaction_firewall_sdk::*` ──────
66
67pub use builder::{
68    build_firewall_lock_args, build_firewall_lock_script, build_firewall_spend_cell_deps,
69};
70pub use errors::{error_codes, FirewallError};
71pub use firewall::{check_transaction, is_blacklisted, preflight_check};
72pub use registry::{encode_governance_header, encode_registry_payload, parse_registry_payload};
73pub use types::{
74    CellDepLike, DepType, FirewallConfig, FirewallLockConfig, FirewallSpendDepsConfig,
75    GovernanceHeader, HashType, OutPointLike, RegistryEntry, RegistryPayload, RegistrySpec,
76    ScriptLike, TransactionCellDep, TxOutputLike, UnsignedTxLike,
77};
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    // ── test helpers ─────────────────────────────────────────────────────────
84
85    fn spec(tag: u8) -> RegistrySpec {
86        let mut type_id = [0u8; 32];
87        type_id[0] = tag;
88        let mut code_hash = [0u8; 32];
89        code_hash[0] = tag;
90        RegistrySpec {
91            code_hash,
92            hash_type: HashType::Type,
93            type_id_value: type_id,
94            required: true,
95        }
96    }
97
98    fn dep_for_spec(s: &RegistrySpec, data: Vec<u8>) -> CellDepLike {
99        // Registry type args are exactly 66 bytes; type_id_value at [34..66].
100        let mut args = vec![0u8; 66];
101        args[34..66].copy_from_slice(&s.type_id_value);
102        CellDepLike {
103            type_script: Some(ScriptLike {
104                code_hash: s.code_hash,
105                hash_type: s.hash_type.clone(),
106                args,
107            }),
108            data,
109        }
110    }
111
112    fn registry(ids: &[&[u8]]) -> Vec<u8> {
113        registry_with_expiry(&ids.iter().map(|id| (*id, 0u64)).collect::<Vec<_>>())
114    }
115
116    fn registry_with_expiry(ids: &[(&[u8], u64)]) -> Vec<u8> {
117        encode_registry_payload(&RegistryPayload {
118            version: 2,
119            governance_header: None,
120            entries: ids
121                .iter()
122                .map(|(id, exp)| RegistryEntry {
123                    identifier: id.to_vec(),
124                    expires_at: *exp,
125                })
126                .collect(),
127        })
128        .unwrap()
129    }
130
131    fn cfg1(s: RegistrySpec) -> FirewallConfig {
132        FirewallConfig {
133            registries: vec![s],
134        }
135    }
136
137    // ── check_transaction tests ───────────────────────────────────────────────
138
139    #[test]
140    fn reject_missing_dep() {
141        let s = spec(1);
142        let tx = UnsignedTxLike {
143            cell_deps: vec![],
144            outputs: vec![],
145        };
146        let err = check_transaction(&cfg1(s), &tx, 0).unwrap_err();
147        assert_eq!(err, FirewallError::MissingRegistryCellDep);
148        assert_eq!(err.code(), 8);
149    }
150
151    #[test]
152    fn reject_blacklisted_lock_args() {
153        let s = spec(1);
154        let dep = dep_for_spec(&s, registry(&[&[0xaa, 0xbb]]));
155        let tx = UnsignedTxLike {
156            cell_deps: vec![dep],
157            outputs: vec![TxOutputLike {
158                lock_args: vec![0xaa, 0xbb],
159                type_args: None,
160            }],
161        };
162        let err = check_transaction(&cfg1(s), &tx, 0).unwrap_err();
163        assert_eq!(err, FirewallError::BlacklistedLockArgs);
164        assert_eq!(err.code(), 11);
165    }
166
167    #[test]
168    fn reject_ambiguous_registry_dep() {
169        let s = spec(1);
170        let dep = dep_for_spec(&s, registry(&[&[0xaa]]));
171        let tx = UnsignedTxLike {
172            cell_deps: vec![dep.clone(), dep],
173            outputs: vec![TxOutputLike {
174                lock_args: vec![0x00],
175                type_args: None,
176            }],
177        };
178        let err = check_transaction(&cfg1(s), &tx, 0).unwrap_err();
179        assert_eq!(err, FirewallError::AmbiguousRegistryCellDep);
180        assert_eq!(err.code(), 17);
181    }
182
183    #[test]
184    fn reject_registry_not_sorted() {
185        let s = spec(1);
186        let dep = dep_for_spec(&s, registry(&[&[0xbb], &[0xaa]]));
187        let tx = UnsignedTxLike {
188            cell_deps: vec![dep],
189            outputs: vec![TxOutputLike {
190                lock_args: vec![0x00],
191                type_args: None,
192            }],
193        };
194        let err = check_transaction(&cfg1(s), &tx, 0).unwrap_err();
195        assert_eq!(err, FirewallError::RegistryNotSorted);
196        assert_eq!(err.code(), 10);
197    }
198
199    #[test]
200    fn reject_blacklisted_type_args() {
201        let s = spec(1);
202        let dep = dep_for_spec(&s, registry(&[&[0x55, 0x66]]));
203        let tx = UnsignedTxLike {
204            cell_deps: vec![dep],
205            outputs: vec![TxOutputLike {
206                lock_args: vec![0x11, 0x22],
207                type_args: Some(vec![0x55, 0x66]),
208            }],
209        };
210        let err = check_transaction(&cfg1(s), &tx, 0).unwrap_err();
211        assert_eq!(err, FirewallError::BlacklistedTypeArgs);
212        assert_eq!(err.code(), 12);
213    }
214
215    #[test]
216    fn reject_v1_registry() {
217        let mut data = Vec::new();
218        data.extend_from_slice(b"BLKL");
219        data.push(1);
220        data.extend_from_slice(&0u32.to_le_bytes());
221        assert_eq!(
222            parse_registry_payload(&data).unwrap_err(),
223            FirewallError::InvalidRegistryData,
224        );
225    }
226
227    #[test]
228    fn reject_unknown_version() {
229        let mut data = Vec::new();
230        data.extend_from_slice(b"BLKL");
231        data.push(3);
232        data.extend_from_slice(&[0u8; 4]);
233        assert_eq!(
234            parse_registry_payload(&data).unwrap_err(),
235            FirewallError::InvalidRegistryData,
236        );
237    }
238
239    #[test]
240    fn parse_v2_registry_with_governance_header() {
241        let data = registry(&[&[0xaa, 0xbb]]);
242        let payload = parse_registry_payload(&data).unwrap();
243        assert_eq!(payload.version, 2);
244        assert_eq!(payload.entries.len(), 1);
245        let gh = payload.governance_header.unwrap();
246        assert_eq!(gh.signer_count, 0);
247    }
248
249    #[test]
250    fn expire_check_active() {
251        let s = spec(1);
252        let dep = dep_for_spec(&s, registry_with_expiry(&[(&[0xaa], 1000)]));
253        let tx = UnsignedTxLike {
254            cell_deps: vec![dep],
255            outputs: vec![TxOutputLike {
256                lock_args: vec![0xaa],
257                type_args: None,
258            }],
259        };
260        assert_eq!(
261            check_transaction(&cfg1(s), &tx, 999).unwrap_err(),
262            FirewallError::BlacklistedLockArgs,
263        );
264    }
265
266    #[test]
267    fn expire_check_expired() {
268        let s = spec(1);
269        let dep = dep_for_spec(&s, registry_with_expiry(&[(&[0xaa], 1000)]));
270        let tx = UnsignedTxLike {
271            cell_deps: vec![dep],
272            outputs: vec![TxOutputLike {
273                lock_args: vec![0xaa],
274                type_args: None,
275            }],
276        };
277        assert!(check_transaction(&cfg1(s), &tx, 1000).is_ok());
278    }
279
280    #[test]
281    fn permanent_entry_always_blacklisted() {
282        let s = spec(1);
283        let dep = dep_for_spec(&s, registry_with_expiry(&[(&[0xbb], 0)]));
284        let tx = UnsignedTxLike {
285            cell_deps: vec![dep],
286            outputs: vec![TxOutputLike {
287                lock_args: vec![0xbb],
288                type_args: None,
289            }],
290        };
291        assert_eq!(
292            check_transaction(&cfg1(s), &tx, u64::MAX).unwrap_err(),
293            FirewallError::BlacklistedLockArgs,
294        );
295    }
296
297    #[test]
298    fn multi_registry_both_checked() {
299        let s1 = spec(1);
300        let s2 = spec(2);
301        let dep1 = dep_for_spec(&s1, registry(&[&[0x11]]));
302        let dep2 = dep_for_spec(&s2, registry(&[&[0x22]]));
303        let firewall_cfg = FirewallConfig {
304            registries: vec![s1, s2],
305        };
306        let tx = UnsignedTxLike {
307            cell_deps: vec![dep1, dep2],
308            outputs: vec![TxOutputLike {
309                lock_args: vec![0x22],
310                type_args: None,
311            }],
312        };
313        assert_eq!(
314            check_transaction(&firewall_cfg, &tx, 0).unwrap_err(),
315            FirewallError::BlacklistedLockArgs,
316        );
317    }
318
319    #[test]
320    fn multi_registry_missing_required() {
321        let s1 = spec(1);
322        let s2 = spec(2);
323        let dep1 = dep_for_spec(&s1, registry(&[]));
324        let firewall_cfg = FirewallConfig {
325            registries: vec![s1, s2],
326        };
327        let tx = UnsignedTxLike {
328            cell_deps: vec![dep1],
329            outputs: vec![],
330        };
331        assert_eq!(
332            check_transaction(&firewall_cfg, &tx, 0).unwrap_err(),
333            FirewallError::MissingRegistryCellDep,
334        );
335    }
336
337    #[test]
338    fn multi_registry_optional_miss_ok() {
339        let s1 = spec(1);
340        let mut s2 = spec(2);
341        s2.required = false;
342        let dep1 = dep_for_spec(&s1, registry(&[]));
343        let firewall_cfg = FirewallConfig {
344            registries: vec![s1, s2],
345        };
346        let tx = UnsignedTxLike {
347            cell_deps: vec![dep1],
348            outputs: vec![TxOutputLike {
349                lock_args: vec![0x99],
350                type_args: None,
351            }],
352        };
353        assert!(check_transaction(&firewall_cfg, &tx, 0).is_ok());
354    }
355
356    #[test]
357    fn v2_trailing_data_rejected() {
358        let mut data = registry(&[&[0xaa]]);
359        data.push(0xff);
360        assert_eq!(
361            parse_registry_payload(&data).unwrap_err(),
362            FirewallError::InvalidRegistryData,
363        );
364    }
365
366    // ── dep matching: exact 66-byte type args ─────────────────────────────────────
367
368    #[test]
369    fn dep_with_wrong_args_length_not_matched() {
370        let s = spec(1);
371        // Build a dep whose type args are 67 bytes (not exactly 66) — should not match.
372        let mut args = vec![0u8; 67];
373        args[34..66].copy_from_slice(&s.type_id_value);
374        let dep = CellDepLike {
375            type_script: Some(ScriptLike {
376                code_hash: s.code_hash,
377                hash_type: s.hash_type.clone(),
378                args,
379            }),
380            data: registry(&[]),
381        };
382        let tx = UnsignedTxLike {
383            cell_deps: vec![dep],
384            outputs: vec![],
385        };
386        assert_eq!(
387            check_transaction(&cfg1(s), &tx, 0).unwrap_err(),
388            FirewallError::MissingRegistryCellDep,
389        );
390    }
391
392    // ── builder tests ─────────────────────────────────────────────────────────
393
394    #[test]
395    fn build_lock_args_roundtrip() {
396        let spec = RegistrySpec {
397            code_hash: [0xab; 32],
398            hash_type: HashType::Type,
399            type_id_value: [0xcd; 32],
400            required: true,
401        };
402        let config = FirewallLockConfig {
403            firewall_code_hash: [0x01; 32],
404            firewall_hash_type: HashType::Type,
405            flags: 0x01,
406            registries: vec![spec],
407            inner_code_hash: [0x02; 32],
408            inner_hash_type: HashType::Data1,
409            inner_args: vec![0x11, 0x22, 0x33],
410        };
411        let args = build_firewall_lock_args(&config).unwrap();
412        assert_eq!(args[0], 0x02); // version
413        assert_eq!(args[1], 0x01); // flags
414        assert_eq!(args[2], 0x01); // registry_count
415        assert_eq!(&args[3..35], &[0xab; 32]); // code_hash
416        assert_eq!(args[35], 0x01); // hash_type = Type
417        assert_eq!(&args[36..68], &[0xcd; 32]); // type_id_value
418        assert_eq!(args[68], 0x01); // required
419        assert_eq!(&args[69..101], &[0x02; 32]); // inner_code_hash
420        assert_eq!(args[101], 0x02); // inner_hash_type = Data1
421        assert_eq!(args[102], 0x03);
422        assert_eq!(args[103], 0x00); // inner_args_len LE
423        assert_eq!(&args[104..107], &[0x11, 0x22, 0x33]); // inner_args
424    }
425
426    #[test]
427    fn build_lock_args_rejects_no_check_bits() {
428        let config = FirewallLockConfig {
429            firewall_code_hash: [0u8; 32],
430            firewall_hash_type: HashType::Type,
431            flags: 0x00,
432            registries: vec![],
433            inner_code_hash: [0u8; 32],
434            inner_hash_type: HashType::Type,
435            inner_args: vec![],
436        };
437        assert_eq!(
438            build_firewall_lock_args(&config).unwrap_err(),
439            FirewallError::InvalidRegistryData,
440        );
441    }
442
443    #[test]
444    fn build_lock_args_rejects_reserved_bits() {
445        let config = FirewallLockConfig {
446            firewall_code_hash: [0u8; 32],
447            firewall_hash_type: HashType::Type,
448            flags: 0x05, // bit 0 + reserved bit 2
449            registries: vec![],
450            inner_code_hash: [0u8; 32],
451            inner_hash_type: HashType::Type,
452            inner_args: vec![],
453        };
454        assert_eq!(
455            build_firewall_lock_args(&config).unwrap_err(),
456            FirewallError::InvalidRegistryData,
457        );
458    }
459
460    // ── encode/decode roundtrip ────────────────────────────────────────────────
461
462    #[test]
463    fn encode_decode_roundtrip() {
464        let original = RegistryPayload {
465            version: 2,
466            governance_header: Some(GovernanceHeader {
467                signer_count: 2,
468                threshold: 2,
469                pubkeys: vec![[0x02; 33], [0x03; 33]],
470                validator_count: 3,
471                validator_merkle_root: [0xee; 32],
472            }),
473            entries: vec![
474                RegistryEntry {
475                    identifier: vec![0x01],
476                    expires_at: 0,
477                },
478                RegistryEntry {
479                    identifier: vec![0x02],
480                    expires_at: 9999,
481                },
482            ],
483        };
484        let encoded = encode_registry_payload(&original).unwrap();
485        let decoded = parse_registry_payload(&encoded).unwrap();
486        assert_eq!(decoded.version, 2);
487        assert_eq!(decoded.entries.len(), 2);
488        let gh = decoded.governance_header.unwrap();
489        assert_eq!(gh.signer_count, 2);
490        assert_eq!(gh.threshold, 2);
491        assert_eq!(gh.pubkeys.len(), 2);
492        assert_eq!(gh.validator_count, 3);
493        assert_eq!(gh.validator_merkle_root, [0xee; 32]);
494    }
495
496    #[test]
497    fn encode_registry_payload_rejects_long_id() {
498        let payload = RegistryPayload {
499            version: 2,
500            governance_header: None,
501            entries: vec![RegistryEntry {
502                identifier: vec![0u8; 256], // 256 > u8::MAX
503                expires_at: 0,
504            }],
505        };
506        assert_eq!(
507            encode_registry_payload(&payload).unwrap_err(),
508            FirewallError::InvalidRegistryData,
509        );
510    }
511
512    // ── preflight_check / is_blacklisted ──────────────────────────────────────
513
514    #[test]
515    fn is_blacklisted_standalone() {
516        let payload = parse_registry_payload(&registry(&[&[0xde, 0xad]])).unwrap();
517        assert!(is_blacklisted(&[0xde, 0xad], &[payload.clone()], 0));
518        assert!(!is_blacklisted(&[0xbe, 0xef], &[payload], 0));
519    }
520
521    #[test]
522    fn preflight_check_standalone() {
523        let payload = parse_registry_payload(&registry(&[&[0xca, 0xfe]])).unwrap();
524        let ok_outputs = vec![TxOutputLike {
525            lock_args: vec![0x00],
526            type_args: None,
527        }];
528        assert!(preflight_check(&ok_outputs, &[payload.clone()], 0).is_ok());
529        let bad_outputs = vec![TxOutputLike {
530            lock_args: vec![0xca, 0xfe],
531            type_args: None,
532        }];
533        assert_eq!(
534            preflight_check(&bad_outputs, &[payload], 0).unwrap_err(),
535            FirewallError::BlacklistedLockArgs,
536        );
537    }
538
539    // ── error code constants ──────────────────────────────────────────────────
540
541    #[test]
542    fn error_code_values_match_contract() {
543        assert_eq!(error_codes::INVALID_ARGS_LAYOUT, 5);
544        assert_eq!(error_codes::UNSUPPORTED_VERSION, 6);
545        assert_eq!(error_codes::UNSUPPORTED_FLAGS, 7);
546        assert_eq!(error_codes::MISSING_REGISTRY_CELL_DEP, 8);
547        assert_eq!(error_codes::INVALID_REGISTRY_DATA, 9);
548        assert_eq!(error_codes::REGISTRY_NOT_SORTED, 10);
549        assert_eq!(error_codes::BLACKLISTED_LOCK_ARGS, 11);
550        assert_eq!(error_codes::BLACKLISTED_TYPE_ARGS, 12);
551        assert_eq!(error_codes::MISSING_INNER_LOCK_CELL_DEP, 13);
552        assert_eq!(error_codes::INVALID_INNER_LOCK_SCRIPT, 14);
553        assert_eq!(error_codes::INNER_LOCK_REJECTED, 15);
554        assert_eq!(error_codes::OUTPUT_SCRIPT_PARSE_FAILED, 16);
555        assert_eq!(error_codes::AMBIGUOUS_REGISTRY_CELL_DEP, 17);
556    }
557}