<?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>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>
    <item>
      <title>The ERC-20 that returns nothing still moved your money</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:22:59 +0000</pubDate>
      <link>https://dev.to/polycratia/the-erc-20-that-returns-nothing-still-moved-your-money-3cii</link>
      <guid>https://dev.to/polycratia/the-erc-20-that-returns-nothing-still-moved-your-money-3cii</guid>
      <description>&lt;p&gt;Three behaviours in deployed token contracts break integrations that trust the return value of a transfer: contracts that return no data at all, contracts that refuse to raise an allowance that is not currently zero, and contracts that credit the receiver with less than the amount you passed. All three succeed on chain. All three leave your ledger wrong, because the thing you checked was the call and the thing that mattered was the balance.&lt;/p&gt;

&lt;p&gt;I have run stablecoin rails in daily production since 2018, and every incident I have had in that area came from the same shape of mistake: some layer of the stack decided that a function signature in a standard was a description of what is actually deployed. It is not. The standard describes what a token should do. The chain holds what somebody compiled years ago and can never change.&lt;/p&gt;

&lt;p&gt;The return value is a claim about the call, not about the money&lt;/p&gt;

&lt;p&gt;ERC-20 says transfer and transferFrom return a bool. Plenty of widely used contracts (including the largest stablecoin by circulation) were written before that convention hardened, and they return nothing. The function body executes, the state changes, and the call returns zero bytes of data.&lt;/p&gt;

&lt;p&gt;That is where naive client code fails, and it fails in the worst direction. A decoder that expects 32 bytes and gets zero either raises, or reads whatever the ABI layer decides to hand back for an empty buffer. So a transfer that actually moved money surfaces as an exception, or as False. The retry logic sees a failure, sends the transfer again, and now the money has moved twice.&lt;/p&gt;

&lt;p&gt;In erc20-transfers I keep the decode step separate from the call for exactly this reason:&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="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;decode_transfer_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;decode_uint256&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;encode_transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&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="mi"&gt;1_500_000&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="nf"&gt;decode_transfer_result&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="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;decode_transfer_result exists because "the token said True", "the token said nothing", and "the token reverted" are three different outcomes, and only the third one means the transfer will not work. Collapsing the first two into a single boolean is the whole bug.&lt;/p&gt;

&lt;p&gt;There is a second trap stacked on top of it, and it is the one I see most often in code review. eth_call, or .call() in web3.py, is a simulation. It runs the function against a local copy of state and returns what it would have returned. Nothing is signed, nothing reaches a mempool, no receipt is produced. A True out of .call() on transferFrom means "this would work", not "the tokens moved". Moving tokens needs a signed transaction and eth_sendRawTransaction. I have seen a service log a successful payout from a simulated call and mark an order as settled while the balance never changed.&lt;/p&gt;

&lt;p&gt;Measure the effect, not the call&lt;/p&gt;

&lt;p&gt;If the return value cannot be trusted and the simulation is not the transfer, the only ground truth is what balanceOf says before and after the transaction was mined. That is the rule the library is built on: check the effect on balances rather than trusting the call to revert.&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;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;send_and_wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signed_tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_uint256&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;treasury&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_number&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_uint256&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;treasury&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;credited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The block pinning is not decoration. If you read "before" against latest, send, and read "after" against latest, any other transfer touching that address between the two reads lands inside your delta. For a hot treasury address that is not an edge case, it is Tuesday. Pinning both reads to the receipt block and its parent makes the delta a property of one transaction instead of a property of how busy you were. For a shared address it is still an approximation over everything in that block that touched it. If you need it exact, the account has to be exclusive to the flow, which is one more argument for per-purpose deposit addresses.&lt;/p&gt;

&lt;p&gt;The fee case falls straight out of this. Some contracts deduct a fee from the transferred amount, so the receiver is credited less than the sender was debited. At least one heavily used stablecoin carries a fee rate in storage that currently happens to be zero: a value somebody can change, not a property of the compiled code. If your ledger credits the amount you passed in, you have written down a number the chain never agreed to, and it will drift away from the balance quietly until somebody reconciles by hand. Credit the measured delta. Debit the requested amount. If they differ, that difference is a real cost and belongs in the ledger as one, not as an unexplained gap.&lt;/p&gt;

&lt;p&gt;The allowance is read, not assumed&lt;/p&gt;

&lt;p&gt;transferFrom moves someone else's tokens and only works while that someone has approved the spender for at least the amount being moved. Short allowance means revert, and a revert from a simulated call is easy to misread as a transient node problem. So the allowance is read first and reported with the numbers in it, before any call data is built:&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;check_allowance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_uint256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encode_allowance&lt;/span&gt;

&lt;span class="n"&gt;allowance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_uint256&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_allowance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&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;spender&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;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_allowance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&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;spender&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;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_500_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;allowance&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;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sufficient&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;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;# 0x2222...2222 holds an allowance of 400000 from 0x1111...1111, but
&lt;/span&gt;    &lt;span class="c1"&gt;# transferFrom of 1500000 needs 1100000 more; the call reverts until the
&lt;/span&gt;    &lt;span class="c1"&gt;# owner approves at least 1500000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sentence is why the module exists. "Execution reverted" tells an operator nothing at three in the morning. The shortfall, the owner, the spender and the amount that needs approving tell them what to do next.&lt;/p&gt;

&lt;p&gt;The non-standard twist here is the reset requirement. Some contracts reject an approve that raises a non-zero allowance to a different non-zero value, and the original reasoning was to close a front-running window between the old and new limits. To raise such an allowance you must first set it to zero, then set the new value. That is two transactions and two nonces, and the state in between is a real state your process can die in: allowance zero, nothing approved, and a queue of payouts that all revert.&lt;/p&gt;

&lt;p&gt;So the top-up is a small state machine, not a function call. Whatever encodes your approve, the discipline around it is the same three rules. Re-read the allowance from the chain before every attempt instead of assuming your last approve landed. Treat "currently zero" as a resumable position rather than an error. And do not build transferFrom call data from a remembered number: encode_checked_transfer_from refuses to produce call data when the allowance it was handed does not cover the amount, which turns a future revert into an immediate, explainable failure.&lt;/p&gt;

&lt;p&gt;Decimals belong to the token too&lt;/p&gt;

&lt;p&gt;While I am refusing to assume things about contracts, the number of decimals is one of them. USDT and USDC use 6, DAI and most others 18, WBTC 8. Assuming 18 against a 6-decimal token inflates a payout by a factor of a million. Read it and pass it explicitly:&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;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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_units&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conversion is exact by construction: amounts are Decimal, floats are refused, and a value with more precision than the token can hold raises instead of silently dropping the digit. A rounding mode you did not choose is a rounding mode you will eventually have to explain to somebody counting their money.&lt;/p&gt;

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

&lt;p&gt;For a long time I handled this with a quirks table: a per-token map of "returns no bool", "needs zero reset", "takes a fee". It works right up until a token you have not classified shows up, or a contract with an upgradeable proxy changes behaviour underneath your entry. The table is a cache of the chain's state maintained by hand, and it goes stale the way every hand-maintained cache does.&lt;/p&gt;

&lt;p&gt;What I would build from the start now is the inverse: assume every token is non-standard, and let measurement be the normal path rather than the fallback. Decode the return value if there is one, and treat its absence as an absence rather than as a denial. Take the credited amount from the balance delta at the receipt block for every transfer, not just for the ones flagged as fee-taking. Read decimals and the allowance from the chain each time, cheaply, rather than remembering them. A well-behaved token costs you two extra eth_calls under that regime. A badly behaved one costs you nothing extra at all, because it is being handled by the same code as everything else.&lt;/p&gt;

&lt;p&gt;The library is at github.com/polycratia/erc20-transfers. It is pre-alpha and the public API is not stable yet, but the shape of the argument is stable: on chain the return value is a claim, and the balance is the evidence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/the-erc-20-that-returns-nothing-still-moved-your-money" 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 withdrawal is a queue entry before it is a transfer</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sun, 06 Sep 2026 13:20:20 +0000</pubDate>
      <link>https://dev.to/polycratia/a-withdrawal-is-a-queue-entry-before-it-is-a-transfer-4nkb</link>
      <guid>https://dev.to/polycratia/a-withdrawal-is-a-queue-entry-before-it-is-a-transfer-4nkb</guid>
      <description>&lt;p&gt;Most withdrawal code collapses two different events into one function call: the user asking for money to leave, and the money actually leaving. Once those are the same operation, everything you might reasonably want to do in between — hold the request for review, batch it with others going to the same chain, re-price it when the fee market moves, cancel it because the destination has a typo in it — has nowhere to live. The fix is not a smarter send function. It is to make the request its own record with an explicit state machine, and to demote the broadcast to one transition inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that causes this
&lt;/h2&gt;

&lt;p&gt;The version I keep finding in custodial systems is a single handler. It validates the destination, debits the user's balance, calls the node, stores the transaction hash on the user's withdrawal row, and returns. It reads fine. It is short. It has no safe moments in it at all.&lt;/p&gt;

&lt;p&gt;The first thing you lose is cancellation. There is no interval during which the operation exists but the money has not moved, so "cancel" is either impossible or it is a support ticket that ends with a manual transfer back. The second thing you lose is batching: to batch you need a population of pending requests, and in this design a request is never pending — it is either absent or already broadcast. The third thing you lose is pricing control. The fee gets decided at the instant a user pressed a button, by whoever happened to press it, rather than at the instant you were ready to send.&lt;/p&gt;

&lt;p&gt;The fourth loss is the one that hurts during an incident. When the node call times out, you have no idea what happened, and the only place to look is the chain. So you write a reconciler that reads the mempool and tries to infer your own intent from someone else's data structure. You are asking the network what you decided.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every transition is a decision someone can make
&lt;/h2&gt;

&lt;p&gt;The alternative is boring in the best way. A withdrawal is a record with a state, and the states are named after the decisions that produce them. In &lt;a href="https://github.com/polycratia/withdrawals" rel="noopener noreferrer"&gt;withdrawals&lt;/a&gt; the path is &lt;code&gt;requested -&amp;gt; approved -&amp;gt; sending -&amp;gt; sent -&amp;gt; confirmed&lt;/code&gt;, with &lt;code&gt;rejected&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; as the two ways out.&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="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="n"&gt;request&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-1041&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;25.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;0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f&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;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&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="n"&gt;sending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;approved&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="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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;confirmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirm&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;confirmed&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;CONFIRMED&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_terminal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four calls, four values. That is the entire point: there are now four moments where the request exists and something can be done to it, instead of one moment where it either worked or did not.&lt;/p&gt;

&lt;p&gt;Two details in that snippet matter more than they look. A request is an immutable value — a transition returns a new request and the caller decides where to store it. That keeps the state machine out of your persistence layer, and it means the natural way to store a withdrawal is one row per version, appended, rather than one row mutated in place until the history is gone. When someone asks in three months why a payout went out at that fee, the answer is a sequence of rows, not a guess.&lt;/p&gt;

&lt;p&gt;The other detail is &lt;code&gt;rejected&lt;/code&gt;. It is reachable only while the money has not moved. That restriction is the state machine's real content. It draws a line through the lifecycle: on one side the operation is a piece of data you own and can freely change your mind about, on the other it is an event on a network that does not care what you decided afterwards. A state machine that lets you reject a broadcast withdrawal is not modelling a withdrawal, it is modelling a wish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cancellable prefix is where the policy lives
&lt;/h2&gt;

&lt;p&gt;Once the prefix exists, things that were architecture problems become ordinary queries.&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;ready_to_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&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;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="k"&gt;if&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;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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a population. Batching is a grouping over it. Fee estimation is a function of it, evaluated at the moment you are actually about to sign rather than at the moment of the request. A four-eyes approval rule is a guard on one transition instead of a feature bolted across a handler. A daily limit is a predicate over requests in the prefix plus requests already past it — and, importantly, both are visible, because a request that has not been sent yet is still a real object with an amount on it.&lt;/p&gt;

&lt;p&gt;Routing belongs here too. A withdrawal whose destination is an address you also custody does not need a chain at all; it is an internal transfer between two accounts you control, and paying a network fee to move it is a donation. But you can only make that choice while nothing has been broadcast. In a design where the request is the transfer, the routing decision has already been made for you by the shape of the code — badly, and always in the expensive direction.&lt;/p&gt;

&lt;p&gt;The repository is honest about where it stands: the request and its state machine are in place, the routing and the senders are not. That ordering is deliberate. The senders are the part everyone writes first and the part that is least interesting, because a sender is just an adapter over a node. The state machine is what decides whether a sender is even reachable, and whether you have anywhere to stand when you need to stop sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency becomes a property of the value
&lt;/h2&gt;

