<?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: Prashant Chaudhari</title>
    <description>The latest articles on DEV Community by Prashant Chaudhari (@pandaa880).</description>
    <link>https://dev.to/pandaa880</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%2F95805%2Ff9f7592b-82d3-4658-a197-68f526931a61.jpg</url>
      <title>DEV Community: Prashant Chaudhari</title>
      <link>https://dev.to/pandaa880</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pandaa880"/>
    <language>en</language>
    <item>
      <title>Seven checks: verifying an AWS Nitro attestation in the browser</title>
      <dc:creator>Prashant Chaudhari</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:32:37 +0000</pubDate>
      <link>https://dev.to/pandaa880/seven-checks-verifying-an-aws-nitro-attestation-in-the-browser-3b4p</link>
      <guid>https://dev.to/pandaa880/seven-checks-verifying-an-aws-nitro-attestation-in-the-browser-3b4p</guid>
      <description>&lt;p&gt;My server says an enclave scored this borrower. The verify page checks that claim itself, against AWS's signature and Solana, without trusting my server. Seven checks, and where the obvious code would have been wrong.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/pandaa880/tee-income-oracle" rel="noopener noreferrer"&gt;TEE Income Oracle&lt;/a&gt;. It reads a borrower's bank statement inside a sealed-off part of an AWS server, works out a credit tier (A, B or C), signs it, and writes it to Solana devnet. A lending pool reads the tier there before it lends.&lt;/p&gt;

&lt;p&gt;That sealed-off part is an AWS Nitro &lt;strong&gt;enclave&lt;/strong&gt;: an isolated machine that nobody, including me as the operator, can log into or look inside while it runs. That's the point of using one. The bank statement goes in; only the tier comes out.&lt;/p&gt;

&lt;p&gt;But a lender has no reason to take my word that the tier came from an enclave, or that the enclave runs the code I published. So the enclave can produce proof, called an &lt;strong&gt;attestation document&lt;/strong&gt;. AWS's hardware signs it, and it says two things: which code booted, and which signing key the enclave created for itself. If the document checks out, a tier signed with that key came from that code.&lt;/p&gt;

&lt;p&gt;The proof only helps if someone other than me checks it. So I wrote a verifier, a TypeScript library that also runs in the browser, on the app's &lt;a href="https://tee-income-oracle-web.vercel.app/verify" rel="noopener noreferrer"&gt;verify page&lt;/a&gt;. You give it a borrower's wallet, it reads Solana directly, and it runs seven checks on that borrower's tier. It doesn't take my server's word for anything.&lt;/p&gt;

&lt;p&gt;This post is for developers who have heard of enclaves or "verifiable compute" and want to see what checking one actually involves. You don't need to know AWS Nitro, or any cryptography beyond "a signature proves who signed some bytes". I'll explain the rest as it comes up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this post covers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's inside an attestation document, and how a tier on Solana leads back to one.&lt;/li&gt;
&lt;li&gt;The seven checks, in plain words.&lt;/li&gt;
&lt;li&gt;Four places where the obvious code would have been wrong: checking a re-packed copy instead of the signed bytes, rejecting a genuine AWS signature, failing every document a few hours after it was made, and a code fingerprint that changes when you edit a comment.&lt;/li&gt;
&lt;li&gt;A gap in the verdict that had nothing to do with cryptography.&lt;/li&gt;
&lt;li&gt;Why the browser checks a copy of the document, and why that's still safe.&lt;/li&gt;
&lt;li&gt;What a pass doesn't prove, and a checklist if you verify attestations yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's inside the document
&lt;/h2&gt;

&lt;p&gt;The document is a small binary file. Inside is a set of named fields, stored in CBOR, a compact binary format that does for binary data what JSON does for text. AWS signs that data and wraps it, with the signature, in a standard envelope called COSE_Sign1 (&lt;a href="https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html" rel="noopener noreferrer"&gt;AWS describes the format here&lt;/a&gt;). Four fields matter for this post:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pcrs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fingerprints of what booted. Each PCR (platform configuration register) is a hash of one part of the software&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;certificate&lt;/code&gt;, &lt;code&gt;cabundle&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The certificate whose key signed the document, and the chain of certificates that links it back to AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the document was made&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A key the enclave generated when it started. My enclave signs tiers with it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My enclave runs on &lt;a href="https://docs.marlin.org/oyster/" rel="noopener noreferrer"&gt;Marlin Oyster&lt;/a&gt;, a service that rents out Nitro enclaves. Oyster boots its own fixed base system, then starts my app from a &lt;code&gt;docker-compose.yml&lt;/code&gt; file. PCR0, PCR1 and PCR2 fingerprint the base system. PCR16 fingerprints my compose file. Hashed together, those four give an &lt;strong&gt;image id&lt;/strong&gt;: one value that names one exact build of the enclave.&lt;/p&gt;

&lt;p&gt;On Solana, a registry keeps one entry per enclave build: its image id, its signing key, a hash of its attestation document, and whether it has been retired. So from a borrower's wallet, the verifier can follow a chain back to AWS's hardware:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the wallet's tier on Solana,&lt;/li&gt;
&lt;li&gt;the registry entry for the build that signed it,&lt;/li&gt;
&lt;li&gt;that build's attestation document,&lt;/li&gt;
&lt;li&gt;AWS's signature on that document.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The seven checks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it asks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Signed by AWS Nitro hardware&lt;/td&gt;
&lt;td&gt;Is the signature valid, and does its certificate chain end at AWS's root?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Marlin's published base image, not debug mode&lt;/td&gt;
&lt;td&gt;Do PCR0–2 match Marlin's published values? (Debug mode zeroes them.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Runs our published code&lt;/td&gt;
&lt;td&gt;Does PCR16 match the compose file at the release tag?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Image id matches the registry&lt;/td&gt;
&lt;td&gt;Do the four PCRs give the image id the registry recorded?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Enclave key = on-chain attester&lt;/td&gt;
&lt;td&gt;Is the document's key the one the registry says signs tiers?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Same document the registry recorded&lt;/td&gt;
&lt;td&gt;Does this document's hash match the one stored on Solana?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Entry is active&lt;/td&gt;
&lt;td&gt;Is the registry entry still active, and for the same type of proof as the tier?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The verifier runs all seven even after one fails, so a bad document shows everything wrong with it at once.&lt;/p&gt;

