<?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: Pentine Pejay</title>
    <description>The latest articles on DEV Community by Pentine Pejay (@pentine_pejay).</description>
    <link>https://dev.to/pentine_pejay</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%2F4080255%2F60701d24-daf1-4ced-b483-687b917b64f7.png</url>
      <title>DEV Community: Pentine Pejay</title>
      <link>https://dev.to/pentine_pejay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pentine_pejay"/>
    <language>en</language>
    <item>
      <title>An 80-second browser freeze in Zulip's Manage channels, fixed by killing quadratic DOM work</title>
      <dc:creator>Pentine Pejay</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:06:01 +0000</pubDate>
      <link>https://dev.to/pentine_pejay/an-80-second-browser-freeze-in-zulips-manage-channels-fixed-by-killing-on-dom-work-1543</link>
      <guid>https://dev.to/pentine_pejay/an-80-second-browser-freeze-in-zulips-manage-channels-fixed-by-killing-on-dom-work-1543</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/zulip/zulip" rel="noopener noreferrer"&gt;Zulip&lt;/a&gt; is an open-source team chat application: a Django backend and a TypeScript web client built on jQuery. The "Manage channels" overlay lists every channel you can see, with live search, sort, and filter. Each keystroke or filter change re-sorts the list in place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bcqlkks1udldcnekj88.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bcqlkks1udldcnekj88.png" alt="Zulip" width="792" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;I fixed &lt;a href="https://github.com/zulip/zulip/issues/39755" rel="noopener noreferrer"&gt;issue #39755&lt;/a&gt;. On a server with about 331 channels, opening Manage channels (or the &lt;code&gt;#channels/new&lt;/code&gt; view, or the All tab) makes the tab unresponsive for roughly 80 seconds. Chrome shows the "Page Unresponsive" dialog. The reporter's Chrome CPU profile pinned about 90% of the freeze on two DOM operations, &lt;code&gt;removeChild&lt;/code&gt; and &lt;code&gt;querySelectorAll&lt;/code&gt;, triggered through the bundled jQuery.&lt;/p&gt;

&lt;p&gt;The code is &lt;code&gt;redraw_left_panel()&lt;/code&gt; in &lt;code&gt;web/src/stream_settings_ui.ts&lt;/code&gt;, which sorts the list by moving DOM rows into their new order. The re-append loop looked like this:&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;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;stream_id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;all_stream_ids&lt;/span&gt;&lt;span class="p"&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;$widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;widgets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;scroll_util&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_content_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#channels_overlay_container .streams-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// re-queried every iteration&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$widget&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 problem is the &lt;code&gt;$("#channels_overlay_container .streams-list")&lt;/code&gt; inside the loop. That is a document-wide &lt;code&gt;querySelectorAll&lt;/code&gt;, run once per channel. A &lt;code&gt;querySelectorAll&lt;/code&gt; forces the browser to flush pending layout, so every one of the N appends triggers a full re-layout of the list. Detaching and re-appending N rows with a forced reflow each time is O(n²). With a few hundred rows, that is the 80-second freeze, and it matches the profile exactly: &lt;code&gt;removeChild&lt;/code&gt; from the per-row detach, &lt;code&gt;querySelectorAll&lt;/code&gt; from the per-row re-query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/kiprutopentine/zulip/pull/1" rel="noopener noreferrer"&gt;https://github.com/kiprutopentine/zulip/pull/1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix, in &lt;code&gt;redraw_left_panel()&lt;/code&gt;:&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="c1"&gt;// Query the content element once, before the loop.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$content_element&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scroll_util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_content_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#channels_overlay_container .streams-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...scan .stream-row a single time, set classes, detach rows...&lt;/span&gt;