&lt;p&gt;Callbacks arrive twice. Node RPCs time out after the node did the work. Any queue you put in front of the sender is at-least-once, and if it claims otherwise it is lying about a network partition it has not met yet. So the interesting question is not how to avoid duplicate delivery but what a duplicate does when it lands.&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;sent&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="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replaying a transition that already happened with the same data returns the same value. The second callback costs nothing, and the handler that processes it does not need to know it was second. That is worth more than a dedup table, because a dedup table has a window and a window is a bet about how late a retry can be.&lt;/p&gt;

&lt;p&gt;The conflicting case is the one worth being loud about:&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;InvalidTransition&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;sent&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;0xfeedface&lt;/span&gt;&lt;span class="sh"&gt;"&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;InvalidTransition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# two hashes for one request: a double broadcast or a replacement
&lt;/span&gt;    &lt;span class="c1"&gt;# we did not record. This is an incident, not a race to smooth over.
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A replay carrying different data is a conflict, not a silent overwrite. If you let the last writer win, you have quietly decided that a second transaction hash for the same request is a normal event, and you will find out otherwise during reconciliation, at the worst possible moment, with the chain as your only witness. The same rule covers any step the machine does not allow: confirming something that was never sent is not a slightly out-of-order message, it is a bug in whatever produced it.&lt;/p&gt;

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

&lt;p&gt;I have built custodial wallets and an on-chain payment system, and stablecoin rails that run daily, and I have got this wrong in a couple of directions before settling here.&lt;/p&gt;

&lt;p&gt;I would persist every version rather than the latest state. The immutable value makes this nearly free, and it converts "why did this go out twice" from forensics into a select.&lt;/p&gt;

&lt;p&gt;I would decide the fee at the sending transition and never at request time. The gap between those two moments is exactly the window the request was created to give you.&lt;/p&gt;

&lt;p&gt;I would reserve the user's balance when the request is created, not when it is broadcast. A queue that does not reserve lets the same balance be spent twice while it waits, and the second spend will look perfectly valid to every check you have.&lt;/p&gt;

&lt;p&gt;And I would keep the terminal set explicit — &lt;code&gt;is_terminal&lt;/code&gt; rather than a set of state names copied into three different retry loops. Reapers, retries and alerting all need the same answer to "is this still ours to touch", and they should get it from the same place.&lt;/p&gt;

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

&lt;p&gt;A withdrawal is a queue entry that sometimes becomes a transfer. The broadcast is the shortest and least controllable step in it, and it is the only step most implementations model. Give the request a record and a state machine, and cancellation, batching, routing and pricing stop being features you have to retrofit — they are just things you can do to a row that has not left yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-withdrawal-is-a-queue-entry-before-it-is-a-transfer" 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 ledger should not own a money type</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 05 Sep 2026 13:20:20 +0000</pubDate>
      <link>https://dev.to/polycratia/a-ledger-should-not-own-a-money-type-2k4g</link>
      <guid>https://dev.to/polycratia/a-ledger-should-not-own-a-money-type-2k4g</guid>
      <description>&lt;p&gt;Most ledger libraries ship their own &lt;code&gt;Money&lt;/code&gt; class, so any system that already had a money type ends up with two of them and a conversion layer in between. A ledger does not need to own a money type. It needs a contract: an exact amount, the asset that amount is denominated in, and the arithmetic of netting — which a protocol can express without defining a class.&lt;/p&gt;

&lt;p&gt;That is how I built &lt;a href="https://github.com/polycratia/ledger-core" rel="noopener noreferrer"&gt;ledger-core&lt;/a&gt;: it posts any value satisfying a &lt;code&gt;MoneyLike&lt;/code&gt; protocol and defines no money type of its own. The protocol itself was the easy part. The interesting part was what a protocol refuses to give you, and where that shows up in the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second money type is the bug
&lt;/h2&gt;

&lt;p&gt;In the payment systems I have worked on since 2018, money arrives in at least three shapes before anyone writes a line of domain code: an integer count of minor units from a provider, a numeric column from the database, and whatever the application decided a currency-carrying value looks like. Each boundary between those shapes is a function that takes a number and a currency code and returns another number and another currency code, and every one of those functions is a place where quantization, code casing, and — worst — the binding between the amount and its currency can be quietly lost. A &lt;code&gt;Decimal&lt;/code&gt; that has lost track of what it is denominated in is just a number, and numbers add up fine no matter how wrong the result is.&lt;/p&gt;

&lt;p&gt;A ledger that ships its own money class adds another shape to that list, in the one component whose entire job is to be right about netting. So: one money type per system, provided by the currency library, consumed by everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ledger actually needs from a money value
&lt;/h2&gt;

&lt;p&gt;All of it fits in one module:&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;MoneyLike&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;An exact amount tied to the asset it is denominated in.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;amount&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;currency&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="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;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__add__&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;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;M&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;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__sub__&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;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;M&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;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__neg__&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;M&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;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__lt__&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;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;M&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;bool&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;Two properties and four operations. &lt;code&gt;amount&lt;/code&gt; is a &lt;code&gt;Decimal&lt;/code&gt; because a ledger that cannot represent an amount exactly has no business claiming entries net to zero. &lt;code&gt;currency&lt;/code&gt; is on the value, not passed alongside it, so the pairing cannot come apart in transit.&lt;/p&gt;

&lt;p&gt;What is missing matters more. There is no multiplication, because a ledger posts and nets; it does not price. There is no division, because splitting one incoming amount across several parties is a separate problem with its own exactness invariant — an integer allocation over minor units, not a fraction of a money object — and it does not belong behind an operator. The contract stays at four operations because those four are all the ledger performs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;M&lt;/code&gt; is a &lt;code&gt;TypeVar&lt;/code&gt; bound to &lt;code&gt;MoneyLike&lt;/code&gt;, so the arithmetic is self-typed: &lt;code&gt;__add__&lt;/code&gt; takes the same type it returns. The protocol does not merely permit some money type, it rules out mixing two implementations in a single expression, which is exactly the failure mode a second money type introduces.&lt;/p&gt;

&lt;p&gt;The ledger does keep one opinion about currency, because it stores currency codes on accounts and needs entries to net per currency:&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;class&lt;/span&gt; &lt;span class="nc"&gt;CurrencyMismatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Raised when amounts in different currencies are combined.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isalpha&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isupper&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&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;currency must be a 3-letter uppercase ISO 4217 code, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;!r}&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="n"&gt;code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More on that check below — it is the part of my own design I would change.&lt;/p&gt;

&lt;h2&gt;
  
  
  A protocol gives you arithmetic, not constructors
&lt;/h2&gt;

&lt;p&gt;This is the part nobody warns you about when they tell you to depend on abstractions. A protocol types values you were handed. It does not give you a way to make one. There is no &lt;code&gt;MoneyLike.zero(currency)&lt;/code&gt;, no parse, no seed value for &lt;code&gt;sum()&lt;/code&gt;. A library that consumes a protocol can fold, compare and negate, but it cannot produce a value out of nothing.&lt;/p&gt;

&lt;p&gt;Most of the ledger never notices, because every amount it works with came in through an entry. Netting the sides of a movement within a currency needs no seed as long as there is at least one posting to start from. The place where it does notice is the empty account. Holds have to answer "what is available here" for an account that has never been posted to, and the honest answer is zero — but zero of which class?&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;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# what the account holds
&lt;/span&gt;&lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# what open holds have reserved
&lt;/span&gt;&lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# balance minus holds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those return &lt;code&gt;Decimal&lt;/code&gt; in the account's currency, not a money object. The ledger owns no money type, so it cannot mint the zero an empty balance would need.&lt;/p&gt;

&lt;p&gt;There were three ways out and I want to be explicit about why I picked this one. Widening the protocol with a &lt;code&gt;zero&lt;/code&gt; classmethod would make the contract prescribe how implementations are constructed, not just how they behave, and every currency library would have to grow a constructor shaped the way my ledger likes. Taking a money factory as a constructor argument pushes the same problem onto the caller at every entry point, and adds a piece of configuration that can be wrong. Returning the exact number, with the currency already known from the account, keeps the contract narrow and leaves construction where the money type lives.&lt;/p&gt;

&lt;p&gt;The rule I would now apply to any library that consumes a value type: consume freely, produce never. Where production is unavoidable, hand back the raw exact quantity and let the caller mint it with the one money type the system already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the stand-in obviously temporary
&lt;/h2&gt;

&lt;p&gt;A package that defines no money type still has to be installable and testable on its own, so &lt;code&gt;ledger_core.Money&lt;/code&gt; exists as a stand-in until the currency library it is meant to pair with is released. It lives in a module named &lt;code&gt;_stand_in.py&lt;/code&gt;, and the README says what it is:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discipline is to keep it useless for anything else. The moment a stand-in grows formatting, conversion, or allocation helpers, applications start importing it for those, and the promise that swapping the money type is an import change stops being true. A stand-in earns its place by staying small enough that nobody wants to build on it.&lt;/p&gt;

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

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;validate_currency&lt;/code&gt; enforces a three-letter uppercase alphabetic code. That is an ISO 4217 assumption, and it is precisely the kind of assumption I have just argued a ledger should not make. Stablecoin rails have been in daily production use in systems I run, and &lt;code&gt;USDT&lt;/code&gt; does not fit that check; neither would most asset identifiers outside fiat. The ledger only needs equality and grouping from an asset code — it never interprets it. The right move is to make the code opaque to the ledger and leave denomination rules, including how many decimal places an asset actually has, to the money type. A ledger that bakes in two decimal places is fine right up until the first eight-decimal asset arrives.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;runtime_checkable&lt;/code&gt; on a protocol only checks that attributes are present, not that their signatures match. &lt;code&gt;isinstance(value, MoneyLike)&lt;/code&gt; is documentation with a smoke alarm attached, not validation. It is useful at a boundary to catch someone passing a bare &lt;code&gt;Decimal&lt;/code&gt;; it is not a reason to skip the currency checks that actually protect the invariant.&lt;/p&gt;

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

&lt;p&gt;The division of labour is clean once you say it out loud. Being exact about a quantity of an asset is one job, and it belongs to a currency library. Guaranteeing that both sides of a movement net to zero and that balances are derived rather than stored is another job, and it belongs to a ledger. Two packages, one money type per system, and a protocol as the only thing that crosses between them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-ledger-should-not-own-a-money-type" 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>Fee estimation belongs inside the coin selection loop</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:20:21 +0000</pubDate>
      <link>https://dev.to/polycratia/fee-estimation-belongs-inside-the-coin-selection-loop-4mm8</link>
      <guid>https://dev.to/polycratia/fee-estimation-belongs-inside-the-coin-selection-loop-4mm8</guid>
      <description>&lt;p&gt;Coin selection on a UTXO chain is usually written as two steps: pick inputs until they cover the amount, then compute the fee and adjust. That order cannot be made to work. The fee is a function of the transaction's virtual size, the size is a function of which inputs you chose, and how many inputs you need is a function of the fee. It is a circular dependency, and the only honest way to resolve it is to estimate the fee inside the selection loop rather than after it.&lt;/p&gt;

&lt;p&gt;I have been building custodial wallets and on-chain payment systems since 2018, and this is the bug I have watched people rediscover most often. It never looks like a bug at first. It looks like a transaction that sits in the mempool longer than it should, or a wallet that reports a spendable balance it cannot actually spend, or a change output worth less than it costs to spend later. All three have the same root: the fee was computed against a transaction that was already decided.&lt;/p&gt;

&lt;p&gt;I keep the reference implementation of the argument in the open at &lt;a href="https://github.com/polycratia/utxo-select" rel="noopener noreferrer"&gt;github.com/polycratia/utxo-select&lt;/a&gt; — a small Python library that does coin selection, change computation, size estimation and failure reporting as one thing rather than four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circle
&lt;/h2&gt;

&lt;p&gt;Spell the dependency out and it stops being abstract.&lt;/p&gt;

&lt;p&gt;A transaction's weight is fixed overhead plus a per-input cost plus a per-output cost, and virtual size is that weight divided by four, rounded up. The per-input cost depends on the script type of the output being spent: a legacy P2PKH input is 148 vbytes, a P2WPKH input is 68, a Taproot key-path input is 58. So each candidate you add pushes the size up by a different amount depending on what kind of output it is, and pushes the required total up by that amount multiplied by the fee rate.&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_vsize&lt;/span&gt;

