<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yuval Abu</title>
    <description>The latest articles on DEV Community by Yuval Abu (@yuval_abu_2de44f01a021b59).</description>
    <link>https://dev.to/yuval_abu_2de44f01a021b59</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4124591%2F41a9f55e-41e5-4eee-a207-fbeba189f8be.jpg</url>
      <title>DEV Community: Yuval Abu</title>
      <link>https://dev.to/yuval_abu_2de44f01a021b59</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuval_abu_2de44f01a021b59"/>
    <language>en</language>
    <item>
      <title>We built a prize draw engine where you never have to trust us. Here is how</title>
      <dc:creator>Yuval Abu</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:54:27 +0000</pubDate>
      <link>https://dev.to/yuval_abu_2de44f01a021b59/we-built-a-prize-draw-engine-where-you-never-have-to-trust-us-here-is-how-1g6h</link>
      <guid>https://dev.to/yuval_abu_2de44f01a021b59/we-built-a-prize-draw-engine-where-you-never-have-to-trust-us-here-is-how-1g6h</guid>
      <description>&lt;h2&gt;
  
  
  The problem with "trust me, I picked randomly"
&lt;/h2&gt;

&lt;p&gt;Every online prize draw has the same weak point. Someone announces a winner, and the only evidence is their word. A spinning wheel, a screen recording, a spreadsheet with a random formula: none of it can be checked after the fact. You can re-run a wheel as many times as you like until it lands on the name you want, and nobody watching ever sees the attempts that did not make the cut.&lt;/p&gt;

&lt;p&gt;We spent a while building DrawSeal, a platform for running prize draws where the winner is not just announced, it is provably the outcome of a process fixed in advance. Not "fixed and then revealed", which is what most tools with a random seed already do. Fixed &lt;strong&gt;before the randomness that decides it exists&lt;/strong&gt;, in a way any third party can check without asking us anything.&lt;/p&gt;

&lt;p&gt;This post is about the actual mechanism, not the product pitch. The whole engine that computes winners is open source (Apache-2.0), published as &lt;a href="https://www.npmjs.com/package/@drawseal-com/verify" rel="noopener noreferrer"&gt;&lt;code&gt;@drawseal-com/verify&lt;/code&gt;&lt;/a&gt; on npm and mirrored at &lt;a href="https://github.com/drawseal/verify" rel="noopener noreferrer"&gt;github.com/drawseal/verify&lt;/a&gt;. If you want to skip the explanation and just read the code, that is the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three commitments, in a specific order
&lt;/h2&gt;

&lt;p&gt;A fair draw needs three things fixed before the result exists, and the order between them matters as much as the values themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The server seed hash.&lt;/strong&gt; Before a draw is even scheduled, DrawSeal generates a 32 byte random seed and publishes &lt;code&gt;sha256(serverSeed)&lt;/code&gt;. The seed itself stays secret until after the draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The drand round.&lt;/strong&gt; DrawSeal commits to a specific round number of &lt;a href="https://drand.love" rel="noopener noreferrer"&gt;drand&lt;/a&gt;, a public randomness beacon run by an independent consortium (Cloudflare, EPFL, Protocol Labs and others). The round is scheduled far enough in the future that its randomness genuinely does not exist yet at commitment time. Drand publishes a new, unpredictable, publicly verifiable random value every round, signed with BLS threshold signatures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The participant pool seal.&lt;/strong&gt; At registration close, the participant list is reduced to a Merkle root and published, along with the pool size. This is the one people forget. Without it, the first two commitments guarantee nothing: someone could still add or remove entries after seeing which drand round will decide the draw, without contradicting either published hash.&lt;/p&gt;

&lt;p&gt;The strict rule enforced in code, &lt;code&gt;sealRound &amp;lt; drandRound&lt;/code&gt;, is a plain integer comparison anyone can redo with the published values alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sealPrecedesRandomness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sealRound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;drandRound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sealRound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;drandRound&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sealRound&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;drandRound&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put together: by the time the participant list is frozen, the randomness that will decide the winner is not yet knowable by anyone, including us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the winner, deterministically
&lt;/h2&gt;

