<?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: polycratia</title>
    <description>The latest articles on DEV Community by polycratia (@polycratia).</description>
    <link>https://dev.to/polycratia</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%2F4054706%2Fac002c30-6c04-4e99-a7e2-1373af5f4833.png</url>
      <title>DEV Community: polycratia</title>
      <link>https://dev.to/polycratia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/polycratia"/>
    <language>en</language>
    <item>
      <title>Sign the quote behind every 402, or the retry pays a different price</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Mon, 28 Sep 2026 09:34:12 +0000</pubDate>
      <link>https://dev.to/polycratia/sign-the-quote-behind-every-402-or-the-retry-pays-a-different-price-4o3</link>
      <guid>https://dev.to/polycratia/sign-the-quote-behind-every-402-or-the-retry-pays-a-different-price-4o3</guid>
      <description>&lt;p&gt;The x402 exchange comes in two halves with an unbounded gap between them. A server answers an unpaid request with 402 and the terms it accepts; some time later the client repeats the request with an X-PAYMENT header built against those terms. If the gate only knows its current price, it checks the second half against state that may have moved since the first half went out. The client can pay a price it was never shown, or get refused for paying exactly the price it was.&lt;/p&gt;

&lt;p&gt;I have spent most of the last eight years on payment systems where the interesting failures sit in the bookkeeping rather than the transfer, and this is a bookkeeping failure in a pricing costume. It is an evidence problem: at retry time the server holds no artifact of what it offered, so it substitutes what it offers now and hopes the two are the same thing.&lt;/p&gt;

&lt;p&gt;Where the gap actually is&lt;/p&gt;

&lt;p&gt;Look at what a gate has in hand when the paid retry arrives. In paygate402 the accepted terms come from either a fixed Accepts list or a Prices table keyed by route. Both are server configuration, read at request time. The payment payload stays opaque on purpose: the package matches only the scheme and the network, because those are the only fields a web layer can honestly compare, and the amount, the asset and the recipient are checked by the facilitator that can read the payload.&lt;/p&gt;

&lt;p&gt;That division of labour is right, and it is exactly what makes the stale-price case invisible. The facilitator verifies the payment against the terms the gate hands it, and the gate hands it today's terms. Nobody in the chain holds the terms that were actually printed in the 402. If the price moved between the challenge and the retry (a deploy, a config reload, a route that started resolving to a different row of the pricing table), the client's signed authorization gets judged against an offer it never saw. When the new price is higher the payment is declined, and the client is refused for doing precisely what the server told it to do. When the new price is lower, the authorization still covers it, and the client pays above the number now on the board.&lt;/p&gt;

&lt;p&gt;The operational version of this is worse than the technical one. A client writes in saying it was quoted one amount and charged against another, and the server cannot contradict it, because the server kept no copy of the quote. Reconciliation work teaches you fast that a disagreement between two parties resolves only when both hold a record of the same row. A 402 that carries a bare number gives the client a record and keeps none.&lt;/p&gt;

&lt;p&gt;The offer is a bearer artifact, not server state&lt;/p&gt;

&lt;p&gt;The fix is to stop treating the price as something the server remembers and start treating it as something the client carries. paygate402 does this with a signer: when Quotes is set, the gate signs the priced offer behind every 402 with its own key (amount, asset, expiry and a nonce) and sends one X-PAYMENT-QUOTE header per accepted term. The client echoes the quote back with its payment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Accepts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
        &lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="s"&gt;"exact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxAmountRequired&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"10000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;          &lt;span class="s"&gt;"https://api.example.com/report"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PayTo&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;merchantAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;usdcAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxTimeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}},&lt;/span&gt;
    &lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPFacilitator&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://facilitator.example.com"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Quotes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSigner&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;quoteKey&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;Nothing is stored for this. There is no quote table and no expiry sweeper, nothing to replicate between instances, because the offer proves its own provenance. The practical consequence: a price change stops being a race. Outstanding quotes stay honourable until they run out, new requests get the new number, and the two coexist for exactly the lifetime you chose when you signed. That is the property I actually wanted out of this, and it is not the one I expected to want.&lt;/p&gt;

&lt;p&gt;Three checks, and only one of them is about forgery&lt;/p&gt;

&lt;p&gt;With a quote in hand, the gate checks three things before a facilitator is asked anything: that this server signed the offer, that the offer has not run out, and that it is the offer for the terms being paid for now.&lt;/p&gt;

&lt;p&gt;They fail differently, so they are worth separating.&lt;/p&gt;

&lt;p&gt;The first is forgery. Without a signature the price is a number the client hands back, which means the client sets it. This one is obvious, and it is the one everybody implements.&lt;/p&gt;

&lt;p&gt;The second is staleness. A quote without an expiry is a perpetual option on your price, written by you and held by whoever asked once. Anyone who has priced anything against a moving asset knows what a free option is worth to the holder and what it costs the writer. Call the expiry the term of the offer, because that is what it does, and hygiene has nothing to do with it.&lt;/p&gt;

&lt;p&gt;The third gets forgotten: substitution. A signature says the artifact is genuine. It does not say the artifact is genuine about this. A valid, unexpired, correctly signed quote for a cheap route, presented on an expensive one, passes both of the first two checks. Binding the offer to the terms being bought is what closes that, and the binding has to live inside the signed bytes rather than get checked alongside them.&lt;/p&gt;

&lt;p&gt;The ordering matters too. The quote is checked before the facilitator call, not after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replayWindow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quotes&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReplayWindowFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quotes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A payment answering no offer this server made, or one that has run out, is not a question worth putting to a facilitator. The facilitator call is the expensive, rate-limited, occasionally unreachable part of the request. Anything you can refuse from local evidence, refuse from local evidence.&lt;/p&gt;

&lt;p&gt;Canonical bytes, not the JSON&lt;/p&gt;

&lt;p&gt;The offer is signed over a canonical length-prefixed form rather than over its JSON representation. Two separate reasons, and both bite in production.&lt;/p&gt;

&lt;p&gt;JSON is not a stable byte string. Key order, whitespace, number formatting and escaping all vary by encoder and by version of encoder. A verifier that reconstructs the JSON to check the signature is betting that its serializer agrees byte for byte with the one that signed. That bet holds until the day it does not, and the symptom is a fleet where some instances reject quotes issued by others.&lt;/p&gt;

&lt;p&gt;The length prefixes are the second reason. Concatenating fields to sign them lets the boundaries move: an amount of 10 followed by a field starting 000 signs the same bytes as an amount of 100 followed by 00. Length prefixes make the parse of the signed bytes unique, which is the whole point: you are not signing some fields, you are signing one unambiguous reading of them.&lt;/p&gt;

&lt;p&gt;The expiry you already wrote is the retention you needed&lt;/p&gt;

&lt;p&gt;There is a quiet payoff here. A spent payment has to be remembered until the offer behind it stops standing, plus a margin for the difference between the clock that stamped the quote and the clock that reads it. Without a signer, paygate402 remembers a used payment for a default hour, a number chosen because some number was required. With a signer, the window comes from the quote's own lifetime: ReplayWindowFor, above. Shorter leaves a window open, longer only makes the store grow.&lt;/p&gt;

&lt;p&gt;This does not make the replay ledger optional. A settled X-PAYMENT header is still a bearer artifact and still has to be recorded as spent. But the retention parameter stops being a guess, because the offer already states when it stops being presentable.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;I built the quote signer as a way to bound replay retention and only afterwards understood it as a correctness property of pricing. That ordering cost me time. The argument that should have come first is the plain one: if you cannot show what you offered, you cannot defend what you charged.&lt;/p&gt;

&lt;p&gt;I would also treat the skew margin as a first-class decision rather than a 0 passed at the call site. Two machines that disagree by a few seconds will produce a narrow band of quotes that one instance considers live and another considers dead, and in the logs that band looks exactly like a client bug...&lt;/p&gt;

&lt;p&gt;The key deserves the same treatment. A signer with a single key works until the first rotation, and rotation on a bearer artifact with a lifetime means accepting the previous key for at least as long as the longest outstanding quote. Small amount of design done early, or an outage done late.&lt;/p&gt;

&lt;p&gt;The gate is at &lt;a href="https://github.com/polycratia/paygate402" rel="noopener noreferrer"&gt;https://github.com/polycratia/paygate402&lt;/a&gt;. The signer is optional there, and the gate without one behaves exactly as it did before. But where the price can move between the challenge and the retry, which is probably most things worth charging for, the optional part is what makes the 402 mean something.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/sign-the-quote-behind-every-402-or-the-retry-pays-a-different-price" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A cryptography inventory is a reconciliation problem</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:27:19 +0000</pubDate>
      <link>https://dev.to/polycratia/a-cryptography-inventory-is-a-reconciliation-problem-3ldc</link>
      <guid>https://dev.to/polycratia/a-cryptography-inventory-is-a-reconciliation-problem-3ldc</guid>
      <description>&lt;p&gt;Most post-quantum migration plans stall on the inventory, before the migration starts. Your source code says one thing about the cryptography you run, the TLS handshake says another, and until both sides are written down in the same format you are guessing about which one holds in production. You cannot migrate what you cannot see, and the algorithms that actually protect your traffic were picked by a handshake nobody in the building has read.&lt;/p&gt;

&lt;p&gt;I have spent most of the last eight years on systems where money moves, so the shape of this problem is familiar. An internal ledger and a bank statement do not agree on the first pass. The rows that matched were the boring part: the work was in deciding what an unmatched row meant, and in refusing to auto-match anything just to make the report look clean. A cryptography bill of materials is the same exercise with different inputs: two sources, a disagreement, and a discipline about what you are allowed to infer.&lt;/p&gt;

&lt;p&gt;That is the premise behind a small Go tool I maintain, &lt;a href="https://github.com/polycratia/cbomscope" rel="noopener noreferrer"&gt;cbomscope&lt;/a&gt;. It reads both sides and writes the result as a CycloneDX 1.6 cryptography bill of materials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ledgers, and they disagree on purpose
&lt;/h2&gt;

&lt;p&gt;The first ledger is the code. Someone wrote a key size into a call years ago, and that line is a fact about intent at the moment it was written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;rsa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scanning for that is easy, and badly incomplete: almost nothing about the cryptography that protects a live connection gets decided in your repository. The second ledger is the endpoint itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cbomscope probe cloudflare.com:443
&lt;span class="go"&gt;POSTURE             ASSET                   WHERE               WHY
quantum_vulnerable  ECDSA-P-256             cloudflare.com:443  Shor's algorithm solves the underlying elliptic-curve discrete logarithm
quantum_vulnerable  ECDSA-SHA256            cloudflare.com:443  Shor's algorithm solves the underlying elliptic-curve discrete logarithm
&lt;/span&gt;&lt;span class="gp"&gt;quantum_reduced     TLS_AES_128_GCM_SHA256  cloudflare.com:443  Grover halves this to about 64 bits of quantum work;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;256-bit keys are the usual answer
&lt;span class="go"&gt;hybrid              X25519MLKEM768          cloudflare.com:443  classical and post-quantum key exchange combined: secure if either half holds
not_applicable      TLS 1.3                 cloudflare.com:443  a protocol version has no quantum posture of its own: see the key exchange and cipher it negotiated

5 asset(s): quantum_vulnerable=2 quantum_reduced=1 hybrid=1 not_applicable=1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hybrid key exchange group is the whole argument for the probe. No source file chose &lt;code&gt;X25519MLKEM768&lt;/code&gt;. A runtime default, a terminating load balancer, a proxy in front of the service, or an operating system package upgrade chose it, and the repository is silent on all four. A scan of the code would have reported this deployment as less prepared than it is.&lt;/p&gt;

&lt;p&gt;The reverse case is the one that costs you. An asset that shows up on the wire and nowhere in your code is a dependency on a decision made outside your change control. The day it moves (a base image bumps, a proxy config changes, a cipher is retired upstream) no diff in your repository will show it. In reconciliation terms that is an unmatched statement line: money arrived that your ledger does not explain, and the explanation lives in a system you do not own. You do not get to ignore it because your side balances.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scanner refuses to guess
&lt;/h2&gt;

&lt;p&gt;The temptation with a static scan is to grep. I did not, because grep produces the exact failure mode that makes reconciliation reports useless: confident rows that are wrong.&lt;/p&gt;

&lt;p&gt;The scanner reads Go syntax trees. Import aliases are followed, so renaming a package at the import does not hide a call. A comment or a string literal that mentions &lt;code&gt;rsa.GenerateKey&lt;/code&gt; is not a finding, because it is not a call. Parameters come from the call site where they are literal (the line above becomes RSA-2048), and where the key size arrives in a variable, the asset is reported with no size at all rather than a plausible default.&lt;/p&gt;

&lt;p&gt;That last rule is the one I would defend hardest. AES-128 and AES-256 do not share a verdict: one is weakened by a quadratic speedup on unstructured search, the other is not meaningfully touched. Filling in a default so the row looks complete is the same move as auto-matching a payment to the nearest invoice because the amounts are close. It closes the ticket and leaves the exposure. An honest gap in an inventory can be assigned to a person. A wrong entry cannot, because nobody will look at it again.&lt;/p&gt;