&lt;span class="c1"&gt;// Re-append each row into the CACHED element. The original re-queried the&lt;/span&gt;
&lt;span class="c1"&gt;// content element here on every iteration; that document-wide querySelectorAll&lt;/span&gt;
&lt;span class="c1"&gt;// forces a synchronous layout flush of the pending append, so each row paid a&lt;/span&gt;
&lt;span class="c1"&gt;// full reflow. Caching it removes the forced layout, and one append form keeps&lt;/span&gt;
&lt;span class="c1"&gt;// the change lint- and mock-compatible with the existing test.&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;stream_id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;all_stream_ids&lt;/span&gt;&lt;span class="p"&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;$widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;widgets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$widget&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;$content_element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$widget&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;Three changes: query the content element once (this removes the per-row &lt;code&gt;querySelectorAll&lt;/code&gt; and the forced synchronous layout it caused), scan &lt;code&gt;.stream-row&lt;/code&gt; a single time instead of twice, and re-append each row into that cached element instead of re-looking it up every iteration. The ordering, the &lt;code&gt;notdisplayed&lt;/code&gt; classes, and the function's return value are all unchanged, so behavior is identical, only the DOM work drops from O(n²) to O(n).&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;I kept the change inside the existing function and its jQuery idiom rather than rewriting the overlay. That was deliberate: Zulip's frontend tests run against a jQuery mock (&lt;code&gt;zjquery&lt;/code&gt;) whose elements are not real DOM nodes, so a raw &lt;code&gt;DocumentFragment&lt;/code&gt; reorder would pass in the browser but break the test suite. Staying on &lt;code&gt;.detach()&lt;/code&gt; / &lt;code&gt;.append()&lt;/code&gt; keeps the existing &lt;code&gt;redraw_left_panel&lt;/code&gt; test valid, which already asserts the sorted order across search, sort, and filter cases.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removed the per-iteration content-element lookup, the O(n²) driver: a document-wide &lt;code&gt;querySelectorAll&lt;/code&gt; that forced a synchronous layout flush of the pending append on every row.&lt;/li&gt;
&lt;li&gt;Collapsed two &lt;code&gt;.stream-row&lt;/code&gt; scans into one.&lt;/li&gt;
&lt;li&gt;Kept the plain &lt;code&gt;.append($row)&lt;/code&gt; form, so the change is behavior- and lint-identical to the original and compatible with Zulip's &lt;code&gt;zjquery&lt;/code&gt; test mock (a raw &lt;code&gt;DocumentFragment&lt;/code&gt; reorder would pass in a browser but break the mock, whose elements are not real DOM nodes).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verification: I provisioned a full Zulip dev environment on a throwaway cloud VM and ran Zulip's own tooling against the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;./tools/test-js-with-node stream_settings_ui&lt;/code&gt; → &lt;code&gt;test: redraw_left_panel&lt;/code&gt; passes ("Test(s) passed. SUCCESS!").&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;./tools/run-tsc&lt;/code&gt; clean.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;./tools/lint --only=eslint,prettier web/src/stream_settings_ui.ts&lt;/code&gt; clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The change is behavior-preserving (same order, &lt;code&gt;notdisplayed&lt;/code&gt; classes, return value), so that existing test, which pins ordering across search, sort, and filter cases, passes unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;This is a performance bug with zero exceptions, so error monitoring alone would never see it. I used Sentry's performance tooling to make the cost visible and to prove the fix.&lt;/p&gt;

