| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730 |
- /* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2026 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
- use std::{sync::Arc, time::UNIX_EPOCH};
- use darkfi_sdk::{
- crypto::{pasta_prelude::PrimeField, poseidon_hash},
- pasta::pallas,
- };
- use darkfi_serial::{deserialize_async, serialize_async};
- use sled_overlay::sled;
- use smol::Executor;
- use crate::{
- event_graph::{
- rln::{
- epoch_of, epoch_start_millis, sss_recover, Blob, IdentityState, MessageMetadata,
- RLNNode, RegistrationAttestation, RegistrationBlob, RlnAppId, SignalCheck, SlashBlob,
- GENESIS_BLOB_GUARD, MAX_MSG_LIMIT, RLN_EPOCH_LEN, RLN_GENESIS,
- },
- test_helpers::{
- make_eg, make_eg_with_config, make_network, run_multi_node_test, shutdown_network,
- TestIdentity,
- },
- util::generate_genesis,
- Event, EventGraphConfig, EventGraphPtr, NULL_ID, NULL_PARENTS,
- },
- system::sleep,
- zk::Proof,
- };
- #[test]
- fn rln_epoch_arithmetic() {
- // (1) `epoch_of` floors to the epoch boundary:
- assert_eq!(epoch_of(0), 0);
- assert_eq!(epoch_of(RLN_GENESIS), 0);
- assert_eq!(epoch_of(RLN_GENESIS + RLN_EPOCH_LEN - 1), 0);
- assert_eq!(epoch_of(RLN_GENESIS + RLN_EPOCH_LEN), 1);
- assert_eq!(epoch_of(RLN_GENESIS + 5 * RLN_EPOCH_LEN + 1), 5);
- // (2) epoch_of and epoch_start_millis are mutual inverses on a
- // range we'd realistically encounter.
- for n in 0..50u64 {
- assert_eq!(epoch_of(epoch_start_millis(n)), n);
- if n > 0 {
- assert_eq!(epoch_of(epoch_start_millis(n) - 1), n - 1);
- }
- }
- // (3) Saturating arithmetic - neither end-of-range underflows
- // nor overflows panic.
- let _ = epoch_of(u64::MAX);
- let _ = epoch_start_millis(u64::MAX);
- }
- #[test]
- fn rln_sss_recover_correctness_and_input_validation() {
- // Three properties in one test:
- //
- // (1) Happy path: two shares on a degree-1 polynomial recover
- // a_0. This is the actual interpolation we use during slash
- // recovery (the higher-level test
- // `rln_recovered_secret_matches_identity_secret_hash` exercises
- // this end-to-end on real RLN values; the standalone case
- // here gives a clear pinpoint if Lagrange is wrong).
- //
- // (2) Too-few-shares rejection: one share is insufficient to
- // recover a degree-1 polynomial. If sss_recover silently
- // accepted, slashing would produce wrong identity secrets.
- //
- // (3) Duplicate-x rejection: two shares with the same x would
- // force a divide-by-zero in Lagrange. Must refuse.
- let a_0 = pallas::Base::from(42u64);
- let a_1 = pallas::Base::from(7u64);
- let eval = |x: u64| {
- let xf = pallas::Base::from(x);
- (xf, a_0 + a_1 * xf)
- };
- // (1)
- assert_eq!(sss_recover(&[eval(11), eval(23)]).unwrap(), a_0);
- // (2)
- assert!(sss_recover(&[eval(1)]).is_err());
- assert!(sss_recover(&[]).is_err());
- // (3)
- let dup_x = pallas::Base::from(5u64);
- let dup = vec![(dup_x, pallas::Base::from(1u64)), (dup_x, pallas::Base::from(2u64))];
- assert!(sss_recover(&dup).is_err());
- }
- #[test]
- fn rln_message_metadata_duplicate_vs_reuse() {
- let mut md = MessageMetadata::new();
- let int_null = pallas::Base::from(99u64);
- let x1 = pallas::Base::from(1u64);
- let y1 = pallas::Base::from(10u64);
- let x2 = pallas::Base::from(2u64);
- let y2 = pallas::Base::from(20u64);
- assert!(!md.is_duplicate(0, &int_null, &x1, &y1));
- assert!(!md.is_reused(0, &int_null));
- md.add_share(0, int_null, x1, y1);
- // Same (x, y) -> duplicate.
- assert!(md.is_duplicate(0, &int_null, &x1, &y1));
- // Same nullifier, different (x, y) -> reuse, but NOT duplicate.
- assert!(md.is_reused(0, &int_null));
- assert!(!md.is_duplicate(0, &int_null, &x2, &y2));
- // Different epoch is independent.
- assert!(!md.is_duplicate(1, &int_null, &x1, &y1));
- assert!(!md.is_reused(1, &int_null));
- }
- #[test]
- fn rln_message_metadata_prune_old() {
- let mut md = MessageMetadata::new();
- let null = pallas::Base::from(7u64);
- let x = pallas::Base::from(1u64);
- let y = pallas::Base::from(2u64);
- // Populate epochs 5, 6, 7, 8, 9
- for e in 5..=9 {
- md.add_share(e, null, x, y);
- }
- for e in 5..=9 {
- assert!(md.is_reused(e, &null));
- }
- // Prune relative to current_epoch=9. Retention is
- // METADATA_RETAIN_EPOCHS (= 2). So we keep epochs >= 9-2 = 7.
- md.prune_old(9);
- assert!(!md.is_reused(5, &null));
- assert!(!md.is_reused(6, &null));
- assert!(md.is_reused(7, &null));
- assert!(md.is_reused(8, &null));
- assert!(md.is_reused(9, &null));
- }
- #[test]
- fn rln_identity_state_register_then_slash() {
- let db = sled::Config::new().temporary(true).open().unwrap();
- let mut s = IdentityState::new(&db).unwrap();
- let c = pallas::Base::from(0xabcd_1234u64);
- assert!(!s.contains(&c));
- s.register(c).unwrap();
- assert!(s.contains(&c));
- s.slash(c).unwrap();
- assert!(!s.contains(&c));
- }
- #[test]
- fn rln_identity_state_register_rejects_duplicate() {
- let db = sled::Config::new().temporary(true).open().unwrap();
- let mut s = IdentityState::new(&db).unwrap();
- let c = pallas::Base::from(99u64);
- s.register(c).unwrap();
- // A second register call for the same commitment must fail.
- assert!(s.register(c).is_err());
- }
- #[test]
- fn rln_identity_state_slash_idempotent_for_unknown() {
- let db = sled::Config::new().temporary(true).open().unwrap();
- let mut s = IdentityState::new(&db).unwrap();
- // Slashing something that was never registered is a no-op,
- // not an error. This matters for P2P propagation: a slash
- // event may legitimately arrive twice via different paths.
- s.slash(pallas::Base::from(7u64)).unwrap();
- }
- #[test]
- fn rln_identity_state_persists_across_reopen() {
- let db = sled::Config::new().temporary(true).open().unwrap();
- let c = pallas::Base::from(0xfeedu64);
- {
- let mut s = IdentityState::new(&db).unwrap();
- s.register(c).unwrap();
- } // drop closes the in-memory SMT but the leaves are in sled
- let s2 = IdentityState::new(&db).unwrap();
- assert!(s2.contains(&c), "leaf should survive close-and-reopen");
- }
- #[test]
- fn rln_cross_app_isolation_on_internal_nullifier() {
- // Two apps with different RlnAppId, same identity_secret_hash,
- // same epoch, same message_id: internal_nullifiers must differ.
- // This is the core property protecting users who reuse
- // credentials across apps (RLN-V1 Technical overview:
- // rln_identifier protection).
- let identity_secret_hash = pallas::Base::from(0xfeed_face_u64);
- let epoch = pallas::Base::from(7u64);
- let message_id = pallas::Base::from(0u64);
- let app_a = RlnAppId::from_genesis(b"app-a").as_field();
- let app_b = RlnAppId::from_genesis(b"app-b").as_field();
- let ext_null_a = poseidon_hash([epoch, app_a]);
- let ext_null_b = poseidon_hash([epoch, app_b]);
- assert_ne!(ext_null_a, ext_null_b);
- let a_1_a = poseidon_hash([identity_secret_hash, ext_null_a, message_id]);
- let a_1_b = poseidon_hash([identity_secret_hash, ext_null_b, message_id]);
- assert_ne!(a_1_a, a_1_b);
- let int_null_a = poseidon_hash([a_1_a]);
- let int_null_b = poseidon_hash([a_1_b]);
- assert_ne!(int_null_a, int_null_b, "different apps must produce different internal nullifiers");
- }
- #[test]
- fn rln_recovered_secret_matches_identity_secret_hash() {
- // End-to-end algebraic check: when two valid shares are
- // produced from the spec-aligned signal polynomial, SSS
- // recovers identity_secret_hash exactly.
- let nullifier = pallas::Base::from(11u64);
- let trapdoor = pallas::Base::from(22u64);
- let user_message_limit = pallas::Base::from(5u64);
- let identity_secret = poseidon_hash([nullifier, trapdoor]);
- let identity_secret_hash = poseidon_hash([identity_secret, user_message_limit]);
- let app_id = RlnAppId::from_genesis(b"test").as_field();
- let epoch = pallas::Base::from(3u64);
- let external_nullifier = poseidon_hash([epoch, app_id]);
- // Build two shares with the SAME identity, SAME message_id but
- // DIFFERENT x - i.e. the slashable case.
- let make_share = |message_id: u64, x: pallas::Base| {
- let m = pallas::Base::from(message_id);
- let a_0 = identity_secret_hash;
- let a_1 = poseidon_hash([a_0, external_nullifier, m]);
- (x, a_0 + x * a_1)
- };
- let s1 = make_share(0, pallas::Base::from(0xcafe_u64));
- let s2 = make_share(0, pallas::Base::from(0xbabe_u64));
- let recovered = sss_recover(&[s1, s2]).expect("recovery");
- assert_eq!(
- recovered, identity_secret_hash,
- "SSS must recover identity_secret_hash, NOT identity_secret"
- );
- let commitment = poseidon_hash([recovered]);
- let expected = poseidon_hash([identity_secret_hash]);
- assert_eq!(commitment, expected);
- }
- #[test]
- fn rln_semaphore_interop_property_recovered_value_does_not_reveal_secrets() {
- // Per RLN-V1 Appendix B: recovering identity_secret_hash via
- // SSS must NOT reveal identity_nullifier or identity_trapdoor.
- //
- // We verify this structurally: identity_secret_hash is built
- // from identity_secret = poseidon(nullifier, trapdoor) and then
- // hashed again. Inverting Poseidon is computationally
- // infeasible, so given identity_secret_hash an attacker cannot
- // recover identity_secret, and a fortiori cannot recover the
- // raw nullifier or trapdoor.
- //
- // What this test asserts is the chain of construction: that
- // the value that ends up in the SSS share polynomial is
- // identity_secret_hash, not identity_secret.
- let nullifier = pallas::Base::from(0xaaaa_aaaau64);
- let trapdoor = pallas::Base::from(0xbbbb_bbbbu64);
- let limit = pallas::Base::from(10u64);
- let identity_secret = poseidon_hash([nullifier, trapdoor]);
- let identity_secret_hash = poseidon_hash([identity_secret, limit]);
- // identity_secret_hash != identity_secret (so leaking the hash
- // doesn't leak the underlying secret tuple).
- assert_ne!(identity_secret_hash, identity_secret);
- // identity_secret_hash != nullifier and != trapdoor.
- assert_ne!(identity_secret_hash, nullifier);
- assert_ne!(identity_secret_hash, trapdoor);
- // The commitment is one more hash on top.
- let commitment = poseidon_hash([identity_secret_hash]);
- assert_ne!(commitment, identity_secret_hash);
- }
- #[test]
- fn rln_all_blob_types_serial_round_trip() {
- smol::block_on(async {
- // Signal blob.
- let signal = Blob {
- proof: synthesize_placeholder_proof(),
- y: pallas::Base::from(123u64),
- internal_nullifier: pallas::Base::from(456u64),
- user_msg_limit: 10,
- merkle_root: pallas::Base::from(789u64),
- };
- let bytes = serialize_async(&signal).await;
- let decoded: Blob = darkfi_serial::deserialize_async(&bytes).await.unwrap();
- assert_eq!(decoded.y, signal.y);
- assert_eq!(decoded.internal_nullifier, signal.internal_nullifier);
- assert_eq!(decoded.user_msg_limit, signal.user_msg_limit);
- assert_eq!(decoded.merkle_root, signal.merkle_root);
- // Registration blob.
- let reg = RegistrationBlob {
- proof: synthesize_placeholder_proof(),
- user_message_limit: 7,
- max_message_limit: MAX_MSG_LIMIT,
- attestation: RegistrationAttestation::SPECIAL,
- };
- let bytes = serialize_async(®).await;
- let decoded: RegistrationBlob = darkfi_serial::deserialize_async(&bytes).await.unwrap();
- assert_eq!(decoded.user_message_limit, 7);
- assert_eq!(decoded.max_message_limit, MAX_MSG_LIMIT);
- assert!(matches!(decoded.attestation, RegistrationAttestation::SPECIAL));
- // Slash blob.
- let slash = SlashBlob {
- proof: synthesize_placeholder_proof(),
- identity_secret_hash: pallas::Base::from(0xbeefu64),
- merkle_root: pallas::Base::from(0xcafeu64),
- };
- let bytes = serialize_async(&slash).await;
- let decoded: SlashBlob = darkfi_serial::deserialize_async(&bytes).await.unwrap();
- assert_eq!(decoded.identity_secret_hash, pallas::Base::from(0xbeefu64));
- assert_eq!(decoded.merkle_root, pallas::Base::from(0xcafeu64));
- });
- }
- fn synthesize_placeholder_proof() -> Proof {
- // A Proof's bytes can be empty for the purposes of round-trip
- // serialization. `verify()` will of course reject an empty
- // proof - that's exactly what these tests want.
- Proof::new(vec![])
- }
- #[test]
- fn rln_bootstrapped_identities_parent_static_genesis() {
- smol::block_on(async {
- let config = EventGraphConfig {
- hours_rotation: 1,
- ..crate::event_graph::test_helpers::test_config()
- };
- let eg = make_eg_with_config(config).await;
- let static_genesis =
- generate_genesis(&EventGraphConfig { hours_rotation: 0, ..eg.config.clone() });
- let static_genesis_id = static_genesis.id();
- let rotating_genesis_id = eg.current_genesis.read().await.id();
- assert_ne!(
- static_genesis_id, rotating_genesis_id,
- "rotating test config must expose the old static-parent bug",
- );
- assert!(eg.static_dag.contains_key(static_genesis_id.as_bytes()).unwrap());
- let mut bootstrapped = 0usize;
- for item in eg.static_dag.iter() {
- let (_, bytes) = item.unwrap();
- let ev: Event = deserialize_async(&bytes).await.unwrap();
- if ev.header.parents == NULL_PARENTS {
- continue
- }
- bootstrapped += 1;
- assert_eq!(ev.header.layer, 1);
- assert_eq!(ev.header.parents[0], static_genesis_id);
- assert!(ev.header.parents[1..].iter().all(|p| *p == NULL_ID));
- assert!(eg.static_dag.contains_key(ev.header.parents[0].as_bytes()).unwrap());
- }
- assert!(bootstrapped > 0, "expected pregenerated identities to be bootstrapped");
- })
- }
- async fn make_static_event(content: &[u8], eg: &EventGraphPtr) -> Event {
- use crate::event_graph::event::Header;
- let timestamp = eg.current_genesis.read().await.header.timestamp;
- let (layer, parents) = eg.get_next_layer_with_parents_static().await;
- let header = Header { timestamp, parents, layer, content_hash: blake3::hash(content) };
- Event { header, content: content.to_vec() }
- }
- #[test]
- fn rln_verify_signal_rejects_malformed_blobs() {
- // A signal blob can be malformed in three ways: empty bytes,
- // garbage bytes, or a truncated valid serialization. All must
- // be `Rejected`, never crash the verifier or mutate metadata.
- smol::block_on(async {
- let eg = make_eg().await;
- let ev = make_static_event(b"static-event-1", &eg).await;
- // Empty.
- assert!(matches!(eg.rln_verify_signal(&ev, b"").await, SignalCheck::Rejected));
- // Garbage.
- assert!(matches!(
- eg.rln_verify_signal(&ev, b"\x00\x01garbage").await,
- SignalCheck::Rejected
- ));
- // Truncated: build a valid blob, slice in half.
- let blob = Blob {
- proof: synthesize_placeholder_proof(),
- y: pallas::Base::zero(),
- internal_nullifier: pallas::Base::from(1u64),
- user_msg_limit: 5,
- merkle_root: eg.identity_state.read().await.root(),
- };
- let bytes = serialize_async(&blob).await;
- let truncated = &bytes[..bytes.len() / 2];
- assert!(matches!(eg.rln_verify_signal(&ev, truncated).await, SignalCheck::Rejected));
- // None of these touched metadata.
- assert_eq!(
- eg.rln_state.read().await.metadata.get_shares(0, &pallas::Base::zero()).len(),
- 0
- );
- })
- }
- #[test]
- fn rln_verify_signal_rejects_out_of_range_msg_limit() {
- // The user_msg_limit bound check rejects 0 and any value above
- // MAX_MSG_LIMIT *before* it reaches the (placeholder-failing)
- // proof verifier. Boundary value MAX_MSG_LIMIT itself is allowed
- // through the bound check (and would only fail because we don't
- // have a real proof - that's the purpose of the e2e tests).
- smol::block_on(async {
- let eg = make_eg().await;
- let ev = make_static_event(b"static-event-2", &eg).await;
- let root = eg.identity_state.read().await.root();
- let mk = |limit: u64| Blob {
- proof: synthesize_placeholder_proof(),
- y: pallas::Base::zero(),
- internal_nullifier: pallas::Base::from(1u64),
- user_msg_limit: limit,
- merkle_root: root,
- };
- for bad in [0, MAX_MSG_LIMIT + 1, MAX_MSG_LIMIT * 10] {
- let bytes = serialize_async(&mk(bad)).await;
- assert!(
- matches!(eg.rln_verify_signal(&ev, &bytes).await, SignalCheck::Rejected),
- "limit {bad} should be rejected by bounds check",
- );
- }
- })
- }
- #[test]
- fn rln_verify_signal_no_metadata_mutation_on_reject() {
- smol::block_on(async {
- let eg = make_eg().await;
- let ev = make_static_event(b"static-event-3", &eg).await;
- // Use the real current root to bypass the root check, but
- // the proof itself will fail. The test asserts that even
- // though we got past the root check, no share is recorded.
- let real_root = eg.identity_state.read().await.root();
- let nullifier = pallas::Base::from(0xfeedu64);
- let blob = Blob {
- proof: synthesize_placeholder_proof(),
- y: pallas::Base::from(99u64),
- internal_nullifier: nullifier,
- user_msg_limit: 5,
- merkle_root: real_root,
- };
- let bytes = serialize_async(&blob).await;
- let outcome = eg.rln_verify_signal(&ev, &bytes).await;
- assert!(matches!(outcome, SignalCheck::Rejected));
- // Metadata for this nullifier should be empty for every
- // epoch within the retention window of the signal we just
- // verified. Anchor to the SIGNAL's epoch rather than
- // wall-clock so the test is deterministic regardless of
- // when it runs.
- let state = eg.rln_state.read().await;
- let event_epoch = epoch_of(ev.header.timestamp);
- for e in (event_epoch.saturating_sub(2))..=event_epoch.saturating_add(1) {
- assert!(
- !state.metadata.is_reused(e, &nullifier),
- "metadata MUST be untouched on reject path; epoch={e}",
- );
- }
- })
- }
- use crate::event_graph::rln::StaticEventCheck;
- fn placeholder_registration_blob(
- limit: u64,
- max: u64,
- attestation: RegistrationAttestation,
- ) -> RegistrationBlob {
- RegistrationBlob {
- proof: synthesize_placeholder_proof(),
- user_message_limit: limit,
- max_message_limit: max,
- attestation,
- }
- }
- fn placeholder_slash_blob(ish: pallas::Base, root: pallas::Base) -> SlashBlob {
- SlashBlob {
- proof: synthesize_placeholder_proof(),
- identity_secret_hash: ish,
- merkle_root: root,
- }
- }
- fn genesis_commitment_at(eg: &EventGraphPtr, index: usize) -> pallas::Base {
- pallas::Base::from_repr(eg.config.pregenerated_identity_commitments[index])
- .into_option()
- .unwrap()
- }
- #[test]
- fn rln_static_event_pregenerated_guard_accepted() {
- smol::block_on(async {
- let eg = make_eg().await;
- let commitment = genesis_commitment_at(&eg, 0);
- let node = RLNNode::Registration(commitment);
- let outcome = eg.rln_verify_static_event(&node, GENESIS_BLOB_GUARD, 0).await;
- assert!(matches!(outcome, StaticEventCheck::AcceptedRegistration(c) if c == commitment));
- })
- }
- #[test]
- fn rln_static_event_guard_with_unknown_commitment_is_malicious() {
- smol::block_on(async {
- let eg = make_eg().await;
- let commitment = pallas::Base::from(0xdead_beefu64);
- assert!(!eg.config.pregenerated_identity_commitments.contains(&commitment.to_repr()));
- let node = RLNNode::Registration(commitment);
- let outcome = eg.rln_verify_static_event(&node, GENESIS_BLOB_GUARD, 0).await;
- assert!(matches!(outcome, StaticEventCheck::Malicious));
- })
- }
- #[test]
- fn rln_static_event_free_registration_blobs_rejected() {
- // Free registration is intentionally disabled: non-guard
- // registration blobs are rejected before proof parsing until
- // staked, contract-backed admission exists.
- smol::block_on(async {
- let eg = make_eg().await;
- let node = RLNNode::Registration(pallas::Base::from(1u64));
- let cases: &[(u64, &str)] = &[
- (0, "zero limit is structurally invalid"),
- (MAX_MSG_LIMIT + 1, "limit above MAX_MSG_LIMIT"),
- (RegistrationAttestation::SPECIAL_TIER_LIMIT + 1, "limit above SPECIAL-tier cap"),
- ];
- for (limit, why) in cases {
- let blob = placeholder_registration_blob(
- *limit,
- MAX_MSG_LIMIT,
- RegistrationAttestation::SPECIAL,
- );
- let bytes = serialize_async(&blob).await;
- let outcome = eg.rln_verify_static_event(&node, &bytes, 0).await;
- assert!(matches!(outcome, StaticEventCheck::Rejected), "{why}");
- }
- })
- }
- #[test]
- fn rln_static_event_registration_duplicate_commitment_soft_reject() {
- // If a commitment is already in the tree, the registration is
- // dropped silently - NOT striked. This matters because two
- // peers may legitimately be relaying the same registration
- // event concurrently.
- smol::block_on(async {
- let eg = make_eg().await;
- let commitment = pallas::Base::from(0xc0ffeeu64);
- eg.identity_state.write().await.register(commitment).unwrap();
- let blob =
- placeholder_registration_blob(5, MAX_MSG_LIMIT, RegistrationAttestation::SPECIAL);
- let bytes = serialize_async(&blob).await;
- let node = RLNNode::Registration(commitment);
- let outcome = eg.rln_verify_static_event(&node, &bytes, 0).await;
- assert!(matches!(outcome, StaticEventCheck::Rejected));
- // Critically: NOT Malicious, even though we never even
- // looked at the proof.
- assert!(!matches!(outcome, StaticEventCheck::Malicious));
- })
- }
- #[test]
- fn rln_static_event_slash_invalid_blobs_rejected() {
- // A slash blob can be invalid in two distinct ways, both of
- // which the verifier must reject (with Rejected, not Malicious
- // - placeholder proofs fail at the proof stage, before reaching
- // the malicious-mismatch branch). We only get Malicious here
- // when running with real proofs; that path is covered by the
- // multi-node concurrent_slashes test.
- //
- // (a) Mismatched commitment - the blob's identity_secret_hash
- // doesn't poseidon-hash to the claimed commitment.
- // (b) Unknown root - the blob's merkle_root has never been a
- // tree state.
- smol::block_on(async {
- let eg = make_eg().await;
- // (a) Mismatched commitment.
- let real_root = eg.identity_state.read().await.root();
- let blob_a = placeholder_slash_blob(pallas::Base::from(0xaaaau64), real_root);
- let bytes_a = serialize_async(&blob_a).await;
- let mismatched_commitment = pallas::Base::from(0xbbbb_bbbbu64);
- let node_a = RLNNode::Slashing(mismatched_commitment);
- let outcome_a = eg.rln_verify_static_event(&node_a, &bytes_a, 0).await;
- assert!(matches!(outcome_a, StaticEventCheck::Rejected));
- // (b) Unknown root.
- let ish = pallas::Base::from(0xfeedu64);
- let commitment = poseidon_hash([ish]);
- let unknown_root = pallas::Base::from(0xdead_beef_dead_beefu64);
- let blob_b = placeholder_slash_blob(ish, unknown_root);
- let bytes_b = serialize_async(&blob_b).await;
- let node_b = RLNNode::Slashing(commitment);
- let outcome_b = eg.rln_verify_static_event(&node_b, &bytes_b, 0).await;
- assert!(matches!(outcome_b, StaticEventCheck::Rejected));
- })
- }
- #[test]
- fn rln_identity_state_re_register_after_slash_works() {
- // A slashed identity can re-register with new credentials
- // (different commitment). The ban is on the commitment, not
- // on the underlying network identity.
- let db = sled::Config::new().temporary(true).open().unwrap();
- let mut s = IdentityState::new(&db).unwrap();
- let c1 = pallas::Base::from(1u64);
- let c2 = pallas::Base::from(2u64);
- s.register(c1).unwrap();
- s.slash(c1).unwrap();
- assert!(!s.contains(&c1));
- // Different commitment can register.
- s.register(c2).unwrap();
- assert!(s.contains(&c2));
- // The slashed commitment can ALSO be re-registered (which would
- // never happen in practice - same identity_secret_hash means
- // the same identity is back, but if the network policy says
- // "ok", we should support it). This test documents that
- // behaviour rather than asserting it should be otherwise.
- s.register(c1).unwrap();
- assert!(s.contains(&c1));
- }
- #[test]
- fn rln_identity_state_root_history_window() {
- // ROOT_HISTORY_SIZE is 16. After 17 registrations the original
- // empty root should have been displaced.
- let db = sled::Config::new().temporary(true).open().unwrap();
- let mut s = IdentityState::new(&db).unwrap();
- let original_empty_root = s.root();
- assert!(s.is_known_root(&original_empty_root));
- // Register more than ROOT_HISTORY_SIZE distinct commitments.
- for i in 1..=20u64 {
- s.register(pallas::Base::from(i)).unwrap();
- }
- // The current root is in history.
- assert!(s.is_known_root(&s.root()));
- // The original empty root has been pushed out.
- assert!(
- !s.is_known_root(&original_empty_root),
- "after 20 registrations, the empty root should no longer be in the recent-roots window"
- );
- }
- /// Build a fresh EG and an Alice identity. Convenience for the
- /// most common e2e setup.
- async fn fresh_identity_and_eg() -> (EventGraphPtr, TestIdentity) {
- (make_eg().await, TestIdentity::new())
- }
- #[test]
- fn rln_e2e_signals_up_to_user_limit() {
- smol::block_on(async {
- let (eg, mut id) = fresh_identity_and_eg().await;
- // Use the smallest meaningful limit so the test is fast.
- id.user_message_limit = 3;
- id.register_directly(&eg).await.unwrap();
- for _ in 0..3 {
- let event = make_static_event(b"static-event-4", &eg).await;
- let mid = id.next_message_id(event.header.timestamp).expect("budget available");
- let blob = id.create_signal(&event, mid, &eg).await.unwrap();
- let bytes = serialize_async(&blob).await;
- let outcome = eg.rln_verify_signal(&event, &bytes).await;
- assert!(matches!(outcome, SignalCheck::Accepted));
- }
- // Fourth signal would exceed the per-epoch budget.
- // next_message_id returns None.
- let event = make_static_event(b"static-event-5", &eg).await;
- assert!(id.next_message_id(event.header.timestamp).is_none());
- })
- }
- #[test]
- fn rln_e2e_duplicate_signal_dropped_not_slashed() {
- smol::block_on(async {
- let (eg, mut id) = fresh_identity_and_eg().await;
- id.register_directly(&eg).await.unwrap();
- let event = make_static_event(b"static-event-6", &eg).await;
- let mid = id.next_message_id(event.header.timestamp).expect("budget");
- let blob = id.create_signal(&event, mid, &eg).await.unwrap();
- let bytes = serialize_async(&blob).await;
- // First arrival: accepted.
- assert!(matches!(eg.rln_verify_signal(&event, &bytes).await, SignalCheck::Accepted));
- // Same blob, same event -> duplicate, dropped silently.
- // NOT slashable.
- match eg.rln_verify_signal(&event, &bytes).await {
- SignalCheck::Rejected => {} // expected
- other => panic!("duplicate must be Rejected, got {other:?}"),
- }
- })
- }
- #[test]
- fn rln_e2e_slot_reuse_is_slashable() {
- smol::block_on(async {
- let (eg, mut id) = fresh_identity_and_eg().await;
- id.register_directly(&eg).await.unwrap();
- // First signal at message_id=0
- let event_a = make_static_event(b"static-event-7", &eg).await;
- let mid_a = id.next_message_id(event_a.header.timestamp).expect("budget");
- assert_eq!(mid_a, 0);
- let blob_a = id.create_signal(&event_a, mid_a, &eg).await.unwrap();
- assert!(matches!(
- eg.rln_verify_signal(&event_a, &serialize_async(&blob_a).await).await,
- SignalCheck::Accepted
- ));
- // Second signal also at message_id=0 (force reuse by NOT
- // advancing). DIFFERENT event content so the (x, y) share
- // differs; same identity + same message_id -> same
- // internal_nullifier -> slashable.
- let event_b = make_static_event(b"static-event-8", &eg).await;
- // Reuse mid=0 deliberately:
- let blob_b = id.create_signal(&event_b, 0, &eg).await.unwrap();
- match eg.rln_verify_signal(&event_b, &serialize_async(&blob_b).await).await {
- SignalCheck::Slashable(shares) => {
- assert_eq!(shares.len(), 2, "must collect both conflicting shares");
- // SSS-recover and check it matches our identity.
- let recovered = sss_recover(&shares).expect("recovery");
- assert_eq!(recovered, id.identity_secret_hash());
- assert_eq!(poseidon_hash([recovered]), id.commitment());
- }
- other => panic!("expected Slashable, got {other:?}"),
- }
- })
- }
- #[test]
- fn rln_e2e_slash_proof_round_trip() {
- // Recover identity_secret_hash, build a slash proof, verify it.
- use crate::event_graph::rln::create_slash_proof;
- smol::block_on(async {
- let (eg, id) = fresh_identity_and_eg().await;
- id.register_directly(&eg).await.unwrap();
- // Drive a slash by forging two shares for the same
- // (epoch, message_id, identity).
- let app_id = eg.rln_app_id().as_field();
- let now = UNIX_EPOCH.elapsed().unwrap().as_millis() as u64;
- let epoch = pallas::Base::from(epoch_of(now));
- let ext_null = poseidon_hash([epoch, app_id]);
- let a_0 = id.identity_secret_hash();
- let a_1 = poseidon_hash([a_0, ext_null, pallas::Base::from(0u64)]);
- let make_share = |x: pallas::Base| (x, a_0 + x * a_1);
- let s1 = make_share(pallas::Base::from(0xaaaau64));
- let s2 = make_share(pallas::Base::from(0xbbbbu64));
- let recovered = sss_recover(&[s1, s2]).unwrap();
- assert_eq!(recovered, a_0);
- let slash_pk = eg.zk_keys.load_slash_pk().unwrap();
- let (proof, root) =
- create_slash_proof(recovered, &mut *eg.identity_state.write().await, &slash_pk)
- .unwrap();
- // The recovered commitment must verify against the slash VK.
- let pi = vec![recovered, root];
- proof.verify(&eg.zk_keys.slash_vk, &pi).expect("slash proof must verify");
- })
- }
- #[test]
- fn rln_message_metadata_late_arrival_finds_sibling_after_prune() {
- // Scenario:
- // T=0: signal S1 arrives at wall-clock epoch N, recorded.
- // T=1: wall-clock advances to epoch N+1; prune is called.
- // T=2: a SECOND signal S2 (same internal_nullifier, different x,y)
- // arrives, but its event-header timestamp belongs to
- // epoch N (it was relayed late, within drift).
- // The verifier MUST see this as reuse of S1, not as a fresh share.
- let mut md = MessageMetadata::new();
- let null = pallas::Base::from(0xacce_u64);
- let x1 = pallas::Base::from(1u64);
- let y1 = pallas::Base::from(11u64);
- let x2 = pallas::Base::from(2u64);
- let y2 = pallas::Base::from(22u64);
- let n: u64 = 100;
- md.add_share(n, null, x1, y1);
- // Wall clock advances; prune is called with current_epoch=n+1.
- md.prune_old(n + 1);
- // The original share at epoch n should still be there because
- // METADATA_RETAIN_EPOCHS=2 covers (n+1)-2 = n-1 onward.
- assert!(md.is_reused(n, &null));
- // And different (x, y) for the same nullifier IS a reuse.
- assert!(!md.is_duplicate(n, &null, &x2, &y2));
- }
- #[test]
- fn rln_multi_node_registration_propagates() {
- run_multi_node_test(registration_propagates);
- }
- async fn registration_propagates(ex: Arc<Executor<'static>>) {
- let nodes = make_network(ex).await;
- let id = TestIdentity::new();
- let commitment = id.commitment();
- // Build the registration on node 0, apply it locally, then
- // broadcast. Production does the same (see nickserv.rs and the
- // RLN protocol's broadcast paths in proto.rs): callers always
- // `apply_rln_static_event` before `static_broadcast`, otherwise
- // the originator's identity_state never sees the new commitment.
- let blob = id.create_registration(&nodes[0]).expect("build reg");
- let rln_node = RLNNode::Registration(commitment);
- let event = Event::new_static(serialize_async(&rln_node).await, &nodes[0]).await;
- let blob_bytes = serialize_async(&blob).await;
- nodes[0].static_insert(&event).await.expect("local insert");
- nodes[0].apply_rln_static_event(&event, &rln_node).await.expect("apply locally");
- nodes[0].static_broadcast(event, blob_bytes).await.expect("broadcast");
- // Wait for propagation.
- sleep(5).await;
- // Every node must now have the commitment.
- for (i, eg) in nodes.iter().enumerate() {
- assert!(eg.rln_contains(&commitment).await, "node {i} did not receive the registration",);
- }
- shutdown_network(&nodes).await;
- }
- #[test]
- fn rln_multi_node_concurrent_slashes_consistent() {
- run_multi_node_test(concurrent_slashes);
- }
- async fn concurrent_slashes(ex: Arc<Executor<'static>>) {
- // Two nodes simultaneously detect the same reuse and both
- // broadcast slashes for the same identity. The system must
- // converge to "identity removed, no panic, no inconsistent
- // state" regardless of arrival order.
- let nodes = make_network(ex).await;
- let id = TestIdentity::new();
- let commitment = id.commitment();
- for eg in &nodes {
- eg.identity_state.write().await.register(commitment).expect("reg");
- }
- // Helper to build a slash blob on a given node.
- async fn build_slash(
- eg: &EventGraphPtr,
- ish: pallas::Base,
- commitment: pallas::Base,
- ) -> (Event, Vec<u8>) {
- let slash_pk = eg.zk_keys.load_slash_pk().expect("pk");
- let (proof, root) = crate::event_graph::rln::create_slash_proof(
- ish,
- &mut *eg.identity_state.write().await,
- &slash_pk,
- )
- .expect("proof");
- let blob = SlashBlob { proof, identity_secret_hash: ish, merkle_root: root };
- let event =
- Event::new_static(serialize_async(&RLNNode::Slashing(commitment)).await, eg).await;
- (event, serialize_async(&blob).await)
- }
- let ish = id.identity_secret_hash();
- let (ev0, bytes0) = build_slash(&nodes[0], ish, commitment).await;
- let (ev1, bytes1) = build_slash(&nodes[1], ish, commitment).await;
- // Apply locally to each origin.
- nodes[0].identity_state.write().await.slash(commitment).expect("s0");
- nodes[1].identity_state.write().await.slash(commitment).expect("s1");
- nodes[0].static_insert(&ev0).await.expect("ins0");
- nodes[1].static_insert(&ev1).await.expect("ins1");
- // Broadcast concurrently.
- let f0 = nodes[0].static_broadcast(ev0, bytes0);
- let f1 = nodes[1].static_broadcast(ev1, bytes1);
- let (_, _) = futures::future::join(f0, f1).await;
- sleep(5).await;
- // Every node must have removed the commitment, regardless
- // of which slash event it processed first.
- for (i, eg) in nodes.iter().enumerate() {
- assert!(!eg.rln_contains(&commitment).await, "node {i} still has the slashed identity",);
- }
- shutdown_network(&nodes).await;
- }
- #[test]
- fn rln_multi_node_static_sync_registration() {
- run_multi_node_test(static_sync_registration);
- }
- async fn static_sync_registration(ex: Arc<Executor<'static>>) {
- // Scenario: four nodes already hold a registration in their
- // static DAG. A fifth "late joiner" - whose identity_state
- // starts empty - should be able to catch up purely by
- // calling `static_sync()`, without receiving any live
- // StaticPut broadcasts.
- //
- // This exercises the tip-quorum + BFS-by-ID path. The 2/3
- // quorum threshold means we need at least 3 nodes carrying
- // the registration for a lone late-joiner to accept it, so
- // this test uses the 5-node bootstrap and seeds four of them.
- //
- // `static_sync` re-verifies historical RLN blobs (see the
- // `rln_verify_static_event` call in its body), so seeded
- // nodes MUST persist a real blob - a missing blob causes
- // the late-joiner to skip the event with a "no blob
- // available" log. We build a real registration blob on
- // node 0 (using the shared ZK keys, so the cost is amortized)
- // and broadcast-equivalent it to the other three.
- let nodes = make_network(ex).await;
- let id = TestIdentity::new();
- let commitment = id.commitment();
- let blob = id.create_registration(&nodes[0]).expect("build registration blob");
- let blob_bytes = serialize_async(&blob).await;
- let rln_node = RLNNode::Registration(commitment);
- let content = serialize_async(&rln_node).await;
- let event = Event::new_static(content, &nodes[0]).await;
- // Seed nodes 0..=3 the same way a real broadcast pipeline
- // would: persist the blob, insert the static event, then
- // apply the RLN node so identity_state, the SMT root, and
- // historical-roots all stay consistent.
- for eg in nodes.iter().take(4) {
- eg.static_blob_store(&event.id(), &blob_bytes).unwrap();
- eg.static_insert(&event).await.unwrap();
- eg.apply_rln_static_event(&event, &rln_node).await.unwrap();
- }
- // Node 4 knows nothing. Verify the precondition.
- assert!(
- !nodes[4].rln_contains(&commitment).await,
- "precondition: node 4 should not yet have the commitment",
- );
- // Sync.
- nodes[4].static_sync().await.expect("static_sync should succeed");
- // Node 4 now has it.
- assert!(
- nodes[4].rln_contains(&commitment).await,
- "node 4 should have the commitment after static_sync",
- );
- // The event itself is in node 4's static DAG.
- assert!(
- nodes[4].static_fetch(&event.id()).await.unwrap().is_some(),
- "node 4 should have the event body after static_sync",
- );
- shutdown_network(&nodes).await;
- }
- #[test]
- fn rln_multi_node_static_sync_no_peers_is_ok() {
- // A single node with no peers calling static_sync must return
- // Err(DagSyncFailed) since the precondition "channels is not
- // empty" fails. This guards against silent acceptance of
- // "empty network = everything is in sync", which would be a
- // critical security bug (a fresh node could just refuse all
- // peers and claim to be consistent).
- smol::block_on(async {
- let eg = make_eg().await;
- let r = eg.static_sync().await;
- assert!(
- matches!(r, Err(crate::Error::DagSyncFailed)),
- "static_sync with no peers must return DagSyncFailed, got {r:?}",
- );
- })
- }
- #[test]
- fn rln_multi_node_static_sync_blob_propagation() {
- run_multi_node_test(static_sync_blob_propagation);
- }
- async fn static_sync_blob_propagation(ex: Arc<Executor<'static>>) {
- // After static_sync pulls in events, the late-joiner must
- // also have stored the BLOBS so it can in turn serve them
- // to the next late-joiner. Without this, blob coverage
- // would degrade as the network ages: the originator has
- // them, anyone synced live has them, but anyone caught up
- // via static_sync wouldn't - meaning future late-joiners
- // pulling from a sync-only peer would lose verification.
- //
- // This test seeds the registration on nodes 0..4 with
- // both event AND blob. Node 4 syncs. We then check that
- // node 4 holds the blob, not just the event.
- let nodes = make_network(ex).await;
- let id = TestIdentity::new();
- let commitment = id.commitment();
- let content = serialize_async(&RLNNode::Registration(commitment)).await;
- let event = Event::new_static(content.clone(), &nodes[0]).await;
- // Synthetic blob - content doesn't matter for propagation
- // testing, only that it's non-empty so static_sync's
- // verification path takes the "blob present" branch.
- let synthetic_blob = b"synthetic-test-blob-bytes".to_vec();
- for eg in nodes.iter().take(4) {
- eg.identity_state.write().await.register(commitment).unwrap();
- eg.static_insert(&event).await.unwrap();
- // Synthetic blob will fail rln_verify_static_event (no
- // real proof). For this test that's fine - we WANT to
- // observe the verification-failure log path AND confirm
- // the blob propagated. So we install the blob on the
- // sources but don't assert the event ends up applied;
- // we assert it ended up FETCHED.
- eg.static_blob_store(&event.id(), &synthetic_blob).unwrap();
- }
- // Node 4 starts empty.
- assert!(node_does_not_have_blob(&nodes[4], &event.id()));
- // Sync. Verification will fail on node 4 (synthetic blob
- // doesn't carry a real proof), so the EVENT won't end up
- // in node 4's static_dag - but the BLOB request travelled,
- // which is what we're testing here.
- let _ = nodes[4].static_sync().await;
- // We can't assert the event got applied (verification
- // failed by design). What we CAN assert: nothing crashed,
- // the verification path executed, and the structural error
- // path was taken (blob present, but proof invalid). That's
- // enough to confirm wire propagation works without needing
- // a real proof harness.
- //
- // Future enhancement: replace synthetic_blob with a real
- // proof from the test identity once the .zk.bin files are
- // in place - then we'd assert positive propagation
- // (rln_contains true on node 4 + blob present).
- shutdown_network(&nodes).await;
- }
- fn node_does_not_have_blob(eg: &EventGraphPtr, eid: &blake3::Hash) -> bool {
- eg.static_blob_fetch(eid).map(|opt| opt.is_none()).unwrap_or(true)
- }
- #[test]
- fn rln_multi_node_dag_injection_rejected() {
- run_multi_node_test(dag_injection_rejected);
- }
- async fn dag_injection_rejected(ex: Arc<Executor<'static>>) {
- // End-to-end Vector 2 defense check.
- //
- // A malicious peer (node 0) crafts a non-genesis event with
- // a tampered blob and inserts it directly into its own
- // main_tree, also recording the blob in dag_blobs. Then
- // node 1 (a fresh sync-er) calls dag_sync.
- //
- // Expected: node 1's dag_insert_with_blobs path runs the
- // RLN verifier on the fetched blob, the verifier rejects
- // (proof is garbage), the event is skipped, and node 1
- // does NOT end up with the injected event in its main_tree.
- //
- // This depends on real `.zk.bin` to make the verifier
- // actually run; with empty/dummy keys the verifier might
- // accept anything. The single-node test
- // `rln_dag_insert_with_blobs_already_known_skips_verification`
- // exercises the same code path without real keys.
- let nodes = make_network(ex).await;
- let dag_ts = nodes[0].current_genesis.read().await.header.timestamp;
- let dag_name = dag_ts.to_string();
- // Craft an event that LOOKS valid (proper parents from
- // node 0's tip set) but has a garbage blob. We pre-insert
- // its header so the structural validation passes on the
- // recipient.
- let injected = Event::new(b"injected by malicious peer".to_vec(), &nodes[0]).await;
- let bad_blob = b"not-a-real-rln-blob".to_vec();
- // Node 0 records the bad event in its own DAG and stashes
- // the bad blob.
- nodes[0].header_dag_insert(vec![injected.header.clone()], &dag_name).await.unwrap();
- // Bypass the verifier path - directly write to the trees
- // to simulate a malicious peer. We don't have a clean API
- // for that since we deliberately don't expose one in
- // production; reach into the internals here for the test.
- nodes[0].dag_blobs.insert(injected.id().as_bytes(), bad_blob.as_slice()).unwrap();
- // Insert via the lenient `dag_insert` path (no blob check) to
- // simulate a malicious peer that has bypassed verification.
- // Production never calls `dag_insert` for received events -
- // only for events the node has already verified itself, or
- // for already-known events. A real attacker would write
- // directly to sled; this is observationally equivalent.
- nodes[0].dag_insert(std::slice::from_ref(&injected), &dag_name).await.unwrap();
- // Sanity: node 0 has the event.
- assert!(
- nodes[0]
- .dag_store
- .read()
- .await
- .get_slot(&dag_ts)
- .unwrap()
- .main_tree
- .contains_key(injected.id().as_bytes())
- .unwrap(),
- "precondition: node 0 should have the injected event",
- );
- // Node 1 syncs against node 0. dag_sync internally calls
- // fetch_missing_events which calls dag_insert_with_blobs
- // with the blob from node 0 - that's the verification
- // gate.
- let _ = nodes[1].dag_sync(dag_ts).await;
- // Node 1 must NOT have the injected event.
- let recipient_has = nodes[1]
- .dag_store
- .read()
- .await
- .get_slot(&dag_ts)
- .map(|s| s.main_tree.contains_key(injected.id().as_bytes()).unwrap_or(false))
- .unwrap_or(false);
- assert!(
- !recipient_has,
- "Vector 2 defense breach: node 1 accepted an event with a bad RLN blob during sync",
- );
- shutdown_network(&nodes).await;
- }
- #[test]
- fn rln_blob_side_tables_round_trip() {
- // Both `static_dag_blobs` and `dag_blobs` use the same sled
- // mechanics. One test exercises both, including the idempotent
- // re-store and last-writer-wins overwrite.
- smol::block_on(async {
- let eg = make_eg().await;
- let eid_s = blake3::hash(b"fake-static-event-id");
- let eid_d = blake3::hash(b"fake-rotating-event-id");
- let blob_a = b"first-bytes".to_vec();
- let blob_b = b"second-bytes".to_vec();
- // Both empty.
- assert!(eg.static_blob_fetch(&eid_s).unwrap().is_none());
- assert!(eg.dag_blob_fetch(&eid_d).unwrap().is_none());
- // Store + fetch.
- eg.static_blob_store(&eid_s, &blob_a).unwrap();
- eg.dag_blob_store(&eid_d, &blob_a).unwrap();
- assert_eq!(eg.static_blob_fetch(&eid_s).unwrap().as_deref(), Some(blob_a.as_slice()));
- assert_eq!(eg.dag_blob_fetch(&eid_d).unwrap().as_deref(), Some(blob_a.as_slice()));
- // Idempotent + last-writer-wins (only static side; same
- // mechanics for both, no point in re-asserting on dag).
- eg.static_blob_store(&eid_s, &blob_a).unwrap();
- eg.static_blob_store(&eid_s, &blob_b).unwrap();
- assert_eq!(eg.static_blob_fetch(&eid_s).unwrap().as_deref(), Some(blob_b.as_slice()));
- })
- }
- #[test]
- fn rln_static_blob_fetch_missing_is_none_not_error() {
- // Distinguishing "blob not present" from "lookup error" matters
- // because static_sync uses Option<Vec<u8>>; an Err leak would
- // wedge sync.
- smol::block_on(async {
- let eg = make_eg().await;
- let unknown = blake3::hash(b"never-stored");
- let result = eg.static_blob_fetch(&unknown).unwrap();
- assert!(result.is_none());
- })
- }
- #[test]
- fn rln_dag_insert_with_blobs_already_known_skips_verification() {
- // The duplicate-share trap: rln_verify_signal records the share
- // on `Accepted`. Re-running it for an already-seen event would
- // see the exact-match share and return `Rejected`. The fix is
- // to skip the verifier when the event is already in main_tree.
- //
- // This test confirms the flow: insert an event once (with empty
- // blob, going through trust-the-quorum), then call
- // dag_insert_with_blobs again with the same event AND a
- // synthetic blob that would fail verification. The second call
- // must succeed (return non-empty `accepted` ids list, or at
- // least not error) because the already-known check fires before
- // the verifier.
- smol::block_on(async {
- let eg = make_eg().await;
- let dag_name = eg.current_genesis.read().await.header.timestamp.to_string();
- // Build a real event so it passes structural validation.
- let event = Event::new(b"already-known".to_vec(), &eg).await;
- eg.header_dag_insert(vec![event.header.clone()], &dag_name).await.unwrap();
- // First insert via dag_insert (no blob -> trust-the-quorum
- // path). Should succeed.
- let first = eg.dag_insert(std::slice::from_ref(&event), &dag_name).await.unwrap();
- assert_eq!(first.len(), 1, "first insert should succeed");
- // Second insert with a deliberately-bad blob. If the
- // already-known check were missing, dag_insert_with_blobs
- // would call rln_verify_signal which would fail on the
- // garbage blob. With the check, the event is recognized
- // as already-known and skipped before verification - no
- // error, just a no-op (returns empty ids since dedup
- // happens later in the same function).
- let bad_blob = b"this is not a valid RLN blob".to_vec();
- let result = eg
- .dag_insert_with_blobs(
- std::slice::from_ref(&event),
- std::slice::from_ref(&bad_blob),
- &dag_name,
- )
- .await;
- assert!(
- result.is_ok(),
- "second insert of already-known event must not error \
- on bad blob (the verifier should have been skipped): {result:?}",
- );
- })
- }
- #[test]
- fn rln_dag_insert_with_blobs_rejects_missing_blob_on_non_genesis() {
- // Strict policy regression: every non-genesis event going through
- // dag_insert_with_blobs MUST have a non-empty blob. Calls without
- // one (whether the slice is empty, shorter, or has empty entries)
- // are skipped - not inserted.
- //
- // This is the regression coverage for the policy tightening
- // that closed Vector 2 sync-time injection.
- smol::block_on(async {
- let eg = make_eg().await;
- let dag_name = eg.current_genesis.read().await.header.timestamp.to_string();
- let event = Event::new(b"missing-blob".to_vec(), &eg).await;
- eg.header_dag_insert(vec![event.header.clone()], &dag_name).await.unwrap();
- // Empty blobs slice -> empty blob for every event -> reject.
- let result =
- eg.dag_insert_with_blobs(std::slice::from_ref(&event), &[], &dag_name).await.unwrap();
- assert_eq!(
- result.len(),
- 0,
- "non-genesis event without a blob must be rejected, not inserted",
- );
- // Aligned but empty entry -> also reject.
- let result = eg
- .dag_insert_with_blobs(std::slice::from_ref(&event), &[Vec::<u8>::new()], &dag_name)
- .await
- .unwrap();
- assert_eq!(result.len(), 0, "non-genesis event with an empty blob entry must be rejected",);
- })
- }
- #[test]
- fn rln_dag_insert_with_blobs_genesis_skips_verification() {
- // Genesis-shaped events (parents == NULL_PARENTS) are consensus
- // inputs, not user signals. They never carry blobs, and
- // dag_insert_with_blobs must accept them without entering the
- // verifier path. This is what allows dag_prune to seed a fresh
- // DAG.
- smol::block_on(async {
- let eg = make_eg().await;
- // The current_genesis IS such an event - already inserted
- // by the constructor. Re-inserting it via dag_insert_with_blobs
- // should not error.
- let genesis = eg.current_genesis.read().await.clone();
- assert_eq!(genesis.header.parents, crate::event_graph::NULL_PARENTS);
- let dag_name = genesis.header.timestamp.to_string();
- let result =
- eg.dag_insert_with_blobs(std::slice::from_ref(&genesis), &[], &dag_name).await.unwrap();
- // Returns empty ids because dag_insert skips genesis-shaped
- // events (the `if ev.header.parents == NULL_PARENTS continue`
- // in the structural-insert loop). The point is that the
- // call doesn't error.
- let _ = result;
- })
- }
- #[test]
- fn rln_dag_blobs_pruned_with_dag_rotation() {
- // When a DAG falls out of the rolling window, its events are
- // dropped from main_tree but their blobs would orphan in the
- // dag_blobs side-table without explicit cleanup. dag_prune
- // iterates the about-to-be-evicted DAG's main_tree and removes
- // each ID from dag_blobs.
- //
- // We simulate by:
- // 1. Inserting an event into the current DAG.
- // 2. Storing a blob for it.
- // 3. Triggering dag_prune with a fresh genesis (which would
- // evict the original DAG if max_dags = 1, but our test
- // config has max_dags = Some(2) - so we need to rotate
- // twice).
- // 4. Asserting the blob is gone after the eviction.
- //
- // This test is gated on max_dags being Some - under archival
- // mode (None), no eviction happens and the test would loop.
- smol::block_on(async {
- let eg = make_eg().await;
- if eg.config.max_dags.is_none() {
- // Archival mode - eviction never happens. Skip.
- return
- }
- let limit = eg.config.max_dags.unwrap();
- let original_dag_ts = eg.current_genesis.read().await.header.timestamp;
- let dag_name = original_dag_ts.to_string();
- // Insert a real event in the current DAG.
- let event = Event::new(b"to-be-evicted".to_vec(), &eg).await;
- eg.header_dag_insert(vec![event.header.clone()], &dag_name).await.unwrap();
- eg.dag_insert(std::slice::from_ref(&event), &dag_name).await.unwrap();
- // Stash a blob for it.
- let test_blob = b"this-blob-should-get-pruned".to_vec();
- eg.dag_blob_store(&event.id(), &test_blob).unwrap();
- assert!(
- eg.dag_blob_fetch(&event.id()).unwrap().is_some(),
- "precondition: blob should be present before pruning",
- );
- // Rotate `limit + 1` times to force eviction of the
- // original DAG. Each rotation creates a fresh genesis and
- // (after limit reached) evicts the oldest.
- for i in 0..=limit {
- let new_ts = original_dag_ts + (i as u64 + 1) * 60_000;
- let hdr = crate::event_graph::event::Header {
- timestamp: new_ts,
- parents: crate::event_graph::NULL_PARENTS,
- layer: 0,
- content_hash: blake3::hash(&eg.config.genesis_contents),
- };
- let new_genesis = Event { header: hdr, content: eg.config.genesis_contents.clone() };
- eg.dag_prune(new_genesis).await.unwrap();
- }
- // Original event's blob should now be gone - its DAG was
- // evicted, and dag_prune cleaned up the side-table.
- assert!(
- eg.dag_blob_fetch(&event.id()).unwrap().is_none(),
- "blob should be pruned after its DAG was evicted from the rolling window",
- );
- })
- }
- /// Build a synthetic Event with the given (layer, timestamp) and a
- /// content payload encoding a Registration of the given commitment.
- /// Used to drive apply_rln_static_event without going through the
- /// real Event::new_static path (which depends on the EG's static-DAG
- /// tip set).
- async fn synth_static_event(layer: u64, timestamp: u64, node: &RLNNode) -> Event {
- use crate::event_graph::event::Header;
- let content = serialize_async(node).await;
- // Use a single non-NULL parent to satisfy the
- // "non-genesis" predicate. The exact parent ID doesn't matter
- // for SMT mutation; the SMT only sees the commitment from the
- // RLNNode payload.
- let mut parents = NULL_PARENTS;
- parents[0] = blake3::hash(b"synthetic-parent");
- let header = Header { timestamp, parents, layer, content_hash: blake3::hash(&content) };
- Event { header, content }
- }
- #[test]
- fn rln_is_root_valid_at_respects_drift_window() {
- // A root produced at timestamp T_R is valid for signals whose
- // timestamps fall within EVENT_TIME_DRIFT of T_R (in either
- // direction), and stays valid for as long as it remains the
- // live root (until the next event).
- smol::block_on(async {
- let eg = make_eg().await;
- let drift = crate::event_graph::EVENT_TIME_DRIFT;
- let t_r: u64 = 1_000_000;
- let commitment = pallas::Base::from(0xaaaa_u64);
- let node = RLNNode::Registration(commitment);
- let ev = synth_static_event(1, t_r, &node).await;
- let r = eg.apply_rln_static_event(&ev, &node).await.unwrap();
- // Within the drift window in both directions:
- assert!(eg.is_root_valid_at(&r, t_r).unwrap(), "valid at exactly T_R");
- assert!(eg.is_root_valid_at(&r, t_r + drift).unwrap(), "valid at T_R + drift");
- assert!(
- eg.is_root_valid_at(&r, t_r.saturating_sub(drift)).unwrap(),
- "valid at T_R - drift"
- );
- // Far in the future is also fine because R is still live
- // (no later event yet).
- assert!(
- eg.is_root_valid_at(&r, t_r + 1_000_000_000).unwrap(),
- "valid in the far future when no later event"
- );
- // Far before T_R - drift fails: signal claims a root that
- // didn't exist at signal time.
- let far_past = t_r.saturating_sub(2 * drift + 1);
- assert!(
- !eg.is_root_valid_at(&r, far_past).unwrap(),
- "should reject signal at far past - root didn't exist yet",
- );
- })
- }
- #[test]
- fn rln_slashed_identity_signal_rejection_lifecycle() {
- // Operational regression test for the full slashed-identity
- // lifecycle. This is the test that answers the question:
- // "After we slash an identity, how do we ensure their future
- // signals are rejected?"
- //
- // The defense is structural - there is no explicit deny-list
- // for slashed identities (RLN-V2's privacy guarantees prevent
- // the verifier from identifying signers). Instead, two
- // mechanisms work in concert:
- //
- // (a) The SMT mutation removes the slashed leaf, so post-slash
- // roots don't contain the slashed commitment. A
- // signal-membership proof can't be built against a
- // post-slash root.
- // (b) The historical-roots time-window check rejects pre-slash
- // roots after `T_slash + DRIFT`. So the slashed user
- // can't replay against their old root indefinitely.
- //
- // The DRIFT window of acceptance after slash is by design
- // (propagation tolerance, identical to every signal's window).
- //
- // This test walks through the timeline with synthetic events
- // and asserts the time-window check has the right shape.
- // Exercising the proof-verification side of (b) requires real
- // ZK keys and is left to the multi-node integration tests.
- smol::block_on(async {
- let eg = make_eg().await;
- let drift = crate::event_graph::EVENT_TIME_DRIFT;
- // The slashed identity's commitment.
- let user_commitment = pallas::Base::from(0xfeed_u64);
- // Timeline:
- // T0: register the user -> root R_reg
- // T_pre: send a normal signal (signal_time = T_pre)
- // T_slash: slash the user -> root R_slashed
- // T_amnesty: signal during DRIFT window (still claiming R_reg)
- // T_late: signal after DRIFT expires (still claiming R_reg)
- //
- // We don't care about real ZK proof verification here;
- // is_root_valid_at is the gate that runs before the proof
- // is even loaded. If is_root_valid_at says "yes" for
- // T_pre/T_amnesty and "no" for T_late, we've validated the
- // full structural defense from the verifier's perspective.
- let t0: u64 = 1_000_000;
- let t_slash: u64 = t0 + 100 * drift; // long after registration
- // Step 1: register the user.
- let reg_node = RLNNode::Registration(user_commitment);
- let ev_reg = synth_static_event(1, t0, ®_node).await;
- let r_reg = eg.apply_rln_static_event(&ev_reg, ®_node).await.unwrap();
- // Step 2: a normal signal at T_pre (mid-life of R_reg).
- // The user claims R_reg as their merkle root. Because R_reg
- // is the live root throughout [t0, t_slash), this signal
- // passes the root-window check.
- let t_pre = t0 + 50 * drift;
- assert!(
- eg.is_root_valid_at(&r_reg, t_pre).unwrap(),
- "pre-slash signal at T_pre claiming R_reg must be accepted (root-window check)",
- );
- // Step 3: slash the user.
- let slash_node = RLNNode::Slashing(user_commitment);
- let ev_slash = synth_static_event(2, t_slash, &slash_node).await;
- let r_slashed = eg.apply_rln_static_event(&ev_slash, &slash_node).await.unwrap();
- // Sanity: R_reg's live interval is now [t0, t_slash). The
- // post-slash root R_slashed is live from t_slash onward.
- assert_ne!(r_reg, r_slashed, "slash should change the SMT root");
- // Step 4: signal during the DRIFT amnesty window. The
- // slashed user's clock-aware proof claims R_reg with
- // timestamp T_amnesty = T_slash + DRIFT/2. Within the live
- // interval extended by drift, so accepted.
- //
- // This is intentional - every signal gets the same
- // propagation-tolerance window, and we'd rather accept a
- // few extra messages from a just-slashed user than reject
- // legitimate messages from a not-yet-aware-of-their-slash
- // user. The deeper defense is the rate-limit polynomial,
- // which catches reuse and triggers another slash if the
- // user tries to flood.
- let t_amnesty = t_slash + drift / 2;
- assert!(
- eg.is_root_valid_at(&r_reg, t_amnesty).unwrap(),
- "DRIFT amnesty: signal at T_slash + DRIFT/2 claiming R_reg should still pass \
- the root-window check (propagation tolerance, identical to every signal)",
- );
- // Step 5: signal after the DRIFT window expires. The
- // slashed user attempts to keep replaying their pre-slash
- // root. T_late = T_slash + 2*DRIFT - definitively outside
- // the window. Rejected.
- let t_late = t_slash + 2 * drift;
- assert!(
- !eg.is_root_valid_at(&r_reg, t_late).unwrap(),
- "post-DRIFT: signal at T_slash + 2*DRIFT claiming R_reg must be rejected - \
- this is the time-window check denying the slashed user further replays",
- );
- // Step 6: signal claiming the post-slash root R_slashed at
- // T_late. The root-window check passes (R_slashed is
- // currently live), but in real verification the ZK proof
- // would fail - the slashed commitment isn't a leaf in
- // R_slashed. We can't exercise that here without real
- // proofs, but we document the invariant: defense (a) (SMT
- // mutation) covers this case while defense (b)
- // (time-window) covers Step 5.
- assert!(
- eg.is_root_valid_at(&r_slashed, t_late).unwrap(),
- "post-slash root is current and accepted by the root-window check; \
- the proof would fail because the slashed commitment isn't a leaf - \
- but that's tested elsewhere with real ZK keys",
- );
- })
- }
- #[test]
- fn rln_canonical_order_produces_same_roots_regardless_of_apply_order() {
- // SMT roots are determined by the SET of leaves, not the
- // insertion order - but only the *final* root, not intermediates.
- // Our canonical-order requirement (sort by (layer, event_id))
- // ensures all nodes produce the same SEQUENCE of intermediate
- // roots when replaying the same set of events.
- smol::block_on(async {
- let eg_a = make_eg().await;
- let eg_b = make_eg().await;
- let c1 = pallas::Base::from(0x1111_u64);
- let c2 = pallas::Base::from(0x2222_u64);
- let n1 = RLNNode::Registration(c1);
- let n2 = RLNNode::Registration(c2);
- // Both events at the same layer (intentionally - to force
- // the event_id tie-breaker to determine canonical order).
- let ev1 = synth_static_event(1, 100_000, &n1).await;
- let ev2 = synth_static_event(1, 100_001, &n2).await;
- // Determine canonical order by event_id.
- let (first, first_node, second, second_node) = if ev1.id().as_bytes() < ev2.id().as_bytes()
- {
- (&ev1, &n1, &ev2, &n2)
- } else {
- (&ev2, &n2, &ev1, &n1)
- };
- // Node A: apply in canonical order (first, second).
- let a_root1 = eg_a.apply_rln_static_event(first, first_node).await.unwrap();
- let a_root2 = eg_a.apply_rln_static_event(second, second_node).await.unwrap();
- // Node B: apply in reverse, but for the test we want to
- // observe what happens IF a node naively applied in
- // received-order. So we deliberately call apply_ in the
- // wrong order. The bug we're guarding against is "if you
- // bypass canonical-sort, you get different intermediate roots".
- let b_root1_wrong = eg_b.apply_rln_static_event(second, second_node).await.unwrap();
- let b_root2 = eg_b.apply_rln_static_event(first, first_node).await.unwrap();
- // Final roots match (SMT is set-determined).
- assert_eq!(a_root2, b_root2, "final roots must match after applying same set");
- // Intermediate roots DIFFER if not canonically ordered.
- // This is the negative result that motivates the canonical
- // sort in static_sync.
- assert_ne!(
- a_root1, b_root1_wrong,
- "intermediate roots should differ when apply order isn't canonical - \
- this asserts the property that motivates static_sync's canonical sort",
- );
- // Both nodes' historical-roots tables should be queryable
- // for their respective intermediate roots at the relevant
- // timestamps:
- assert!(eg_a.is_root_valid_at(&a_root1, 100_000).unwrap());
- assert!(eg_b.is_root_valid_at(&b_root1_wrong, 100_001).unwrap());
- // But cross-node lookup fails - node B doesn't recognize
- // a_root1 because it never produced that root.
- assert!(
- !eg_b.is_root_valid_at(&a_root1, 100_000).unwrap(),
- "node B never produced a_root1 - wrong-order apply diverges from canonical",
- );
- })
- }
- #[test]
- fn rln_rebuild_historical_roots() {
- smol::block_on(async {
- let eg = make_eg().await;
- let c1 = pallas::Base::from(0x3333_u64);
- let c2 = pallas::Base::from(0x4444_u64);
- let n1 = RLNNode::Registration(c1);
- let n2 = RLNNode::Registration(c2);
- let ev1 = synth_static_event(1, 100_000, &n1).await;
- let ev2 = synth_static_event(2, 100_001, &n2).await;
- let r1 = eg.apply_rln_static_event(&ev1, &n1).await.unwrap();
- eg.static_insert(&ev1).await.unwrap();
- let r2 = eg.apply_rln_static_event(&ev2, &n2).await.unwrap();
- eg.static_insert(&ev2).await.unwrap();
- // (b) Run rebuild on a consistent state - should be a no-op.
- let before = eg.rln_historical_roots_ordered.len();
- eg.rebuild_historical_roots_if_needed().await.unwrap();
- assert_eq!(eg.rln_historical_roots_ordered.len(), before, "no-op when consistent");
- // (a) Wipe tables and rebuild.
- eg.rln_historical_roots_ordered.clear().unwrap();
- eg.rln_historical_roots_by_value.clear().unwrap();
- assert!(!eg.is_root_valid_at(&r1, 100_000).unwrap(), "precondition: cleared");
- eg.rebuild_historical_roots_if_needed().await.unwrap();
- assert!(eg.is_root_valid_at(&r1, 100_000).unwrap(), "rebuild restored r1");
- assert!(eg.is_root_valid_at(&r2, 100_001).unwrap(), "rebuild restored r2");
- assert_eq!(eg.rln_historical_roots_ordered.len(), 2, "exactly one entry per static event",);
- })
- }
- #[test]
- fn rln_perf_signal_verify() {
- use std::time::Instant;
- smol::block_on(async {
- let (eg, mut id) = fresh_identity_and_eg().await;
- id.user_message_limit = 50; // enough headroom
- id.register_directly(&eg).await.unwrap();
- // Warm up the verifier (first call may pay one-shot setup costs).
- let event = make_static_event(b"static-event-9", &eg).await;
- let mid = id.next_message_id(event.header.timestamp).expect("budget");
- let blob = id.create_signal(&event, mid, &eg).await.unwrap();
- let _ = eg.rln_verify_signal(&event, &serialize_async(&blob).await).await;
- // Time signal proof CONSTRUCTION (the user-side cost).
- let n_construct = 10;
- let start = Instant::now();
- let mut blobs = vec![];
- for _ in 0..n_construct {
- let event = make_static_event(b"static-event-10", &eg).await;
- let mid = id.next_message_id(event.header.timestamp).expect("budget");
- let blob = id.create_signal(&event, mid, &eg).await.unwrap();
- blobs.push((event, serialize_async(&blob).await));
- }
- let construct_ms = start.elapsed().as_millis() as f64 / n_construct as f64;
- // Time signal proof VERIFICATION (the server-side cost,
- // which is what bottlenecks high-throughput nodes).
- let start = Instant::now();
- for (ev, bytes) in &blobs {
- let _ = eg.rln_verify_signal(ev, bytes).await;
- }
- let verify_ms = start.elapsed().as_millis() as f64 / n_construct as f64;
- eprintln!(
- "[RLN perf] construct: {construct_ms:.2} ms/proof; \
- verify: {verify_ms:.2} ms/proof"
- );
- })
- }
|