forked from yvv/decision
v3 : vote par la chaîne, corrections de la relecture de 74f96be (serveur)
- adresse : refusée au-delà de 64 caractères avant tout décodage (le base58
coûte le carré de la longueur : 80 000 caractères prenaient 38 s) ;
- tâche de fond : 50 bulletins par passe, le moins récemment lu d'abord ;
lu une dernière fois à son échéance, une heure de grâce si l'indexeur se tait ;
- plafond de 20 par identité : en attente, plus lâchés depuis une heure ;
purge des lâchés après un jour, des expirés après une semaine ;
- bulletin Ğecko : seul un commentaire payé le tranche, une copie nue s'ignore ;
- finalité : seul un bloc ≤ squidStatus.height compte ; recherche filtrée
(auteur lié, ou virement vers le compte des votes) et paginée par 50 ;
- « ton vote » : un bulletin en attente plus ancien qu'un vote confirmé se tait ;
- reçu (GET /api/ballots/{id}/receipt) et historique (GET /api/ballots),
pour qui l'a demandé seulement ;
- un nom vu seulement par un vote sur la chaîne reste libre pour un lien d'équipe ;
- totaux affichés isolés (shown_tally) pour une publication par paliers.
Les sondes de la relecture deviennent des tests, vus rouges avant le correctif.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,12 @@ struct Chain {
|
||||
comments: Vec<Value>,
|
||||
silent: bool,
|
||||
asked: usize,
|
||||
/// The last final block, served as `squidStatus.height`; `None`: every block is final.
|
||||
finalized: Option<u32>,
|
||||
/// An indexer that ignores the filter it is given (the hash only holds).
|
||||
lax: bool,
|
||||
/// The filters the server asked with, in order.
|
||||
filters: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -49,21 +55,66 @@ struct Signer<'a> {
|
||||
identity: Option<(u32, &'a str, bool)>,
|
||||
}
|
||||
|
||||
/// What the real indexer's `filter` keeps, for the two filters the server may ask.
|
||||
fn kept_by(node: &Value, filter: &Value) -> bool {
|
||||
if let Some(author) = filter["authorId"]["equalTo"].as_str()
|
||||
&& node["author"]["id"] != author
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let some = &filter["transfersByCommentId"]["some"];
|
||||
if some.is_null() {
|
||||
return true;
|
||||
}
|
||||
let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0);
|
||||
node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| {
|
||||
some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to)
|
||||
&& t["amount"].as_str().and_then(|a| a.parse::<u64>().ok()).is_some_and(|a| a >= min)
|
||||
})
|
||||
}
|
||||
|
||||
/// The size of a page: `first: N` in the query, as the server writes it.
|
||||
fn page_size(query: &str) -> usize {
|
||||
query
|
||||
.split("first:")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next())
|
||||
.and_then(|n| n.parse().ok())
|
||||
.unwrap_or(100)
|
||||
}
|
||||
|
||||
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
||||
let mut c = chain.lock().expect("lock");
|
||||
c.asked += 1;
|
||||
if c.silent {
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
let hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
|
||||
let vars = &body["variables"];
|
||||
let hash = vars["hash"].as_str().unwrap_or_default().to_owned();
|
||||
let filter = vars["filter"].clone();
|
||||
c.filters.push(filter.clone());
|
||||
let mut nodes: Vec<Value> = c
|
||||
.comments
|
||||
.iter()
|
||||
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
|
||||
.filter(|n| c.lax || kept_by(n, &filter))
|
||||
.cloned()
|
||||
.collect();
|
||||
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
|
||||
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
|
||||
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
|
||||
// Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one.
|
||||
let start: usize =
|
||||
vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0);
|
||||
let page: Vec<Value> =
|
||||
nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect();
|
||||
let end = start + page.len();
|
||||
Json(json!({ "data": {
|
||||
"squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) },
|
||||
"txComments": {
|
||||
"pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") },
|
||||
"nodes": page,
|
||||
},
|
||||
} }))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
impl Indexer {
|
||||
@@ -103,6 +154,19 @@ impl Indexer {
|
||||
fn asked(&self) -> usize {
|
||||
self.chain.lock().expect("lock").asked
|
||||
}
|
||||
|
||||
/// The chain is final up to this block.
|
||||
fn finalized(&self, block: Option<u32>) {
|
||||
self.chain.lock().expect("lock").finalized = block;
|
||||
}
|
||||
|
||||
fn lax(&self, on: bool) {
|
||||
self.chain.lock().expect("lock").lax = on;
|
||||
}
|
||||
|
||||
fn filters(&self) -> Vec<Value> {
|
||||
self.chain.lock().expect("lock").filters.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn alice() -> Signer<'static> {
|
||||
@@ -138,6 +202,16 @@ impl Client {
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let (status, _, value) = self.call_with_headers(method, uri, body).await;
|
||||
(status, value)
|
||||
}
|
||||
|
||||
async fn call_with_headers(
|
||||
&mut self,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||
if let Some(c) = &self.cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
@@ -155,8 +229,9 @@ impl Client {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let headers = res.headers().clone();
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||
@@ -169,6 +244,10 @@ impl Client {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
||||
}
|
||||
|
||||
async fn login_admin(&mut self, name: &str) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await;
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
@@ -396,17 +475,22 @@ async fn a_wrong_amount_or_recipient_is_refused() {
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
|
||||
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
||||
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
|
||||
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
|
||||
// Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free.
|
||||
let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
for b in [&short, &elsewhere, &remark_only] {
|
||||
let s = c.status(b).await;
|
||||
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
||||
}
|
||||
assert_eq!(c.status(&gecko).await["status"], "pending");
|
||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
||||
}
|
||||
|
||||
@@ -559,8 +643,10 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
|
||||
// Read one last time at its end: nothing on the chain, it is let go.
|
||||
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
|
||||
assert_eq!(c.status(&b).await["status"], "expired");
|
||||
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
|
||||
// Seen on the chain afterwards: too late, it no longer counts.
|
||||
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
@@ -668,6 +754,391 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
||||
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// After the independent review of 74f96be (lot B2): its probes, kept as tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn db_status(state: &AppState, id: &str) -> (String, Option<String>) {
|
||||
sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row")
|
||||
}
|
||||
|
||||
fn id_of(b: &Value) -> &str {
|
||||
b["id"].as_str().expect("id")
|
||||
}
|
||||
|
||||
fn comment_of(b: &Value) -> &str {
|
||||
b["comment"].as_str().expect("comment")
|
||||
}
|
||||
|
||||
async fn age(state: &AppState, set: &str, seconds: i64, id: &str) {
|
||||
sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?"))
|
||||
.bind(ld_server::util::in_seconds(seconds))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
}
|
||||
|
||||
/// P5: the `author` field was decoded in a time that grows as the square of its
|
||||
/// length, on a thread of the server (250 000 characters: 32 s).
|
||||
#[tokio::test]
|
||||
async fn a_long_author_is_refused_at_once() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
// The body may hold 256 KiB: an author of 250 000 characters passes it.
|
||||
for n in [80_000usize, 250_000] {
|
||||
let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) });
|
||||
let t = std::time::Instant::now();
|
||||
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author")));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
/// P1: the background task read the same 50 oldest pending ballots at every pass;
|
||||
/// a vote paid behind them was never confirmed, then expired.
|
||||
#[tokio::test]
|
||||
async fn the_background_task_reaches_every_pending_ballot() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
||||
a.login("Anna").await;
|
||||
let doc = a.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let mut b = a.other();
|
||||
b.login("Bruno").await;
|
||||
let mut c = a.other();
|
||||
c.login("Carla").await;
|
||||
for _ in 0..20 {
|
||||
a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
b.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
}
|
||||
let mut dora = a.other();
|
||||
dora.login("Dora").await;
|
||||
let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||
indexer.pay(100, comment_of(&mine), alice());
|
||||
// 51 pending, at most 50 read per pass: the least recently read come first.
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed");
|
||||
let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("n");
|
||||
assert_eq!(unread, 0, "every pending ballot was read");
|
||||
}
|
||||
|
||||
/// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines.
|
||||
#[tokio::test]
|
||||
async fn letting_ballots_go_does_not_lift_the_cap() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
||||
a.login("Anna").await;
|
||||
let doc = a.doc("monnaie-libre").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
for _ in 0..20 {
|
||||
let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await;
|
||||
}
|
||||
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
|
||||
let url = format!("/api/docs/{doc_id}/ballots");
|
||||
let (s, v) = a.call("POST", &url, Some(one.clone())).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
|
||||
// The cap holds for the identity, whatever the session.
|
||||
let mut again = a.other();
|
||||
again.login("Anna").await;
|
||||
assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT);
|
||||
// An hour later, there is room again.
|
||||
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
||||
.bind(ld_server::util::in_seconds(-3601))
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
// Let go more than a day ago: purged, lines and all; the pending one stays.
|
||||
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
||||
.bind(ld_server::util::in_seconds(-(24 * 3600 + 1)))
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let count = || async {
|
||||
sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("n")
|
||||
};
|
||||
assert_eq!(count().await, (1, 1));
|
||||
// Not seen on the chain in time: it says so under the article for a week, then it is purged.
|
||||
age(&state, "expires_at", -3601 - 5, id_of(&kept)).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired");
|
||||
age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(count().await, (1, 1));
|
||||
age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(count().await, (0, 0));
|
||||
}
|
||||
|
||||
/// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good.
|
||||
#[tokio::test]
|
||||
async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() {
|
||||
let indexer = Indexer::start().await;
|
||||
// An indexer that does not filter: the rule of the engine holds alone.
|
||||
indexer.lax(true);
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]);
|
||||
indexer.add(
|
||||
99,
|
||||
2,
|
||||
comment_of(&b),
|
||||
Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) },
|
||||
&[(VOTE_ACCOUNT, 9)],
|
||||
);
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing");
|
||||
indexer.pay(100, comment_of(&b), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let s = c.status(&b).await;
|
||||
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
||||
}
|
||||
|
||||
/// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes
|
||||
/// an unbound ballot — its lines count for the one who paid.
|
||||
#[tokio::test]
|
||||
async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
||||
indexer.pay(100, comment_of(&b), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let s = c.status(&b).await;
|
||||
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie")));
|
||||
let voters: Vec<String> =
|
||||
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
|
||||
assert_eq!(voters, ["idty:7"]);
|
||||
}
|
||||
|
||||
/// Point 5 of the review: a pending ballot older than a confirmed vote on the same
|
||||
/// line no longer shows under the article.
|
||||
#[tokio::test]
|
||||
async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
// A Ğecko code shown, never paid; then a vote from the wallet, confirmed.
|
||||
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||
let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
indexer.pay(100, comment_of(&newer), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
assert_eq!(find(&d, &i1)["myVote"], "for");
|
||||
assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]);
|
||||
let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
||||
assert!(p["proposal"]["myBallot"].is_null());
|
||||
// A ballot asked after the vote shows, waiting.
|
||||
let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
|
||||
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]);
|
||||
}
|
||||
|
||||
/// Point 6: only a comment in a final block counts, or refuses.
|
||||
#[tokio::test]
|
||||
async fn a_comment_counts_once_its_block_is_final() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await;
|
||||
indexer.finalized(Some(99));
|
||||
indexer.pay(100, comment_of(&b), alice());
|
||||
indexer.add(
|
||||
100,
|
||||
2,
|
||||
comment_of(&r),
|
||||
Signer { account: BOB, identity: Some((42, "Bob", false)) },
|
||||
&[(VOTE_ACCOUNT, 10)],
|
||||
);
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
|
||||
assert_eq!(db_status(&state, id_of(&r)).await.0, "pending");
|
||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
||||
indexer.finalized(Some(100));
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||
assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into())));
|
||||
}
|
||||
|
||||
/// Point 6: the search by hash went no further than 20 comments; copies could hide the real one.
|
||||
#[tokio::test]
|
||||
async fn copies_never_hide_the_real_comment() {
|
||||
let indexer = Indexer::start().await;
|
||||
// An indexer that would not filter: pages, one after the other.
|
||||
indexer.lax(true);
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||
for n in 0..120 {
|
||||
indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
||||
}
|
||||
indexer.pay(300, comment_of(&b), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let s = c.status(&b).await;
|
||||
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
||||
// The indexer is asked for what may settle: the comments of the bound account;
|
||||
// for a Ğecko code, those paid to the vote account.
|
||||
indexer.lax(false);
|
||||
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let filters = indexer.filters();
|
||||
assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}");
|
||||
assert!(
|
||||
filters.iter().any(|f| {
|
||||
let some = &f["transfersByCommentId"]["some"];
|
||||
some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10"
|
||||
}),
|
||||
"{filters:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Point 5: a Ğecko code keeps its ballot; the background task settles it, the
|
||||
/// page closed — even paid at the last moment, read once more at its end.
|
||||
#[tokio::test]
|
||||
async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
age(&state, "expires_at", -5, id_of(&b)).await;
|
||||
indexer.pay(100, comment_of(&b), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||
// Its end come while no indexer answers: kept and read again, for an hour at most.
|
||||
let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||
age(&state, "expires_at", -5, id_of(&late)).await;
|
||||
indexer.silent(true);
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&late)).await.0, "pending");
|
||||
age(&state, "expires_at", -3601, id_of(&late)).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
||||
}
|
||||
|
||||
/// Point 7: the research note promises everyone can check their own vote with its
|
||||
/// salt. The receipt goes to the one who asked, never to anyone else.
|
||||
#[tokio::test]
|
||||
async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0"));
|
||||
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
|
||||
indexer.pay(100, comment_of(&b), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let url = format!("/api/ballots/{}/receipt", id_of(&b));
|
||||
let (s, headers, r) = c.call_with_headers("GET", &url, None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default();
|
||||
assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}");
|
||||
assert_eq!(
|
||||
(r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()),
|
||||
(Some("ld1"), Some("confirmed"), Some(100), Some("Alice"))
|
||||
);
|
||||
assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE)));
|
||||
// It holds: its lines and its salt seal the remark the chain carries.
|
||||
let lines: Vec<ld_engine::ballot::Line> = r["lines"]
|
||||
.as_array()
|
||||
.expect("lines")
|
||||
.iter()
|
||||
.map(|l| ld_engine::ballot::Line {
|
||||
proposal_id: l["proposalId"].as_str().expect("p").into(),
|
||||
revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"),
|
||||
choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
let doc_id = r["doc"].as_str().expect("doc");
|
||||
let salt = r["salt"].as_str().expect("salt");
|
||||
let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal");
|
||||
assert_eq!(
|
||||
(sealed.comment.as_str(), sealed.remark_hash.as_str()),
|
||||
(comment_of(&b), r["remarkHash"].as_str().expect("h"))
|
||||
);
|
||||
assert_eq!(
|
||||
r["canonical"].as_str(),
|
||||
Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str())
|
||||
);
|
||||
// Each line says which article it is.
|
||||
assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1"));
|
||||
// In her history, never in anyone else's.
|
||||
let mine = c.ok("GET", "/api/ballots", None).await;
|
||||
assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2)));
|
||||
assert_eq!(mine["ballots"][0]["status"], "confirmed");
|
||||
let mut yvv = c.other();
|
||||
yvv.login("Yvv").await;
|
||||
assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([]));
|
||||
let mut nobody = c.other();
|
||||
assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/// Point 8: an identity known only from a vote on the chain does not hold its name
|
||||
/// against a person who joins by a team link; a Ğ1 identity that signed in does.
|
||||
#[tokio::test]
|
||||
async fn a_name_known_only_from_a_chain_vote_stays_free() {
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut admin, state) = Client::new(chain_config(&indexer)).await;
|
||||
admin.login_admin("Yvv").await;
|
||||
let doc = admin.doc("monnaie-libre").await;
|
||||
let i1 = version(&doc, "I1", "inForce");
|
||||
for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })]
|
||||
{
|
||||
let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
indexer.pay(block, comment_of(&b), signer);
|
||||
}
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
// Charlie has since signed in with his Ğ1 identity.
|
||||
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'")
|
||||
.bind(ld_server::util::now())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("seen");
|
||||
let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 });
|
||||
let v = admin.ok("POST", "/api/access/team-links", Some(link)).await;
|
||||
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||
let mut charlie = admin.other();
|
||||
let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||
let mut alice = admin.other();
|
||||
let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Without the chain, and in managed groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+483
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"data": {
|
||||
"squidStatus": {
|
||||
"height": 3407492
|
||||
},
|
||||
"txComments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "0000196944-60692-000003",
|
||||
@@ -85,6 +92,482 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000821130-de04d-000008",
|
||||
"blockNumber": 821130,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"identity": {
|
||||
"index": 3947,
|
||||
"name": "Jade971",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001281169-c9002-000006",
|
||||
"blockNumber": 1281169,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"identity": {
|
||||
"index": 7210,
|
||||
"name": "Tell",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq",
|
||||
"amount": "18000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001303965-1b924-000006",
|
||||
"blockNumber": 1303965,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"identity": {
|
||||
"index": 13370,
|
||||
"name": "Florence76",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J",
|
||||
"amount": "1500"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810083-37092-000004",
|
||||
"blockNumber": 1810083,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK",
|
||||
"amount": "58900"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810417-7e2d8-000003",
|
||||
"blockNumber": 1810417,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001850707-782ab-000003",
|
||||
"blockNumber": 1850707,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"identity": {
|
||||
"index": 13722,
|
||||
"name": "LibrA",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb",
|
||||
"amount": "70680"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002197191-e9abb-000006",
|
||||
"blockNumber": 2197191,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"identity": {
|
||||
"index": 17203,
|
||||
"name": "Elodietexier",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn",
|
||||
"amount": "3534"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002727091-bd26d-000003",
|
||||
"blockNumber": 2727091,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"identity": {
|
||||
"index": 18509,
|
||||
"name": "RobBiovoix",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002825411-05f96-000003",
|
||||
"blockNumber": 2825411,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"identity": {
|
||||
"index": 18506,
|
||||
"name": "BrunoAlmeida",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002939378-e9fef-000006",
|
||||
"blockNumber": 2939378,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002945277-0f2cb-000003",
|
||||
"blockNumber": 2945277,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003114157-3fc38-000006",
|
||||
"blockNumber": 3114157,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB",
|
||||
"amount": "4000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003124959-a6247-000003",
|
||||
"blockNumber": 3124959,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"identity": {
|
||||
"index": 7377,
|
||||
"name": "Sylvaine",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003128170-4521a-000003",
|
||||
"blockNumber": 3128170,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"identity": {
|
||||
"index": 8912,
|
||||
"name": "Altheaagathe",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK",
|
||||
"amount": "40000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003226425-d15c3-000009",
|
||||
"blockNumber": 3226425,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "14604"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003227886-5270d-000003",
|
||||
"blockNumber": 3227886,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"identity": {
|
||||
"index": 16572,
|
||||
"name": "zola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx",
|
||||
"amount": "6000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003229458-31a61-000006",
|
||||
"blockNumber": 3229458,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "12170"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003297346-087b4-000006",
|
||||
"blockNumber": 3297346,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003360722-61edb-000006",
|
||||
"blockNumber": 3360722,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"identity": {
|
||||
"index": 14694,
|
||||
"name": "Coca-Cola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv",
|
||||
"amount": "6085"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003384655-8fbc0-000003",
|
||||
"blockNumber": 3384655,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"identity": {
|
||||
"index": 4018,
|
||||
"name": "GeraldineGarrigues",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399415-51dbe-000003",
|
||||
"blockNumber": 3399415,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399566-d43e7-000003",
|
||||
"blockNumber": 3399566,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"identity": {
|
||||
"index": 18630,
|
||||
"name": "moidann12",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user