&lt;span class="n"&gt;vsize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_vsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2TR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;],&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;vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12_000&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;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2PKH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_vsize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# marginal cost of one more legacy input
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fee rates here are quoted per 1000 virtual bytes and everything is an integer of base units, because satoshi arithmetic that touches a float is a bug waiting for a rounding boundary. &lt;code&gt;12_000&lt;/code&gt; is twelve satoshi per vbyte.&lt;/p&gt;

&lt;p&gt;Now run the naive algorithm. You need 100,000. You take inputs by descending value until you have 100,000. Then you compute the fee — say it comes to 1,800 — and discover you are short. So you add another input. That input is worth 45,000, which covers the shortfall easily, but it also added 68 vbytes to the transaction, which added another 816 to the fee. This time you are still fine. Next time, with a smaller candidate, you will not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the second pass does not save you
&lt;/h2&gt;

&lt;p&gt;The fix people reach for is a second pass: cover the amount, compute the fee, add inputs until the fee is covered too. This terminates on well-funded wallets and quietly diverges on the wallets where it matters.&lt;/p&gt;

&lt;p&gt;The reason is that an input is not free money. Spending an output of value &lt;code&gt;v&lt;/code&gt; at fee rate &lt;code&gt;r&lt;/code&gt; costs &lt;code&gt;estimate_fee(script.input_vsize, r)&lt;/code&gt;, and what the input is actually worth to this transaction is the difference. That difference is the effective value, and it can be zero or negative. An output holding 500 satoshi, spent as P2WPKH at twelve satoshi per vbyte, costs 816 to spend. Adding it to close a shortfall makes the shortfall larger.&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;effective_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utxo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;script_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&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;utxo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is one line, and it is the line that turns "add inputs until it fits" from a heuristic into an algorithm. Candidates with a non-positive effective value are not candidates; a wallet made entirely of them cannot fund anything at any amount, which is a distinct failure from being merely underfunded, and the library reports it as &lt;code&gt;dust_only&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat on &lt;code&gt;input_vsize&lt;/code&gt; that took me an embarrassingly long time to internalize: rounding each input's weight to vbytes independently overstates the total, because the rounding happens once per input instead of once per transaction. It is a safe number to compare two candidates with — it is an upper bound, and upper bounds are what you want when the failure mode is underpaying — but it is not a term to sum into a total. The total goes through &lt;code&gt;estimate_vsize&lt;/code&gt; over the actual input and output lists.&lt;/p&gt;

&lt;p&gt;So the loop looks like this. Nothing exotic, just the fee re-derived on every iteration against the transaction as it currently stands:&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_vsize&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2TR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;utxo&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&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="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&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;chosen&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;utxo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;utxo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="n"&gt;vsize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_vsize&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&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;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;required&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;chosen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is &lt;code&gt;required&lt;/code&gt;, recomputed after every append. There is no point in the function where the amount to beat is a constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change output is a term in the size, not a leftover
&lt;/h2&gt;

&lt;p&gt;The second place the circularity bites is change, and it bites harder because the coupling runs the other way. Change is not what is left over after the fee. Change is an output, an output has a size, and that size is in the fee you just computed.&lt;/p&gt;

&lt;p&gt;A P2WPKH change output is 31 vbytes, which at twelve satoshi per vbyte costs 372 to create. So the moment the remainder drops below the dust threshold — 546 in the usual configuration — you face a decision the naive pipeline has no place to express: you cannot create the output, and you cannot silently drop it either, because dropping it shrinks the transaction, lowers the fee, and increases the remainder you were about to discard.&lt;/p&gt;

&lt;p&gt;The honest resolutions are a small, closed set, which is why change policy is an input to selection rather than a post-processing step:&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChangePolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SelectionRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Utxo&lt;/span&gt;

&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SelectionRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;),),&lt;/span&gt;
    &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dust_threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;546&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;change_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ChangePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALLOW_CHANGE&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;&lt;code&gt;ALLOW_CHANGE&lt;/code&gt; gives a remainder too small to be worth an output to the fee instead — you overpay slightly, but nothing unspendable is created. &lt;code&gt;REQUIRE_CHANGE&lt;/code&gt; keeps adding inputs until the change clears dust, which is what you want when the change address is doing accounting work downstream. &lt;code&gt;FORBID_CHANGE&lt;/code&gt; never creates one at all. What you must not do is create a 400-satoshi output and call the selection successful. That output costs 816 to spend. You have not given the user change; you have given them a liability and charged them a fee for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changeless spends make the coupling explicit
&lt;/h2&gt;

&lt;p&gt;Branch-and-bound is the strategy where the whole argument becomes visible, because it optimizes for the change output not existing. It searches for a subset of candidates that pays the targets and the fee exactly, with no remainder worth returning.&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;select_branch_and_bound&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;select_branch_and_bound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utxos&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&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;Selection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&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="n"&gt;has_change&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;changeless&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;fee&lt;/span&gt;&lt;span class="p"&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;vsize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Exactly" needs a tolerance, and the tolerance is derived, not tuned. Dropping the change output saves its fee now — 372 — and saves the fee of spending that output later — 816. So a subset that overshoots the target by less than roughly 1,188 at this rate is still an improvement over the alternative, and can be accepted as a solution. That bound is not a magic constant. It is two fee estimates, both of which require knowing the script types involved, which means the search cannot be separated from size estimation any more than the greedy loop could.&lt;/p&gt;

&lt;p&gt;Exact matches are rare in real wallets, so the search runs against a budget — 100,000 nodes by default, tunable — and falls back to the largest-first result when it runs out. A selector that returned a failure there would be lying: an answer existed, it just was not the elegant one.&lt;/p&gt;

&lt;p&gt;One more consequence worth stating plainly. Because the fee lives inside the loop, the selector can tell you &lt;em&gt;why&lt;/em&gt; it failed in terms a human can act on. "The candidates do not hold the target amount" and "they hold the target amount but not the fee on top of it" are different sentences, and only the second one is fixed by waiting for a cheaper fee rate. A selector that added the fee afterwards cannot distinguish them, because at the moment it gave up it did not know what the fee was going to be.&lt;/p&gt;

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

&lt;p&gt;The first version I ever wrote returned a boolean and mutated a transaction builder in place. Both were mistakes. Returning a value that is either a balanced selection — inputs equal targets plus change plus fee, checked — or a structured failure carrying &lt;code&gt;available&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt; and &lt;code&gt;shortfall&lt;/code&gt; removed an entire category of bug, because there is no longer a state where the caller holds a half-built transaction and a &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other thing I would fix earlier: round up, everywhere, without apology. Virtual size rounds up. The fee rounds up. Signatures are counted at their maximum encoded length rather than their typical one. Paying one base unit more than necessary costs nothing anybody will ever measure. Paying one less drops you below the rate a fee estimator quoted, and a transaction that misses its band by a single satoshi is stuck just as thoroughly as one that missed by a thousand.&lt;/p&gt;

&lt;p&gt;Coin selection reads like a knapsack problem, and it is tempting to treat the fee as a constraint you apply to the solution. It is not a constraint on the solution. It is a function of the solution. Write the loop that way and the awkward cases — dust, changeless spends, wallets that are technically funded and practically not — stop being special cases and start being outcomes the same arithmetic produces on its own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/fee-estimation-belongs-inside-the-coin-selection-loop" 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>Exactly-once deposits: a broker cannot deduplicate a reorg</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:21:57 +0000</pubDate>
      <link>https://dev.to/polycratia/exactly-once-deposits-a-broker-cannot-deduplicate-a-reorg-58df</link>
      <guid>https://dev.to/polycratia/exactly-once-deposits-a-broker-cannot-deduplicate-a-reorg-58df</guid>
      <description>&lt;p&gt;A service that credits user balances from on-chain deposits has to tell its consumer about each deposit exactly once. The usual reflex is to push the problem onto the transport: turn on deduplication in the queue, set a dedup id, move on. That solves the wrong half. A broker deduplicates the &lt;em&gt;delivery&lt;/em&gt; of a message you already decided to send. Nothing in it deduplicates the &lt;em&gt;decision&lt;/em&gt;, and on a chain the decision is the part that repeats.&lt;/p&gt;

&lt;p&gt;I have been building custodial wallets, on-chain payment systems and fiat-to-crypto onramps since 2018, through three market cycles, and every exactly-once bug I have had to fix in that time lived on the producer side of the queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two problems wearing one name
&lt;/h2&gt;

&lt;p&gt;A deposit watcher polls. Every poll re-reads a range of blocks that overlaps the previous one, because that overlap is the only way to notice that a block you already read is gone. So the same transfer comes back on poll after poll, by design, not by accident. Add a process restart, a node resync, an operator replaying from height zero after a bad deploy, and the same payment is presented to your code an unbounded number of times over an unbounded stretch of wall-clock time.&lt;/p&gt;

&lt;p&gt;Broker deduplication only helps if you hand it the same dedup id each time. But knowing that this transfer is the same one you already reported &lt;em&gt;is&lt;/em&gt; the state problem. If you can compute a stable id, you have already solved deduplication and the broker is doing nothing for you; if you cannot, the broker has nothing to work with. Exactly-once at the queue is a property you supply, not one you receive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key names the payment, not the message
&lt;/h2&gt;