&lt;p&gt;I built a benchmark harness (&lt;code&gt;@sentry/browser&lt;/code&gt; via the loader script) that rebuilds the exact redraw both ways against N real &lt;code&gt;.stream-row&lt;/code&gt; nodes and wraps each run in a Sentry span named &lt;code&gt;manage_channels.redraw_left_panel&lt;/code&gt; (&lt;code&gt;op: ui.render&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OLD&lt;/strong&gt; detaches each row and re-appends one at a time, re-querying the content element every iteration (forcing a synchronous layout per row): O(n²).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NEW&lt;/strong&gt; queries the content element once and re-inserts in a single batch: O(n).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured in the harness, which is deliberately minimal (plain rows) so the numbers isolate the algorithm itself rather than Zulip's full per-row render cost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;channels&lt;/th&gt;
&lt;th&gt;OLD (buggy)&lt;/th&gt;
&lt;th&gt;NEW (fixed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;331&lt;/td&gt;
&lt;td&gt;115.8 ms&lt;/td&gt;
&lt;td&gt;3.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;477.4 ms&lt;/td&gt;
&lt;td&gt;7.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;2340.4 ms&lt;/td&gt;
&lt;td&gt;15.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OLD grows super-linearly, close to quadratic at the larger sizes: doubling the list from 1000 to 2000 channels roughly quintuples the time. NEW scales linearly and stays in the low milliseconds. At 2000 channels the redraw drops from 2.3 seconds to 15 ms, about 155x. In the production overlay each row is far heavier (real templates, jQuery, the scroll container), so the same pattern is what turns "a few hundred channels" into the reporter's ~80-second freeze instead of a fraction of a second. The harness proves the mechanism and the fix; the 80 seconds is the real-world symptom of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance / tracing:&lt;/strong&gt; the OLD run does not just take longer, it blocks the browser's main thread. Sentry's tracing recorded it as a &lt;code&gt;ui.long-animation-frame — Main UI thread blocked&lt;/code&gt; span (about 1.3 s in this trace), exactly the kind of long task that makes the tab unresponsive. The NEW run produces no such block.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc9jzl50amnk5ru44bxvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc9jzl50amnk5ru44bxvr.png" alt="trace" width="799" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each run is tagged with its &lt;code&gt;channels&lt;/code&gt; count and its &lt;code&gt;kind&lt;/code&gt; (old/new), so the regression is visible &lt;em&gt;inside&lt;/em&gt; Sentry, not just in the console. Filtering the &lt;code&gt;manage_channels.redraw_left_panel&lt;/code&gt; transactions shows the fixed runs sitting in the low milliseconds (4–30 ms) while the buggy runs climb with the channel count, from ~100 ms up past 2 seconds, and beyond 17 seconds on a larger list. Sentry is not just recording one slow event; it shows the shape of the algorithm, and that the fix flattens it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb2b7p22p7n9metid26un.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb2b7p22p7n9metid26un.png" alt="old and new" width="799" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session Replay:&lt;/strong&gt; the replay of the OLD run shows the list locked and unresponsive; the NEW run is instant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujuh6y6jp1606w8o64m0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujuh6y6jp1606w8o64m0.png" alt="reply" width="799" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three Sentry surfaces carried this fix: &lt;strong&gt;tracing&lt;/strong&gt; timed each redraw as a &lt;code&gt;ui.render&lt;/code&gt; transaction and plotted the cost curve as the channel count grew, its &lt;strong&gt;long-animation-frame&lt;/strong&gt; detection flagged the blocked main thread on its own, and &lt;strong&gt;Session Replay&lt;/strong&gt; captured the freeze the way a user feels it. Run it yourself, the harness is public at &lt;a href="https://github.com/kiprutopentine/sentry" rel="noopener noreferrer"&gt;https://github.com/kiprutopentine/sentry&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
    </item>
    <item>
      <title>Dog Passport: a soulbound vaccination record your vet signs, on Solana</title>
      <dc:creator>Pentine Pejay</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:45:59 +0000</pubDate>
      <link>https://dev.to/pentine_pejay/dog-passport-a-soulbound-vaccination-record-your-vet-signs-on-solana-5ek9</link>
      <guid>https://dev.to/pentine_pejay/dog-passport-a-soulbound-vaccination-record-your-vet-signs-on-solana-5ek9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-08-13"&gt;Weekend Challenge: Dog Days Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Dog Passport turns a dog's vaccination and pedigree record into a&lt;br&gt;
non-transferable Token-2022 on Solana that the issuing vet signs with their own&lt;br&gt;
keypair.&lt;/p&gt;

&lt;p&gt;A boarding kennel, groomer, border agent, or a new owner can open the passport,read it straight from the chain, and see two things at once: the dog's details,and whether the record was really signed by a known vet. No app account, no central server to trust, no company that can shut down and take the records with it. The token is soulbound, so it stays bound to the dog and cannot be sold or swapped like an NFT.&lt;/p&gt;

&lt;p&gt;The problem is small and real. Paper vaccination cards get lost right when you need them for boarding or travel. Pet-record apps die with the company that made them. Breeders hand over pedigrees you have no way to check. A vet-signed on-chain record fixes the parts that hurt: it is portable, tamper-evident, and outlives any single vendor.&lt;/p&gt;

&lt;p&gt;The passport carries a photo, passport number, owner, breed, date of birth, colour, microchip, the vaccination history, and the issuing vet's key. Every one of those fields, including the photo, is covered by the vet's signature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyiyi4afb90zfipttfjpr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyiyi4afb90zfipttfjpr.png" alt="passport" width="799" height="566"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live (Solana devnet, no wallet or setup needed):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App: &lt;a href="https://bosco-eta.vercel.app" rel="noopener noreferrer"&gt;https://bosco-eta.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://bosco-eta.vercel.app" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;bosco-eta.vercel.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;Open a passport directly: &lt;a href="https://bosco-eta.vercel.app/?mint=52SP5ACC7m8BwnWge1CX3DyidhfBXWLxAz9yNdS2sf3G" rel="noopener noreferrer"&gt;https://bosco-eta.vercel.app/?mint=52SP5ACC7m8BwnWge1CX3DyidhfBXWLxAz9yNdS2sf3G&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The landing page has a gallery of five real dogs plus two test states, so you can click through every verification outcome:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqkf7y10j53fa00u7ny4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqkf7y10j53fa00u7ny4.png" alt="Viewer" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentic&lt;/strong&gt; — signed by a trusted vet. Green "VERIFIED" stamp, all checks pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untrusted issuer&lt;/strong&gt; — a valid signature from a vet who is not in the trusted list. Amber stamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tampered&lt;/strong&gt; — a field was altered on-chain after signing, so the signature nolonger matches. Red "INVALID" stamp.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4utaaez0x9awkgzfv6kc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4utaaez0x9awkgzfv6kc.png" alt="verification-untrasted" width="575" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2y7p1kaegyxmd39tsn8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2y7p1kaegyxmd39tsn8.png" alt="tampered" width="575" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The viewer renders a proper passport data page: photo, fields, a machine-readable zone, per-vaccine validity status (up to date, due soon, overdue), a verification checklist, and a QR code of the shareable link. It can also scan a printed passport QR with the device camera.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F139ujtxs8mv1p2h762l8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F139ujtxs8mv1p2h762l8.png" alt="overdue" width="575" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open the live link above and click the gallery chips to see each outcome for yourself, straight from devnet.&lt;/p&gt;

&lt;p&gt;The verification is honest. Change any field in the record and the signature no longer matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;original verifies  : true
tampered breed     : false
tampered vaccine   : false
wrong issuer key   : false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/kiprutopentine" rel="noopener noreferrer"&gt;
        kiprutopentine
      &lt;/a&gt; / &lt;a href="https://github.com/kiprutopentine/dog-passport" rel="noopener noreferrer"&gt;
        dog-passport
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Dog Passport&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A dog's vaccination and pedigree record issued as a non-transferable Token-2022
on Solana. A vet signs each record with their keypair, so a boarding kennel
groomer, border agent, or new owner can verify the record is authentic and came
from a specific vet, without trusting any central server or app.&lt;/p&gt;
&lt;p&gt;Runs on Solana devnet.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why on-chain&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Paper vaccination records get lost. App-based pet records die when the app or
company shuts down. A breeder can hand you a pedigree with no way to check it.
Putting the record on-chain as a soulbound token makes it portable,
tamper-evident, and independent of any single vendor. The passport is bound to
the dog, not tradeable, so it cannot be sold or swapped like an NFT.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;Each passport is one Token-2022 mint with three extensions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NonTransferable&lt;/strong&gt; — the token is soulbound. Once minted to the owner it
cannot be…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/kiprutopentine/dog-passport" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; plain &lt;code&gt;@solana/web3.js&lt;/code&gt; + &lt;code&gt;@solana/spl-token&lt;/code&gt;, no Anchor program. A handful of Node scripts plus a static viewer, small enough to build and verify in a weekend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solana part that matters.&lt;/strong&gt; Each passport is one Token-2022 mint that combines three SPL Token Extensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NonTransferable&lt;/strong&gt; makes the token soulbound. I de-risked this first with a   proof-of-concept that mints a token and then tries to transfer it. The program rejects the transfer, which is exactly what a passport needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MetadataPointer&lt;/strong&gt; points the mint at itself for metadata, so there is no separate metadata account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TokenMetadata&lt;/strong&gt; stores the whole record on-chain in &lt;code&gt;additionalMetadata&lt;/code&gt;:passport number, name, breed, dob, sex, colour, microchip, owner, photo URL andits SHA-256 hash, the vaccine list, the issuing authority, the vet's public key, and the vet's signature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkbz6lpgelntl67wtdetf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkbz6lpgelntl67wtdetf.png" alt="solana" width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd78yrd2f92xblkm5j1qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd78yrd2f92xblkm5j1qq.png" alt="metadata" width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issuer-signed records.&lt;/strong&gt; The value is not "data on a chain," it is &lt;em&gt;who signed it&lt;/em&gt;. The vet holds an Ed25519 keypair. Before minting, the vet signs a canonical serialization of the record (stable key order, reproducible byte-for-byte) with &lt;code&gt;tweetnacl&lt;/code&gt;. That detached signature goes on-chain. Verification recomputes the canonical record from the on-chain fields, checks the signature against the vet's&lt;br&gt;
public key, then checks that key against a trusted-vet list. Tampering fails because the bytes being verified no longer match the bytes that were signed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photo integrity.&lt;/strong&gt; The image is bound to the record by hashing its bytes and signing the hash. The viewer re-hashes the photo it displays and compares, so the picture cannot be swapped without breaking verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The viewer and hosting.&lt;/strong&gt; The on-chain read and the verification run in one &lt;code&gt;loadAndVerifyPassport&lt;/code&gt; function shared by the CLI and the web. On Vercel it runs as a serverless function at &lt;code&gt;/api/passport&lt;/code&gt;; locally the same function runs behind a small Node server. The function is host-agnostic: it reads the photo and the trusted-vet list over HTTP from its own deployment, so nothing from the local&lt;br&gt;
filesystem has to be bundled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two things that fought back.&lt;/strong&gt; The public devnet faucet was rate-limited to death, so funding falls back across endpoints (an Alchemy demo endpoint saved the day). And on Vercel, &lt;code&gt;@solana/web3.js&lt;/code&gt; pulls in &lt;code&gt;rpc-websockets&lt;/code&gt;, which bundles an ESM-only &lt;code&gt;uuid&lt;/code&gt; and then &lt;code&gt;require()&lt;/code&gt;s it, crashing the function with &lt;code&gt;ERR_REQUIRE_ESM&lt;/code&gt;; pinning &lt;code&gt;uuid&lt;/code&gt; to its CommonJS build via an npm &lt;code&gt;overrides&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Solana.&lt;/strong&gt; - Used Token-2022's NonTransferable extension to make the passport soulbound, on-chain TokenMetadata to carry the record, and the issuer-signature model to make verification about the vet rather than a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Real vet identity: a registry or attestations instead of a local trusted list.&lt;/li&gt;
&lt;li&gt;An owner-transfer flow for rehoming and adoption, without making the token freely tradeable.&lt;/li&gt;
&lt;li&gt;Mainnet custody and a kennel/border scanner app.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
  </channel>
</rss>