&lt;p&gt;AWS's root certificate, where every chain must end, is built into the verifier. It's never downloaded, because a verifier that downloads its root trusts whoever answers the download. A test checks the built-in copy against the fingerprint AWS publishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check the bytes that were signed, not a re-packed copy
&lt;/h2&gt;

&lt;p&gt;A signature covers exact bytes. Two files can hold the same data and still differ byte for byte, and then the signature fits only one of them.&lt;/p&gt;

&lt;p&gt;That's the trap here. CBOR can store a set of fields in two ways: with the number of fields written at the start, or with an "end" marker after the last one. Nitro uses the second way. A CBOR library reads it fine, but when it packs the data back up, it uses the first. I tried it on one of my archived documents: its signed data, 4,478 bytes, became 4,477, and AWS's signature no longer matched.&lt;/p&gt;

&lt;p&gt;So the verifier decodes the data only to read its fields. It checks the signature over the bytes exactly as they arrived.&lt;/p&gt;

&lt;p&gt;The same rule applies to the certificates. A certificate's signature covers one section of the certificate, and the verifier checks that section exactly as it appears in the file, never a version it decoded and packed again.&lt;/p&gt;

&lt;p&gt;My tests nearly missed this. AWS's real certificates use DER, the strict standard encoding for certificates, so re-packing gives back the same bytes and both approaches pass. My AI code reviewer asked for a test that tells them apart. It takes a real certificate and writes one length field the long way round: the same certificate, but different bytes, and not the bytes AWS signed. A verifier that re-packs turns it back into the signed bytes and accepts it. One that checks the bytes as received rejects it, as it should.&lt;/p&gt;

&lt;p&gt;So re-packing fails in both directions: it rejects genuine documents, as the CBOR example shows, and it accepts bytes nobody signed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A genuine AWS signature my library rejected
&lt;/h2&gt;

&lt;p&gt;The signature scheme here, ECDSA, has a quirk: every valid signature has a twin. From one valid signature, anyone can compute a second, different one that's also valid. Blockchains such as Bitcoin and Ethereum accept only one of the two, called the "low-S" form, so a transaction can't be given a second id. Standard ECDSA has no such rule. The library I use, &lt;a href="https://github.com/paulmillr/noble-curves" rel="noopener noreferrer"&gt;noble-curves&lt;/a&gt;, applies it by default anyway.&lt;/p&gt;

&lt;p&gt;AWS has no reason to follow a blockchain rule, and a standard signer produces the other twin about half the time. One of my three archived documents has it, so with noble's default settings, a genuine AWS signature failed. With &lt;code&gt;lowS: false&lt;/code&gt;, it passes. I checked both on the real document. The verifier uses the same setting for the certificate signatures in the chain.&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;// sigStructure wraps the payload bytes exactly as they arrived.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p384&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;sha384&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sigStructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cose&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="nx"&gt;publicKeyPoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lowS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prehash&lt;/span&gt;&lt;span class="p"&gt;:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning that rule off is safe here. The rule matters where a signature works as an id, like a transaction id. Here, the registry stores the hash of the whole document, signature included, so a copy carrying the twin signature fails check 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Check the certificates as of the document's date, not today's
&lt;/h2&gt;

&lt;p&gt;Certificates expire. AWS's guide says to check that today's date falls inside every certificate's validity period. That's right when you've just asked a running enclave to prove itself.&lt;/p&gt;

&lt;p&gt;My verifier checks archived documents, and the certificate that signs each one is valid for only about three hours. When I checked against today's date, all three archived documents failed. The oldest is from 2026-10-08, and some certificates in its chain had already expired. Checked against today, every verification would fail a few hours after the document was made.&lt;/p&gt;

&lt;p&gt;So the verifier checks the certificates as of the document's own timestamp:&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;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;leaf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;certificate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cabundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cabundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pinnedRoot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AWS_NITRO_ROOT_DER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;atMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestampMs&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 gives something up: the check no longer shows the document is recent. So that has to come from Solana instead. The document's hash was stored in the registry when the build was registered, right after a live check of the running enclave, and check 6 compares against it. The entry must also still be active (check 7). When the enclave restarts, it creates a new key and gets a new entry. By default, my rotation script then retires the old entry, and tiers it signed stop passing.&lt;/p&gt;

&lt;p&gt;I've made one exception. The devnet enclave stopped around 2026-10-22, and I believe the old machine and its key are gone, so I kept its entry active for the demo. That's an accepted risk, not a proof, and I plan to retire the entry once its last tiers are past their 30-day limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A code fingerprint that changes when you edit a comment
&lt;/h2&gt;