&lt;p&gt;This is where most implementations go wrong, and it is a modelling error rather than a distributed-systems one. The identity you deduplicate on has to name the payment itself, independent of when you learned about it and where it was mined.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;chain-watch&lt;/code&gt;, a small library I maintain (&lt;a href="https://github.com/polycratia/chain-watch" rel="noopener noreferrer"&gt;https://github.com/polycratia/chain-watch&lt;/a&gt;), that identity is &lt;code&gt;DepositKey&lt;/code&gt;: the &lt;code&gt;(tx_id, output_index)&lt;/code&gt; pair naming the transaction output the money landed on. Every deposit carries it as &lt;code&gt;deposit.key&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DepositWatcher&lt;/span&gt;

&lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&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;addr-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;policy&lt;/span&gt;&lt;span class="o"&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;reorg_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&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;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;confirmed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;credit&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;key&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;address&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;amount&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;asset&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;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;reverted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;withdraw&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that with the two keys people reach for instead. A broker message id is attempt-scoped: two polls that see the same transfer produce two ids, so it deduplicates retries of one publish and nothing else. A block-scoped identity — block hash plus index, or a poll cursor — is location-scoped: it survives ordinary repetition and then changes precisely when the transfer is mined into a different block, which is the one moment you needed it to hold. Location-based keys fail exactly when the chain gets interesting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(tx_id, output_index)&lt;/code&gt; says what was paid, not where it was mined. It holds across polls, duplicated blocks, restarts and reorgs, and it is also the natural unique constraint downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dedup windows are measured in time; finality is measured in blocks
&lt;/h2&gt;

&lt;p&gt;Even a broker that does deduplicate on a key you supply does it inside a window, and that window is expressed in minutes. Chain finality is not expressed in minutes. It is expressed in depth, and depth per 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;chain_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConfirmationPolicy&lt;/span&gt;

&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfirmationPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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="n"&gt;per_asset&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;ETH&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="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;12&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="nf"&gt;depth_for&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 6, the default
&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;depth_for&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="c1"&gt;# 12, the override
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six blocks is an hour on one chain and a couple of minutes on another. A resync replays a year of history in the time it takes to read it. There is no minute count you can configure that means "never report this key again, whatever the source says afterwards" — the units do not match, so the guarantee cannot be stated in the broker's vocabulary at all.&lt;/p&gt;

&lt;p&gt;It can be stated in the watcher's. The state carries three things: &lt;code&gt;pending&lt;/code&gt; transfers that are not deep enough yet, &lt;code&gt;reported&lt;/code&gt; deposits already notified but still inside the reorg window, and &lt;code&gt;settled&lt;/code&gt; — the keys of deposits buried below it, final, never reverted and never notified again.&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WatcherState&lt;/span&gt;

&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WatcherState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&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;addr-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;policy&lt;/span&gt;&lt;span class="o"&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;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&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;settled&lt;/code&gt; only grows, and the snapshot grows with the number of deposits ever seen. That is the price of the guarantee, and it is worth being honest about it rather than dressing it up. A time window is cheap because it forgets. Forgetting is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retraction is not a duplicate
&lt;/h2&gt;

&lt;p&gt;There is a second thing a broker cannot model: an event that was true and stopped being true. Blocks disappear. A deposit you credited at three confirmations can be reorged out, and the consumer needs to hear about that too. A poll therefore returns two lists, not one.&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;PollResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;confirmed&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="n"&gt;Deposit&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;reverted&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="n"&gt;Deposit&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful part is the invariant between them: a key that was reverted may confirm again, but only after the revert was reported. Credits and withdrawals always alternate. That single rule is what lets the consumer stay dumb — it can apply each side blindly, without comparing amounts, reconciling against a running balance, or deciding whether this confirmation is the first or the third.&lt;/p&gt;

&lt;p&gt;On the consumer side the whole thing collapses into one transaction:&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;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&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;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;confirmed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insert into deposit_credit (tx_id, output_index, address, amount, asset)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; values (%s, %s, %s, %s, %s) on conflict do nothing&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="o"&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;key&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;address&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;deposit&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;deposit&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="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;reverted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete from deposit_credit where tx_id = %s and output_index = %s&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="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="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update watcher_snapshot set data = %s&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_dict&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 unique constraint on &lt;code&gt;(tx_id, output_index)&lt;/code&gt; is the downstream mirror of &lt;code&gt;DepositKey&lt;/code&gt;, and &lt;code&gt;on conflict do nothing&lt;/code&gt; is a seatbelt rather than the guarantee. The guarantee is that the credit and the snapshot commit together.&lt;/p&gt;

&lt;p&gt;Which is the whole point about placement, stated once: commit the snapshot in the same transaction as the notification and you get exactly once; commit it after and a crash in between replays the poll, so you get at least once; commit it before and the same crash drops the notification, so you get at most once. The broker sits downstream of that choice and cannot improve on it. If you do need to fan out to other services, publish from that same transaction — the transport's job is transport, and its own deduplication becomes a nicety rather than a load-bearing part of the design.&lt;/p&gt;

&lt;p&gt;One demand this puts on the chain source: &lt;code&gt;transfers()&lt;/code&gt; must describe the current best chain, so a transfer it stops returning is a transfer the chain no longer has. If your node or provider cannot promise that, no amount of bookkeeping above it will save you, and that is worth checking before anything else.&lt;/p&gt;

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

&lt;p&gt;Earlier custodial systems I built keyed deduplication on things that were not the payment. One keyed on the provider's webhook id, which is really "this notification", so a provider that reissued notifications after an outage produced duplicate credits that looked like a balance bug for a week. Another keyed on block-scoped identity, which held beautifully until the first reorg deep enough to matter.&lt;/p&gt;

&lt;p&gt;The subtler mistake was structural. I kept a &lt;code&gt;notified&lt;/code&gt; boolean on the same row the scanner was updating with confirmation counts — mixing the record of what the chain says with the record of what I told downstream. Those are two different facts with two different lifetimes, and once they share a row every reorg becomes a data-repair task rather than an event.&lt;/p&gt;

&lt;p&gt;What I would build first now is the identity: a key that names the output rather than the message or the block, and a persisted, restorable set of what has already been said. Everything else — the polling loop, the depth policy, the choice of transport — is replaceable around that. The queue is a pipe. Exactly-once was decided before the message reached it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/exactly-once-deposits-a-broker-cannot-deduplicate-a-reorg" 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 deposit is confirmed by the tip, not by first sight</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:20:32 +0000</pubDate>
      <link>https://dev.to/polycratia/a-deposit-is-confirmed-by-the-tip-not-by-first-sight-2hmd</link>
      <guid>https://dev.to/polycratia/a-deposit-is-confirmed-by-the-tip-not-by-first-sight-2hmd</guid>
      <description>&lt;p&gt;Crediting on-chain deposits fails in a specific way. The watcher sees a transfer, writes a row, and starts incrementing a confirmations column on a timer. Two things are already wrong: the count is stored instead of derived, and the first sighting has been treated as a fact rather than as an event. A confirmation count is not a property of a transfer. It is the distance between the block that currently contains it and the current tip, and that distance can go down as well as up.&lt;/p&gt;

&lt;p&gt;I have been running custodial wallets and on-chain payment flows since 2018, through three market cycles, and the reorg itself is not the hard part. The hard part is that a reorg arrives after you have already told the rest of the system something. A ledger entry, a released order, a notification. Re-deriving chain state is cheap. Un-saying things is not. So the design question is not how to count confirmations. It is which single fact you own that the chain cannot rewrite underneath you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two facts, and only one of them is yours
&lt;/h2&gt;

&lt;p&gt;Everything a deposit watcher knows splits cleanly in two.&lt;/p&gt;

&lt;p&gt;The chain owns where a transfer is: which block, at which height, under which hash, and how far that block sits below the tip. All of it is re-readable on every poll and all of it can change. There is no reason to persist any of it as authority, and every reason not to — a stored confirmation count is a cached answer to a question whose inputs move.&lt;/p&gt;

&lt;p&gt;You own what you have already said out loud. That is the only thing the chain cannot re-derive for you, because it never happened on the chain. It happened in your process, to a consumer that has already acted on it.&lt;/p&gt;

&lt;p&gt;That split is the whole architecture. Recount from the tip every time, and keep exactly one piece of state: the set of payments you have already reported. I wrote &lt;a href="https://github.com/polycratia/chain-watch" rel="noopener noreferrer"&gt;chain-watch&lt;/a&gt; around that split, so the poll loop reads as two lists rather than as a state machine:&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_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DepositWatcher&lt;/span&gt;

&lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&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;addr-1&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;addr-2&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="o"&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;reorg_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&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;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;confirmed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;credit&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;deposit&lt;/span&gt; &lt;span class="ow"&gt;in&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;reverted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;withdraw&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transfer that is still too shallow does not appear in either list. It sits in &lt;code&gt;watcher.pending&lt;/code&gt; and is recounted next time. Nothing downstream hears about a deposit that has not yet reached the depth you asked for, which means a shallow transfer that vanishes in a reorg produces no work at all — the ordinary case costs nothing.&lt;/p&gt;

&lt;p&gt;The demand this puts on the data source is worth stating plainly, because it is the one assumption the whole thing rests on: &lt;code&gt;transfers()&lt;/code&gt; must describe the current best chain. A transfer the source stops returning is a transfer the chain no longer has. If your node adapter serves a cached union of everything it has ever seen, this design cannot work, and neither can any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depth is a policy, and it is per asset
&lt;/h2&gt;

&lt;p&gt;How deep is deep enough is not a library's decision. It is a risk decision, it differs per asset, and on rails where I have moved stablecoins in production it differs by an order of magnitude from what a low-value native transfer needs.&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_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConfirmationPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DepositWatcher&lt;/span&gt;

&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfirmationPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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="n"&gt;per_asset&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;ETH&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="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;12&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&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;addr-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;policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&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="nf"&gt;depth_for&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 6, the default
&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;depth_for&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="c1"&gt;# 12, the override
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there are deliberate and both are about failure modes.&lt;/p&gt;

&lt;p&gt;A mapping of overrides leaves the default at one confirmation, not at some large safe-looking number. The reasoning is that an asset nobody listed should be reported rather than held forever. A silently stuck deposit is the worst outcome in this system: no error, no alert, just a user whose money never arrives and a support ticket that takes a day to trace. Loud and shallow beats silent and deep.&lt;/p&gt;

&lt;p&gt;Asset names are matched exactly as the chain source spells them. That looks unhelpful until you have shipped a source adapter that reports &lt;code&gt;usdt&lt;/code&gt; while the policy says &lt;code&gt;USDT&lt;/code&gt;, and case-insensitive matching quietly papers it over on one deployment and not another. Exact matching turns a config mistake into an observable one: the asset falls to the default depth, which you can see in the policy rather than guess at from behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dedup key names the payment, not the block
&lt;/h2&gt;

&lt;p&gt;The set of things you have already said needs a key, and choosing it badly is how exactly-once quietly becomes approximately-once.&lt;/p&gt;

&lt;p&gt;Anything that includes the block — a block hash, a height, a composite of both with the transaction — is a key that changes when the chain reorganises. The same payment reappears under a new identity and gets credited twice. Keying on the address plus the amount is worse: two identical payments to the same address are indistinguishable, so a legitimate second deposit gets swallowed.&lt;/p&gt;

&lt;p&gt;The key that holds is the one that names the payment itself. In chain-watch that is &lt;code&gt;DepositKey&lt;/code&gt;, the &lt;code&gt;(tx_id, output_index)&lt;/code&gt; pair naming the transaction output the money landed on. It says what was paid, not where it was mined, so it survives polls, duplicated blocks, restarts and reorgs. Every deposit carries it as &lt;code&gt;deposit.key&lt;/code&gt;, and it is also the key downstream should deduplicate on, because your consumer needs the same protection your watcher has.&lt;/p&gt;

&lt;p&gt;On top of that key the guarantees become statable, which matters more than it sounds — a guarantee you cannot write in one sentence is one you cannot test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a transfer the source returns on every poll is confirmed once;&lt;/li&gt;
&lt;li&gt;a source that replays a range it already served changes nothing;&lt;/li&gt;
&lt;li&gt;a key that was reverted may confirm again, but only after the revert was reported, so credits and withdrawals always alternate;&lt;/li&gt;
&lt;li&gt;a key below the reorg window is settled: never reverted, never reported again, whatever the source says afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third one is the one that saves your ledger. A deposit that reorgs out and is mined again is held from scratch and confirms a second time, but the revert is always reported first. Downstream never has to reason about a credit that arrives twice with a withdrawal owed somewhere in between. The pairing is enforced, so the accounting stays a simple alternation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where you write the snapshot decides what the pipeline delivers
&lt;/h2&gt;

&lt;p&gt;Memory is not a guarantee. A process that restarts with an empty dedup set will re-report every deposit still inside its window, so the bookkeeping has to be a value you can persist and hand 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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chain_watch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WatcherState&lt;/span&gt;

&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WatcherState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DepositWatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&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;addr-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;policy&lt;/span&gt;&lt;span class="o"&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;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&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;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_dict&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That three-line ordering is the actual delivery semantics of your pipeline, and it is worth being explicit that no library can decide it for you. Storing the snapshot in the same transaction as the notification gives exactly once. Storing it after gives at least once, because a crash in between replays the poll. Storing it before gives at most once, because the same crash drops the notification. Same watcher, three different products.&lt;/p&gt;

&lt;p&gt;This is why the state is a plain value with &lt;code&gt;to_dict()&lt;/code&gt; and &lt;code&gt;from_dict()&lt;/code&gt; rather than a database integration. The interesting boundary is your transaction, not mine, and the only way to land inside it is to be a value you can write next to your own rows. The serialised form keeps amounts as strings so no decimal is rounded on the way out and back — money that survives a chain reorg and then loses a fractional unit to a JSON float is a comedy I would rather not stage.&lt;/p&gt;

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

&lt;p&gt;One cost here is real and I have not solved it. Settled keys are kept for the lifetime of the state. That is what makes a replay from height zero safe, and it is also what makes the snapshot grow with the number of deposits ever seen. It is the honest trade: unbounded safety bought with unbounded memory.&lt;/p&gt;

&lt;p&gt;In earlier wallet services I built, I would have reached for a stored confirmation count and a periodic repair job, and the repair job is exactly the thing that turns a reorg into a data-integrity incident instead of a routine poll. I would not do that again. But I would bound the settled set — once you accept a finality horizon, keys below it can be dropped, since a source that replays them is reporting blocks you have already declared unreachable. The reason it is not in there yet is that the horizon is a per-chain claim, and I would rather ship the version that is safe against a source I do not control than the version that is cheap against one I have assumed things about.&lt;/p&gt;

&lt;p&gt;The underlying discipline generalises past crypto. Anywhere a system observes an external source and tells someone about it, the same split applies: derive everything you can re-read, persist only what you have already said, and key that record on the thing being described rather than on the circumstances of the observation. First inclusion is an event. Exactly-once notification is the product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-deposit-is-confirmed-by-the-tip-not-by-first-sight" 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>Coin selection should return a reason, not None</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:23:09 +0000</pubDate>
      <link>https://dev.to/polycratia/coin-selection-should-return-a-reason-not-none-123</link>
      <guid>https://dev.to/polycratia/coin-selection-should-return-a-reason-not-none-123</guid>
      <description>&lt;p&gt;A withdrawal fails at three in the morning and the log line says the coin selector returned &lt;code&gt;None&lt;/code&gt;. That one value covers at least four different situations: two of them are answered by funding the hot wallet, two by changing the request, and nothing in the log says which one you are looking at. The information existed inside the function for a few microseconds and was discarded at the &lt;code&gt;return&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;I have shipped custodial BTC and ETH wallets and on-chain payment flows since 2018, and this is the failure mode I keep meeting. Coin selection gets written as a predicate — it either produced inputs or it did not — and then the whole operational cost of that decision lands on whoever is awake. They reconstruct the reason by hand from a balance snapshot and a fee chart. That reconstruction is the library's job.&lt;/p&gt;

&lt;p&gt;This is the thing I wanted to get right in &lt;a href="https://github.com/polycratia/utxo-select" rel="noopener noreferrer"&gt;utxo-select&lt;/a&gt;, a small coin selection library I maintain. It is pre-alpha: the models, size estimation, largest-first and branch-and-bound are in place, and the remaining strategies are not. But the failure type was designed before either strategy was, and that ordering turned out to matter more than the strategies did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four answers hiding behind one None
&lt;/h2&gt;

&lt;p&gt;A selection can fail in ways that a caller would answer differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;reason&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What fixes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_funds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the candidates do not hold the targets, fee aside&lt;/td&gt;
&lt;td&gt;more coins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_after_fees&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;they hold the targets but not the fee on top&lt;/td&gt;
&lt;td&gt;more coins, or a lower fee rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dust_only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every candidate costs more to spend than it holds&lt;/td&gt;
&lt;td&gt;more coins, and consolidate later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;change_below_dust&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;they can pay, but leave no change worth relaying&lt;/td&gt;
&lt;td&gt;relax the change policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one only fails a selection under &lt;code&gt;REQUIRE_CHANGE&lt;/code&gt;. Under the default policy a remainder too small to be worth its own output is given to the fee instead, which is the right default and a surprising one the first time you see the fee come out larger than you asked for.&lt;/p&gt;

&lt;p&gt;The distinction that earns its keep operationally is the first two. &lt;code&gt;insufficient_funds&lt;/code&gt; means no fee rate on earth makes this transaction; the wallet is simply too small for what was asked. &lt;code&gt;insufficient_after_fees&lt;/code&gt; means the wallet covers the payment and loses to the fee, so waiting for a quieter mempool or picking fewer, larger inputs can still close the gap. Those are different pages in a runbook, and a boolean cannot tell them apart.&lt;/p&gt;

&lt;p&gt;Because the outcome is a returned value rather than an exception, the caller narrows it with &lt;code&gt;isinstance&lt;/code&gt; and the type checker keeps the branches honest:&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FailureReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SelectionFailure&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select_largest_first&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&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;Selection&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SelectionFailure&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&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;Selection&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;return &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;ok: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&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;inputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; inputs, fee &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;fee&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&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;change &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;change&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, vsize &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;vsize&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;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;FailureReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INSUFFICIENT_FUNDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;underfunded before any fee, short by &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;shortfall&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;FailureReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INSUFFICIENT_AFTER_FEES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &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;a lower fee rate closes a gap of &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;shortfall&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&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;spending everything would owe &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;fee&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;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;FailureReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DUST_ONLY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;all &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;candidate_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; candidates are dust at this rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&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;can pay, but change would not clear dust; needs &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;shortfall&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select_largest_first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utxos&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That function is the entire on-call improvement. It is also the thing you cannot write against a selector that returns &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fee is inside the loop, not after it
&lt;/h2&gt;

&lt;p&gt;Here is why two of those reasons are even distinguishable, and why they are hard.&lt;/p&gt;

&lt;p&gt;The naive shape of coin selection is: sum the candidates, compare against the targets plus the fee, take inputs until the comparison passes. That shape does not work, because the fee is not a constant you can compute up front. A transaction costs a fixed overhead, plus 41 virtual bytes and an unlocking script for every input, plus 9 virtual bytes and a locking script for every output. Every input you add to cover the fee makes the fee larger. Sometimes it makes it larger than the input was worth.&lt;/p&gt;

&lt;p&gt;So the required amount is a function of the answer you are still computing. Selection and fee estimation are one loop:&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;utxo_select&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_vsize&lt;/span&gt;

&lt;span class="n"&gt;fee_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12_000&lt;/span&gt;  &lt;span class="c1"&gt;# per 1000 virtual bytes
&lt;/span&gt;
&lt;span class="n"&gt;vsize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_vsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2TR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;],&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;vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# What one more input actually costs you at this rate:
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2WPKH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fee_rate&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="nf"&gt;estimate_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ScriptType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P2PKH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_vsize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fee_rate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That marginal number is the whole story. An output is worth its value minus the fee of spending it — its effective value — and an output whose effective value is negative should never be picked up, no matter how short the selection is. That is what &lt;code&gt;dust_only&lt;/code&gt; names: a wallet with a visible balance that cannot pay anything at all at the current rate, because every coin in it is underwater against its own input cost. Users report this as "my balance is wrong". It is not wrong. It is unspendable, which is a different bug report entirely, and the selector is the only component that knows.&lt;/p&gt;