&lt;p&gt;The probe has a matching rule pointing the other way: it skips certificate verification on purpose. The job is to record what an endpoint presents, and an expired or self-signed certificate is exactly the inventory that needs attention, not an error that should abort the reading. The connection carries no application data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every verdict is a row, and every row cites a document
&lt;/h2&gt;

&lt;p&gt;What makes this usable by someone other than me is that the classification is not code. It is a table in &lt;code&gt;internal/classify/table.go&lt;/code&gt;, and every row names the document it came from. RSA, DSA, DH, and everything elliptic-curve are quantum-vulnerable on Shor; AES and SHA-2 are judged by key and digest size; ML-KEM, ML-DSA and SLH-DSA are quantum-safe on FIPS 203, 204 and 205; MD5, SHA-1, RC4, 3DES and TLS 1.0/1.1 are broken already, on RFC 6151, RFC 7465, SP 800-131A and RFC 8996.&lt;/p&gt;

&lt;p&gt;The citation travels with the finding, into the JSON output and into the CBOM as a &lt;code&gt;cbomscope:citation&lt;/code&gt; property, so a reviewer can check a posture against the standard instead of trusting the tool. And because the verdicts are data rather than a chain of conditions, the tool can print what it believes without anyone reading its source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cbomscope table
&lt;span class="go"&gt;FAMILY          VERDICT             SOURCE
X25519MLKEM768  hybrid              draft-ietf-tls-hybrid-design: hybrid key exchange in TLS 1.3
ML-KEM          quantum_safe        NIST FIPS 203: ML-KEM, module-lattice key encapsulation
&lt;/span&gt;&lt;span class="gp"&gt;RSA             quantum_vulnerable  Shor 1997, SIAM J. Comput. 26(5): polynomial-time factoring and discrete logarithms;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;NIST IR 8547
&lt;span class="gp"&gt;AES             by size             Grover 1996, STOC: a quadratic speedup for unstructured search;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;NIST IR 8547
&lt;span class="c"&gt;...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A family with no row comes out as &lt;code&gt;unknown&lt;/code&gt;, with a rationale saying a person has to judge it and with no citation attached. That omission is deliberate. A source printed next to an answer nobody actually has would make the gap look reviewed, and that is worse than leaving it visibly open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate has to be one you will still respect next quarter
&lt;/h2&gt;

&lt;p&gt;Only &lt;code&gt;broken&lt;/code&gt; fails the command by default. &lt;code&gt;quantum_vulnerable&lt;/code&gt; describes almost every deployment on earth right now, mine included: making it an error would turn the exit code into noise on the first run and probably get the tool pulled from CI inside a week. &lt;code&gt;-fail-on&lt;/code&gt; is there for teams that have already done the work and want a tighter gate.&lt;/p&gt;

&lt;p&gt;This is the same judgement as alerting on reconciliation breaks. If the alert fires on every unmatched line, it fires on all of them, and nobody reads any of them. You alert on the class you intend to act on this week, and you report the rest.&lt;/p&gt;

&lt;p&gt;The artifact is what you are after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cbomscope cbom &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-probe&lt;/span&gt; api.example.com:443 &lt;span class="nt"&gt;-o&lt;/span&gt; cbom.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I first treated this as an audit: run it, read the output, file the work. That was wrong, for the same reason a once-a-year reconciliation is wrong. The source-code side of the inventory only changes when someone commits, so a diff already covers it. The wire side changes without anyone committing anything, which means a CBOM generated once is a statement about a day that has already gone by. Generate it on a schedule against the real endpoints, commit the artifact, and diff it. The signal you want is the change in the file that no pull request explains.&lt;/p&gt;

&lt;p&gt;The tool is narrow. Source scanning is Go only, covering the standard library's crypto packages, &lt;code&gt;x/crypto/chacha20poly1305&lt;/code&gt; and &lt;code&gt;crypto/mlkem&lt;/code&gt;; the probe covers TLS version, cipher suite, key exchange group, and the certificate's key and signature. It needs Go 1.25 or newer, because reporting the negotiated key exchange group requires it, and it has no dependencies outside the standard library.&lt;/p&gt;

&lt;p&gt;None of this predicts when a cryptographically relevant quantum computer arrives. It says what would fall if one did, from a table you can check, against two sources that are allowed to disagree.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-cryptography-inventory-is-a-reconciliation-problem" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why leniency in a DER parser becomes a signature bypass</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:22:21 +0000</pubDate>
      <link>https://dev.to/polycratia/why-leniency-in-a-der-parser-becomes-a-signature-bypass-a8p</link>
      <guid>https://dev.to/polycratia/why-leniency-in-a-der-parser-becomes-a-signature-bypass-a8p</guid>
      <description>&lt;p&gt;Two implementations can read the same X.509 certificate and disagree about what it says. The cause is almost never the cryptography. BER permits several encodings of one value, DER permits exactly one, and a parser that quietly accepts the alternates has handed the document a second meaning, one the signature covers just as well as the first.&lt;/p&gt;

&lt;p&gt;Most of my production work since 2018 has been payments, crypto rails and compliance tooling, and a good part of it involved documents that have to hold up as evidence after the fact: legally binding e-signature services and the document workflow around them, multi-party deal signing, on-chain tokens that accumulate signed state from listing to closing. The lesson that kept repeating there has nothing to do with algorithms. A signature attests to bytes. Between bytes and meaning sits a parser, and if that parser admits more than one reading, the signature attests to less than you think it does.&lt;/p&gt;

