diff --git a/packages/paywall/src/browser/StellarPaywall.tsx b/packages/paywall/src/browser/StellarPaywall.tsx
index 1362988..b67475e 100644
--- a/packages/paywall/src/browser/StellarPaywall.tsx
+++ b/packages/paywall/src/browser/StellarPaywall.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from "react";
import type { PaymentRequired, PaymentRequirements } from "@x402/core/types";
-import { getNetworkDisplayName } from "./utils";
+import { getNetworkDisplayName, isBalanceInsufficient } from "./utils";
import { Spinner } from "./Spinner";
import { statusError, statusInfo, type Status } from "./status";
import { useStellarBalance } from "./useStellarBalance";
@@ -21,6 +21,9 @@ type StellarPaywallMainProps = {
const STELLAR_PAYMENT_SCALE = 10_000_000;
+/** Circle's testnet faucet, the only public source of Stellar testnet USDC. */
+const CIRCLE_FAUCET_URL = "https://faucet.circle.com/";
+
/**
* Paywall experience for Stellar networks. Validates that a Stellar payment
* requirement exists and either renders the error shell or delegates to the
@@ -91,6 +94,7 @@ function StellarPaywallMain({
const {
isFetchingBalance,
+ tokenBalanceRaw,
tokenBalanceFormatted,
isMissingTrustline,
assetMetadata,
@@ -123,6 +127,11 @@ function StellarPaywallMain({
const chainName = getNetworkDisplayName(network);
+ // `null` while the balance is still unknown (not connected, still loading, or
+ // the read failed) — only `true` blocks the Pay button.
+ const insufficientBalance = isBalanceInsufficient(tokenBalanceRaw, stellarRequirement.amount);
+ const canFundFromFaucet = network === "stellar:testnet";
+
const handleConnect = useCallback(async () => {
await connect();
}, [connect]);
@@ -147,17 +156,23 @@ function StellarPaywallMain({
return;
}
- if (tokenBalanceFormatted === "") {
+ // Re-read the balance whenever it is not known, then block on the fresh
+ // value. Previously this only ran while the balance was unknown, so a
+ // connected wallet with a known-too-low balance went straight to signing
+ // and only failed at settlement.
+ let balanceRaw = tokenBalanceRaw;
+ if (balanceRaw === null) {
setStatus(statusInfo(`Checking ${assetCode} balance...`));
- const freshBalance = await refreshBalance();
- if (Number(freshBalance) < amount) {
- setStatus(
- statusError(
- `Insufficient balance. Make sure you have enough ${assetCode} on ${chainName}.`,
- ),
- );
- return;
- }
+ balanceRaw = await refreshBalance();
+ }
+
+ if (isBalanceInsufficient(balanceRaw, stellarRequirement.amount)) {
+ setStatus(
+ statusError(
+ `Insufficient balance. Make sure you have enough ${assetCode} on ${chainName}.`,
+ ),
+ );
+ return;
}
try {
@@ -169,12 +184,13 @@ function StellarPaywallMain({
x402,
walletSigner,
address,
- tokenBalanceFormatted,
- amount,
+ tokenBalanceRaw,
+ stellarRequirement.amount,
assetCode,
chainName,
refreshBalance,
submitPayment,
+ setStatus,
]);
return (
@@ -249,9 +265,15 @@ function StellarPaywallMain({
>
)}
@@ -259,6 +281,46 @@ function StellarPaywallMain({
{status &&
{status.message}
}
+ {address && insufficientBalance === true && !isMissingTrustline && (
+
+
+ !
+
+
+
+ Not enough {assetCode} to pay ${amount}
+
+
+ This account holds{" "}
+
+ {tokenBalanceFormatted || "0"} {assetCode}
+ {" "}
+ on {chainName}.{" "}
+ {canFundFromFaucet ? (
+ <>
+ Get testnet {assetCode} from the{" "}
+
+ Circle faucet ↗
+
+ , then{" "}
+ >
+ ) : (
+ <>Add {assetCode} to this account, then >
+ )}
+
+ .
+
+
+
+ )}
+
{address && isMissingTrustline && (
diff --git a/packages/paywall/src/browser/styles.css b/packages/paywall/src/browser/styles.css
index bf4b94f..27d87d7 100644
--- a/packages/paywall/src/browser/styles.css
+++ b/packages/paywall/src/browser/styles.css
@@ -303,7 +303,8 @@ body {
height: 1rem;
}
-.trustline-banner {
+.trustline-banner,
+.notice-banner {
width: 100%;
display: flex;
align-items: flex-start;
@@ -318,7 +319,26 @@ body {
line-height: 1.5;
}
-.trustline-icon {
+/* Inline action rendered inside body copy — looks like a link, behaves like a
+ button (keyboard-focusable, no bogus href). */
+.link-button {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: var(--button-primary-color, #6e56cf);
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.link-button:disabled {
+ cursor: default;
+ opacity: 0.6;
+ text-decoration: none;
+}
+
+.trustline-icon,
+.notice-icon {
flex-shrink: 0;
display: flex;
align-items: center;
@@ -334,28 +354,35 @@ body {
line-height: 1;
}
-.trustline-body {
+.trustline-body,
+.notice-body {
flex: 1;
min-width: 0;
}
-.trustline-title {
+.trustline-title,
+.notice-title {
font-weight: 600;
color: #4e2009;
margin-bottom: 0.125rem;
font-size: 0.8125rem;
}
-.trustline-text {
+.trustline-text,
+.notice-text {
color: #4e2009;
}
-.trustline-text a {
+.trustline-text a,
+.notice-text a,
+.notice-text .link-button {
color: #ad5700;
text-decoration: underline;
font-weight: 500;
}
-.trustline-text a:hover {
+.trustline-text a:hover,
+.notice-text a:hover,
+.notice-text .link-button:hover:not(:disabled) {
color: #4e2009;
}
diff --git a/packages/paywall/src/browser/useStellarBalance.ts b/packages/paywall/src/browser/useStellarBalance.ts
index 82f8441..2961650 100644
--- a/packages/paywall/src/browser/useStellarBalance.ts
+++ b/packages/paywall/src/browser/useStellarBalance.ts
@@ -25,7 +25,13 @@ export type UseBalanceReturn = {
tokenBalanceFormatted: string;
isMissingTrustline: boolean | null;
assetMetadata: AssetMetadata | null;
- refreshBalance: () => Promise;
+ /**
+ * Re-reads the balance and returns it in raw atomic units, or `null` when the
+ * read failed. Raw units are returned (rather than the formatted string) so
+ * callers can compare against `PaymentRequirements.amount` exactly, without
+ * a float round-trip.
+ */
+ refreshBalance: () => Promise;
resetBalance: () => void;
};
@@ -90,10 +96,10 @@ export function useStellarBalance({
}
}, [network, asset, runtimeRpcUrl]);
- const refreshBalance = useCallback(async (): Promise => {
+ const refreshBalance = useCallback(async (): Promise => {
if (!address) {
resetBalance();
- return "";
+ return null;
}
setIsFetchingBalance(true);
@@ -142,7 +148,7 @@ export function useStellarBalance({
setTokenBalanceRaw(balanceRaw);
setTokenBalanceFormatted(balanceFormatted);
setIsMissingTrustline(false);
- return balanceFormatted;
+ return balanceRaw;
} catch (error) {
console.error("Failed to fetch Stellar USDC balance", error);
const msg = parseError(error, "Unable to read balance. Please retry.");
@@ -154,7 +160,7 @@ export function useStellarBalance({
onStatus(statusError(msg));
}
resetBalance();
- return "";
+ return null;
} finally {
setIsFetchingBalance(false);
}
diff --git a/packages/paywall/src/browser/utils.test.ts b/packages/paywall/src/browser/utils.test.ts
index d1326ea..1b36159 100644
--- a/packages/paywall/src/browser/utils.test.ts
+++ b/packages/paywall/src/browser/utils.test.ts
@@ -1,6 +1,6 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
-import { formatPaymentError } from "./utils.ts";
+import { formatPaymentError, isBalanceInsufficient } from "./utils.ts";
describe("formatPaymentError", () => {
it("returns status code when body is empty", () => {
@@ -139,3 +139,41 @@ describe("formatPaymentError", () => {
);
});
});
+
+describe("isBalanceInsufficient", () => {
+ it("returns null when the balance is not known yet", () => {
+ assert.equal(isBalanceInsufficient(null, "1000"), null);
+ });
+
+ it("returns null when the requirement carries no amount", () => {
+ assert.equal(isBalanceInsufficient(10n, undefined), null);
+ });
+
+ it("returns null when the amount is not a valid integer string", () => {
+ assert.equal(isBalanceInsufficient(10n, "0.51"), null);
+ assert.equal(isBalanceInsufficient(10n, "not-a-number"), null);
+ });
+
+ it("reports insufficient when the balance is below the amount", () => {
+ assert.equal(isBalanceInsufficient(0n, "5100000"), true);
+ assert.equal(isBalanceInsufficient(5099999n, "5100000"), true);
+ });
+
+ it("reports sufficient when the balance covers the amount exactly", () => {
+ assert.equal(isBalanceInsufficient(5100000n, "5100000"), false);
+ });
+
+ it("reports sufficient when the balance is above the amount", () => {
+ assert.equal(isBalanceInsufficient(5100001n, "5100000"), false);
+ });
+
+ it("treats a zero-amount requirement as payable", () => {
+ assert.equal(isBalanceInsufficient(0n, "0"), false);
+ });
+
+ it("stays exact past the range where doubles lose precision", () => {
+ // Both operands round to 9007199254740992 as doubles, so a Number-based
+ // comparison would wrongly report this balance as sufficient.
+ assert.equal(isBalanceInsufficient(9007199254740993n, "9007199254740994"), true);
+ });
+});
diff --git a/packages/paywall/src/browser/utils.ts b/packages/paywall/src/browser/utils.ts
index c68c9d0..b9ec9a9 100644
--- a/packages/paywall/src/browser/utils.ts
+++ b/packages/paywall/src/browser/utils.ts
@@ -48,6 +48,39 @@ export function formatUnits(value: bigint, decimals: number): string {
return isNegative ? `-${result}` : result;
}
+/**
+ * Compares a raw on-chain balance against the atomic amount a payment
+ * requirement asks for.
+ *
+ * Both values are in the asset's own atomic units, so the comparison is exact:
+ * it never converts through `Number`, and it does not need to know the asset's
+ * decimals.
+ *
+ * @param balanceRaw - Raw balance read from the asset contract, or `null` when unknown.
+ * @param requiredAmount - `PaymentRequirements.amount`, an atomic-unit string.
+ * @returns `true`/`false` when both values are known, `null` when the balance is
+ * not known yet or the required amount is unparseable — so callers can tell
+ * "not enough" apart from "don't know yet".
+ */
+export function isBalanceInsufficient(
+ balanceRaw: bigint | null,
+ requiredAmount: string | undefined,
+): boolean | null {
+ if (balanceRaw === null || !requiredAmount) {
+ return null;
+ }
+
+ let required: bigint;
+ try {
+ required = BigInt(requiredAmount);
+ } catch {
+ console.warn(`Unparseable payment requirement amount: ${requiredAmount}`);
+ return null;
+ }
+
+ return balanceRaw < required;
+}
+
export function formatPaymentError(
prefix: string,
status: number,