&lt;p&gt;Estimates are upper bounds and both the virtual size and the fee round up. Underpaying is what leaves a transaction stuck in the mempool, and a transaction stuck in the mempool at three in the morning is strictly worse than a selection that refused with a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  A failure that carries its own arithmetic
&lt;/h2&gt;

&lt;p&gt;A reason code alone still makes the operator go and look things up. The numbers behind the verdict should travel with it: what the candidates hold (&lt;code&gt;available&lt;/code&gt;) against what they would have needed (&lt;code&gt;required&lt;/code&gt;), the &lt;code&gt;shortfall&lt;/code&gt; between them, the &lt;code&gt;fee&lt;/code&gt; a transaction spending every candidate would owe, the &lt;code&gt;target_value&lt;/code&gt; that was asked for, and how many candidates were worth spending at all.&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;select_largest_first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utxos&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&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;SelectionFailure&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&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;required&lt;/span&gt;&lt;span class="p"&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;shortfall&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spendable_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;candidate_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spendable&lt;/span&gt;&lt;span class="sh"&gt;"&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dust_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dust at this rate&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gap not caused by the fee rate:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;required&lt;/span&gt; &lt;span class="o"&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;fee&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one I reach for most. &lt;code&gt;required - fee&lt;/code&gt; is the part of the gap the fee rate is not responsible for, which immediately tells you whether waiting for the mempool to calm down is a strategy or a waste of an hour. &lt;code&gt;dust_count&lt;/code&gt; tells you whether the wallet needs a consolidation transaction rather than a top-up. None of these are new computations — the selector did all of them on its way to failing. It just has to not throw them away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a failure is a value and a success is checked
&lt;/h2&gt;

&lt;p&gt;Two choices hold this together.&lt;/p&gt;

&lt;p&gt;First, failure is a returned value, not an exception. Not being able to pay is an ordinary outcome of asking a wallet to pay, not an exceptional one, and modelling it as a value forces every caller to look at it. An exception gets swallowed by a broad &lt;code&gt;except&lt;/code&gt; three frames up and turns back into &lt;code&gt;None&lt;/code&gt; with extra steps.&lt;/p&gt;

&lt;p&gt;Second, success is verified before it can exist. A &lt;code&gt;Selection&lt;/code&gt; checks its own identity in &lt;code&gt;__post_init__&lt;/code&gt; — inputs equal targets plus change plus fee — and refuses to be constructed otherwise:&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;if&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;total_input&lt;/span&gt; &lt;span class="o"&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;total_output&lt;/span&gt; &lt;span class="o"&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;fee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&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;selection does not balance: &lt;/span&gt;&lt;span class="si"&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;total_input&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; in, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&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;total_output&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; out, &lt;/span&gt;&lt;span class="si"&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;fee&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; fee&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;This is the same discipline as a double-entry ledger, and it is there for the same reason: the expensive failures are not the loud ones. An underpaying selection is a transaction that looks fine, broadcasts fine, and sits unconfirmed. Making that state unconstructible is worth more than any amount of downstream validation.&lt;/p&gt;

&lt;p&gt;The pairing also lets a strategy degrade honestly. Branch-and-bound searches for a subset that pays the targets and the fee exactly, so the transaction carries no change output at all — which saves the change output's fee now and the fee of spending it later. Exact matches are the exception, not the rule. When the search budget runs out (100000 nodes by default, tunable via &lt;code&gt;max_tries&lt;/code&gt;), the largest-first result is returned instead. The caller gets the best available answer rather than a failure, and it never has to guess which strategy produced it, because a &lt;code&gt;Selection&lt;/code&gt; reports its own &lt;code&gt;fee&lt;/code&gt;, &lt;code&gt;change&lt;/code&gt; and &lt;code&gt;has_change&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;I would write the failure type before the first strategy, not after — and this time I did, which is the only reason branch-and-bound needed no new reasons when it landed. The four outcomes are properties of the problem, not of the algorithm. Every strategy fails for the same four causes; if adding a strategy adds a reason, the reason vocabulary was wrong.&lt;/p&gt;

&lt;p&gt;The thing I underestimated is how much of the failure payload is inference rather than measurement. &lt;code&gt;available&lt;/code&gt; and &lt;code&gt;candidate_count&lt;/code&gt; are facts. &lt;code&gt;required&lt;/code&gt; is a claim that depends on which script types you assumed and how you rounded, and &lt;code&gt;spendable_count&lt;/code&gt; depends on the fee rate at the moment you asked. Those numbers are only true for one request. I would put more of that context into the failure itself rather than letting a log line imply it was universal.&lt;/p&gt;

&lt;p&gt;A coin selector is not a predicate over a wallet. It is an oracle about a wallet at a fee rate, and the answer "no" is the least interesting thing it knows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/coin-selection-should-return-a-reason-not-none" 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 balance is a query, and a hold is not a column</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:22:37 +0000</pubDate>
      <link>https://dev.to/polycratia/a-balance-is-a-query-and-a-hold-is-not-a-column-oo0</link>
      <guid>https://dev.to/polycratia/a-balance-is-a-query-and-a-hold-is-not-a-column-oo0</guid>
      <description>&lt;p&gt;Custodial systems lose money in two ordinary writes: &lt;code&gt;UPDATE accounts SET balance = balance + :amount&lt;/code&gt;, and the one next to it that nudges a &lt;code&gt;frozen&lt;/code&gt; column up and down while a withdrawal is pending. Both are read-modify-write against a cell that several code paths believe they own, and both turn an accounting question into a concurrency question. The fix is not a better lock. It is to stop storing the answer: derive the balance from paired postings, and make a reservation a first-class object that settles or releases, rather than a number parked beside the balance.&lt;/p&gt;

&lt;p&gt;I have been building payment and custodial systems in production since 2018 — per-user virtual account ledgering with separate borrowing and investing sides, attribution of incoming transfers, custodial wallets for BTC and ETH, stablecoin rails in daily use. Nearly every reconciliation bug I have chased in that time was a variation of one thing: a number was stored as authoritative, and more than one path was allowed to edit it. I put the shape I keep re-deriving into a small library, &lt;a href="https://github.com/polycratia/ledger-core" rel="noopener noreferrer"&gt;ledger-core&lt;/a&gt;, and I will use it here, because it is easier to argue with code than with adjectives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint: a withdrawal is two moments, not one
&lt;/h2&gt;

&lt;p&gt;The thing that makes custodial balances harder than they look is that the interesting states are not moments. A card deposit is one movement. A withdrawal is two: the funds stop being spendable now, and they leave later — or they never leave, because the payout failed, or compliance stopped it, or the customer cancelled while the batch was still queued.&lt;/p&gt;

&lt;p&gt;If the only representation you have is a balance cell, you are forced to encode that gap somewhere. The usual answer is a second cell. Now every code path has to remember to touch both, atomically, in the right order, and the invariant that used to be arithmetic becomes procedure — something enforced by everyone remembering. That is the shape that fails under retry, under partial failure, and under two operators clicking at once.&lt;/p&gt;

&lt;p&gt;So the design has two halves. Postings are the only thing written. Everything else — the balance, what is reserved, what may be spent — is derived from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Movements: two sides, one id, no partial writes
&lt;/h2&gt;

&lt;p&gt;A movement has two sides and one id. Nothing in the system writes one side 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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An entry that does not net to zero in every currency it touches raises &lt;code&gt;UnbalancedEntry&lt;/code&gt; at construction. That is the part worth sitting with: the invalid object does not exist. There is no window in which a one-sided movement is in memory waiting for a validator to catch it, no repair job that finds orphaned legs at 3am. More than two postings are fine — fee splits, multi-party settlements — as long as they sum to zero.&lt;/p&gt;

&lt;p&gt;Batches inherit the rule. &lt;code&gt;Journal.extend&lt;/code&gt; is all-or-nothing: a duplicate id or a correction of something nobody wrote rejects the batch, not the offending row. Half-applied imports are the single most expensive category of reconciliation work I have done, and they are almost always the residue of a loop that committed per item.&lt;/p&gt;

