<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Hamza Aziz]]></title><description><![CDATA[Hamza Aziz]]></description><link>https://hamzaaziz.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 05 Sep 2026 20:05:13 GMT</lastBuildDate><atom:link href="https://hamzaaziz.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[tecPATH_DRY means five different things]]></title><description><![CDATA[If you've built anything on the XRP Ledger that moves issued tokens, you've hit
this:
tecPATH_DRY

The docs will tell you it means no path was found to deliver the payment. That's
accurate and almost ]]></description><link>https://hamzaaziz.hashnode.dev/tecpath-dry-means-five-different-things</link><guid isPermaLink="true">https://hamzaaziz.hashnode.dev/tecpath-dry-means-five-different-things</guid><category><![CDATA[xrpl]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[debugging]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Hamza Aziz]]></dc:creator><pubDate>Thu, 03 Sep 2026 10:54:12 GMT</pubDate><content:encoded><![CDATA[<p>If you've built anything on the XRP Ledger that moves issued tokens, you've hit
this:</p>
<pre><code>tecPATH_DRY
</code></pre>
<p>The docs will tell you it means no path was found to deliver the payment. That's
accurate and almost useless, because "no path" is what the payment engine says
when it gives up, not why it gave up.</p>
<p>I spent a week building a regulated-asset issuance demo on XRPL and hit this
error in five structurally different situations. Same code every time. Here they
all are, with a minimal reproduction for each, and a checklist at the end for
narrowing it down.</p>
<p>Everything below is verified against testnet. I got the count wrong twice while
writing this — first I thought four, then three — so if you find a sixth, I'd
like to know.</p>
<h2>1. The destination has no trust line</h2>
<p>The most common one, and the only one people usually know about.</p>
<p>XRPL tokens are opt-in. You can't push an issued token at an account that hasn't
said it's willing to hold it.</p>
<pre><code class="language-ts">const { wallet: stranger } = await client.fundWallet()

await client.submitAndWait({
  TransactionType: 'Payment',
  Account: issuer.address,
  Destination: stranger.address,
  Amount: { currency: 'PRP', issuer: issuer.address, value: '10' },
}, { wallet: issuer })
// tecPATH_DRY
</code></pre>
<p><strong>How to check:</strong> <code>account_lines</code> on the destination, filtered by currency and
issuer. If there's no matching line, that's your answer.</p>
<p><strong>Fix:</strong> the destination submits a <code>TrustSet</code>. Only they can do it — the issuer
can't create it on their behalf.</p>
<h2>2. The trust line exists but isn't authorized</h2>
<p>If the issuer has <code>RequireAuth</code> set, a trust line is inert until the issuer
blesses it. The line is there, the limit is set, and nothing can move.</p>
<pre><code class="language-ts">// issuer has asfRequireAuth (2)
// holder has submitted TrustSet
// issuer has NOT authorized it

await client.submitAndWait({
  TransactionType: 'Payment',
  Account: issuer.address,
  Destination: holder.address,
  Amount: { currency: 'PRP', issuer: issuer.address, value: '10' },
}, { wallet: issuer })
// tecPATH_DRY
</code></pre>
<p>This one is worth dwelling on if you're building anything compliance-related,
because a failed KYC check surfaces to your user as "no liquidity." That's a
terrible error message for what is actually "you haven't been approved yet."</p>
<p><strong>How to check:</strong> read the issuer's <code>Flags</code> via <code>account_info</code> and test for
<code>lsfRequireAuth</code> (<code>0x00040000</code>). Then check the trust line for <code>peer_authorized</code>.</p>
<p><strong>A trap here:</strong> on <code>account_lines</code>, the field named <code>authorized</code> means <em>this
account authorized the counterparty</em>. Querying the holder, that's the holder
authorizing the issuer — the opposite of what you want. <code>peer_authorized</code> is the
one that tells you the issuer authorized the line. Trust lines are two-sided
objects and every field has a direction.</p>
<p><strong>Fix:</strong> the issuer submits a <code>TrustSet</code> with <code>tfSetfAuth</code>, naming the holder as
counterparty.</p>
<h2>3. The line is frozen — on either side</h2>
<p>An issuer can freeze an individual holder. A holder can freeze their own side.
Either blocks the transfer.</p>
<pre><code class="language-ts">// issuer freezes alice
await client.submitAndWait({
  TransactionType: 'TrustSet',
  Account: issuer.address,
  LimitAmount: { currency: 'PRP', issuer: alice.address, value: '0' },
  Flags: 0x00100000, // tfSetFreeze
}, { wallet: issuer })

// alice tries to pay bob
// tecPATH_DRY
</code></pre>
<p>This is the one that caught my own diagnostic code. I was reading the trust line
on the <em>destination</em> — checking whether bob was frozen. Bob was fine. Alice was
frozen, and I never looked.</p>
<p><strong>How to check:</strong> read trust lines for both the sender and the destination.
<code>freeze_peer</code> means the issuer froze that line; <code>freeze</code> means the account froze
its own side. Check both fields on both accounts.</p>
<p>Alice's balance is completely intact while frozen, incidentally. She just can't
move it. So a balance check tells you nothing here.</p>
<h2>4. Global freeze on the issuer</h2>
<p>The issuer can freeze everything at once, which blocks every holder
simultaneously without touching any individual trust line.</p>
<pre><code class="language-ts">await client.submitAndWait({
  TransactionType: 'AccountSet',
  Account: issuer.address,
  SetFlag: 7, // asfGlobalFreeze
}, { wallet: issuer })