&lt;p&gt;Once the drand round fires and the server seed is revealed, computing the winner is pure arithmetic, no server call, no hidden state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeRankFinalHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;serverSeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;drandValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;participantsFingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;serverSeed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;drandValue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;participantsFingerprint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hash then selects a participant from the sealed pool, weighted by entry count, using a cumulative interval walk over &lt;code&gt;BigInt&lt;/code&gt; (a &lt;code&gt;Number&lt;/code&gt; sum would lose precision past 2^53 and could shift the selected interval by a hair, which is not acceptable when the winner has to be exact, not merely probable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;finalHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cumulative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;participant&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cumulative&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;participant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cumulative&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;participant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The modulo bias here is negligible: with a 256 bit hash, it is bounded by &lt;code&gt;total / 2^256&lt;/code&gt;. For multi-winner draws, the same rank formula runs in a cascade: derive the hash for rank 1, pick, remove that participant from the pool, derive the hash for rank 2 on the reduced pool, and so on. Fully deterministic, fully replayable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subtle attack this design has to defeat
&lt;/h2&gt;

&lt;p&gt;Here is the part that took the most thought, and the reason "the drand signature is valid" is not, by itself, a sufficient check.&lt;/p&gt;

&lt;p&gt;A valid BLS signature on a drand beacon only proves the beacon genuinely came from the drand network. It does &lt;strong&gt;not&lt;/strong&gt; prove it is the specific round the platform committed to. Imagine an operator with write access to the database: they could leave &lt;code&gt;serverSeed&lt;/code&gt; and its published hash untouched (so that commitment still checks out), and simply substitute the randomness for that of a &lt;em&gt;different, already-past&lt;/em&gt; drand round, chosen after the fact so the computation lands on whoever they want. Every other check would still pass.&lt;/p&gt;

&lt;p&gt;What closes that hole is checking round number &lt;strong&gt;and&lt;/strong&gt; randomness value together against the original commitment, not signature validity alone. That is what the verifier's &lt;code&gt;beaconOk&lt;/code&gt; check does, and it is the one control that makes the other six meaningless without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it yourself, not just reading about it
&lt;/h2&gt;

&lt;p&gt;The point of all this falls apart if verification requires trusting the same company that ran the draw. So the checker is a separate, standalone package, not an endpoint on our servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyDraw&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@drawseal-com/verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyDraw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;commitment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// published BEFORE the draw: seed hash + drand round&lt;/span&gt;
  &lt;span class="nx"&gt;seal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// published at registration close: Merkle root + round&lt;/span&gt;
  &lt;span class="nx"&gt;beacon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// the drand beacon, revealed after&lt;/span&gt;
  &lt;span class="nx"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// seed, randomness, participants, winner count&lt;/span&gt;
  &lt;span class="nx"&gt;claimedWinners&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MISMATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs seven checks (seed hash, pool seal, seal ordering, beacon authenticity and identity, recomputed winners, recomputed redraws, and the raw recomputed values to compare against external witnesses yourself), and &lt;code&gt;ok&lt;/code&gt; is only true if all seven are. It never throws on a mismatch either, a mismatch is a result to display, not an exception to catch.&lt;/p&gt;

&lt;p&gt;It is isomorphic (Node and browser), and deliberately has only two dependencies, both from the same author: &lt;code&gt;@noble/hashes&lt;/code&gt; and &lt;code&gt;@noble/curves&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more design choice worth mentioning: the verifier refuses to conclude anything when it is asked to check a document produced by a newer algorithm version than the one it supports. A verifier that silently applies stale rules to a document it does not actually understand would compute a different winner and report "fraud" on a perfectly honest draw, which is worse than having no verifier at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Witnesses outside our own control
&lt;/h2&gt;

&lt;p&gt;Open sourcing the checker still leaves one gap: what stops someone from publishing a &lt;em&gt;different&lt;/em&gt; npm package under a similar name that always says "valid"? Two answers. First, only the &lt;code&gt;@drawseal-com&lt;/code&gt; npm scope and &lt;code&gt;github.com/drawseal/verify&lt;/code&gt; are ours (there is an unrelated, unscoped &lt;code&gt;drawseal&lt;/code&gt; package on npm since 2018, a Chinese seal-drawing library with nothing to do with any of this, which is exactly why we scoped ours). Second, and more importantly, every draw is also anchored outside anything we control: an OpenTimestamps proof in the Bitcoin chain for pure antecedence, and a mirror in a public repo (one file per draw, full history) so the published document itself cannot be quietly edited after the fact without it showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to look
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verifier source and README: &lt;a href="https://github.com/drawseal/verify" rel="noopener noreferrer"&gt;github.com/drawseal/verify&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package: &lt;a href="https://www.npmjs.com/package/@drawseal-com/verify" rel="noopener noreferrer"&gt;npmjs.com/package/@drawseal-com/verify&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drand: &lt;a href="https://drand.love" rel="noopener noreferrer"&gt;drand.love&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions on the design in the comments, including the parts I glossed over here (redraws on unclaimed prizes, the anti-fraud filtering that runs before the pool is sealed, or the Merkle proof each participant gets so they can check their own entry was counted without downloading the whole list).&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