&lt;p&gt;I wrote derstrict (&lt;a href="https://github.com/polycratia/derstrict" rel="noopener noreferrer"&gt;https://github.com/polycratia/derstrict&lt;/a&gt;) as a header-only C++17 reader that refuses everything DER already forbids. What follows is the reasoning behind that, not an API tour.&lt;/p&gt;

&lt;p&gt;One value, four encodings&lt;/p&gt;

&lt;p&gt;Here is the integer 5 inside a SEQUENCE, written four ways. Only the first is DER.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 03 02 01 05           SEQUENCE { INTEGER 5 }        valid DER
30 80 02 01 05 00 00     indefinite length             BER only
30 81 03 02 01 05        length written long-form      not minimal
30 04 02 02 00 05        INTEGER 00 05                 padded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same holds one level down. An OBJECT IDENTIFIER arc is a base-128 number, and base 128 has no leading zero digit any more than base ten does, so &lt;code&gt;80 01&lt;/code&gt; and &lt;code&gt;01&lt;/code&gt; are one arc written two ways.&lt;/p&gt;

&lt;p&gt;None of these are corrupt. Each one decodes, under BER, to exactly the value the first one holds, and that is the whole problem. A parser that accepts all four has not become more robust than a parser that accepts one: it has agreed to four documents where the standard defines one.&lt;/p&gt;

&lt;p&gt;Leniency is a claim about every other parser in the system&lt;/p&gt;

&lt;p&gt;"Be liberal in what you accept" is advice for a transport. It inverts for a signed document, because there the parse result is the thing being attested, and the attestation is shared with implementations you did not write and cannot inspect.&lt;/p&gt;

&lt;p&gt;A signature bypass of this family does not require anyone to break a hash. It requires only that two components in one pipeline read the same bytes differently: the component that checks a name, an extension or a constraint reads encoding A, the component that acts on the value reads encoding B, and both verify the signature successfully, because the signature is over the bytes and the bytes never changed. The attacker does not need to control what the document means. They need the two readings to differ, and every alternate encoding you tolerate is one more place where that difference can be manufactured.&lt;/p&gt;

&lt;p&gt;So the question a DER reader has to answer is not "can I make sense of this?" It is "is there exactly one sense to make?" Refusal is the mechanism that forces two independent implementations to agree, and I think that is worth more than any accommodation I could add on top.&lt;/p&gt;

&lt;p&gt;The length rules are decided before any content is read&lt;/p&gt;

&lt;p&gt;What matters structurally is ordering. Every length rule in derstrict is settled against the enclosing element before a single content byte is touched, so an inner element cannot reach into bytes its parent does not cover. Containment becomes arithmetic done up front, instead of a check somebody remembers to perform after the fact.&lt;/p&gt;

&lt;p&gt;Four things are refused at that point. Indefinite length (&lt;code&gt;0x80&lt;/code&gt;), because it is BER and because guessing where an element ends is precisely how parsers diverge. A long-form length where the short form fits, because &lt;code&gt;81 05&lt;/code&gt; and &lt;code&gt;05&lt;/code&gt; mean the same thing and only one encoding may exist. The reserved length byte &lt;code&gt;0xFF&lt;/code&gt;, because X.690 reserves it and it therefore announces no byte count at all: reading it as "127 length bytes follow" would be a parser inventing a meaning. And a length that runs past the end of the enclosing element, measured before descent.&lt;/p&gt;

&lt;p&gt;The content rules apply the same idea one layer in: a leading &lt;code&gt;0x00&lt;/code&gt; in an INTEGER is a sign byte only in front of a set top bit and a leading &lt;code&gt;0xFF&lt;/code&gt; is sign extension only in front of a clear one, an OID arc may not end with the continuation bit still set, a BIT STRING's unused-bits count is at most seven, is zero when there is no last byte, and counts bits that are themselves zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;make demo
&lt;span class="go"&gt;well formed                accepted: 1.2.840.113549.1.1.11
indefinite length          refused: indefinite length is BER, not DER
length written long-form   refused: the length is encoded the long way
oid ending mid-arc         refused: the object identifier ends mid-arc
bytes after the element    refused: bytes remain after the element
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cursor that cannot lie about how much it read&lt;/p&gt;

&lt;p&gt;The last refusal in that list is the one people skip, and it carries the thesis most directly. Bytes after the outermost element mean somebody else read this document differently from you. There is no benign version of that.&lt;/p&gt;

&lt;p&gt;Enforcing it means knowing exactly how many bytes were consumed, which is why the cursor carries a sticky failure and an exact &lt;code&gt;remaining()&lt;/code&gt;. &lt;code&gt;remaining()&lt;/code&gt; never underflows, and it is zero once a read has failed, because a failed parser reads nothing further. That is what makes a whole run of reads checkable at the end instead of at every step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;"derstrict/derstrict.hpp"&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="n"&gt;derstrict&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;derstrict&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sequence&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="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;derstrict&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&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="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at_end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at_end&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"trailing data"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;at_end()&lt;/code&gt; calls are not defensive noise. The inner one says the constructed element contained what it claimed and nothing more, the outer one says the document did. Without an exact byte count you have neither statement available, and "I parsed it and it looked fine" is not the same claim at all.&lt;/p&gt;

&lt;p&gt;Nothing is copied and nothing is owned: &lt;code&gt;content&lt;/code&gt; points into the caller's buffer. For a library whose entire job is to decide what a specific byte range means, that is a correctness property as much as a performance one, because there is no second copy that could drift from the bytes the signature covers.&lt;/p&gt;

&lt;p&gt;Integers, where exactness usually dies&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unsigned_integer()&lt;/code&gt; handles the small fields: versions, counts, the things that fit in 64 bits. Serial numbers and key material do not fit, so &lt;code&gt;integer()&lt;/code&gt; reads one of any width or sign and hands back a view of the encoding: &lt;code&gt;negative()&lt;/code&gt;, and &lt;code&gt;magnitude()&lt;/code&gt; for a non-negative value's bytes where they already lie.&lt;/p&gt;

&lt;p&gt;A negative value is the interesting case, and it is where I made the deliberate ergonomic sacrifice. Its magnitude is not in the document (the document holds the two's complement), so producing the magnitude means writing bytes somewhere, and this library owns no memory to write them into. &lt;code&gt;magnitude_into()&lt;/code&gt; therefore writes into a buffer the caller owns. That is less convenient than returning a big integer, and it is the right trade: the alternative is allocation inside a parser that is supposed to be a pure decision about bytes that already exist.&lt;/p&gt;

&lt;p&gt;Refusing something that is legal&lt;/p&gt;

&lt;p&gt;High-tag-number form is valid DER. derstrict refuses it anyway, because no field it currently reaches uses it, and a parser that half-supports a construct is worse than one that declines it: the half-support is exactly the region where two implementations diverge. Refusing something legal is a smaller error than guessing at it, and it is a loud error rather than a quiet one.&lt;/p&gt;

&lt;p&gt;The same logic governs what is not there yet: &lt;code&gt;UTCTime&lt;/code&gt; and &lt;code&gt;GeneralizedTime&lt;/code&gt;, string types with their character-set rules, context-specific tags. Those have real encoding rules, and shipping a lenient version of them to look complete would undo the point of the library. Every refusal listed above has a test built from hand-written bytes, 181 checks under AddressSanitizer and UndefinedBehaviorSanitizer, because in a library like this the tests for what it declines are the tests that carry the value.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;The pressure I would resist harder, and earlier, is convenience. Every request a strict reader receives is a request to accept one more thing: a certificate from a device fleet that emits long-form lengths, an old signer that pads its integers. Each one arrives as a small compatibility fix, and each one is a permanent statement that this document has two readings.&lt;/p&gt;

&lt;p&gt;The honest way to carry that pressure is scope. derstrict reads the encoding and not the semantics: there is no certificate structure in it, it builds no chains, it verifies no signatures, and pretending otherwise would be the dangerous kind of convenience. It is not a replacement for a reviewed library in a codebase that already has one. It is for the case where the alternative is a hand-rolled loop over a buffer, and that is a case I have walked into more than once...&lt;/p&gt;

&lt;p&gt;The framing I would keep is the one I started with. A parser's permissiveness is not a local property. It is a claim about every other implementation that will read the same bytes, and in a signed document that claim is the security boundary.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/why-leniency-in-a-der-parser-becomes-a-signature-bypass" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Constant-time comparison and erasure the compiler cannot delete</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sun, 20 Sep 2026 13:27:16 +0000</pubDate>
      <link>https://dev.to/polycratia/constant-time-comparison-and-erasure-the-compiler-cannot-delete-2g4d</link>
      <guid>https://dev.to/polycratia/constant-time-comparison-and-erasure-the-compiler-cannot-delete-2g4d</guid>
      <description>&lt;p&gt;Two defects outlive most of the code that still contains them. A MAC compared with &lt;code&gt;memcmp&lt;/code&gt; returns at the first differing byte, so anyone who can time the call learns how many leading bytes they guessed right, and recovers the tag one byte at a time (CWE-208). A key cleared with &lt;code&gt;memset&lt;/code&gt; at the end of a function is a store into a buffer that is dead afterwards, and the optimizer is permitted to delete it, which leaves the key sitting in memory for a core dump or the next allocation to find (CWE-14). Neither needs a clever fix. Both need a fix the compiler is not permitted to undo.&lt;/p&gt;

&lt;p&gt;I have been shipping payment and crypto systems since 2018, and in that time these same two lines have turned up in webhook signature verification, in session token checks, and in custodial wallet code handling key material. They are not exotic. They are what you write when you are thinking about the protocol and not about the code generator. I eventually pulled the fix into a small header so I would stop re-deriving it: &lt;a href="https://github.com/polycratia/ctsafe" rel="noopener noreferrer"&gt;ctsafe&lt;/a&gt;, header-only, C++17, no allocation, no exceptions. The API is trivial. The threat model is not, and that is what the rest of this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape they ship in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* accept */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ... and later, at the end of the function&lt;/span&gt;
&lt;span class="n"&gt;memset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines are correct in the sense that a careful reader will nod at them. &lt;code&gt;memcmp&lt;/code&gt; compares the bytes, &lt;code&gt;memset&lt;/code&gt; writes zeroes. What neither line is is a statement about the machine. &lt;code&gt;memcmp&lt;/code&gt; is specified to return a sign, not to take a fixed time, and every real implementation exits early because early exit is what makes it fast. &lt;code&gt;memset&lt;/code&gt; is a store, and in the abstract machine a store nobody can read back is unobservable, so deleting it changes nothing the standard cares about. At &lt;code&gt;-O2&lt;/code&gt; it commonly vanishes.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that the compiler and the attacker want the same thing here. The compiler wants to skip work no one can observe. The attacker's whole premise is that they can observe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading every byte, and saying so
&lt;/h2&gt;

&lt;p&gt;The comparison has to read the full length, keep every branch independent of the contents, and push the accumulated difference through something the optimizer is not allowed to reason across.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;"ctsafe/ctsafe.hpp"&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="c1"&gt;// Spans: both lengths travel with the data, so the size is never retyped.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presented_tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* accept */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Or a pointer and a length, when that is what the caller has.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* accept */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The span overload accepts anything with &lt;code&gt;data()&lt;/code&gt; and &lt;code&gt;size()&lt;/code&gt;, so &lt;code&gt;std::array&lt;/code&gt;, &lt;code&gt;std::vector&lt;/code&gt;, &lt;code&gt;std::string&lt;/code&gt;, a C array, &lt;code&gt;std::span&lt;/code&gt; on C++20, and that matters more than it looks. Most instances of this bug I have found were not a missing constant-time routine. They were a correct routine called with a length retyped at the call site, where it can drift away from the buffer it describes. When the length travels with the data, that drift is not expressible.&lt;/p&gt;

&lt;p&gt;A size mismatch answers &lt;code&gt;false&lt;/code&gt; before a byte is read. That is deliberate, and worth saying plainly: the length is not secret. Two buffers of different sizes are a structural error rather than a guess to protect, and reading the shorter one past its end to preserve symmetry would be a far worse bug than the leak you avoided.&lt;/p&gt;

&lt;p&gt;The demo in the repository prints the property directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;correct tag            accepted=1
wrong in the last byte accepted=0
wrong in the first     accepted=0
&lt;/span&gt;&lt;span class="gp"&gt;  (all three read all 16 bytes;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;memcmp would not&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrong in the first byte costs what wrong in the last byte costs. That is the whole claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak moves up one line
&lt;/h2&gt;

&lt;p&gt;Nobody warns you about this part. You replace &lt;code&gt;memcmp&lt;/code&gt;, you feel finished, and then you write the code that uses the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presented_tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;derived_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&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 comparison no longer leaks. The branch around the &lt;code&gt;memcpy&lt;/code&gt; does, the same signal one line further out. Accepted and rejected requests now take visibly different paths, with different stores and a different cache footprint. The fix is to stop treating the answer as a branch condition and start treating it as arithmetic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mask_from_bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presented_tag&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;copy_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;derived_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;derived_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select&lt;/code&gt; and &lt;code&gt;copy_if&lt;/code&gt; read both sides and write every byte of the destination whichever way the mask goes, so a rejected copy costs exactly the stores an accepted one costs. The subtle part is the mask itself, which passes through a value barrier before the loop. Without it the compiler can discover what the mask holds and helpfully rewrite the masked loop as a branch around a &lt;code&gt;memcpy&lt;/code&gt;, putting back the leak the mask was there to remove. That recurs across this whole area: each of these routines is one inference away from being optimized back into the bug it fixes.&lt;/p&gt;

&lt;p&gt;And a mask is &lt;code&gt;0xFF&lt;/code&gt; or &lt;code&gt;0x00&lt;/code&gt;, nothing else. These functions are arithmetic, not a test. Handed &lt;code&gt;0x01&lt;/code&gt;, &lt;code&gt;copy_if&lt;/code&gt; mixes the two sides bit by bit instead of rejecting it. Masks come from &lt;code&gt;eq&lt;/code&gt; or &lt;code&gt;mask_from_bool&lt;/code&gt;, and a value invented elsewhere is a bug that no validation inside the loop could catch without (of course) branching on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Erasure is a platform routine, not a clever loop
&lt;/h2&gt;

&lt;p&gt;The right move for zeroing is not to argue with the optimizer. Every platform I ship on already provides a routine whose entire purpose is to not be elided:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;erase&lt;/code&gt; calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SecureZeroMemory&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glibc &amp;gt;= 2.25, OpenBSD, FreeBSD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;explicit_bzero&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Annex K available (&lt;code&gt;__STDC_LIB_EXT1__&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memset_s&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anything else&lt;/td&gt;
&lt;td&gt;stores through a &lt;code&gt;volatile&lt;/code&gt; pointer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;volatile&lt;/code&gt; row is the last resort, not the design. Every path ends in a compiler barrier, an empty &lt;code&gt;asm&lt;/code&gt; block with a memory clobber on GCC and Clang, &lt;code&gt;_ReadWriteBarrier()&lt;/code&gt; on MSVC, so the buffer cannot be treated as dead across the call. On Windows the header reaches &lt;code&gt;SecureZeroMemory&lt;/code&gt; through &lt;code&gt;&amp;lt;windows.h&amp;gt;&lt;/code&gt;, and defining &lt;code&gt;CTSAFE_NO_WINDOWS_H&lt;/code&gt; keeps that out of the translation unit and takes the fallback instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;erase_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// size taken from the type, not retyped&lt;/span&gt;
&lt;span class="n"&gt;ctsafe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;erase_backend_name&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// which routine that erase actually called&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things there I would defend in review. &lt;code&gt;erase_object&lt;/code&gt; takes the size from the type, which removes the retyped-length failure that the span overload removes from the comparison. And &lt;code&gt;erase_backend_name&lt;/code&gt; exists because a guarantee you have to guess at is not one: preprocessor-selected behaviour you cannot interrogate at runtime is how a build quietly lands on the fallback path and nobody notices for two years. The test suite runs at &lt;code&gt;-O2&lt;/code&gt; as well as under the sanitizers, because an erasure that only survives at &lt;code&gt;-O0&lt;/code&gt; is the bug being tested for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this buys
&lt;/h2&gt;

&lt;p&gt;A unit test cannot prove constant time. Only reading the generated instructions can, and even then the CPU has the last word: caches, branch prediction and speculative execution sit outside the reach of a portable header. What you get is source that does not &lt;em&gt;ask&lt;/em&gt; the compiler to leak, plus barriers against the two specific optimizations that break these routines. That is a smaller claim than "constant time", and I think it is the honest one.&lt;/p&gt;

&lt;p&gt;Erasing a buffer also does not erase its copies. Nothing in that table reaches a register spill, a block that &lt;code&gt;realloc&lt;/code&gt; moved, or a page the kernel already wrote to swap. Erase clears the bytes you name, at the moment you name them. Everywhere else the value travelled is a different problem, and mostly an architectural one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Earlier I treated the comparison as the fix, and I was wrong in two directions. The branch at the call site kept the leak alive while I congratulated myself on the loop, and I verified the erasure in a debug build, where it is present anyway and therefore proves nothing. The checks I care about now: the erase backend is asserted in the test run rather than assumed, secret material lives in one type so its size comes from the type at every erase site, and the real work is reducing the number of places a key exists at all rather than trusting erase to clean up after a value that was copied four times on the way in.&lt;/p&gt;

&lt;p&gt;Both defects survive review because the code says what the author meant. What the machine does with it is a separate document, and the only durable repair is to state the property in a form the compiler is not permitted to discard, then make the build tell you which form it chose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/constant-time-comparison-and-erasure-the-compiler-cannot-delete" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>An address validator should return a reason, not a boolean</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 19 Sep 2026 13:27:17 +0000</pubDate>
      <link>https://dev.to/polycratia/an-address-validator-should-return-a-reason-not-a-boolean-1m05</link>
      <guid>https://dev.to/polycratia/an-address-validator-should-return-a-reason-not-a-boolean-1m05</guid>
      <description>&lt;p&gt;A user pastes an address into a withdrawal form and gets back "invalid address". Sometimes the string was truncated by a copy that clipped at a line break. Sometimes it is a perfectly good address for a network you are not sending on. Sometimes it is an encoding your build does not know yet. Three different situations, three different fixes, and a validator that returns a boolean has thrown away the distinction before any caller could act on it.&lt;/p&gt;

&lt;p&gt;I have built custodial wallets for BTC and ETH, an on-chain payment system, and a fiat-to-crypto onramp where addresses arrive from forms, from support tickets, and from other systems' APIs. The shape that survived all of it is small: validation returns a result carrying a typed reason, the UI and the support tooling branch on that reason, and the list of supported chains grows underneath without any of those branches changing. &lt;code&gt;chain-addresses&lt;/code&gt; (&lt;a href="https://github.com/polycratia/chain-addresses" rel="noopener noreferrer"&gt;https://github.com/polycratia/chain-addresses&lt;/a&gt;) is where I keep the current version of that shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejections are not interchangeable
&lt;/h2&gt;

&lt;p&gt;Start from what the caller has to do next, not from what the validator knows.&lt;/p&gt;

&lt;p&gt;A checksum that does not match means the bytes on screen are not the bytes anyone intended. The fix is mechanical: ask the user to copy it again, and there is a good chance the second attempt works. This is the one failure where "try again" is honest advice.&lt;/p&gt;

&lt;p&gt;An address that decodes cleanly but belongs to another network is the opposite. Nothing is mistyped. Asking the user to re-copy sends them back to the same clipboard for the same string, and they will paste it again, more annoyed. The useful message names the network the address is actually for and stops there, because the real fix lives somewhere else entirely: a different withdrawal flow, a different asset, or a support conversation.&lt;/p&gt;

&lt;p&gt;An unexpected version byte inside a well-formed encoding is a third thing, and often not the user's fault at all. Someone is handing you an address type you do not serve yet. That is a roadmap signal rather than a validation error, and it deserves to be counted separately from typos.&lt;/p&gt;

&lt;p&gt;One boolean cannot carry any of that, so every call site invents its own message out of the absence of information. That is how you end up with three screens telling the same user three different half-truths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_addresses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validate_address&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;testnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_CHECKSUM&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;ask_again&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;that address did not check out - copy it again&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WRONG_NETWORK&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;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;that address belongs to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;that address format is not supported here&lt;/span&gt;&lt;span class="sh"&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 result is falsy when the address is not acceptable, so the guard reads like a boolean check and the detail is sitting there when you need it. A valid result carries what it recognised: &lt;code&gt;result.format&lt;/code&gt; is one of the format names the package exposes, and &lt;code&gt;result.network&lt;/code&gt; is the network the address belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Precedence is the design, not a detail
&lt;/h2&gt;

&lt;p&gt;This part only shows up once you stop returning booleans. A single bad string is usually wrong in more than one way at once, and you have to decide which truth to report.&lt;/p&gt;

&lt;p&gt;Take a testnet address with one character mangled. It fails its checksum, and its version byte says testnet. Both statements are true. If you check the network first, you tell a mainnet user "this is a testnet address", advice derived from bytes you have no reason to trust, since a failed checksum means you may be reading a corruption rather than a version.&lt;/p&gt;

&lt;p&gt;So encoding is checked first: a corrupted testnet address is a bad checksum, not a wrong network. You do not name a network from bytes that did not survive their own integrity check. A validator returning a boolean never has to make this call, which is exactly why the question goes unasked until support is reading a ticket that says "it told me this was a Bitcoin address, it is not." Confidently wrong output is worse than a vague rejection, and precedence is the only place you get to prevent it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The set that grows must not be the set you branch on
&lt;/h2&gt;

&lt;p&gt;This is the structural argument, and it is why adding a chain does not ripple into callers.&lt;/p&gt;

&lt;p&gt;There are two sets in play. Address formats are an open set: &lt;code&gt;bitcoin-p2pkh&lt;/code&gt;, &lt;code&gt;bitcoin-p2sh&lt;/code&gt;, &lt;code&gt;bitcoin-p2wpkh&lt;/code&gt;, &lt;code&gt;bitcoin-p2tr&lt;/code&gt;, their testnet variants, EIP-55 hex for EVM chains, and whatever comes next. Failure reasons are a closed set: the encoding did not verify, the address is for another network, the version is not one you serve. New chains land in the first set continuously. The second set has barely moved for me in years.&lt;/p&gt;

&lt;p&gt;Callers must branch on the closed set. The moment a call site matches on format names or parses an error string, every new chain becomes an edit in the UI, in the support panel, in the API serializer, and in whatever internal script someone wrote last quarter. That is the real cost a boolean hides: it does not just lose information, it pushes the open set into the branch structure of code that has no business knowing about it.&lt;/p&gt;

&lt;p&gt;The same separation holds on the generation side. Every encoder takes a 33-byte compressed public key and returns a string, behind one interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@runtime_checkable&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddressEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turns a compressed public key into an address for one chain and format.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the interface is that narrow, code holding an encoder never learns which chain it is serving, and a new chain is a new entry in the &lt;code&gt;ENCODERS&lt;/code&gt; registry instead of a change in every caller. &lt;code&gt;get_encoder&lt;/code&gt; raises &lt;code&gt;AddressError&lt;/code&gt; on a name it does not know, so a typo in a configuration value fails at lookup instead of producing something plausible further down.&lt;/p&gt;

&lt;p&gt;When a flow only serves one chain, you narrow at the call rather than branching afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mainnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;formats&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EVM addresses are worth a note here, because they show the limit of what a validator can honestly say. EIP-55 hex carries no network marker at all: the checksum is over the hex digits, not over a chain identifier. In this package such an address belongs to &lt;code&gt;Network.ANY&lt;/code&gt; and passes any network check, which is not a shortcut but the truth, since the same address is valid on every EVM chain. "Wrong network" is not expressible for it, and a validator that pretended otherwise would be guessing. The chain selector owns that decision; validation does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The addresses you can pay to outnumber the ones you can generate
&lt;/h2&gt;

&lt;p&gt;The tempting implementation of validation is a round trip: decode the string, re-encode it with each encoder, accept it if something matches. It is compact, it reuses code you already trust, and it is wrong.&lt;/p&gt;

&lt;p&gt;Those two sets are not the same size. A version 0, 32-byte witness program (P2WSH) is a perfectly reasonable withdrawal destination, and it validates as &lt;code&gt;bitcoin-p2wsh&lt;/code&gt; even though no encoder in the package produces one. That is deliberate: deriving deposit addresses is something you do for yourself and can keep narrow, while accepting a destination is something you do for the rest of the world, which is wider than your own wallet. A round-trip validator quietly makes the two identical and rejects a customer's perfectly good address because your key derivation happens not to produce that shape.&lt;/p&gt;

&lt;p&gt;A boolean cannot express that asymmetry either. A result that names a format can: the format it recognised may be one you never generate, and that is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I would introduce the reason type before the second chain, not after the third. Retrofitting reasons into a boolean API is easy enough. Replacing the messages that every call site has already invented is the slow part, because by then support has memorised them and someone has written a runbook against the wording.&lt;/p&gt;

&lt;p&gt;I would also log the reason rather than the address from the start. Reasons aggregate; addresses do not, and storing them is a liability you do not need. A count per reason is a genuinely useful operational signal: a rise in bad checksums probably points at something clipping strings in a UI, and a rise in wrong-network rejections after a release usually means a field got relabelled, not that users suddenly got careless. Neither is visible when the only thing you recorded was that something was invalid.&lt;/p&gt;

&lt;p&gt;The general form of this is not about addresses at all. Any validator at a boundary where a human is still available should return what it learned, not whether it approved. The boolean is a summary you can always compute later; the reason is the thing you can never get back once you have thrown it away.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/an-address-validator-should-return-a-reason-not-a-boolean" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The only stateful part of an x402 payment gate is the replay ledger</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:27:17 +0000</pubDate>
      <link>https://dev.to/polycratia/the-only-stateful-part-of-an-x402-payment-gate-is-the-replay-ledger-2c2k</link>
      <guid>https://dev.to/polycratia/the-only-stateful-part-of-an-x402-payment-gate-is-the-replay-ledger-2c2k</guid>
      <description>&lt;p&gt;An x402 paywall is three steps: the server answers an unpaid request with &lt;code&gt;402&lt;/code&gt; and the terms it accepts, the client repeats the same request carrying an &lt;code&gt;X-PAYMENT&lt;/code&gt; header, and the server verifies, settles, and only then serves. Two of those steps can be stateless. The third cannot. Unless the server remembers which payments it has already settled, a captured &lt;code&gt;X-PAYMENT&lt;/code&gt; header buys the resource again on every replay, and the gate is decoration with a status code.&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://github.com/polycratia/paygate402" rel="noopener noreferrer"&gt;paygate402&lt;/a&gt; to sit in front of a Go HTTP handler, and the part of it I keep coming back to is not the protocol plumbing. It is the ledger of spent payments and the exact moment a key gets written to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three steps, one of which repeats
&lt;/h2&gt;

&lt;p&gt;The wiring is small. The gate holds the terms it will accept and a facilitator to ask about payments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Accepts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
        &lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="s"&gt;"exact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxAmountRequired&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"10000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;          &lt;span class="s"&gt;"https://api.example.com/report"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PayTo&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;merchantAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;usdcAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxTimeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}},&lt;/span&gt;
    &lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPFacilitator&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://facilitator.example.com"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/report"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reportHandler&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note where the boundary is. This package does not check signatures and does not move money. In x402 that is the facilitator's job: it reads the scheme-specific payload, recovers the signature, checks the allowance, submits the transfer. So the payload stays opaque here, and matching compares only what a web layer can honestly compare — the scheme and the network. The amount, the asset and the recipient are checked by the component that can read the payload. A middleware that decoded some base64 and called it verification would be worse than no middleware, because it would look like a paywall while charging nobody.&lt;/p&gt;

&lt;p&gt;That boundary has a consequence people miss. Everything the gate holds in its hands is a bearer artifact. The &lt;code&gt;X-PAYMENT&lt;/code&gt; header is a self-contained, signed instruction, sent over a stateless protocol, with nothing binding it to a session, a connection, or a single attempt. Anything that sees the header holds a complete payment: a proxy log, a retry loop, a shared trace, a browser extension, a support ticket with a captured request. The signature inside it stays valid on the second presentation — that is what a signature is for. The only thing that can make the second presentation different from the first is server memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offer is fine stateless
&lt;/h2&gt;

&lt;p&gt;Before the spend there is the offer, and the offer is the half of this that genuinely does not need a database.&lt;/p&gt;

&lt;p&gt;A quote is the priced offer behind a &lt;code&gt;402&lt;/code&gt;: an amount, an asset, the moment the offer stops standing, and a nonce that makes it one of a kind. It is signed with the server's own key, so an offer that comes back can be checked against what was actually offered rather than against what a client says was offered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSigner&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TTL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// …later, with the quote a client returned:&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// the signature first, then the expiry&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature covers a canonical form rather than the JSON: a domain tag, then every set field in a fixed order, each part written as its length followed by its bytes. Lengths rather than separators mean no value can be read as two fields — the classic way a signature over concatenated strings gets forged is by moving the boundary between two of them. A field left empty is not written at all, which buys forward compatibility for free: a field added in a later version leaves the signed bytes of an older quote exactly as they were, and signatures issued before it keep verifying.&lt;/p&gt;

&lt;p&gt;The expiry is signed as whole Unix seconds, and that is not fussiness. A timestamp is only safe inside a signature if both sides can reconstruct the identical bytes; carry sub-second precision through a JSON round trip and the two sides eventually disagree in the last digit, which reads as a forged quote rather than as a serialization bug. Truncating to whole seconds removes the disagreement.&lt;/p&gt;

&lt;p&gt;All of that makes the offer re-checkable without the server storing anything. But a quote signature is this server's, not a chain's. It says "these were my terms", and nothing whatsoever about whether anyone paid. Collapsing those two classes of fact — treating a valid signed artifact as evidence of settlement — is the single most expensive mistake available in this design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spend cannot be stateless
&lt;/h2&gt;

&lt;p&gt;So the ledger. A captured &lt;code&gt;X-PAYMENT&lt;/code&gt; header is refused the second time, and the interface for remembering is deliberately one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// SeenStore remembers payments that have already been used, so that a captured&lt;/span&gt;
&lt;span class="c"&gt;// X-PAYMENT header cannot be replayed against the same server.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;SeenStore&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// SeenBefore records a key and reports whether it was already there.&lt;/span&gt;
    &lt;span class="n"&gt;SeenBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// PaymentKey is the replay key for a payment.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;PaymentKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="n"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&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;Two decisions are load-bearing here.&lt;/p&gt;

&lt;p&gt;The key is a digest of the whole payload rather than a nonce field. Every x402 scheme carries its own payload shape, and reaching into one to find "the nonce" breaks the moment a new scheme appears — the web layer would have to grow a parser for a format it explicitly refuses to interpret everywhere else. Digesting the bytes keeps the gate scheme-agnostic. The trade-off is real and worth stating plainly: two distinct encodings of the same underlying authorization would hash to two keys. That is a property of the scheme, and it is catchable in the component that actually reads payloads. What the digest does refuse, completely, is the exact captured header replayed verbatim — which is the threat that actually exists in a bearer-token protocol.&lt;/p&gt;

&lt;p&gt;The second decision is &lt;code&gt;SeenBefore&lt;/code&gt; recording and reporting in one call. Check-then-set as two calls is a race, and it is the specific race that costs money: two concurrent requests carrying the same header both read "not seen", both settle. One call that atomically records and tells you whether it was already there is the only shape that survives concurrency, and it happens to be the shape a shared store implements naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the key gets written
&lt;/h2&gt;

&lt;p&gt;This is the part I would argue about with someone, so here is the reasoning.&lt;/p&gt;

&lt;p&gt;The key is recorded only after verification passes. A payment the facilitator rejected never touched the chain, and a client who fixes their allowance may legitimately resend the same signed payload. Burning the key on a rejection turns a recoverable client error into a permanently unusable payment.&lt;/p&gt;

&lt;p&gt;Once settlement has been attempted, the payment stays spent — even when settlement failed. A rejected verification is a known non-event. A failed settlement is an unknown: the transfer may have landed and the response may have been lost. The ambiguous case must never risk a double charge, so it refuses in the direction of refusal.&lt;/p&gt;

&lt;p&gt;Two orderings around it matter just as much. Settle after the handler and serve after settlement: the handler runs into a buffer, so if the work fails nothing is charged, and if settlement fails nothing is served. And an unreachable facilitator is a &lt;code&gt;502&lt;/code&gt;, not a &lt;code&gt;402&lt;/code&gt;. "We could not ask" and "the answer is no" are different outcomes; answering &lt;code&gt;402&lt;/code&gt; when the facilitator is down tells a client to pay a second time for something they may already have paid for. That is the same instinct as the ledger, expressed in a status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default store is wrong for the deployment you will actually have
&lt;/h2&gt;

&lt;p&gt;The bundled store keeps seen payments in the process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;MemoryStore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SeenBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c"&gt;// Expiry is swept on write. A payment gate sees writes on exactly the&lt;/span&gt;
    &lt;span class="c"&gt;// requests that matter, so no background goroutine has to exist.&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is right for one instance and wrong for several: a second replica does not share the map, so a payment could be replayed once per replica. That is why &lt;code&gt;SeenStore&lt;/code&gt; is an interface rather than a struct field — a shared implementation drops straight in. Sweeping expiry on write is a small pleasure: a payment gate sees writes on exactly the requests that matter, so retention costs nothing and no background goroutine has to exist.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ttl&lt;/code&gt; argument is a retention question with a security floor. The key has to outlive every window in which the same payload could still be accepted by anything downstream — the quote's standing time, the facilitator's own tolerance, the settlement's finality. Short of that, the entry expires and the replay works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;If the service will ever run more than one replica, I would not ship the in-process store at all, not even briefly. Replay windows do not announce themselves; they show up as a reconciliation discrepancy weeks later, and by then the header that caused it is long gone from the logs. Eight years of payment work has taught me exactly one durable thing about this class of bug: a refusal is cheap and a double charge is not, so every ambiguous branch should point at refusal.&lt;/p&gt;

&lt;p&gt;The other thing I would change is smaller. &lt;code&gt;SeenBefore&lt;/code&gt; returns a bool, which is enough to gate a request and not enough to explain one. If I were operating this at any scale I would want the ledger to also be able to say when the key was first seen, because "this payment has already been used" is a support ticket, and the answer to that ticket lives in the store.&lt;/p&gt;

&lt;p&gt;The gate is the part that looks like the product. The ledger is the part that makes it true.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/the-only-stateful-part-of-an-x402-payment-gate-is-the-replay-ledger" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Threshold approvals belong on the request, not on the button</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:27:16 +0000</pubDate>
      <link>https://dev.to/polycratia/threshold-approvals-belong-on-the-request-not-on-the-button-3b2b</link>
      <guid>https://dev.to/polycratia/threshold-approvals-belong-on-the-request-not-on-the-button-3b2b</guid>
      <description>&lt;p&gt;Above a configured amount, a withdrawal should not leave the system because somebody clicked approve. It leaves because enough distinct people have signed that specific request, and because those signatures still satisfy the policy at the moment a rail is actually touched. Most of the approval bugs I have found in payment and custody systems come from the opposite arrangement: the click is treated as the event that moves money, and the request record is a log line written afterwards.&lt;/p&gt;

&lt;p&gt;That inversion is what makes both classes of failure possible. If the click sends, then a second click sends again, and a policy change tomorrow cannot invalidate a signature collected yesterday, because there is nothing left to re-measure. I have built moderation and issuance tiers for a lending marketplace, and real-time compliance queues for an onramp, and the same lesson showed up in both: the reviewer's action has to be recorded as a property of the thing under review, not as a trigger wired to a side effect.&lt;/p&gt;

&lt;p&gt;I keep this pattern in &lt;a href="https://github.com/polycratia/withdrawals" rel="noopener noreferrer"&gt;withdrawals&lt;/a&gt;. It is deliberately small: a request, a state machine, an approval gate, a routing decision, and idempotent submission. The senders are not implemented, and that is on purpose: everything interesting happens before the rail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Waiting is a property of the state machine, not of the handler
&lt;/h2&gt;

&lt;p&gt;If the gate is a check at the top of the send handler, it is one refactor away from being skipped, and every new call path has to remember it. It holds up better when &lt;code&gt;approved&lt;/code&gt; is simply unreachable until the policy is satisfied. A request travels &lt;code&gt;requested -&amp;gt; approved -&amp;gt; sending -&amp;gt; sent -&amp;gt; confirmed&lt;/code&gt;, and the sender only ever receives requests that are already on the far side of the gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ApprovalPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;WithdrawalRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;WithdrawalState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ApprovalPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;four_eyes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;above&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;large&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WithdrawalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w-1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5000.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xab5801a7d398351b8be11c439e05c5b3259aec9b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;requested_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;waiting&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;large&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;WithdrawalState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUESTED&lt;/span&gt;

&lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;carol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;WithdrawalState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APPROVED&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approvers&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;carol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bob's approval is not rejected and it is not parked somewhere else. It is recorded on the request, and the request stays in &lt;code&gt;requested&lt;/code&gt;, because the amount cleared a rule that asks for two distinct approvers. The policy is a set of rules, each with the amount it starts at and the number of approvers it asks for: the rule with the highest threshold the amount clears is the one that applies, and one rule starts at zero so that every amount matches exactly one. No amount falls through the policy unmatched.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;request.approve()&lt;/code&gt; and &lt;code&gt;approve(request, who, policy)&lt;/code&gt; are different calls. The first is the bare state move, useful in tests and for amounts below any threshold. The second is the one that asks the policy first. Keeping them apart means the policy-aware path is a real function with a name, not an implicit behaviour of a method that also does something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  An approval is evidence, and evidence gets re-checked
&lt;/h2&gt;

&lt;p&gt;Every approval names who gave it, when they gave it with a timezone-aware timestamp, and the rule that was in force at the time. All of it travels with the request all the way to &lt;code&gt;confirmed&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approvals&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="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;four-eyes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last field is the one people leave out, and it is the one that matters most a year in. Thresholds move. A limit that asked for two approvers above one amount gets tightened after an incident or a compliance review. If the only thing you stored was a boolean, every request approved under the old rule is now indistinguishable from one approved under the new one, and you find the difference in an audit rather than in your code.&lt;/p&gt;

&lt;p&gt;So the check happens twice: once when the approval is given, and once at the boundary, immediately before the request is handed to a rail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ensure_approved&lt;/span&gt;

&lt;span class="nf"&gt;ensure_approved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises NotApproved if the signatures no longer suffice
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works because the request is an immutable value and the approvals are part of it. Transitions return a new request and you decide where to store it, so nothing can quietly mutate the evidence between the gate and the send. &lt;code&gt;ensure_approved&lt;/code&gt; measures the request against the policy as it stands right now. A request approved yesterday under a looser rule does not slip out today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same click twice is not a second pair of eyes
&lt;/h2&gt;

&lt;p&gt;Two more things the approval layer refuses. The person who asked for the money cannot be one of the approvers, which raises &lt;code&gt;SelfApproval&lt;/code&gt;. And the same approver recorded twice counts once, so a double-clicked button, a retried request, or a duplicated webhook from an approval UI does not manufacture the second signature.&lt;/p&gt;

&lt;p&gt;That is idempotency at the human layer, and it is worth naming as such, because it has the same shape as the machine-layer problem further down the pipeline. The identity that matters is the approver, not the click. Once you see it that way, the rest of the pipeline is a question of asking, at each step, &lt;em&gt;which identity is this step keyed by?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  After the gate, two more identities
&lt;/h2&gt;

&lt;p&gt;They are not the same identity, and collapsing them is where money gets sent twice.&lt;/p&gt;

&lt;p&gt;Routing is keyed by the destination. A withdrawal to an address you custody does not reach a chain: crediting it is a ledger move with no fee, no confirmations, and nothing to wait for. The decision comes back as a value with the reason behind it, and it is logged, so the two paths stay distinguishable afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryCustody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;

&lt;span class="n"&gt;custody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCustody&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct-77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;custody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct-77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;needs_confirmations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An internal route without an account and an external route with one are both refused at construction. The decision cannot be half-formed.&lt;/p&gt;

&lt;p&gt;Submission is keyed by neither the destination nor the withdrawal id, but by a request key the &lt;em&gt;client&lt;/em&gt; chooses. A client that does not hear back retries, and it retries with the same key. The first call under a key runs the send, every later call returns what the first one produced, and the rail is touched once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Submissions&lt;/span&gt;

&lt;span class="n"&gt;submissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Submissions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;start_sending&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mark_sent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xdeadbeef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client-req-9f21&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client-req-9f21&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same key carrying a different withdrawal raises &lt;code&gt;IdempotencyConflict&lt;/code&gt; rather than paying twice. And the case I care about most: when the send itself raises, the key stays claimed and a retry raises &lt;code&gt;SubmissionInFlight&lt;/code&gt;, because nobody yet knows whether the rail saw it. An RPC timeout is not a failure and it is not a success. It is a third outcome, and I think the only honest response is to refuse to guess. You close it deliberately once it has been reconciled, with &lt;code&gt;resolve(key, outcome)&lt;/code&gt; if the send did land, or &lt;code&gt;release(key)&lt;/code&gt; if it did not.&lt;/p&gt;

&lt;p&gt;The state machine holds the same line on the callback side. Replaying a transition that already happened returns the same value, so a confirmation delivered twice costs nothing, while a replay carrying &lt;em&gt;different&lt;/em&gt; data (the same withdrawal marked sent under a different reference) raises &lt;code&gt;InvalidTransition&lt;/code&gt; instead of silently overwriting what you already told your accounting system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;In earlier systems I stored approvals in a generic audit table and a boolean on the withdrawal row. It reads as separation of concerns and it is not: the audit table is written for humans, so nothing in the code path ever reads it back, and the boolean is the only thing the sender consults. Once the threshold changes, the boolean is a claim with no supporting evidence attached, and reconstructing which rule applied means joining against a table nobody maintained as a source of truth.&lt;/p&gt;

&lt;p&gt;I would also not key retries on the withdrawal id again. It feels natural (one withdrawal, one send) but it quietly assumes the client cannot generate two withdrawals for the same intent. Clients under a timeout do exactly that. The key has to come from the caller, because the caller is the only party that knows whether this is a new intent or the same one again.&lt;/p&gt;

&lt;p&gt;Write the three identities down explicitly when you design this: the approver decides whether the request may leave &lt;code&gt;requested&lt;/code&gt;, the request decides which transitions are legal, and the client's request key decides how many times the rail is touched. None of them is the click.&lt;/p&gt;

&lt;p&gt;What is left is a pipeline in which the interesting decisions are values you can log, replay and test without a chain, a bank, or a person in the room. The senders are the easy part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/threshold-approvals-belong-on-the-request-not-on-the-button" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Parse crypto amounts like hostile input, declare rounding first</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 12 Sep 2026 13:21:43 +0000</pubDate>
      <link>https://dev.to/polycratia/parse-crypto-amounts-like-hostile-input-declare-rounding-first-2df3</link>
      <guid>https://dev.to/polycratia/parse-crypto-amounts-like-hostile-input-declare-rounding-first-2df3</guid>
      <description>&lt;p&gt;Every crypto amount enters a system as a string: a form field, a JSON body from an exchange API, a row in a payout file, a webhook from a provider. Between that string and the ledger two decisions get made: whether the amount is representable in the asset at all, and what happens when it is not. In most codebases the first decision is skipped and the second one is implicit, so the amount becomes whatever the last arithmetic operation, or the database column, happened to leave behind. That is most of the bug class, I think. Precision belongs to the asset, and rounding is a policy you declare before you compute, not a residue you discover afterwards.&lt;/p&gt;

&lt;p&gt;I have been building payment and crypto systems in production since 2018: custodial wallets for BTC and ETH, a fiat-to-crypto onramp, exchange order books, stablecoin rails in daily use. The amounts that gave me the most trouble were not the large ones. They were the ones that arrived with one digit too many and were quietly made to fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parse is the last place there is still someone to ask
&lt;/h2&gt;

&lt;p&gt;When a user types &lt;code&gt;0.000000001&lt;/code&gt; into a BTC withdrawal field, that string carries information: they asked for something the asset cannot express. There are two honest responses, and both of them require knowing it happened. You can refuse and show them the problem, or you can round by a rule you decided on in advance and tell them what you did.&lt;/p&gt;

&lt;p&gt;The response that is not available, though it is the one that happens most in practice, is to truncate it on the way into a &lt;code&gt;NUMERIC(18, 8)&lt;/code&gt; column and move on. By the time the value reaches the ledger the user is gone, the request is over, and the difference between what was asked for and what was recorded has turned into dust that will show up much later as reconciliation drift nobody can attribute.&lt;/p&gt;

&lt;p&gt;So in &lt;a href="https://github.com/polycratia/cryptomoney" rel="noopener noreferrer"&gt;cryptomoney&lt;/a&gt; the precision check lives in the parser, and its default is refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptomoney&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_money&lt;/span&gt;

&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# 0.5 BTC
&lt;/span&gt;&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; 1 234.50 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1234.50 USDT
&lt;/span&gt;&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.25e3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 1250 USDT
&lt;/span&gt;&lt;span class="nf"&gt;parse_money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5 BTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 0.5 BTC
&lt;/span&gt;&lt;span class="nf"&gt;parse_money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12.5btc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 12.5 BTC
&lt;/span&gt;&lt;span class="nf"&gt;parse_money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BTC 0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 0.5 BTC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The input is treated as text written by someone who may not be careful and may not be friendly. Whitespace, a leading sign, thousands separators, exponent notation, a symbol before or after or glued to the number: all of it is accepted, because all of it occurs in real payloads. What is not accepted is text that does not describe exactly one amount of exactly one asset, or an amount finer than the asset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ROUND_DOWN&lt;/span&gt;

&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1e-9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# ParseError: needs 9 decimal places
&lt;/span&gt;&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1e-9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROUND_DOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 0.00000000 BTC
&lt;/span&gt;&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# ParseError
&lt;/span&gt;&lt;span class="nf"&gt;parse_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# TypeError: float is refused
&lt;/span&gt;&lt;span class="nf"&gt;parse_money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5 XMR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# UnknownAsset
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line is where the whole design sits. Rounding is possible, it is just not free: you name the mode, at the call site, in the code path where you know what the amount means. A quote engine that floors a displayed rate and a withdrawal endpoint that must not create value out of nothing are different call sites with different answers, and neither of them should inherit a default written by a library author who has never seen either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Floats are refused because the float already lost the evidence
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parse_amount(0.5, BTC)&lt;/code&gt; raising &lt;code&gt;TypeError&lt;/code&gt; looks pedantic until you ask where the float came from. It came from a JSON decoder that turned the sender's text into a binary double before your code ever saw it. The original digits are gone at that point, and with them your ability to say whether the sender wrote a representable amount or not. A parser that accepts floats is not parsing, it is laundering a decision that was already made badly upstream.&lt;/p&gt;

&lt;p&gt;The same rule holds at construction, so a rounding error cannot enter a balance through a different door:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptomoney&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ETH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Money&lt;/span&gt;

&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# 0.5 BTC
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.000125&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# 0.000125 BTC
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# TypeError: float is refused
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.000000001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# ValueError: BTC is divisible into 8 decimal places
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ETH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# CurrencyMismatch
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice this means the boundary code has to hand over the raw text. For HTTP that is a decoder configured to keep numbers as strings, or a schema that types the field as a string. A small amount of friction in exactly one place, and what you get for it is the guarantee that every &lt;code&gt;Money&lt;/code&gt; in the system was checked against its asset when it was born.&lt;/p&gt;

&lt;p&gt;Still on the subject of hostile input: the parser also caps input length and bounds the exponent range, because an unbounded exponent in decimal arithmetic is a way to make a single field allocate an enormous number. Any parser that will run against public input needs the equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Precision is a property of the asset, so the asset has to be a value
&lt;/h2&gt;

&lt;p&gt;The check has to compare against something. In this library that something is not a constant in the codebase but a small frozen value object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A crypto asset and the number of decimal places it is divisible into.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its constructor validates what it is given: the symbol must be a non-empty, unpadded string, and &lt;code&gt;decimals&lt;/code&gt; must be a real &lt;code&gt;int&lt;/code&gt; (a &lt;code&gt;bool&lt;/code&gt; is explicitly rejected) within a fixed upper bound. An &lt;code&gt;Asset&lt;/code&gt; that exists is an &lt;code&gt;Asset&lt;/code&gt; that makes sense, which means the precision rule cannot be &lt;code&gt;None&lt;/code&gt; at the moment a parse needs it.&lt;/p&gt;

&lt;p&gt;The package ships a registry of common assets, and that registry is a convenience rather than an authority. The same ticker has different precision on different chains, and a system that hardcodes six decimals for USDT will eventually meet an eighteen-decimal deployment of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptomoney&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AssetRegistry&lt;/span&gt;

&lt;span class="n"&gt;assets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# USDT on BNB Smart Chain
&lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;XMR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AssetRegistry&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POINTS&lt;/span&gt;&lt;span class="sh"&gt;"&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;# or start from nothing
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parse_money&lt;/code&gt; looks symbols up in the default registry unless you pass &lt;code&gt;assets=&lt;/code&gt;, and a symbol that is not registered raises &lt;code&gt;UnknownAsset&lt;/code&gt; instead of guessing. An unknown ticker is a configuration gap, and the worst thing a parser can do with a configuration gap is invent a precision for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding has no default anywhere, not just in the parser
&lt;/h2&gt;

&lt;p&gt;If rounding is a declared policy at the edge and an accident inside, you have moved the bug rather than fixed it. So the same rule runs through the arithmetic. Operations that are exact are plain operators. Operations that may not fit the asset's precision are methods that require a mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ROUND_DOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROUND_HALF_UP&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptomoney&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Money&lt;/span&gt;

&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;                                      &lt;span class="c1"&gt;# TypeError: / is refused
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROUND_DOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# 0.16666666 BTC
&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.015&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROUND_HALF_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 0.01500000 BTC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refusing &lt;code&gt;/&lt;/code&gt; is the part people argue about, and it is the part I would keep. Division is where the money disappears, and an operator is a syntax that invites you not to think. A method with a mandatory keyword argument puts the policy where the reviewer sees it, in the diff.&lt;/p&gt;

&lt;p&gt;Quantized division still loses the remainder, which is correct for a fee and wrong for a distribution. When the total has to survive (splitting a settlement across investors, dividing a batch payout) quantization is the wrong tool entirely, and the operation works in base units instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00000010&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# ['0.00000004 BTC', '0.00000003 BTC', '0.00000003 BTC']
&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shares&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="n"&gt;shares&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;# 0.00000010 BTC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remainder goes to the first shares and the parts add back up to the original. That property is worth more than any fairness heuristic, because it is the one an auditor checks.&lt;/p&gt;

&lt;p&gt;The payoff for refusing unrepresentable amounts at the parse arrives at the chain boundary. Chain APIs speak integers (satoshi, wei), and conversion in both directions is exact precisely because an amount finer than its asset never existed in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptomoney&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_wei&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_satoshi&lt;/span&gt;

&lt;span class="nf"&gt;to_satoshi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BTC&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;            &lt;span class="c1"&gt;# 50000000
&lt;/span&gt;&lt;span class="nf"&gt;from_wei&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="c1"&gt;# 0.000000000000000001 ETH
&lt;/span&gt;
&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_base_units&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# 12500000
&lt;/span&gt;&lt;span class="n"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_base_units&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12500000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;USDT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# 12.500000 USDT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no rounding mode on those calls because there is nothing to round. The refusal at the edge is what makes the conversion at the other edge total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Earlier systems I built put the leniency at the front and the truncation at the back: accept whatever the client sends, coerce it into a decimal, let the column width decide the precision. It reads as robustness. It is really a silent policy decision made by a schema migration, applied uniformly to fees, quotes, payouts and refunds, none of which want the same rule. The failure does not announce itself as a parse error. It announces itself as amounts that do not reconcile, in the smallest units, long after the request that created them.&lt;/p&gt;

&lt;p&gt;The inversion I would now apply from the first commit is small: the only way to construct an amount is from text plus an asset, precision violations raise by default, and every operation that cannot be exact takes a rounding mode with no fallback. It costs a few explicit arguments at call sites. In exchange every rounding decision in the system is greppable, and the amounts that could not be represented were rejected while there was still someone on the other end of the request to tell...&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/parse-crypto-amounts-like-hostile-input-declare-rounding-first" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Issuing deposit addresses without holding a private key</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:21:43 +0000</pubDate>
      <link>https://dev.to/polycratia/issuing-deposit-addresses-without-holding-a-private-key-4l11</link>
      <guid>https://dev.to/polycratia/issuing-deposit-addresses-without-holding-a-private-key-4l11</guid>
      <description>&lt;p&gt;A custodial service that shows a user a deposit address has to answer a question the user does not ask: which process produced that address, and what else can that process do? If the answer is that the node generated it, then the component reachable from an HTTP request is also the component that can spend. Deriving deposit addresses from an account-level extended public key splits those two jobs apart. The issuer walks derivation paths and encodes public keys, and it holds nothing that could move a coin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What asking the node for an address couples together
&lt;/h2&gt;

&lt;p&gt;Asking a node for a fresh address is the shortest path to a working deposit flow, and it hands the node three jobs at once. It owns the keys. It owns the address gap, meaning how many addresses have been issued and how far ahead the wallet is willing to look for funds. It also owns the recovery story, which becomes a wallet file you restore and cannot verify by reading.&lt;/p&gt;

&lt;p&gt;None of that has to be a node problem. Which index was handed to which user is application state, and it belongs in the same database as the user. How far ahead the issuer may go before it refuses is an issuance policy, and the gap limit is where that policy gets written down. Recovery, done properly, is re-derivation from a seed that never touched the server.&lt;/p&gt;

&lt;p&gt;The first job is the one that shapes the architecture. If issuing an address is a call into something that can sign, then every route that reaches the issuer is a route that reaches spending authority, and the only thing standing in between is configuration: an unlocked wallet, an RPC allowlist, a method filter. Configuration can be wrong for months without anything failing visibly... That is the property I want to remove, not tighten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is arithmetic, not configuration
&lt;/h2&gt;

&lt;p&gt;An extended public key can derive its non-hardened children and nothing else. Hardened derivation mixes the private key into the child, so a process holding only public material cannot perform it. Not because it is forbidden, but because the input does not exist. That turns a policy into a fact about the code path, and a fact is reviewable.&lt;/p&gt;

&lt;p&gt;This is the whole interface of chain-addresses (&lt;a href="https://github.com/polycratia/chain-addresses" rel="noopener noreferrer"&gt;https://github.com/polycratia/chain-addresses&lt;/a&gt;), a small package I maintain for exactly this job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_addresses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ExtendedPublicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_encoder&lt;/span&gt;

&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ExtendedPublicKey&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="n"&gt;account_xpub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;deposit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;derive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0/17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bitcoin-p2wpkh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;encoder&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="n"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# bc1q...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the package accepts or stores a private key. Passing &lt;code&gt;"0'/17"&lt;/code&gt; or an &lt;code&gt;xprv&lt;/code&gt; raises instead of silently doing something else: a hardened path and a private key are both refused at the edge rather than half-handled. The signer stays offline and exports one account-level extended public key, and the service that answers "give me a deposit address" imports this and nothing else.&lt;/p&gt;

&lt;p&gt;The reason to care about the refusal rather than about a convention is code review. "The issuer cannot sign" becomes something you confirm by reading imports, instead of a claim about how a node was configured on a host you are not looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  One key, many address formats
&lt;/h2&gt;

&lt;p&gt;Every encoder in the package takes a 33-byte compressed public key and returns a string. Which encoding that is (Base58Check, bech32, bech32m, or EIP-55 hex) stays inside the encoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_addresses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ENCODERS&lt;/span&gt;

&lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;derive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0/17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&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="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ENCODERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That maps one derived key to &lt;code&gt;bitcoin-p2pkh&lt;/code&gt;, &lt;code&gt;bitcoin-p2sh&lt;/code&gt;, &lt;code&gt;bitcoin-p2wpkh&lt;/code&gt;, &lt;code&gt;bitcoin-p2tr&lt;/code&gt;, their testnet variants, and &lt;code&gt;evm&lt;/code&gt;. The package's own test asserts that all of those addresses come out distinct, and that is the part worth internalising: the address format is a presentation decision downstream of derivation, not a key decision. Moving deposits from P2SH-wrapped segwit to native segwit, or adding taproot, does not touch the account key, the derivation policy, or the offline signer's export. It changes which script the signer will later have to satisfy, and nothing before that.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;evm&lt;/code&gt; encoder is the sharper case. There is exactly one of it because an EVM address carries no chain identity at all, so the same key produces the same twenty bytes on every EVM chain. Your database owns the chain and the address does not. A deposit record without a chain field is unfinished, and a user who sends a stablecoin on a network you are not watching has sent it to an address that is genuinely theirs, on a chain nobody is polling. On the stablecoin rails I have run in production this is a support queue, not a thought experiment. The issuer cannot prevent it, but it can refuse to render an address unless the deposit intent named the chain it is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-derivation is a free integrity check
&lt;/h2&gt;

&lt;p&gt;Because the issuer holds no secret, everything it does can be repeated anywhere: in a second process, in another language, in a test. That changes what a stored address is. It is a cache of a pure function of account key, path and format, rather than a fact you have to preserve.&lt;/p&gt;

&lt;p&gt;Application-side, issuance is two lines and a database write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;derive_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExtendedPublicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;derive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;get_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address_format&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="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the path and the format next to the address. A periodic job can then walk the table, re-derive, and compare. A mismatch means the configured account key is not the one that row was issued under, and that is the failure I actually worry about.&lt;/p&gt;

&lt;p&gt;It has no natural signal. A wrong extended public key in configuration produces perfectly valid, perfectly checksummed addresses for a wallet you cannot spend from, and there is nothing in the string to inspect. Two cheap defences, both built from what the extended key already carries: check at boot that the parsed key's &lt;code&gt;depth&lt;/code&gt; and &lt;code&gt;parent_fingerprint&lt;/code&gt; match what the offline signer exported, and keep one known index (an address the signer itself produced during setup) as a fixture the issuer must reproduce before it serves traffic.&lt;/p&gt;

&lt;p&gt;The package takes the same approach internally. Its test suite carries its own private-key derivation and checks that public derivation reaches the same children, so the public-only path is verified against the private one it is meant to replace, and the encoders are checked against published vectors rather than against themselves. It is pre-alpha, and honest about the edges: BIP32 public derivation and the address formats above are implemented, while chain metadata and gap-limit scanning are not written yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;On the first custodial wallet work I did, the node handed out addresses because that was the fast path and deriving them myself was an afternoon of work. The cost did not arrive as an incident. It arrived as a permanent constraint: the deposit service could not be deployed anywhere the keys could not go, so its blast radius quietly set the security posture of everything sitting next to it.&lt;/p&gt;

&lt;p&gt;Two things I would set up from the start now. First, treat the account extended public key as a pinned configuration object verified at boot, using depth, parent fingerprint, and one reproduced fixture address, rather than a string that gets trusted the first time somebody requests a deposit. Second, keep the issuer format-agnostic from day one. Per-chain issuer services look reasonable until the third chain, and at that point you probably have three copies of the index-allocation logic and three separate ways to be wrong about which address was handed to whom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;HD derivation being elegant is beside the point for me. The property worth having is that a service can hand out an unbounded number of deposit addresses while being unable to spend from any of them, and that this reduces to two things a reviewer can check in an afternoon: the issuer only ever sees an extended public key, and non-hardened derivation is the only derivation it is capable of performing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/issuing-deposit-addresses-without-holding-a-private-key" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>ERC-20 amounts are meaningless without the token's decimals</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Thu, 10 Sep 2026 13:25:30 +0000</pubDate>
      <link>https://dev.to/polycratia/erc-20-amounts-are-meaningless-without-the-tokens-decimals-7f7</link>
      <guid>https://dev.to/polycratia/erc-20-amounts-are-meaningless-without-the-tokens-decimals-7f7</guid>
      <description>&lt;p&gt;An ERC-20 balance is a &lt;code&gt;uint256&lt;/code&gt; counting the token's smallest unit, and nothing in that integer tells you how many units make one token. You have to ask the contract. Assume eighteen decimals against a six-decimal token and every amount you compute is off by a factor of a million, with no revert, no exception, and a perfectly valid integer on both sides of the mistake.&lt;/p&gt;

&lt;p&gt;I have run stablecoin payment rails in production long enough to stop treating this as a display concern. It is not formatting. It is the gap between a payout of one and a half dollars and a payout of one and a half million, written in code that type-checks either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain stores units, your product stores amounts
&lt;/h2&gt;

&lt;p&gt;The whole problem fits in one table. These are the tokens most payment systems actually touch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Decimals&lt;/th&gt;
&lt;th&gt;1.5 tokens, in units&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;USDT&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1500000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USDC&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1500000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WBTC&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;150000000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DAI&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;1500000000000000000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those integers is a legal &lt;code&gt;uint256&lt;/code&gt;. If your encoder scales &lt;code&gt;1.5&lt;/code&gt; by &lt;code&gt;10**18&lt;/code&gt; and hands the result to USDT, the call data is well-formed, the ABI encoding is correct, the transaction is valid, and the transfer either moves a million times too much or reverts on an insufficient balance. Which of the two you get depends on how well funded the sender happens to be. That is the genuinely dangerous part: the failure mode is not deterministic. On a thin hot wallet it looks like a funding bug. On a well-funded one it looks like nothing at all until reconciliation runs.&lt;/p&gt;

&lt;p&gt;No invariant in your system catches it either. There is no checksum on scale. A number does not describe itself: &lt;code&gt;1500000000000000000&lt;/code&gt; is a valid amount of some token, just not of this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decimals are a property of the contract, and optional at that
&lt;/h2&gt;

&lt;p&gt;In the ERC-20 standard, &lt;code&gt;decimals&lt;/code&gt; is an optional method. Most tokens implement it, tokens are free not to, and the value that comes back is the token's business rather than a convention you can assume. Six for the dollar stablecoins, eighteen for DAI and most of the long tail, eight for WBTC because it inherited Bitcoin's granularity. There is no default.&lt;/p&gt;

&lt;p&gt;So it gets read. In &lt;code&gt;erc20-transfers&lt;/code&gt; (&lt;a href="https://github.com/polycratia/erc20-transfers" rel="noopener noreferrer"&gt;https://github.com/polycratia/erc20-transfers&lt;/a&gt;) that read is one call, encoded and decoded like every other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;erc20_transfers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;decode_decimals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encode_decimals&lt;/span&gt;

&lt;span class="n"&gt;decimals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_decimals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eth_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;encode_decimals&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the library deliberately does not do is remember the answer for you. &lt;code&gt;decimals&lt;/code&gt; is a required argument on every conversion. That looks like friction until you price the alternative: a library that caches on your behalf owns a cache whose key it cannot see. It does not know which chain you are on, whether the address you passed is the token you think it is, or whether your process lives long enough for the cache to matter. Making the parameter explicit pushes that decision down to the only layer with enough context to make it.&lt;/p&gt;

&lt;p&gt;In my own services that layer is the token registry, and the cache key is the pair that actually identifies a contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_decimals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decimals_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_decimals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_decimals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_decimals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eth_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;encode_decimals&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_decimals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the symbol. Symbols are not unique, are not stable, and are the first thing a counterfeit token copies. The same three letters on two chains are two contracts with independent decimals, and the cheap way to learn this is to key your registry on the address before you find out the expensive way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert at the edges, and only at the edges
&lt;/h2&gt;

&lt;p&gt;Once decimals stop being a constant, the interesting question is where in the system they get to appear. My answer, after enough rewrites of the same code: in exactly two places, both of them boundaries with a human on the other side.&lt;/p&gt;

&lt;p&gt;Input is the first one. A person types &lt;code&gt;1.5&lt;/code&gt;, and that string means nothing until it is bound to a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;erc20_transfers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;encode_transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_units&lt;/span&gt;

&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;to_units&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1500000 on USDT
&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Display is the second one, and it needs the same pairing to read a balance back off the chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;erc20_transfers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TokenAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encode_balance_of&lt;/span&gt;

&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TokenAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_return_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;eth_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;encode_balance_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decimals&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1.500000 1500000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between those two edges, nothing needs decimals, and that is the actual payoff of the discipline. Comparing units to units is scale-free. &lt;code&gt;check_allowance&lt;/code&gt; takes a &lt;code&gt;required&lt;/code&gt; and a &lt;code&gt;current&lt;/code&gt; and never asks what a token is; &lt;code&gt;measure_received&lt;/code&gt; compares a balance before against a balance after; the amount inside &lt;code&gt;encode_checked_transfer_from&lt;/code&gt; is an integer of the smallest unit. An internal function that handles money in units cannot be given the wrong scale, because it does no scaling at all.&lt;/p&gt;

&lt;p&gt;The corollary is a lint rule you can apply by reading: any internal function whose signature takes an amount as a &lt;code&gt;Decimal&lt;/code&gt; or a &lt;code&gt;float&lt;/code&gt;, with no token beside it, is a scale bug waiting for the wrong token to arrive. The amount and the token identity travel together, or the amount is not yet meaningful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusing is better than rounding
&lt;/h2&gt;

&lt;p&gt;The boundary has a second job, and it is the one most homegrown helpers get wrong. Not every decimal number is representable in a given token. Six decimals cannot hold a seventh digit. The convenient behaviour is to round it away. The correct behaviour is to refuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;to_units&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0000005&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A silently dropped digit is a loss that reconciles to nothing. It is tiny, half a millionth of a token, and it happens on the leg where you convert a price, an exchange rate or a fee percentage into a transfer. Do it on every payout and you get a slow drift between what your ledger recorded and what the chain moved, with no single transaction anyone can point at. Raising turns that into an input validation problem, visible at the top of the call stack, where a human decides whether to round up, round down, or reject the request. That is a product decision and it does not belong inside a units helper.&lt;/p&gt;

&lt;p&gt;The same reasoning is why floats are refused outright. &lt;code&gt;0.1&lt;/code&gt; is not &lt;code&gt;0.1&lt;/code&gt; in binary, and a value that is already approximate does not become exact by being multiplied by a power of ten. &lt;code&gt;Decimal&lt;/code&gt; in, integer out, or an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Three things, all learned by not doing them first.&lt;/p&gt;

&lt;p&gt;Read decimals when a token is admitted to the system, not when a transfer is about to go out. Onboarding is a place where a failed contract call is an operator's problem and the token simply does not become available. The payout path is not: there, an RPC hiccup on a metadata read has no good outcome, and whatever fallback you write under deadline pressure will probably turn into the constant you were trying to avoid.&lt;/p&gt;

&lt;p&gt;Treat a token that does not answer &lt;code&gt;decimals&lt;/code&gt; as unsupported rather than as eighteen. The method is optional in the standard, so a missing answer is legitimate, but defaulting is guessing, and guessing about scale is the one guess with a million-fold error bar.&lt;/p&gt;

&lt;p&gt;Store the decimals you used alongside every recorded amount. Contracts are immutable and in practice decimals do not change, but your registry row can be corrected, re-imported, or written by an earlier version of your code. If a historical payout carries the scale it was computed with, a bad registry row is a display artefact you can fix. If it does not, every amount you ever recorded hangs off a mutable row, and you have to trust that row to read your own books.&lt;/p&gt;

&lt;p&gt;None of this is difficult. It is the difference between an amount and a number, held consistently, at two boundaries instead of everywhere...&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/erc-20-amounts-are-meaningless-without-the-token-s-decimals" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Idempotency belongs in the ledger, not in every caller</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:25:30 +0000</pubDate>
      <link>https://dev.to/polycratia/idempotency-belongs-in-the-ledger-not-in-every-caller-31a</link>
      <guid>https://dev.to/polycratia/idempotency-belongs-in-the-ledger-not-in-every-caller-31a</guid>
      <description>&lt;p&gt;A payment callback that arrives twice must not move money twice. The usual answer is a unique constraint on the entry id, which turns the second attempt into an error, and an error is not the same thing as knowing the first attempt landed. That gap is where duplicate postings come from.&lt;/p&gt;

&lt;p&gt;I have been building payment and custodial systems since 2018, and the shape of this bug has not changed once in that time. A provider redelivers a deposit callback. A queue redelivers what it already delivered. An HTTP client gives up at thirty seconds on a write that committed at thirty-one. In every one of those cases the caller ends up asking the ledger for the same movement a second time, and the ledger has to answer without moving anything again.&lt;/p&gt;

&lt;h2&gt;
  
  
  A refusal is not an answer
&lt;/h2&gt;

&lt;p&gt;The first defence you reach for is identity. Give the entry a deterministic id, put a unique index behind it, let the second write fail. &lt;code&gt;ledger-core&lt;/code&gt; does exactly this at the journal level: a duplicate id is rejected, and &lt;code&gt;Journal.extend&lt;/code&gt; applies the same rule to a batch, so if any entry in it is refused, none of the batch is kept.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ledger_core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AccountType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Journal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Money&lt;/span&gt;

&lt;span class="n"&gt;cash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AccountType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ASSET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AccountType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LIABILITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;deposit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;debit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;25.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;memo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card deposit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;journal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Journal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;journal&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="n"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;journal&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="n"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# refused: the id is taken
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Necessary rule, insufficient one. Identity protects the journal and does nothing for the caller. When the second &lt;code&gt;append&lt;/code&gt; fails, the caller is holding an exception that fits at least three different worlds: my earlier attempt landed and this is my own retry, my earlier attempt never ran and something else is sitting on this id, my earlier attempt landed but wrote something other than what I am holding now. The exception does not separate them, and the caller needs that separation before it can answer the provider with a success.&lt;/p&gt;

&lt;p&gt;So the caller writes recovery code. Catch the error, read the entry back, compare it field by field against what it meant to write, decide whether the write counts as done. That is idempotency logic, and once it exists it exists in the deposit handler, and then again in the reconciliation job that reposts stuck movements, and again in the admin tool an operator uses at two in the morning. Three copies, each subtly different, each written under different pressure. The one in the admin tool is the one that will be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key is the question the caller asked
&lt;/h2&gt;

&lt;p&gt;Stop treating the retry as a collision and treat it as a repeated question. The entry id says which posting this is. The operation key says which &lt;em&gt;write request&lt;/em&gt; this is, and the same request asked twice is one write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ledger_core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Operations&lt;/span&gt;

&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;journal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_deposit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psp-deposit:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;post&lt;/code&gt; writes the entry the first time and, every time after that, returns the entry the first call wrote, read back out of the journal rather than out of a cache of whatever the caller happened to pass in. &lt;code&gt;post_many&lt;/code&gt; does the same for a batch under one key, under the journal's all-or-none rule.&lt;/p&gt;

&lt;p&gt;The return type carries the design. &lt;code&gt;post&lt;/code&gt; hands you back an &lt;code&gt;Entry&lt;/code&gt;, not a boolean and not &lt;code&gt;None&lt;/code&gt;. A caller written against it cannot branch on &lt;em&gt;whether the write was new&lt;/em&gt;, because that fact is never offered. It gets the postings that are in the journal under its key, which is the only thing it ever needed in order to answer the provider. Retry-awareness stops being a code path and becomes the ordinary path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same key, different work
&lt;/h2&gt;

&lt;p&gt;A key that only stored "this key was used" would be a trap. Keys get derived badly: someone keys a payout by user id and calendar day, someone reuses a request id across two movements, and a naive dedup table would answer the second, genuinely different write with the first write's entries and no complaint. Money would go missing quietly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Operations&lt;/code&gt; stores a fingerprint alongside the key: a stable digest over each entry's id, timestamp, memo, correction target, every posting's account, amount, currency and side, plus the entry's metadata in sorted order. The amount is normalized before it is hashed, so a retry that rebuilt &lt;code&gt;25.0&lt;/code&gt; where the first attempt built &lt;code&gt;25.00&lt;/code&gt; is recognised as the same work instead of a conflict. Applying a settled key to different work is refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ledger_core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OperationMismatch&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psp-deposit:evt_88&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other_entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;OperationMismatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# this key already carries different postings; the derivation is wrong,
&lt;/span&gt;    &lt;span class="c1"&gt;# not the retry
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fingerprint turns a class of silent corruption into a loud failure at the boundary. It also has a consequence worth knowing before you deploy it, and I will come back to that...&lt;/p&gt;

&lt;h2&gt;
  
  
  The record has to outlive the process
&lt;/h2&gt;

&lt;p&gt;Deduplication that lives only in memory is deduplication with a hole in it the width of every restart. A deploy, a crash, an autoscaler killing a pod, and the key that was settled two seconds ago is unknown again, right when the provider is still retrying.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Operations&lt;/code&gt; keeps its records as plain frozen values, so you can store them beside the journal and hand them back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# every key settled so far
# ... persist records, restart, reload the journal ...
&lt;/span&gt;&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;journal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restore&lt;/code&gt; refuses a record naming entries the journal does not hold, because such a record describes some other journal, and settling a key against a write nobody made is worse than having no record at all. So the record and the entries have to be persisted together, atomically. If they can diverge, you have two truths about the same write, and the whole point of the key was to have one.&lt;/p&gt;

&lt;p&gt;The library itself keeps the journal in memory, and persistence stays the host application's job. What the library fixes is the shape: because an &lt;code&gt;Operation&lt;/code&gt; is a value with a key, its entry ids, its fingerprint and a timestamp, storing it is a row rather than a serialization problem, and the same key settled in one process stays settled in the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Two things, both learned the annoying way.&lt;/p&gt;

&lt;p&gt;Derive the key at the edge, from something the upstream already owns. The provider's event id, the payout request id, the settlement batch id. Do not generate it inside the retry loop: a key built from &lt;code&gt;uuid4()&lt;/code&gt; or from &lt;code&gt;datetime.now()&lt;/code&gt; at the top of the handler is a new key on every attempt, which is a dedup mechanism that deduplicates nothing while looking like it works.&lt;/p&gt;

&lt;p&gt;Capture the entry the same way. The fingerprint covers &lt;code&gt;entry_id&lt;/code&gt; and &lt;code&gt;occurred_at&lt;/code&gt;, which means the retry has to rebuild the &lt;em&gt;same&lt;/em&gt; entry, not an equivalent one. A handler that stamps &lt;code&gt;datetime.now(timezone.utc)&lt;/code&gt; fresh on each attempt produces a different fingerprint and gets an &lt;code&gt;OperationMismatch&lt;/code&gt; where it expected its original postings back, technically the system protecting itself, practically a page at three in the morning. Decide the timestamp and the entry id once, when the request first arrives, and carry them through every retry alongside the key. Deterministic ids and a captured timestamp cost nothing at write time and remove a whole category of incident.&lt;/p&gt;

&lt;p&gt;The underlying move is small and it probably generalises past ledgers: when a write can be asked for twice, make the second answer be the first result. Refusing the duplicate protects your data and leaves the caller guessing. Returning the original protects your data and ends the conversation. Only one of those keeps the guessing out of six different call sites.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/polycratia/ledger-core" rel="noopener noreferrer"&gt;https://github.com/polycratia/ledger-core&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/idempotency-belongs-in-the-ledger-not-in-every-caller" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>under_investigation is a status, not an excuse</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:21:26 +0000</pubDate>
      <link>https://dev.to/polycratia/underinvestigation-is-a-status-not-an-excuse-834</link>
      <guid>https://dev.to/polycratia/underinvestigation-is-a-status-not-an-excuse-834</guid>
      <description>&lt;p&gt;A scanner tells you a component in your build carries a known advisory. It cannot tell you whether the vulnerable code is reachable in your product, and that judgement is the entire content of a VEX document. So the interesting design question is not how to express &lt;code&gt;affected&lt;/code&gt; or &lt;code&gt;not_affected&lt;/code&gt;. It is what your tooling does with the findings nobody has judged yet. The common answer, defaulting them to &lt;code&gt;not_affected&lt;/code&gt; because the release is on Friday, turns a triage backlog into a signed assurance.&lt;/p&gt;

&lt;p&gt;I build payments and crypto backends, so I ship services whose dependency lists are long, boring, and audited by people who did not write them. The Cyber Resilience Act expects manufacturers to answer the reachability question quickly and in writing for every product they ship, which is a schedule problem before it is a security problem. Schedule problems are where defaults do their damage: nobody decides to publish a false claim, they decide not to block the release, and the default publishes the claim for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the default actually asserts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;not_affected&lt;/code&gt; is a claim about your code. It says: I looked, and the vulnerable path is not in the execute path, or the component is not present, or the adversary cannot control the input. &lt;code&gt;under_investigation&lt;/code&gt; is a claim about your process. It says: this is in the queue and it has not been answered.&lt;/p&gt;

&lt;p&gt;Both are publishable. Only one of them can be falsified by an attacker with a weekend and a debugger, and it is not the honest one.&lt;/p&gt;

&lt;p&gt;The consequence shows up in the shape of the document rather than in any single statement. If unreviewed findings default to &lt;code&gt;not_affected&lt;/code&gt;, the number of &lt;code&gt;not_affected&lt;/code&gt; statements stops tracking how much review happened and starts tracking how big your SBOM is. A downstream reader (a customer's security team, an auditor, the person who inherits the service) cannot tell eighty components somebody worked through from eighty components nobody opened. That distinction is the only reason the document exists.&lt;/p&gt;

&lt;p&gt;The other failure mode is quieter: dropping undecided findings entirely, so they simply do not appear. Absence is worse than an honest status, because absence is ambiguous. Did the matcher not find this advisory, or did it find it and get ignored?&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of unknown, each with a reason attached
&lt;/h2&gt;

&lt;p&gt;When I wrote &lt;a href="https://github.com/polycratia/vexdesk" rel="noopener noreferrer"&gt;vexdesk&lt;/a&gt;, the rule I started from was that nothing which cannot be determined gets quietly cleared. In practice "cannot be determined" is not one thing, and the reasons are not interchangeable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vexdesk match &lt;span class="nt"&gt;-sbom&lt;/span&gt; sbom.cyclonedx.json &lt;span class="nt"&gt;-advisories&lt;/span&gt; ./advisories
&lt;span class="go"&gt;4 component(s) compared against the advisory set

STATUS    ADVISORY      COMPONENT        VERSION  REASON
affected  FIXTURE-0001  widget           1.2.3    version falls inside the advisory's affected range
affected  FIXTURE-0004  cogwheel         4.0.0    version is in the advisory's affected version list
unknown   FIXTURE-0003  Example_Fixture  3.1.2    ECOSYSTEM range for PyPI needs that ecosystem's own version ordering, which is not implemented

Not checked (1):
  vendored-blob  no package URL: nothing to look up
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different human actions hide behind those lines. A component with no package URL cannot be looked up in any advisory database, so somebody has to identify that vendored blob by hand, and no amount of tooling will do it for them. A PyPI &lt;code&gt;ECOSYSTEM&lt;/code&gt; range needs PEP 440 ordering, which the tool does not implement; that is a gap in my matcher, not a property of your product, and the right response is to compare the version manually or fix the matcher. An &lt;code&gt;affected&lt;/code&gt; line is a reachability question for whoever owns that code path.&lt;/p&gt;

&lt;p&gt;The reason string is the payload here, not the status. An &lt;code&gt;unknown&lt;/code&gt; with no reason is indistinguishable from a bug in the tool, and a status a reviewer cannot interrogate is a status they will either rubber-stamp or ignore. The same rule shapes version parsing: &lt;code&gt;2023-08-01&lt;/code&gt; is not read as major version 2023, it is refused. A date that silently outranks every real version produces a confident wrong answer, which is the most expensive output a matcher can produce, worse than no answer, because it terminates the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The absence of a decision is itself a statement
&lt;/h2&gt;

&lt;p&gt;Decisions live in a file, separate from the matcher, because they are human output and the match is machine output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"vulnerability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FIXTURE-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pkg:golang/github.com/example/widget@v1.2.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_affected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"justification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vulnerable_code_not_in_execute_path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"impact_statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the affected parser is only reached from the admin importer, which this build does not include"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that matters is what happens to the findings this file does not mention. They are not dropped and they are not cleared: they are emitted as &lt;code&gt;under_investigation&lt;/code&gt;. The undecided finding survives into the published document as an undecided finding, and the document stays a rendering of the triage queue rather than a summary of the parts of it somebody got around to.&lt;/p&gt;

&lt;p&gt;This also removes the incentive that produces bad &lt;code&gt;not_affected&lt;/code&gt; statements. &lt;code&gt;not_affected&lt;/code&gt; requires one of the five OpenVEX justification codes (&lt;code&gt;component_not_present&lt;/code&gt;, &lt;code&gt;vulnerable_code_not_present&lt;/code&gt;, &lt;code&gt;vulnerable_code_not_in_execute_path&lt;/code&gt;, &lt;code&gt;vulnerable_code_cannot_be_controlled_by_adversary&lt;/code&gt;, &lt;code&gt;inline_mitigations_already_exist&lt;/code&gt;) and the document refuses to build without one. &lt;code&gt;affected&lt;/code&gt; requires an action statement: what should the user do. &lt;code&gt;under_investigation&lt;/code&gt; requires nothing, because there is nothing to say yet.&lt;/p&gt;

&lt;p&gt;That asymmetry is deliberate. If the only way to make the build pass were to write a justification, engineers under deadline would pick the code that sounds closest to true, and a reviewer six months later would have no way to tell a considered &lt;code&gt;vulnerable_code_not_in_execute_path&lt;/code&gt; from a guessed one. Leaving one status available at zero prose cost means the pressure valve is honesty rather than a plausible code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A status that survives review carries its why
&lt;/h2&gt;

&lt;p&gt;A VEX statement is read by someone who was not in the room. That reader has exactly two questions: what did you conclude, and on what basis. The justification code answers the second in a form they can compare against every other statement you have made, which is the whole reason to require a code alongside the prose rather than prose alone. Free text is unanswerable at scale. A code is sortable.&lt;/p&gt;

&lt;p&gt;The same logic applies to the document as an artefact. In vexdesk the document id is derived from the statements, so an unchanged set of decisions rebuilds to the same id instead of looking newly issued on every CI run. That sounds cosmetic until you try to review a burn-down. If the id churns on every build, no consumer can tell a re-publish from a re-decision, and a finding flipping from &lt;code&gt;under_investigation&lt;/code&gt; to &lt;code&gt;not_affected&lt;/code&gt;, the single most important event in this whole workflow, disappears into noise. Document comparison lives in its own package in that repository for the same reason: the interesting object is the delta between two documents, not either document alone.&lt;/p&gt;

&lt;p&gt;And because &lt;code&gt;match&lt;/code&gt; exits 1 when anything needs attention, the backlog can gate a pipeline rather than sit in a wiki:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

vexdesk match &lt;span class="nt"&gt;-sbom&lt;/span&gt; sbom.cyclonedx.json &lt;span class="nt"&gt;-advisories&lt;/span&gt; ./advisories &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"findings need review"&lt;/span&gt;

vexdesk vex &lt;span class="nt"&gt;-sbom&lt;/span&gt; sbom.cyclonedx.json &lt;span class="nt"&gt;-advisories&lt;/span&gt; ./advisories &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-decisions&lt;/span&gt; decisions.json &lt;span class="nt"&gt;-author&lt;/span&gt; &lt;span class="s2"&gt;"Example Ltd"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; vex.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the shape: the match result informs, the document still builds. A gate that refuses to produce a VEX document until every finding is resolved gets disabled in a week. A gate that publishes the truth, including the unresolved parts, survives contact with a release schedule, which is probably the only property that matters, because a control nobody can ship past is a control nobody keeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;under_investigation&lt;/code&gt; has a half-life. It is honest on the day you publish it and it is an indictment three months later, and nothing in the document as it stands expresses that difference. A statement carries what you concluded, not when you first saw the finding, so a stale queue and a fresh one look identical to a reader.&lt;/p&gt;

&lt;p&gt;If I were extending this, that is where I would go next: record when a finding first appeared undecided, and let the age of an &lt;code&gt;under_investigation&lt;/code&gt; statement be visible to the person reading it. Not as a gate, since an aging finding is not automatically a problem and plenty of them are waiting on an upstream fix, but as the thing a reviewer should look at first. That is not built, and I would rather say so than describe it as though it were.&lt;/p&gt;

&lt;p&gt;The rest of the design I would keep unchanged. A tool that refuses to answer a question it cannot answer is more useful than one that answers everything, because the second kind trains you to stop reading its output. &lt;code&gt;under_investigation&lt;/code&gt; is not the tool admitting defeat. It is the one status in the vocabulary that is always available and never a lie, and a document that uses it freely is a document a reviewer can actually work with.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/under-investigation-is-a-status-not-an-excuse" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>crypto</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