// any holder-to-holder payment now:
// tecPATH_DRY
</code></pre>
<p><strong>How to check:</strong> <code>lsfGlobalFreeze</code> (<code>0x00400000</code>) in the issuer's account flags.</p>
<p>This one is invisible if you're only looking at trust lines. Every line will
look healthy — authorized, unfrozen, funded — and nothing will move.</p>
<h2>5. DefaultRipple isn't enabled</h2>
<p>The subtlest one, and the only one that isn't a compliance control.</p>
<p>Without <code>DefaultRipple</code> on the issuer, tokens can only move between the issuer
and a holder. Holder-to-holder transfers fail. Which means no secondary market at
all, and no obvious reason why.</p>
<pre><code class="language-ts">// issuer does NOT have asfDefaultRipple (8)
// alice is authorized and holds 100 PRP
// bob is authorized

// alice -&gt; bob
// tecPATH_DRY
</code></pre>
<p>Every trust line involved is healthy. Nothing is frozen. Alice has the balance.
And it still fails.</p>
<p><strong>How to check:</strong> <code>lsfDefaultRipple</code> (<code>0x00800000</code>) on the issuer.</p>
<p><strong>Fix:</strong> the issuer sets <code>asfDefaultRipple</code>. Note this is a policy decision, not
a bug — an issuer that deliberately wants a closed system where all transfers
route through them would leave it off.</p>
<h2>The one that isn't tecPATH_DRY</h2>
<p>Insufficient balance returns <strong><code>tecPATH_PARTIAL</code></strong>, a different code entirely.</p>
<p>I originally wrote — in a public README, no less — that insufficient balance was
one of the <code>tecPATH_DRY</code> causes. A test against the real network corrected me.</p>
<p>The boundary is exact. Holding 100 and sending 100 succeeds. Sending 101 returns
<code>tecPATH_PARTIAL</code>, meaning a path was found but couldn't carry the full amount.</p>
<p>If you want partial delivery to be acceptable, set <code>tfPartialPayment</code> and it
succeeds with less delivered.</p>
<h2>A checklist</h2>
<p>In this order, because each step rules out the ones after it:</p>
<ol>
<li><strong>Destination trust line exists?</strong> <code>account_lines</code> on the destination →
no match means cause 1.</li>
<li><strong>Issuer requires auth, and has it authorized?</strong> <code>account_info</code> on the issuer
for <code>lsfRequireAuth</code>, then <code>peer_authorized</code> on the line → cause 2.</li>
<li><strong>Either line frozen?</strong> <code>freeze</code> and <code>freeze_peer</code> on <em>both</em> sender and
destination lines → cause 3.</li>
<li><strong>Global freeze?</strong> <code>lsfGlobalFreeze</code> on the issuer → cause 4.</li>
<li><strong>DefaultRipple off?</strong> <code>lsfDefaultRipple</code> on the issuer → cause 5.</li>
</ol>
<p>Cause 5 is the only one reached by elimination rather than positive evidence.
Everything else you can point at directly.</p>
<p>Four ledger reads gets you a definitive answer in every case. The information
was always available — it just isn't in the error.</p>
<h2>Why the error is like this</h2>
<p>Not a criticism, mostly. The payment engine's job is to find a route that
delivers the amount. It tries, fails, and reports that it failed. It doesn't
carry a diagnostic trail of everything it considered and rejected, because that
would be expensive to compute and larger than the transaction result itself.</p>
<p>The information is one query away. It's just on the caller to go get it.</p>
<h2>I packaged this up</h2>
<p>I got tired of doing those four reads by hand, so it's a library now:
<a href="https://www.npmjs.com/package/xrpl-why"><code>xrpl-why</code></a>.</p>
<pre><code>npm i xrpl-why
</code></pre>
<pre><code class="language-ts">const why = await explain(client, code, {
  kind: 'payment',
  account: sender.address,
  destination: recipient.address,
  amount: { currency: 'PRP', issuer: issuer.address, value: '100' },
})

console.log(why.summary)
// "The issuer requires authorization and has not authorized this trust line."
</code></pre>
<p>It returns the list of checks it ran alongside the diagnosis, so when it's wrong
you can see why rather than just being misled more confidently.</p>
<p>Early days, and the checklist above is more valuable than the package. Use
whichever.</p>
<hr />
<p><em>All five cases have integration tests against testnet in
<a href="https://github.com/hamzaaziz1/xrpl-why">the repo</a>. No mocks — the whole point is
knowing what the ledger actually does, and I'd already been wrong twice about
that.</em></p>
]]></content:encoded></item></channel></rss>