&lt;p&gt;Check 3 is what ties the running enclave to code anyone can read. Oyster computes PCR16 from the full text of the compose file it started from (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/verifier/src/nitro/image-id.ts" rel="noopener noreferrer"&gt;the recipe&lt;/a&gt; is a few lines of hashing). Every byte counts, including comments and the newline at the end. Fix a typo in a comment, and the fingerprint no longer matches the running enclave.&lt;/p&gt;

&lt;p&gt;Two things follow. The compose file names my app's container image by its digest, a hash of the image itself, so the text points at exact code rather than a tag that can be moved. And the verifier reads the compose file from the build's &lt;strong&gt;release tag&lt;/strong&gt;, not from the &lt;code&gt;main&lt;/code&gt; branch, because &lt;code&gt;main&lt;/code&gt; keeps changing. The deployment record stores the tag for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Seven passes weren't the verdict
&lt;/h2&gt;

&lt;p&gt;My first version printed PASS when all seven checks passed. My AI code reviewer found the gap, and it had nothing to do with cryptography.&lt;/p&gt;

&lt;p&gt;The seven checks prove that the registry entry is genuine. They don't prove that the tier went through it. Other accounts can write records that look like tiers. What makes a tier trustworthy is who wrote it. The oracle program checks the enclave's signature before it writes a tier, and it always writes from one fixed address that only the program controls. A tier written from any other address never went through that check, however genuine the registry entry it points to.&lt;/p&gt;

&lt;p&gt;The first version even showed "UNEXPECTED signer" on screen, and still printed PASS. Now the writer is part of the verdict:&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;verdictOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;attestation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AttestationFacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signerOk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Verdict&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;allChecksPass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;check&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;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pass&lt;/span&gt;&lt;span class="dl"&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;attestation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signerOk&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;allChecksPass&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PASS&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="s2"&gt;FAIL&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same review gave me a rule I now follow everywhere: never trust a value you can work out yourself. The verifier computes the program's fixed address from the program's id instead of reading it from a config file, and it checks that the program ids in the deployment file match the ones built into the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Why the browser checks a copy
&lt;/h2&gt;

&lt;p&gt;The enclave serves its attestation over plain HTTP. A page loaded over HTTPS can't fetch from plain HTTP: browsers block it as "mixed content".&lt;/p&gt;

&lt;p&gt;So the verify page ships the archived documents as part of the site, loaded only when needed. That sounds like "trust my copy", but it isn't. AWS signed the document, and its hash is stored on Solana. A changed copy fails check 6. It doesn't matter who delivers the bytes.&lt;/p&gt;

&lt;p&gt;I also dropped a planned option to fetch the document live from the enclave. Oyster's attestation server signs a fresh document for every request, so a live document never has the same hash as the archived one on Solana. A live document can confirm the key and the image id, but it can't replace the archive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PASS doesn't prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;That the code is correct.&lt;/strong&gt; A pass proves which code ran, not that the code is right. Nothing has been audited yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That registration was honest.&lt;/strong&gt; An admin key registers each build after checking its document off chain. Every registration is public, but someone holding a stolen admin key could register a key whose document would still pass. Beyond the demo, that should be a multisig, and later Solana itself should check the document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That a lender should accept the tier today.&lt;/strong&gt; A pass says where the tier came from. Whether it has expired, and whether it meets a pool's rules, are separate checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That the bank data was real.&lt;/strong&gt; The demo bank is a sandbox: it speaks the real protocol, with demo keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That the RPC node told the truth.&lt;/strong&gt; The page reads Solana through the public devnet endpoint and takes its answers as given.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That a retired machine's key is really gone.&lt;/strong&gt; One build's entry stays active although its machine stopped (see section 3). If that key had leaked, tiers signed with it would still pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you verify attestations yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the signature over the bytes you received.&lt;/strong&gt; Decode only to read fields; never re-pack before verifying. Write a test with an unusual but valid encoding, because real data won't catch the mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect signatures in the "high-S" form&lt;/strong&gt; if your ECDSA library rejects them by default, on the certificates as well as on the document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide which date you check certificates against.&lt;/strong&gt; Today, for a live attestation. The document's own date, for an archived one, and then show it's still current some other way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the root certificate into your code.&lt;/strong&gt; Don't download it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read measured files from the release tag.&lt;/strong&gt; A fingerprint of a text file changes with every byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check who wrote the claim, not just what it points to.&lt;/strong&gt; Seven green checks on the wrong writer is still a fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verifier is about 1,600 lines of TypeScript. It doesn't use the browser's built-in crypto API, so the same code runs in Node and in the browser: &lt;a href="https://github.com/pandaa880/tee-income-oracle/tree/v0.5.5/verifier" rel="noopener noreferrer"&gt;&lt;code&gt;verifier/&lt;/code&gt;&lt;/a&gt;. The &lt;a href="https://tee-income-oracle-web.vercel.app/verify" rel="noopener noreferrer"&gt;verify page&lt;/a&gt; runs it in your browser.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://prashantchaudhari.dev/posts/seven-checks-nitro-attestation/" rel="noopener noreferrer"&gt;prashantchaudhari.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cryptography</category>
      <category>typescript</category>
      <category>solana</category>
    </item>
    <item>
      <title>The browser doesn't trust the server: a Solana frontend that checks its own backend</title>
      <dc:creator>Prashant Chaudhari</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/pandaa880/the-browser-doesnt-trust-the-server-a-solana-frontend-that-checks-its-own-backend-3758</link>
      <guid>https://dev.to/pandaa880/the-browser-doesnt-trust-the-server-a-solana-frontend-that-checks-its-own-backend-3758</guid>
      <description>&lt;p&gt;My server can tell the web page that a borrower got credit tier A. The page doesn't show it until Solana agrees.&lt;/p&gt;

&lt;p&gt;That sounds like paranoia about my own code, so here's the context. I'm building &lt;a href="https://github.com/pandaa880/tee-income-oracle" rel="noopener noreferrer"&gt;TEE Income Oracle&lt;/a&gt;. A borrower's bank statement is scored inside a secure enclave, the enclave signs a credit tier, and the tier is written to Solana, where a lending pool reads it before it lends. Between the browser and all of that sits a server I wrote, the gateway. It talks to the enclave, streams progress to the page, and pays transaction fees so a demo wallet needs no SOL.&lt;/p&gt;

&lt;p&gt;The whole product claims that a lender doesn't have to trust me. If the page repeats whatever my gateway says, that claim stops at the page. So the page treats the gateway as a messenger: it can carry bytes, but it never becomes the source of anything the page calls verified.&lt;/p&gt;

&lt;p&gt;This post is for frontend developers building on Solana. You know transactions, signatures and RPC calls; you don't need to know anything about enclaves. It walks through five places the page checks its own backend, each as the shortcut, what can go wrong with it, and what the page does instead. The page still trusts two things, the RPC node it reads from and the code I serve it, and I'll come back to both at the end. Every link points to release &lt;a href="https://github.com/pandaa880/tee-income-oracle/tree/v0.5.5/web/src" rel="noopener noreferrer"&gt;v0.5.5&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Sign your own copy
&lt;/h2&gt;

&lt;p&gt;Before scoring starts, the borrower signs a short text that ties the session to their wallet. It's a plain message signature, the kind a wallet's &lt;code&gt;signMessage&lt;/code&gt; produces, not a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tee-income-oracle: bind session
session: &amp;lt;session id&amp;gt;
wallet: &amp;lt;borrower's address&amp;gt;
policy: &amp;lt;policy hash&amp;gt;
expires: &amp;lt;unix seconds&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy hash identifies the scoring rules the enclave will apply; each pool says which policy it accepts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shortcut:&lt;/strong&gt; the gateway sends this text, the page passes it to the wallet, and the wallet signs it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can go wrong:&lt;/strong&gt; the gateway now holds the pen. Whatever it puts in that text gets signed: a different wallet, an expiry next year, a policy no pool lends under. In the demo the wallet lives in the browser tab and signs without a prompt, so nobody reads the text first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the page does:&lt;/strong&gt; it writes the text itself and compares it with the gateway's copy. It signs only when the two are identical. The comparison is the defense. The enclave wrote that text from the wallet and policy my gateway asked for, and it checks the signature against that text, so its copy is only as honest as the gateway's request. The wallet line comes from the page alone. The other values come from the gateway, so the page can only hold them to rules: the session id must match the session the page will submit under, the policy must be one a listed pool lends under, and the expiry must fall within the next ten minutes (plus a minute for clock skew). A gateway can still choose the session and the policy within those rules. It can't get a signature for anything else. (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/domain/intent.ts" rel="noopener noreferrer"&gt;&lt;code&gt;intent.ts&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;This isn't a quirk of my setup. &lt;a href="https://github.com/anza-xyz/kit" rel="noopener noreferrer"&gt;&lt;code&gt;@solana/kit&lt;/code&gt;&lt;/a&gt;, Solana's JavaScript library (formerly released as web3.js 2.0), makes the same argument from the other side in its documentation for &lt;a href="https://github.com/anza-xyz/kit/blob/d50fc28223a537e8cc66c137e818cc79ed52b5d7/packages/offchain-messages/README.md" rel="noopener noreferrer"&gt;signing off-chain messages&lt;/a&gt;: a valid signature from a wallet proves little until you've also confirmed it covers the message you meant to have signed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check that the signature you got back is yours
&lt;/h2&gt;

&lt;p&gt;To borrow, the page builds a transaction and the borrower signs it. The gateway's relayer pays the fee, so it signs too, sends the transaction, and returns its signature. The page polls that signature until it's confirmed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shortcut:&lt;/strong&gt; take the returned signature, wait for "confirmed", show "borrowed".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can go wrong:&lt;/strong&gt; every confirmed transaction has a signature. A buggy or hostile relayer could hand back one from an earlier borrow, or from someone else's, and the page would celebrate a loan that never happened. Reading the loan account afterwards helps, but on public RPC that read can come from a node that's behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the page does:&lt;/strong&gt; on Solana, a transaction's id is its first signature, and the first signature belongs to the fee payer, signed over the transaction's message bytes. The fee payer here is the relayer. So the page checks that the returned id is a valid relayer signature over the exact bytes it built. With Kit, that's a decode and a verify:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isOurTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;returnedId&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;relayerKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CryptoKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messageBytes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTransactionDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wire&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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBase58Encoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;returnedId&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="nf"&gt;isSignatureBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relayerKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messageBytes&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;Solana's &lt;a href="https://solana.com/docs/core/transactions/partial-signing" rel="noopener noreferrer"&gt;guide to partial signing&lt;/a&gt; gives a similar rule to whoever assembles a transaction from several signers: before you submit, make sure each signature you collected is over the message you froze. The page applies it from the other end, after submission, to the one id that comes back. A signature over any other message fails, so the id is tied to these exact message bytes. (The excerpt is condensed from &lt;code&gt;isOurTransaction&lt;/code&gt; in &lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/app/use-loan.ts" rel="noopener noreferrer"&gt;&lt;code&gt;use-loan.ts&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;That still isn't complete replay protection. Ed25519 signatures are deterministic: the same key signing the same bytes always gives the same signature, so two identical transactions have the same id. The chain is safe either way: validators drop a transaction they've already processed, so nothing executes twice. The risk is to what the page believes, and it takes four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The borrower borrows, and it confirms.&lt;/li&gt;
&lt;li&gt;The borrower repays. The page refuses to borrow while a loan is open, so a repay has to come first.&lt;/li&gt;
&lt;li&gt;Within about a minute, while the blockhash is still valid, the borrower borrows the same amount again, and the RPC node hands back the same blockhash. Blockhashes change every slot, so this takes a node that is lagging or lying, but it's the node the page asks.&lt;/li&gt;
&lt;li&gt;The bytes are identical, so the id is too. Validators drop the second transaction as already processed, yet its id shows as confirmed because the first borrow confirmed. The relayer doesn't have to lie for this. The page reads the loan account after confirmation, but that read has no lower bound on freshness, so a node still showing the first loan lets it through.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Show what the chain says, not what the server says
&lt;/h2&gt;

&lt;p&gt;When scoring finishes, the gateway streams the result: the tier, and the payload it says the oracle wrote to Solana. The payload is the signed part of the on-chain record, including the tier, the policy, the statement dates and the enclave build that scored it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shortcut:&lt;/strong&gt; show the tier straight away, and read the chain for extra details. My first version did this. My AI code reviewer described it as the gateway's word with a seal on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can go wrong:&lt;/strong&gt; the page looks verified while showing an unverified value. My first fix had a subtler hole. It matched the chain record to the gateway's report by timestamp, then kept displaying the gateway's tier. A gateway could relay a real tier C record and report tier A with the same timestamp. The timestamps match, and the page says A.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the page does:&lt;/strong&gt; it shows "reading your tier back from Solana…" until the payload on chain matches the reported payload, field by field. It then displays that payload and the record's expiry, both as read from Solana. If a payload at least as new as the report differs in any field, the page shows an error. That doesn't prove the gateway lied, because the oracle lets a newer record replace an older one, but the chain no longer confirms the report, so the page fails closed. (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/domain/borrow-view.ts" rel="noopener noreferrer"&gt;&lt;code&gt;borrow-view.ts&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The same mistake hid in a smaller place. A "registered on Solana" chip was fed by a gateway endpoint. Now only a read of the on-chain registry can say "registered". The gateway still moves the page from one step to the next, and it supplies the link to its transaction. It never supplies a value the page shows as fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A "valid" badge is a promise about the program
&lt;/h2&gt;

&lt;p&gt;The loan page shows a badge for the borrower's tier against a pool: valid, stale, expired, policy mismatch and so on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shortcut:&lt;/strong&gt; check the rules that seem important. My first version did that, and the same reviewer found four of the pool's checks missing. The badge could say "valid" about a credential the program would reject.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the page does:&lt;/strong&gt; it runs every credential check the pool program runs, in the program's order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the record was written by the oracle,&lt;/li&gt;
&lt;li&gt;it hasn't expired,&lt;/li&gt;
&lt;li&gt;the pool lends to this tier at all,&lt;/li&gt;
&lt;li&gt;it was scored under the pool's policy,&lt;/li&gt;
&lt;li&gt;it's fresh enough (how old the record is, how old the bank statement is, and how many days it covers),&lt;/li&gt;
&lt;li&gt;the enclave build that scored it is approved by the pool and not revoked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order matters because the badge names the first check that fails, and that should match what the program would say about the same credential. (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/domain/credential.ts" rel="noopener noreferrer"&gt;&lt;code&gt;credential.ts&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/programs/demo-pool/src/instructions/borrow.rs" rel="noopener noreferrer"&gt;&lt;code&gt;borrow.rs&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Even then, "valid" makes a narrow promise: the credential passes this pool's checks at the state and time the page read. A borrow can still fail for reasons outside the credential: the amount is over the tier's limit, there's already a loan in this pool, the vault is short, a token account is frozen, or the state changes before the transaction runs. The funders' loan book, a page that lists every open loan, runs the same function for every borrower, so "fresh" there makes the same narrow promise.&lt;/p&gt;

&lt;p&gt;Choosing which checks to run is a product decision too. Repay first sat behind the same "is your tier valid?" gate as Borrow, which locked a borrower with a stale tier out of repaying, and repaying needs no tier at all. Repay now has no tier check in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The RPC node is a server too
&lt;/h2&gt;

&lt;p&gt;Reading "from the chain" really means reading from an RPC node, which is someone else's server. Two things went wrong there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A node one step behind.&lt;/strong&gt; Public devnet RPC is load-balanced, so the read right after a confirmation can land on a node that hasn't caught up. The tests didn't catch this; a manual run on the live site did. After a repay, the page read the pool and saw the loan was gone. Then the data layer fetched again, hit a lagging node, and the closed loan came back on screen.&lt;/p&gt;

&lt;p&gt;The fix has two parts. The read-back retries a few times, and it reads the pool, its vault and the loan in one call, so all three come from the same node. Then the read that proved the change goes straight into the cache of &lt;a href="https://tanstack.com/query" rel="noopener noreferrer"&gt;TanStack Query&lt;/a&gt;, the page's data layer, and any fetch already in flight is cancelled, so it can't overwrite it:&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;await&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;poolKey&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setQueryData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;poolKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provingRead&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixes the race I saw, and no more. A later refetch, say when the tab regains focus, can still hit a lagging node. Solana's RPC has a parameter for the general case, &lt;code&gt;minContextSlot&lt;/code&gt;. Account reads such as &lt;code&gt;getMultipleAccounts&lt;/code&gt; return the slot they were evaluated at in &lt;code&gt;context.slot&lt;/code&gt;. Keep the highest slot you've accepted, pass it as &lt;code&gt;minContextSlot&lt;/code&gt; on later reads with the same commitment level, and a node that hasn't reached that slot returns an error (code &lt;code&gt;-32016&lt;/code&gt;, "Minimum context slot has not been reached") instead of older data. The page doesn't do this yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reply that's one short.&lt;/strong&gt; The funders' loan book asks for up to 100 accounts per call and matches each reply to its request by position. Nothing checked the reply's length. The RPC spec says a node returns one entry per address, with &lt;code&gt;null&lt;/code&gt; for an account that doesn't exist, but the page took that on trust. A node, or a proxy in front of one, that returned 99 accounts for 100 addresses would shift every row after the gap, and show one borrower with the next borrower's "fresh" badge. Now a reply of the wrong length is an error (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/adapters/rpc.ts" rel="noopener noreferrer"&gt;&lt;code&gt;rpc.ts&lt;/code&gt;&lt;/a&gt;). So is a duplicated loan row, which would inflate every total, and so is a record that doesn't name the borrower it was fetched for. One bad row fails the whole book (&lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/web/src/app/use-book.ts" rel="noopener noreferrer"&gt;&lt;code&gt;use-book.ts&lt;/code&gt;&lt;/a&gt;), because a partial book presented as the book is worse than an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the trust actually stops
&lt;/h2&gt;

&lt;p&gt;None of this makes the page trustless:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reads one RPC node.&lt;/strong&gt; It checks what's cheap to check: reply lengths, account sizes and types, which wallet a record belongs to, and signatures over bytes it built. A node that invented consistent data could still fool it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Confirmed" also comes from that node.&lt;/strong&gt; The signature check proves the id belongs to the bytes the page built; the node still reports whether they landed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I serve the page.&lt;/strong&gt; A different build could skip every check. That's why the source is public at release tags, and why the receipt checks also run from a &lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.5/verifier/README.md" rel="noopener noreferrer"&gt;command line&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the checks do buy: my gateway is no longer the only authority for a tier, a loan or a registration. The page checks each of those claims against what an RPC node returns, and most gateway bugs now show up as an error on screen instead of a wrong number. The limits above still apply: a stale read, or a reused identical transaction, can get past these checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for your own frontend
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild anything the user signs&lt;/strong&gt; from data you already hold, and sign your copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a signature from an API as a claim.&lt;/strong&gt; If you built the transaction, verify the signature over its bytes. It ties the id to those bytes; it doesn't stop an identical transaction being reused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show values from a chain read&lt;/strong&gt;, not from the API that triggered the write. Compare every field of the reported payload, display the chain's values, and fail closed when the payloads differ. Read anything outside the payload, such as expiry, straight from the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make a status badge mirror the program's checks&lt;/strong&gt;, in the program's order, and say what it doesn't cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After a write, keep the read that proved it.&lt;/strong&gt; For later reads, carry the highest slot you've accepted forward as &lt;code&gt;minContextSlot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check reply lengths&lt;/strong&gt; whenever you match results to requests by position.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To try this on your own app, pick one value your UI shows as confirmed, and trace where it comes from. If the answer is "the API told us", that's the first check to add.&lt;/p&gt;

</description>
      <category>solana</category>
      <category>frontend</category>
      <category>typescript</category>
      <category>security</category>
    </item>
    <item>
      <title>Money without floats: parsing rupees as integer paise</title>
      <dc:creator>Prashant Chaudhari</dc:creator>
      <pubDate>Sun, 25 Oct 2026 14:33:34 +0000</pubDate>
      <link>https://dev.to/pandaa880/money-without-floats-parsing-rupees-as-integer-paise-53mg</link>
      <guid>https://dev.to/pandaa880/money-without-floats-parsing-rupees-as-integer-paise-53mg</guid>
      <description>&lt;p&gt;Why 96 of 100 paise endings repeat in binary, how that can flip a signed credit tier, and how I parse bank amounts from text into integer paise.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/pandaa880/tee-income-oracle" rel="noopener noreferrer"&gt;TEE Income Oracle&lt;/a&gt;, a service that reads a borrower's bank statement inside a secure enclave and signs a credit tier: A, B, C or reject. A lending pool reads that tier on chain. The score has two implementations: the enclave scorer in Rust, and a second scorer, written separately in TypeScript, that produces the expected tier for a set of test statements. The Rust tests fail unless the two agree.&lt;/p&gt;

&lt;p&gt;So I need the same statement to produce the same tier, every time, in two languages. This post follows one amount through that pipeline: from the bank's text, to exact paise, to arithmetic that both scorers do the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tier that depends on the order of addition
&lt;/h2&gt;

&lt;p&gt;One rule for tier A is about FOIR: monthly loan repayments (EMIs) as a share of monthly income. My policy measures it in whole basis points, rounded down, and tier A allows at most 4,000 (40.00%).&lt;/p&gt;

&lt;p&gt;Take a ₹50,000 income and three EMIs that add up to ₹20,005.00. That's 40.01%, or 4,001 basis points, so this borrower is not tier A. Here's the check with floats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;income&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50000&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;emis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;5000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12345.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2659.94&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// ₹20,005.00: 40.01%, not tier A&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;income&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;forward&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// 20004.999999999996&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backward&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// 20005&lt;/span&gt;

&lt;span class="nf"&gt;bps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 4000 → tier A&lt;/span&gt;
&lt;span class="nf"&gt;bps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backward&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 4001 → not tier A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same amounts, same rule, two tiers. Nothing here is a bug in the usual sense: both orders are reasonable, and two programs written separately can easily pick different ones. With floats, they don't have to be wrong to disagree. And in my system the tier gets signed, so a wrong one is a signed claim about a real person.&lt;/p&gt;

&lt;p&gt;With integer paise, the order can't matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emisPaise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1234506&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;265994&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;emisPaise&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emisPaise&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emisPaise&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// 2000500n&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;5000000&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 4001n in any order → not tier A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why floats can't hold most rupee amounts
&lt;/h2&gt;

&lt;p&gt;A float stores numbers in binary, and binary can only write some fractions exactly. Decimal has the same problem with 1/3: you can't write it, only &lt;code&gt;0.3333…&lt;/code&gt; forever. Binary can write halves, quarters and eighths, but not tenths or hundredths. So 0.05 gets stored as the nearest value binary can hold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "0.050000000000000002775557561563"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked all 100 possible paise endings with a script: &lt;strong&gt;96 of them can't be stored exactly.&lt;/strong&gt; Only &lt;code&gt;.00&lt;/code&gt;, &lt;code&gt;.25&lt;/code&gt;, &lt;code&gt;.50&lt;/code&gt; and &lt;code&gt;.75&lt;/code&gt; can, and only if the whole amount fits the format's precision.&lt;/p&gt;

&lt;p&gt;That's a statement about endings, not about real transactions, which don't spread evenly across them. For amounts with any of those 96 endings, a binary float holds an approximation. Each error is tiny. Across hundreds of transactions, rounding errors can accumulate, and as the example above shows, a tiny error is enough when it lands on a boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the amounts come from
&lt;/h2&gt;

&lt;p&gt;The statement arrives in the format India's Account Aggregator network uses. ReBIT defines it as an XML schema, and the &lt;a href="https://specifications.rebit.org.in/api_schema/account_aggregator/documentation/deposit.html" rel="noopener noreferrer"&gt;DEPOSIT schema&lt;/a&gt; types &lt;code&gt;Transaction.amount&lt;/code&gt; as &lt;a href="https://www.w3.org/TR/xmlschema-2/#float" rel="noopener noreferrer"&gt;&lt;code&gt;xs:float&lt;/code&gt;&lt;/a&gt;. Balances and limits are &lt;code&gt;xs:string&lt;/code&gt;, with no pattern.&lt;/p&gt;

&lt;p&gt;ReBIT doesn't say why, and it isn't specific to this field: the whole schema family types numbers this way.&lt;sup id="fnref1"&gt;1&lt;/sup&gt; I read it as "this is a number", not as a choice of binary format. Strictly, &lt;code&gt;xs:float&lt;/code&gt; means a 32-bit float, and taken literally a 32-bit float can't hold ₹1,23,456.78 (it becomes 123456.78125).&lt;/p&gt;

&lt;p&gt;The schema describes XML, but the data my service accepts is JSON. &lt;a href="https://finvu.github.io/sandbox/finvu_fiu_integration.html#fetch-data" rel="noopener noreferrer"&gt;Finvu's sample fetch response&lt;/a&gt;, from the Account Aggregator I target, shows the mapping: attributes become keys, &lt;code&gt;amount&lt;/code&gt; arrives as a JSON number (&lt;code&gt;6036&lt;/code&gt;), and balances arrive as JSON strings (&lt;code&gt;"322644.09"&lt;/code&gt;). So my parser sees two forms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON numbers&lt;/strong&gt;, such as &lt;code&gt;85000&lt;/code&gt;, &lt;code&gt;1234.050&lt;/code&gt; or &lt;code&gt;1.2E7&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numeric strings&lt;/strong&gt;, such as &lt;code&gt;"112340.50"&lt;/code&gt;. Here the schema allows any text, so XML-style spellings like &lt;code&gt;"+5"&lt;/code&gt; and &lt;code&gt;".5"&lt;/code&gt; can appear too. They aren't valid JSON numbers, only valid inside strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;1.2E7&lt;/code&gt; is a real possibility, not a curiosity. Java's &lt;a href="https://docs.oracle.com/javase/8/docs/api/java/lang/Double.html#toString-double-" rel="noopener noreferrer"&gt;&lt;code&gt;Double.toString&lt;/code&gt;&lt;/a&gt; writes values of 10⁷ and above in scientific notation, so a producer that uses it sends ₹1.2 crore as &lt;code&gt;1.2E7&lt;/code&gt;. My first parser accepted only plain decimals with at most two places, and it would have rejected that spelling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integer paise, parsed from the text
&lt;/h2&gt;

&lt;p&gt;The money type in the oracle is a count of paise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Money in integer paise (₹1 = 100 paise). The only money type in&lt;/span&gt;
&lt;span class="cd"&gt;/// `tio-core`.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;Paise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;₹1,234.05 is &lt;code&gt;123405&lt;/code&gt;. Integer sums are exact as long as every intermediate result stays in range, so the Rust scorer adds with checked arithmetic and does ratio maths in &lt;code&gt;i128&lt;/code&gt;. The TypeScript scorer uses &lt;code&gt;bigint&lt;/code&gt; with the same bounds.&lt;/p&gt;

&lt;p&gt;The amount has to become an integer without ever becoming a float. If a JSON library turns &lt;code&gt;1234.05&lt;/code&gt; into a float first, the error is in before my code sees the value. So the parser reads the raw text of each JSON value and never converts through a float. That avoids adding error at this step. It can't recover precision a producer already lost upstream.&lt;/p&gt;

&lt;p&gt;The rule: &lt;strong&gt;within the supported grammar and range, accept any spelling that is exactly a whole number of paise, and reject everything else. Never round.&lt;/strong&gt; Rounding would hide bad bank data inside a signed result.&lt;/p&gt;

&lt;p&gt;The idea is simple: read the amount as text, move the decimal point two places to the right, and insist that what's left is a whole number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1234.05&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,23,405 paise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1234.050&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,23,405 paise: a trailing zero changes nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.2E7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,20,00,00,000 paise (₹1.2 crore)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1234.567&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected: the last digit would be a fraction of a paisa&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The code works with digit counts rather than moving a character around, so scientific notation goes through the same path as a plain decimal. It also turns away anything too big to be a real amount, such as a hostile &lt;code&gt;1e999999999&lt;/code&gt;, before doing any arithmetic. The full parser is about 220 lines with comments: &lt;a href="https://github.com/pandaa880/tee-income-oracle/blob/v0.5.3/tio-core/src/money.rs" rel="noopener noreferrer"&gt;&lt;code&gt;tio-core/src/money.rs&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding that both scorers agree on
&lt;/h2&gt;

&lt;p&gt;Integers remove accidental rounding, not all rounding. A scorer still divides: FOIR is a ratio, and two other features need a median and a square root. So each of those steps rounds down, the spec says so, and both scorers do exactly that. Every amount involved is zero or positive, so "round down" means the same thing in Rust and in JavaScript's &lt;code&gt;bigint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Rounding down is a policy choice, and it has a visible effect: ₹20,000.01 against ₹50,000 is 40.00002%, which rounds down to 4,000 basis points and still counts as tier A. I'm fine with that band in an eligibility rule, because it's written down and both scorers apply it. If you need "at most 40%" exactly, skip the division and compare products: &lt;code&gt;obligations × 5 ≤ income × 2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other ways to do it
&lt;/h2&gt;

&lt;p&gt;Integer paise isn't the only sound design. Exact decimal types, such as Java's &lt;code&gt;BigDecimal&lt;/code&gt;, also avoid binary rounding.&lt;/p&gt;

&lt;p&gt;I chose integers for my own constraints: two languages that must agree to the paisa, arithmetic that is mostly sums and comparisons plus a few specified ratios, and an output that gets signed. Payment APIs work the same way: &lt;a href="https://docs.stripe.com/currencies" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt; takes &lt;code&gt;1099&lt;/code&gt; to charge 10.99 USD, and &lt;a href="https://razorpay.com/docs/api/orders/create/" rel="noopener noreferrer"&gt;Razorpay&lt;/a&gt; takes amounts as integers in the smallest currency sub-unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you handle money in JavaScript or TypeScript
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep amounts as integers in the smallest unit&lt;/strong&gt;, and check intermediate results too, not just amounts. &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt; is about ₹90 lakh crore in paise, but a paise amount times 10,000 leaves the safe range above about ₹900 crore. Use &lt;code&gt;bigint&lt;/code&gt; when products can get that big.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't multiply a float by 100 and hope.&lt;/strong&gt; &lt;code&gt;19.99 * 100&lt;/code&gt; is &lt;code&gt;1998.9999999999998&lt;/code&gt;. Parse the string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get the raw text if an API sends JSON numbers.&lt;/strong&gt; Newer runtimes pass the original text to the &lt;code&gt;JSON.parse&lt;/code&gt; reviver (I checked Node 24):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"amount": 1234.05}&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&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;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { amount: "1234.05" }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;typeof&lt;/code&gt; check matters if the field can also arrive as a string. For a JSON string, &lt;code&gt;context.source&lt;/code&gt; is the original text including its quotes (&lt;code&gt;'"1234.05"'&lt;/code&gt;), while &lt;code&gt;value&lt;/code&gt; is already the string you want.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't round money with &lt;code&gt;toFixed&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;(1.005).toFixed(2)&lt;/code&gt; returns &lt;code&gt;"1.00"&lt;/code&gt;, because the stored value is slightly below 1.005.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the rounding rules down&lt;/strong&gt;, and use the same ones in every implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the spec, not just the sample.&lt;/strong&gt; The sample I started from had plain decimals. The schema allows much more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Integer paise keeps the money exact. Specified rounding rules make the remaining calculations reproducible across both implementations. Together, the same statement gives the same signed tier in Rust and in TypeScript.&lt;/p&gt;

&lt;p&gt;If you have a money parser, try it with &lt;code&gt;1234.05&lt;/code&gt;, &lt;code&gt;1234.050&lt;/code&gt;, &lt;code&gt;1.2E7&lt;/code&gt; and &lt;code&gt;1234.567&lt;/code&gt;. The first two should give the same value, the third should keep its full value (₹1.2 crore), and the last should fail rather than round.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://prashantchaudhari.dev/posts/money-without-floats/" rel="noopener noreferrer"&gt;prashantchaudhari.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Across the 23 FI type schemas in the &lt;a href="https://github.com/Sahamati/account-aggregator-standards" rel="noopener noreferrer"&gt;Account Aggregator standards repo&lt;/a&gt;, the numeric declarations use &lt;code&gt;xs:float&lt;/code&gt; and integer types, including &lt;code&gt;xs:integer&lt;/code&gt; and &lt;code&gt;xs:positiveInteger&lt;/code&gt;. None uses &lt;code&gt;xs:decimal&lt;/code&gt;. The deposit schema has typed amounts this way since the earliest version in that repo (April 2021), and version 2.0.0 keeps it.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>rust</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Struggles of finding jobs for dropouts</title>
      <dc:creator>Prashant Chaudhari</dc:creator>
      <pubDate>Sun, 07 Jun 2020 04:09:50 +0000</pubDate>
      <link>https://dev.to/pandaa880/struggles-of-finding-jobs-for-dropouts-504o</link>
      <guid>https://dev.to/pandaa880/struggles-of-finding-jobs-for-dropouts-504o</guid>
      <description>&lt;p&gt;All the self taught developers and dropouts out there. What are your experiences with finding the job ? Do you tell the recruiter beforehand that you don't have a relevant degree or after the process is over ? I am from India and here it is difficult to get a job for dropouts very few companies allow it. Specially if anyone is from India and in similar situation, please share your experiences. So I want to hear your opinions and thoughts.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>jobs</category>
      <category>dropout</category>
    </item>
  </channel>
</rss>