&lt;p&gt;Entries are never edited or deleted. To undo one, you write the entry that undoes 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="n"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reversal&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-2&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="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;refund&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not purity for its own sake. An append-only journal is what lets you answer &lt;em&gt;why&lt;/em&gt; a balance is what it is, months later, when a customer disputes it — and the answer is the same one your finance side will compute independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Balances: derived, snapshotted, never edited
&lt;/h2&gt;

&lt;p&gt;A balance is what the entries add up to, counted in the account's normal direction:&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;Balances&lt;/span&gt;

&lt;span class="n"&gt;balances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Balances&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;balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Decimal("25.00")
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading writes nothing, so two readers cannot overwrite each other's arithmetic — which is exactly how a mutable column updated by read-modify-write silently loses a movement. The lost update is not a rare race in a busy custodial system; it is the normal outcome of a webhook redelivery landing next to a manual adjustment.&lt;/p&gt;

&lt;p&gt;The objection to deriving is performance, and it is a real objection: folding the whole journal on every read gets slower as the journal grows. The answer is to cache the fold, not to abandon it. Each fold is kept as a snapshot — how far it got and what it had by then — and the next read resumes from there, replaying only what has been written since:&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;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&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;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;    &lt;span class="c1"&gt;# what the account held
&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;through&lt;/span&gt;  &lt;span class="c1"&gt;# how many entries are folded into it
&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;    &lt;span class="c1"&gt;# when the last of them occurred
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snapshots are plain values. They can be stored, shipped between processes and handed back: &lt;code&gt;Balances(journal, [snapshot])&lt;/code&gt;. The discipline around them is what keeps this from becoming a stored balance in disguise. A snapshot is only ever replaced by one that reaches further, never edited, and one claiming more entries than the journal holds is refused with &lt;code&gt;SnapshotMismatch&lt;/code&gt;. A stale snapshot therefore costs a replay, never a wrong answer. That asymmetry — degrade into slowness, never into incorrectness — is the property I want from every cache that sits in front of money.&lt;/p&gt;

&lt;p&gt;One deliberate exception: a balance &lt;em&gt;as of a moment&lt;/em&gt; ignores snapshots entirely and replays in full, because a position in the journal says nothing about a point in time. Entries can be appended with earlier &lt;code&gt;occurred_at&lt;/code&gt; values than the ones before them; back-dated settlement files do this routinely.&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;balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Holds: a reserve with a state, not a number
&lt;/h2&gt;

&lt;p&gt;Now the second half of the thesis. A hold writes nothing to the ledger. It stands in front of the balance:&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;Holds&lt;/span&gt;

&lt;span class="n"&gt;holds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Holds&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;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;hold_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;h-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;account&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;10.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;placed_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;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;withdrawal to IBAN&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;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# what the account holds
&lt;/span&gt;&lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# what open holds have reserved
&lt;/span&gt;&lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# balance minus holds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;available&lt;/code&gt; is the number every spend decision should be asking about, and it is derived too — balance less everything still open. A hold that does not fit raises &lt;code&gt;InsufficientFunds&lt;/code&gt;: the reservation is refused rather than the account going short. Refusing at reservation time is the whole point. If the check lives at capture time instead, you have already told the customer their withdrawal is in progress.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Hold&lt;/code&gt; itself is a frozen record with a state — &lt;code&gt;OPEN&lt;/code&gt;, &lt;code&gt;RELEASED&lt;/code&gt;, &lt;code&gt;CAPTURED&lt;/code&gt; — and it settles exactly once. &lt;code&gt;released()&lt;/code&gt; and &lt;code&gt;captured()&lt;/code&gt; return a new hold rather than mutating one, and calling either on an already-settled hold raises &lt;code&gt;HoldNotOpen&lt;/code&gt;. Compare that to a &lt;code&gt;frozen_amount&lt;/code&gt; column: a double release against a column is an arithmetic error that shows up days later as a balance that no longer reconciles, while a double release against a state machine is an exception at the call site.&lt;/p&gt;

&lt;p&gt;If the payout falls through, the reservation goes 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;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h-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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it goes through, the hold is settled by the entry that moves the money — the same paired posting as any other movement, no special case. The settled hold keeps a reference to the entry that settled it, so "which movement discharged this reservation" is a stored fact rather than a join on amount and timestamp. And a settling entry that does not move exactly what was held is refused with &lt;code&gt;CaptureMismatch&lt;/code&gt;, which closes the gap where a partial capture quietly leaves stranded funds reserved forever. Every long-lived custodial system I have worked on accumulates those: reservations nobody can explain, on accounts nobody can release.&lt;/p&gt;

&lt;p&gt;Holds read through the same projection as balances. Pass &lt;code&gt;Holds(journal, balances=balances)&lt;/code&gt; to share snapshots with an existing projection, or let it build its own — but there is only ever one arithmetic, not a reserve calculation that has drifted from the balance calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The money type the ledger refuses to own
&lt;/h2&gt;

&lt;p&gt;The third piece is the one people argue with most, so it is worth stating plainly: the ledger should not own a money type. It posts any value that exposes an exact &lt;code&gt;amount&lt;/code&gt;, the &lt;code&gt;currency&lt;/code&gt; it is denominated in, and &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, unary &lt;code&gt;-&lt;/code&gt; and ordering. That is the &lt;code&gt;MoneyLike&lt;/code&gt; protocol, and it is the entire contract.&lt;/p&gt;

&lt;p&gt;This is not abstraction for its own sake. Money types are opinionated and domain-specific — minor-unit precision, rounding policy, whether eight decimal places are normal, how currency codes are compared. A ledger that ships its own &lt;code&gt;Money&lt;/code&gt; forces every system that adopts it into a conversion layer at the boundary, and conversion layers between two money types are where rounding differences are born. Accepting a protocol means the type you already trust is the type that gets posted.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;ledger-core&lt;/code&gt; there is a small stand-in &lt;code&gt;Money&lt;/code&gt; so the library stays usable and testable on its own, with the real pairing behind an extra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"ledger-core[crypto]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swapping is an import change; nothing else in the API moves.&lt;/p&gt;

&lt;p&gt;The refusal has one visible consequence I like a lot. &lt;code&gt;balance&lt;/code&gt;, &lt;code&gt;held&lt;/code&gt; and &lt;code&gt;available&lt;/code&gt; come back as &lt;code&gt;Decimal&lt;/code&gt; in the account's own currency, not as money objects — because the ledger owns no money type, it cannot mint the zero that an empty balance would need. The return type tells you the truth about where the boundary is instead of papering over it.&lt;/p&gt;

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

&lt;p&gt;On earlier systems I reached for the stored balance first and the ledger second, usually with a nightly job that recomputed from history and flagged discrepancies. That job is a confession: it exists because the primary number is not trustworthy. It also arrives too late — by the time it flags drift, the wrong number has already been shown to a customer, used in a spend decision, and possibly settled against.&lt;/p&gt;

&lt;p&gt;The other thing I would change earlier is treating holds as a feature of the withdrawal flow rather than a concept of the ledger. When the reserve belongs to one flow, the second flow that needs one — a pending trade, a compliance freeze, a chargeback provision — invents its own, and &lt;code&gt;available&lt;/code&gt; stops being a single answer. Making the hold a ledger-level object with its own id, state and settling entry is what keeps a fifth reservation type from being a fifth column.&lt;/p&gt;

&lt;p&gt;What I would keep: derivation with a snapshot in front of it, snapshots that can only move forward, and validation at construction rather than at commit.&lt;/p&gt;

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

&lt;p&gt;Stored balances and freeze columns feel like performance decisions. They are not; they are decisions to make a derived fact editable, and everything expensive that follows — the lost updates, the repair jobs, the stranded reservations, the nightly recompute nobody trusts — descends from that. Postings are the only thing worth writing down. The balance is a question you ask them, and a hold is an object that stands in front of the answer until it settles or goes away.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-balance-is-a-query-and-a-hold-is-not-a-column" 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>Declare once, apply to many: scoping VEX decisions without lying</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:29:29 +0000</pubDate>
      <link>https://dev.to/polycratia/declare-once-apply-to-many-scoping-vex-decisions-without-lying-36jn</link>
      <guid>https://dev.to/polycratia/declare-once-apply-to-many-scoping-vex-decisions-without-lying-36jn</guid>
      <description>&lt;p&gt;A vulnerability scanner does not remember what you decided yesterday. Every build re-reports the same CVEs against the same dependencies, and someone re-reads the same advisory to reach the same conclusion. VEX exists to record that conclusion once, but a VEX statement is an assertion about a concrete product at a concrete version — so the moment anything bumps, you either re-triage from zero or you quietly reuse a claim you never re-checked. Neither of those is a process; the first is attrition and the second is a lie with good formatting.&lt;/p&gt;

&lt;p&gt;I hit this on services where the scanner output is not decoration. In payment and compliance-adjacent systems, someone eventually asks for the basis of a specific claim about a specific artifact that was running on a specific day. "We marked it not affected" is not an answer. "We marked it not affected in March against version 2.4.1, and the artifact you are asking about shipped 2.4.9, and nobody looked again" is at least a true answer — but only if the system was built to know that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ways to be wrong
&lt;/h2&gt;

&lt;p&gt;The first way is to pin every decision to the exact tuple of product version and dependency version. This is honest and unusable. A patch bump on a transitive dependency invalidates a decision that is still obviously correct, the queue fills with re-triage that produces the same verdict, and within two months the team is clicking through it without reading. An honest model that nobody follows produces dishonest data.&lt;/p&gt;

&lt;p&gt;The second way is to declare component-wide: this CVE against this library is not affected, full stop, whatever version. This is usable and false. It keeps asserting after the fact underneath it stopped being true, and it fails silently, which is the worst failure mode available for a security claim.&lt;/p&gt;

&lt;p&gt;Both failures come from treating scope as a UI convenience — a checkbox for "apply to all versions" — rather than as part of the claim itself. Scope is part of the claim. A decision without an explicit &lt;code&gt;applies_to&lt;/code&gt; is not a compact decision; it is an ambiguous one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope follows the justification
&lt;/h2&gt;

&lt;p&gt;Here is the part that made the model tractable for me. Under OpenVEX, a &lt;code&gt;not_affected&lt;/code&gt; status carries a justification (or a written impact statement), and each justification is a statement about a different thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Justification&lt;/th&gt;
&lt;th&gt;It is a property of&lt;/th&gt;
&lt;th&gt;Survives a dependency bump?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;component_not_present&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your build and packaging&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vulnerable_code_not_present&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;that exact dependency version&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vulnerable_code_not_in_execute_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your code's call graph&lt;/td&gt;
&lt;td&gt;usually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vulnerable_code_cannot_be_controlled_by_adversary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your input and deployment path&lt;/td&gt;
&lt;td&gt;usually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inline_mitigations_already_exist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your configuration&lt;/td&gt;
&lt;td&gt;usually&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of the five are assertions about your product. They are legitimately component-wide with respect to the dependency, and they break when &lt;em&gt;you&lt;/em&gt; change — when packaging changes, when someone starts calling the function you swore you never call, when a mitigating config option gets flipped in one environment. Exactly one, &lt;code&gt;vulnerable_code_not_present&lt;/code&gt;, is an assertion about the dependency's source at a version. Carrying that one across a version bump is not a shortcut; it is a fabricated claim, because the whole content of the claim was "this code is not in this version".&lt;/p&gt;

