Skip to content

About

Tell one operator behind many accounts apart from real people, inside your canister. Behavioural signals only, no identity check, no payment, no verdict.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sybil-signals

Tell one operator behind many accounts apart from real people, inside your canister.

This is not a payment gate and not an identity check. It reads no passport, makes no outcall, and returns no verdict. It watches how accounts write and hands you signals and a score. What you do with that — charge, throttle, hide, ignore — stays on your side, where it belongs.

Install

mops add sybil-signals
import Sybil "mo:sybil-signals";

Why the obvious detector fails

These numbers come from a real incident on a public forum on the Internet Computer, 27 July 2026. 2000 posts in 1.6 hours, from 57 accounts. Counted out, it looked like this:

  • 90 text templates, one of them used by 51 different accounts
  • 1999 of 2000 posts opened with a [Name] prefix
  • posting interval 154 s ± 128 s — deliberately jittered

The third line is the one that matters. Everyone's first instinct is to look for machine-like regularity. Whoever ran this fleet had already thought of that. What they could not randomise is the thing the campaign existed to deliver: the text. Change it and there is no campaign left.

So this library looks at how many different accounts used the same wording, not at how often one account repeated it. In that incident each account only posted a handful of times. No single account stood out. The distribution across accounts did.

Use

import Sybil "mo:sybil-signals";
import Time "mo:base/Time";

persistent actor Forum {
  var sybil = Sybil.empty();

  public shared ({ caller }) func post(text : Text) : async () {
    if (Sybil.isAutomated(sybil, Sybil.defaults, caller)) {
      // charge, throttle, queue for review — your call
    };

    // … actually store the post …

    sybil := Sybil.observe(sybil, Sybil.defaults, caller, text, Time.now());
  };

  public query func why(who : Principal) : async {
    score : Nat; automated : Bool; signals : ?Sybil.Signals
  } {
    Sybil.report(sybil, Sybil.defaults, who);
  };
}

Call observe only once the post really exists. If you call it on rejected attempts, failed tries drive the score for something that never happened.

What it measures

Signal What it is Weight
shared_ how many different other accounts used one of your wordings up to 55
burst most posts within one window up to 40
persona share of posts opening with a [Name] prefix up to 20

Text is reduced to a fingerprint before comparing: case, links, a leading [Name] and everything that is not a letter are dropped, then the first 60 letters are hashed. The same template with a different persona and a different tracking link lands on the same value. That is the whole trick.

Two independent tells, or nothing

No single signal crosses the default threshold of 60. That is deliberate, and it costs sensitivity.

Shared text on its own proves nothing. People share text. They forward, they quote the same announcement, they paste the same link about the same event. Anyone who fires at "five accounts, one wording" will eventually hit a news cycle instead of a fleet. Only when the form agrees as well — persona prefixes throughout, or a rate no hand achieves — does it become a case worth acting on.

The reverse follows: a fleet that writes without a recognisable form gets through. That is the trade. Better to miss a few than to bill people.

There is a test for exactly this. Seven accounts posting an identical announcement raise no alarm; the same seven posting a shared template with persona prefixes do.

What it does not do

  • No identity. It says nothing about who someone is, only about how the writing behaves.
  • No single-account flooding. One account posting the same thing forever is not a sybil problem; burst catches the fast case, a rate limit catches the rest.
  • No verdict. isAutomated is a threshold over a score you can read, override and explain. Keep an override on your side — false positives hit real people, and they deserve a way out that is one call, not a support ticket.
  • No memory of what it forgot. forget(state, who) removes an account completely, for deletion requests and for the case where an assessment was simply wrong.

Cost and growth

observe walks the text once and the ring once. The ring holds the last 400 fingerprints by default and is the only structure that grows independently of your account count — everything else is one small record per account that has ever posted.

The knobs are in Sybil.Config: window length, how long a template echoes, ring size, threshold, fingerprint length, burst limit. Sybil.defaults suits a forum with humans in it.

Composing

This library decides who. It does not decide what happens next. It sits in front of whatever you already use:

  • x402-gate — charge crawlers in USDC over HTTP 402
  • ic402 — payments and marketplaces on ICP, ICRC and EVM
  • or your own ICRC-2 call, or a plain rejection

License

MIT.

Built by Mingle Berry Media.

About

Tell one operator behind many accounts apart from real people, inside your canister. Behavioural signals only, no identity check, no payment, no verdict.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages