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.
mops add sybil-signals
import Sybil "mo:sybil-signals";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.
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.
| 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.
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.
- 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;
burstcatches the fast case, a rate limit catches the rest. - No verdict.
isAutomatedis 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.
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.
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
MIT.
Built by Mingle Berry Media.