&lt;p&gt;So the scope is not a preference. It follows from the justification — and a decision whose scope contradicts its justification is a bug you can detect mechanically. &lt;a href="https://github.com/polycratia/vexdesk" rel="noopener noreferrer"&gt;vexdesk&lt;/a&gt; does not enforce that check yet; today it is a review rule I apply by hand when a decision lands in the file, and encoding it is the next change on the list. I will come back to why it matters more than anything else here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scope a decision actually carries
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/polycratia/vexdesk" rel="noopener noreferrer"&gt;vexdesk&lt;/a&gt; I keep decisions as source rather than as scanner-tool state: a JSON file in the repository, one entry per judgement, applied to whatever the current scan finds. The tool is Go, standard library only, and the scope model it ships today is deliberately the small one — a single axis, on the component:&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;// Scope says how far one decision reaches.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c"&gt;// ScopeVersion keeps the decision on the exact product it names. It is the&lt;/span&gt;
    &lt;span class="c"&gt;// default: a judgement made about one build does not travel unless asked.&lt;/span&gt;
    &lt;span class="n"&gt;ScopeVersion&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"version"&lt;/span&gt;
    &lt;span class="c"&gt;// ScopeComponent applies the decision to the same component at any version,&lt;/span&gt;
    &lt;span class="c"&gt;// so a justification written once survives the next scan. A version-scoped&lt;/span&gt;
    &lt;span class="c"&gt;// decision naming a particular version wins over it, which is how a rule is&lt;/span&gt;
    &lt;span class="c"&gt;// taken back for that version.&lt;/span&gt;
    &lt;span class="n"&gt;ScopeComponent&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"component"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Vulnerability&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;                &lt;span class="s"&gt;`json:"vulnerability"`&lt;/span&gt;
    &lt;span class="n"&gt;Product&lt;/span&gt;       &lt;span class="kt"&gt;string&lt;/span&gt;                &lt;span class="s"&gt;`json:"product"`&lt;/span&gt;
    &lt;span class="n"&gt;AppliesTo&lt;/span&gt;     &lt;span class="n"&gt;Scope&lt;/span&gt;                 &lt;span class="s"&gt;`json:"applies_to,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;        &lt;span class="n"&gt;openvex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;        &lt;span class="s"&gt;`json:"status"`&lt;/span&gt;
    &lt;span class="n"&gt;Justification&lt;/span&gt; &lt;span class="n"&gt;openvex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Justification&lt;/span&gt; &lt;span class="s"&gt;`json:"justification,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Impact&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;                &lt;span class="s"&gt;`json:"impact_statement,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Action&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;                &lt;span class="s"&gt;`json:"action_statement,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A decision file entry looks like this:&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;"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;"CVE-0000-00000"&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:pypi/somelib@2.4.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"applies_to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"component"&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 vulnerable parser is never invoked; we only use the encoder"&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;&lt;code&gt;applies_to&lt;/code&gt; defaults to &lt;code&gt;version&lt;/code&gt;: a judgement made about one build does not travel unless you asked it to. "Every version" is a value you had to type, not a default you inherited. And an unreadable scope is refused at load time rather than narrowed to the default — a typo would otherwise silently shrink a rule, which is the quiet kind of wrong this whole design exists to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolution: the exact decision wins
&lt;/h2&gt;

&lt;p&gt;Precedence is specificity, not recency. A decision naming the exact product is used as written; only when there is none does a component-wide rule for the same component apply. That is also how you take a blanket rule back for one version — write the version-specific decision, and it outranks the rule without deleting it:&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;if&lt;/span&gt; &lt;span class="n"&gt;d&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;exact&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="m"&gt;2&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;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Advisory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Component&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURL&lt;/span&gt;&lt;span class="p"&gt;}];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&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;componentWide&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="m"&gt;2&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;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Advisory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;componentKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Component&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURL&lt;/span&gt;&lt;span class="p"&gt;)}];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Product&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;Anything no decision reaches comes out as &lt;code&gt;under_investigation&lt;/code&gt; — the honest status for "we have seen it and have not finished looking". The tool never upgrades that on its own: deciding that vulnerable code is unreachable is an engineering judgement, and a tool that guesses it produces documents that look authoritative and are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every carried decision must say it was carried
&lt;/h2&gt;

&lt;p&gt;A status is not enough. It matters &lt;em&gt;how&lt;/em&gt; the status reached this artifact, because "declared for exactly this build" and "inherited from a rule written months ago against a different version" are different epistemic states and belong in different review queues. So when a component-wide rule fires, the emitted statement says so, in the statement itself:&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;note&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"carried from the decision recorded for %s by an applies_to=%s rule"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;carriedFrom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ScopeComponent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line ends up in the published document, so a reader of the VEX file — not just a reader of my internal state — can tell a pinned judgement from an inherited one. It is a deliberately blunt mechanism: provenance as text, in the field people actually read. A structured &lt;code&gt;carried_from&lt;/code&gt; object with the source decision, its date and a review deadline is the obvious next step, and it belongs in an internal store rather than the published document — OpenVEX products are concrete identifiers, there is no wildcard with agreed semantics, and the published document should say what is true about this artifact while the store says how you came to believe it.&lt;/p&gt;

&lt;p&gt;One consequence of the current model worth stating plainly: the carried note lands in &lt;code&gt;impact_statement&lt;/code&gt;, which OpenVEX defines for &lt;code&gt;not_affected&lt;/code&gt;. For rules with other statuses that placement is questionable, and it is on the list to move.&lt;/p&gt;

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

&lt;p&gt;My first version of this was a table keyed by &lt;code&gt;(product, cve)&lt;/code&gt; with a &lt;code&gt;status&lt;/code&gt; column and a &lt;code&gt;notes&lt;/code&gt; field, because that is what the triage session in front of me needed. Ranges got bolted on later as a nullable string, and the meaning of &lt;code&gt;NULL&lt;/code&gt; drifted between "all versions" and "nobody filled this in" depending on which script wrote the row. Cleaning that up cost more than modelling scope explicitly from the start would have.&lt;/p&gt;

&lt;p&gt;The model I keep converging on has two independent version axes — which versions of my product, and which versions of the vulnerable component — with precedence by specificity on both, and a tie between two equally specific decisions treated as a build failure rather than resolved by recency. Picking the newest one is how you get an audit trail that is technically complete and substantively meaningless. vexdesk today ships the one-axis version of that model on purpose: it covers the cases my triage actually produces, and every scope concept it does have is spelled out rather than implied. The two-axis model earns its complexity the day a &lt;code&gt;vulnerable_code_not_present&lt;/code&gt; claim needs to coexist with a product-side rule for the same CVE — and that is exactly the day the justification check from earlier stops being a review habit and must become code, refusing at authoring time the one blanket rule that is always a fabrication.&lt;/p&gt;

&lt;p&gt;The other thing I would change earlier: make a review deadline mandatory on any decision that can be carried. An unbounded assertion about your own call graph is fine on the day you verify it and slowly rots afterwards, and the only cheap defence is that carried decisions expire into a review queue instead of quietly aging into permanence. A carried decision past its review date should degrade to &lt;code&gt;under_investigation&lt;/code&gt; rather than keep asserting.&lt;/p&gt;

&lt;p&gt;None of this makes triage disappear. It makes triage compound: the component-wide rules absorb the churn they are entitled to absorb, the version-specific facts stay pinned to the versions they are facts about, and everything inherited is labelled as inherited. An audit trail that distinguishes what you checked from what you assumed is worth having. One that does not is a guess with timestamps on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/declare-once-apply-to-many-scoping-vex-decisions-without-lying" 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>Multi-carrier shipping rates are quotes, not prices</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:26:41 +0000</pubDate>
      <link>https://dev.to/polycratia/multi-carrier-shipping-rates-are-quotes-not-prices-361j</link>
      <guid>https://dev.to/polycratia/multi-carrier-shipping-rates-are-quotes-not-prices-361j</guid>
      <description>&lt;p&gt;The shipping amount a customer sees at checkout comes from a carrier API call that happened seconds or minutes earlier, computed from package dimensions and an address that can both change before anyone buys a label. If you store that amount as a price column on the order, every gap between what you quoted and what the carrier actually billed becomes manual work that never ends.&lt;/p&gt;

&lt;p&gt;I have spent years on the delivery side of a cross-border shopping platform: rates, labels and tracking from several carriers behind one checkout. The design decision that mattered most was refusing to treat a rate as a number. A rate is a quote — an object with inputs, an issuer, a carrier-side reference and an expiry. Once it is modelled that way, most of the ugly cases stop being ugly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rate is a function of inputs you do not fully own
&lt;/h2&gt;

&lt;p&gt;A carrier price depends on billable weight (which may be volumetric rather than actual), on whether the destination is classified residential or commercial, on surcharges that change without notice, on declared value, and on which service code you asked for. Half of those inputs are decided after checkout, by a warehouse operator choosing a box.&lt;/p&gt;

&lt;p&gt;So the first thing to write down is not the price. It is the exact input vector the price was computed from, hashed, so you can later ask a precise question: is this quote still about the same shipment?&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;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asdict&lt;/span&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;QuoteInputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;origin_zone&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;dest_country&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;dest_postcode&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;dest_kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;              &lt;span class="c1"&gt;# 'residential' or 'commercial'
&lt;/span&gt;    &lt;span class="n"&gt;billable_weight_grams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;dims_mm&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;int&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;declared_value_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;currency&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;incoterm&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;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;QuoteInputs&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;asdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sort_keys&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;separators&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;,&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;:&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="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is deliberately absent: cart contents, customer id, promo codes. The fingerprint covers the shipment, not the order. Adding a second identical item changes the weight and must invalidate the quote; changing the billing email must not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist the quote, not the number
&lt;/h2&gt;

&lt;p&gt;Every offer a carrier returns gets a row. Not the winning one — every one. The alternatives are what let you answer, months later, why a parcel went out with the carrier it did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;shipping_quote&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                 &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shipment_id&lt;/span&gt;        &lt;span class="n"&gt;uuid&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;carrier&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;service_code&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;carrier_quote_ref&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount_minor&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;           &lt;span class="nb"&gt;char&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="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_fingerprint&lt;/span&gt;  &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;raw_response&lt;/span&gt;       &lt;span class="n"&gt;jsonb&lt;/span&gt;       &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fetched_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;shipping_quote_lookup&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;shipping_quote&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetched_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two columns there are load-bearing and easy to skip.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;carrier_quote_ref&lt;/code&gt; is the identifier the carrier hands back with its rate. Where a carrier honours it at purchase time, buying against that reference is the difference between paying what you quoted and paying whatever the rate engine feels like today.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;raw_response&lt;/code&gt; is the untouched body. Normalized columns throw away precisely the field you will need when an unfamiliar surcharge shows up on an invoice line and you have to prove whether it was visible at quote time.&lt;/p&gt;

&lt;p&gt;Fetching is the other place where multi-carrier integrations rot, because it is tempting to gather everything into one list and move on:&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;refresh_quotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;carriers&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="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quote_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;issued_at&lt;/span&gt; &lt;span class="o"&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;quotes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unavailable&lt;/span&gt; &lt;span class="o"&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;for&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;carriers&lt;/span&gt;&lt;span class="p"&gt;:&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;offers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&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;CarrierError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unavailable&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="nc"&gt;CarrierOutage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&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;exc&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;offer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;quotes&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="nf"&gt;store_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="o"&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;input_fingerprint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;fetched_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;min&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="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_ttl&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ttl&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="n"&gt;quotes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unavailable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty list from a carrier means it does not serve that destination. An exception means you do not know. Those are different facts and they belong in different variables. Collapse them into 'no options available' and you will quietly stop selling to a country for as long as one integration is broken, and nobody will file a bug, because the checkout looks fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buying the label against a quote, not against a service code
&lt;/h2&gt;

&lt;p&gt;The purchase step is where the model earns its keep. It has one job: refuse to spend money if the world has moved.&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;buy_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UUID&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;Label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;quote&lt;/span&gt; &lt;span class="o"&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;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_for_update&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quote_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;shipment&lt;/span&gt; &lt;span class="o"&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;shipment&lt;/span&gt;

        &lt;span class="k"&gt;if&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;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QuoteExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;quote_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&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;input_fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ShipmentChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;carriers&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;carrier&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;quote_ref&lt;/span&gt;&lt;span class="o"&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;carrier_quote_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;service_code&lt;/span&gt;&lt;span class="o"&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;service_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&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;label:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;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="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;record_charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;label&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_minor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_minor&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="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="o"&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;carrier_ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tracking_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;label_api&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="n"&gt;label&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency key is derived from the quote id rather than generated per call, because a retried label purchase is not a duplicate row in a report — it is a second parcel and a second charge. Deriving the key from something already persisted means a retry after a timeout, a redeploy, or an operator double-click all land on the same key.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ShipmentChanged&lt;/code&gt; is the interesting failure. It fires when the warehouse repacked, when the customer corrected an address, when the classification of the destination flipped. The correct response is to re-quote and show the new price to whoever is standing at the packing bench — not to buy the old one and file the difference under 'shipping variance'.&lt;/p&gt;

&lt;h2&gt;
  
  
  The carrier reprices after you shipped
&lt;/h2&gt;

&lt;p&gt;This is the part nobody warns you about. Carriers re-measure parcels in their own hubs. Address corrections, remote-area surcharges and dimensional-weight adjustments arrive days later, on an invoice, keyed by tracking number — not by your order id, and not through the API that sold you the label.&lt;/p&gt;

&lt;p&gt;If shipping cost is a column, you now have to overwrite it, and the quote is gone. Make it a ledger instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;shipping_charge&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shipment_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;quote_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt;        &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;shipping_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- quoted | label | adjustment | refund&lt;/span&gt;
    &lt;span class="n"&gt;amount_minor&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- signed&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;     &lt;span class="nb"&gt;char&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="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;carrier_ref&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- tracking number or invoice line id&lt;/span&gt;
    &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- checkout | label_api | invoice&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;carrier_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_minor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occurred_at&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;Rows are appended, never updated. What the customer paid is one row. What the label cost is another. Every later adjustment is its own row, attached by &lt;code&gt;carrier_ref&lt;/code&gt; because that is the only key the invoice actually contains. Margin on a shipment becomes a sum, reconciliation becomes a query, and 'what did we quote and why' remains answerable forever.&lt;/p&gt;

&lt;p&gt;The uniqueness constraint matters because invoice ingestion is a file-parsing job, and file-parsing jobs get run twice.&lt;/p&gt;

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

&lt;p&gt;I would introduce the input fingerprint at the same time as the quote table, not later. Without it, quote expiry is the only guard you have, and expiry does not catch the case that actually costs money: a quote that is still fresh but no longer describes the parcel on the bench.&lt;/p&gt;

&lt;p&gt;I would model carrier unavailability as a first-class value returned to the caller from day one, rather than a log line. Everything downstream — checkout messaging, alerting, deciding whether a destination is genuinely unserved — needs that distinction, and retrofitting it means touching every call site.&lt;/p&gt;

&lt;p&gt;And I would store the raw carrier payload immediately. It costs a &lt;code&gt;jsonb&lt;/code&gt; column, and it is the only thing that makes a surcharge dispute a five-minute lookup instead of an argument.&lt;/p&gt;

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

&lt;p&gt;A price is a fact. A rate is a promise made under assumptions, by a third party, with a shelf life. Almost every recurring problem in multi-carrier shipping — quoted-versus-charged drift, duplicate labels, mystery invoice lines, silently disappearing destinations — comes from storing the second thing in a shape designed for the first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/multi-carrier-shipping-rates-are-quotes-not-prices" 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>Custodial ETH withdrawals: the nonce is a database row, not a node call</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:25:40 +0000</pubDate>
      <link>https://dev.to/polycratia/custodial-eth-withdrawals-the-nonce-is-a-database-row-not-a-node-call-5gng</link>
      <guid>https://dev.to/polycratia/custodial-eth-withdrawals-the-nonce-is-a-database-row-not-a-node-call-5gng</guid>
      <description>&lt;p&gt;A custodial wallet service has to turn a user's withdrawal request into exactly one on-chain transaction, while every layer around it — HTTP clients, queues, restarts, impatient users — is at-least-once. The first thing that breaks is the nonce. Two workers ask the node for the next transaction count, both get the same number, both sign, and one transaction quietly replaces the other on the network. Nothing throws. The user sees a confirmation and no coins.&lt;/p&gt;

&lt;p&gt;I have built custodial wallet services for BTC and ETH and the on-chain payment system around them. Account-model chains punish sloppiness here more than UTXO chains do, because the nonce is a strictly sequential resource owned by the sending address, and nothing in the JSON-RPC API will defend it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The node's nonce is a report, not a reservation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eth_getTransactionCount&lt;/code&gt; answers one of two different questions depending on the block tag: how many transactions from this address are mined (&lt;code&gt;latest&lt;/code&gt;), or how many the node currently holds in its own mempool view (&lt;code&gt;pending&lt;/code&gt;). Neither one is an allocation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;latest&lt;/code&gt; ignores everything in flight, so under any concurrency it hands the same number to everybody. &lt;code&gt;pending&lt;/code&gt; reflects a single node's mempool, which is not a consensus object: it evicts transactions under memory pressure, it cannot see what a sibling node accepted, and it forgets everything on restart. Building a withdrawal queue on &lt;code&gt;pending&lt;/code&gt; means your correctness depends on a cache you do not own and cannot audit after the fact.&lt;/p&gt;

&lt;p&gt;So the rule I end up at every time: the nonce is allocated by my database, in the same transaction that persists the signed payload. The node is told about the result afterwards, as many times as necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three states, not one
&lt;/h2&gt;

&lt;p&gt;"Send a withdrawal" is not one operation. It is three, and they have completely different repeat semantics.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;state&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;th&gt;safe to repeat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;requested&lt;/td&gt;
&lt;td&gt;an intent exists, nothing is signed&lt;/td&gt;
&lt;td&gt;yes, freely — discard and rebuild at will&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;signed&lt;/td&gt;
&lt;td&gt;a nonce is consumed, a payload is durable&lt;/td&gt;
&lt;td&gt;never — this is the irreversible step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;broadcast&lt;/td&gt;
&lt;td&gt;the network has been told at least once&lt;/td&gt;
&lt;td&gt;yes, endlessly, with the same bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;settled&lt;/td&gt;
&lt;td&gt;the nonce slot is mined&lt;/td&gt;
&lt;td&gt;terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most broken pipelines I have inherited collapse &lt;code&gt;signed&lt;/code&gt; and &lt;code&gt;broadcast&lt;/code&gt; into one &lt;code&gt;sent&lt;/code&gt; state. After a crash they cannot distinguish "we never built this transaction" from "we built it and the RPC call timed out" — and those two have opposite recovery paths. One should rebuild, the other must never rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allocating the nonce
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;hot_account&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_nonce&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;withdrawal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;      &lt;span class="nb"&gt;bigint&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;from_address&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;hot_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;to_address&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount_wei&lt;/span&gt;   &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;78&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;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'requested'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nonce&lt;/span&gt;        &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;raw_tx&lt;/span&gt;       &lt;span class="n"&gt;bytea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tx_hash&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;withdrawal_nonce_uniq&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;withdrawal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details there matter more than they look.&lt;/p&gt;

&lt;p&gt;The partial unique index is the invariant, expressed in the only place that can actually enforce it under concurrency. If a bug ever lets two workers allocate the same nonce, I want a constraint violation and one stuck withdrawal, not two valid signatures racing on the network.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;next_nonce&lt;/code&gt; lives in its own tiny table so it can be locked on its own. That lock serialises every withdrawal leaving that address, so the critical section has to stay short — which is exactly why no network call belongs inside 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;eth_account&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;

&lt;span class="n"&gt;GAS_LIMIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;21_000&lt;/span&gt;  &lt;span class="c1"&gt;# plain value transfer
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allocate_and_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;withdrawal_id&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;chain_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority_fee&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# one database transaction
&lt;/span&gt;        &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;select state, from_address, to_address, amount_wei &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;from withdrawal where id = %s for update&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;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_wei&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;requested&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="c1"&gt;# already allocated by someone else; not our job
&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;select next_nonce from hot_account where address = %s for update&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;from_address&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="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;signed&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;sign_transaction&lt;/span&gt;&lt;span class="p"&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;chainId&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nonce&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;to_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_wei&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gas&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GAS_LIMIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;max_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;maxPriorityFeePerGas&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;priority_fee&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;update hot_account set next_nonce = next_nonce + 1 where address = %s&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;from_address&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update withdrawal set state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;signed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, nonce = %s, raw_tx = %s, tx_hash = %s &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;where id = %s&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;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_transaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;withdrawal_id&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 ordering is the whole point. The lock, the increment, the signature and the stored payload commit together. If the process dies halfway, the transaction rolls back, the nonce is not consumed, and nothing was ever broadcast — the request is still cleanly &lt;code&gt;requested&lt;/code&gt;. If it commits, exactly one signed payload exists for that nonce and it is durable before a single packet leaves the process.&lt;/p&gt;

&lt;p&gt;Note also what is absent: any RPC call. Signing is local. Fee parameters are read before the transaction opens and passed in; a plain transfer's gas limit is a constant. Fetching fees inside the critical section would put a network timeout inside a lock that every other withdrawal from that address is waiting on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebroadcast is free; re-signing is not
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ALREADY_HAVE_IT&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;already known&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;known transaction&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;transaction already exists&lt;/span&gt;&lt;span class="sh"&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;rpc_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;arg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&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;if&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;message&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arg&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="k"&gt;else&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;arg&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;text&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;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select raw_tx from withdrawal where id = %s and state in (&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;signed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;broadcast&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&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;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;row&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;w3&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="nf"&gt;send_raw_transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rpc_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nonce too low&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# the slot is already mined; the watcher decides by whom
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;known&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALREADY_HAVE_IT&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update withdrawal set state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;broadcast&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; where id = %s and state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;signed&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;withdrawal_id&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 error handling is the interesting half. &lt;code&gt;already known&lt;/code&gt; is not a failure of this withdrawal, it is evidence the network already has the exact bytes I am holding. Sending the same signed payload a thousand times produces one transaction, because the payload is its own identity.&lt;/p&gt;

&lt;p&gt;The dangerous instinct is the opposite one: a send times out, and somebody "retries" by rebuilding the transaction with a fresh nonce. Now two signed payloads exist for one intent, and if the first one was in fact accepted, both can mine, and the user is paid twice from a hot wallet. Once a payload is signed, the retry is always the same bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fee bumps create siblings
&lt;/h2&gt;

&lt;p&gt;Under a rising fee market a transaction can sit unmined for a long time, and because nonces are sequential, everything behind it waits. The fix is a replacement: same nonce, higher fee, new signature. Most clients require a meaningful increase before they will evict the old one — geth's default price bump is ten percent — so the bump has to be real, not cosmetic.&lt;/p&gt;

&lt;p&gt;That produces the part nobody warns you about. One withdrawal now has several valid transaction hashes, and only one of them will ever be mined. If the confirmation watcher polls a single stored &lt;code&gt;tx_hash&lt;/code&gt;, a successful bump is indistinguishable from a lost transaction, and the operator on shift starts investigating a payment that actually went through.&lt;/p&gt;

&lt;p&gt;So attempts get their own table, and the watcher keys on the pair that is genuinely unique on-chain — &lt;code&gt;(from_address, nonce)&lt;/code&gt; — rather than on any one hash.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;withdrawal_attempt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;withdrawal_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;withdrawal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;tx_hash&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;raw_tx&lt;/span&gt;        &lt;span class="n"&gt;bytea&lt;/span&gt;       &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_fee_wei&lt;/span&gt;   &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;78&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;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&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;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;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;select from_address, nonce from withdrawal where id = %s&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;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
    &lt;span class="n"&gt;from_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;mined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w3&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="nf"&gt;get_transaction_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latest&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;mined&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# the slot is still open; keep rebroadcasting or bump
&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;select tx_hash from withdrawal_attempt where withdrawal_id = %s&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;withdrawal_id&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_hash&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;():&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;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w3&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="nf"&gt;get_transaction_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update withdrawal set state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, tx_hash = %s where id = %s&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;tx_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;withdrawal_id&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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;nonce &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;from_address&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; mined by an unknown transaction&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;That last line is deliberate. If the slot is consumed and none of my siblings has a receipt, something signed with my key that I did not record. That is not a retry condition, it is an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head-of-line blocking is the price
&lt;/h2&gt;

&lt;p&gt;Per-address serialisation means one stuck transaction blocks every later withdrawal from that address. This is not a bug to engineer away; it is the chain's model. The only real lever is how many sending addresses you run. Assigning withdrawals to lanes by hashing the withdrawal id gives independent nonce sequences and independent failure domains, at the cost of splitting the hot balance across them and needing a rebalancing job that is itself a withdrawal — and therefore must use the same allocator, not a special path.&lt;/p&gt;

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

&lt;p&gt;Persist the signed payload before touching the node, always. I have worked on code that broadcast first and recorded the hash after. The gap between those two lines is where a crashed process becomes an untracked transaction spending real funds, and reconciling that afterwards means scanning the chain for your own address to find out what you did.&lt;/p&gt;

&lt;p&gt;Never expose a cancel button that deletes the row. Once a nonce is signed and broadcast, the only cancel that exists is a replacement at the same nonce: zero value, self-transfer, higher fee. A cancel that just marks the database row as cancelled leaves a live transaction on the network with a stale user expectation attached to it.&lt;/p&gt;

&lt;p&gt;And keep &lt;code&gt;nonce too low&lt;/code&gt; visible in metrics even though the broadcaster treats it as benign. In steady state it appears exactly when a slot has just settled. A cluster of it anywhere else is the signature of double allocation, which is the one failure in this design that costs money rather than time.&lt;/p&gt;

&lt;p&gt;The nonce is the only part of a withdrawal that cannot be recreated after the fact. Once I started treating it as a row I allocate and a payload I persist — with the node as a broadcast medium rather than a source of truth — withdrawals stopped being the frightening part of a custodial system and became the boring part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/custodial-eth-withdrawals-the-nonce-is-a-database-row-not-a-node-call" 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>
