From 08b4d2ada73d58c6be10b2b779656c1d8289903e Mon Sep 17 00:00:00 2001
From: Pedro
Date: Tue, 11 Aug 2026 18:34:31 -0300
Subject: [PATCH 1/2] feat(paywall): add a trustline flow signed by the
connected wallet
`useAddTrustline` builds a `change_trust` for the asset read off the SAC,
signs it with the wallet already connected to the paywall, submits it,
waits for confirmation and reports each failure distinctly: rejected
signature, network rejection, on-ledger failure, confirmation timeout.
`Asset`, `Operation`, `TransactionBuilder` and `rpc.Server` all come from
`@stellar/stellar-sdk`, which the paywall already bundles for the balance
read, so this costs about 2 KB.
---
.../paywall/src/browser/useAddTrustline.ts | 140 ++++++++++++++++++
1 file changed, 140 insertions(+)
create mode 100644 packages/paywall/src/browser/useAddTrustline.ts
diff --git a/packages/paywall/src/browser/useAddTrustline.ts b/packages/paywall/src/browser/useAddTrustline.ts
new file mode 100644
index 0000000..2f39979
--- /dev/null
+++ b/packages/paywall/src/browser/useAddTrustline.ts
@@ -0,0 +1,140 @@
+import { useCallback, useState } from "react";
+import { StellarWalletsKit } from "@creit.tech/stellar-wallets-kit/sdk";
+import { Asset, BASE_FEE, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
+import { Server } from "@stellar/stellar-sdk/rpc";
+import type { Network } from "@x402/core/types";
+import { getNetworkPassphrase, getRpcUrl } from "@x402/stellar";
+import { parseError } from "@x402-stellar/shared";
+import { statusError, statusInfo, statusSuccess, type Status } from "./status";
+import type { AssetMetadata } from "./useStellarBalance";
+
+/** How long to wait for the trustline transaction to leave the pending state. */
+const CONFIRMATION_TIMEOUT_MS = 30_000;
+const CONFIRMATION_POLL_INTERVAL_MS = 1_000;
+
+export type UseAddTrustlineParams = {
+ address: string | null;
+ network: Network;
+ assetMetadata: AssetMetadata | null;
+ onStatus: (status: Status | null) => void;
+ onAdded: () => void;
+};
+
+export type UseAddTrustlineReturn = {
+ isAddingTrustline: boolean;
+ /** `null` when the paywall does not have everything it needs to offer the action. */
+ addTrustline: (() => Promise) | null;
+};
+
+/**
+ * Adds the trustline the payment asset requires, signing with the wallet that
+ * is already connected to the paywall.
+ *
+ * Without a trustline the account cannot hold the asset at all, so the paywall
+ * is otherwise a dead end: the Pay button is disabled and the only way forward
+ * is to leave, add the trustline elsewhere, and come back.
+ *
+ * @param params - Hook parameters.
+ * @param params.address - Connected wallet address that will hold the trustline.
+ * @param params.network - Network to submit on (CAIP-2 format).
+ * @param params.assetMetadata - Asset code and issuer, read from the SAC.
+ * @param params.onStatus - Callback for status messages.
+ * @param params.onAdded - Invoked once the trustline is confirmed on-ledger.
+ * @returns The submit handler, or `null` when the action cannot be offered.
+ */
+export function useAddTrustline({
+ address,
+ network,
+ assetMetadata,
+ onStatus,
+ onAdded,
+}: UseAddTrustlineParams): UseAddTrustlineReturn {
+ const [isAddingTrustline, setIsAddingTrustline] = useState(false);
+ const runtimeRpcUrl = window.x402?.config?.rpcUrl;
+
+ const addTrustline = useCallback(async () => {
+ if (!address || !assetMetadata) {
+ return;
+ }
+
+ setIsAddingTrustline(true);
+ try {
+ const networkPassphrase = getNetworkPassphrase(network);
+ const server = new Server(getRpcUrl(network, { url: runtimeRpcUrl }));
+
+ onStatus(statusInfo(`Building ${assetMetadata.code} trustline...`));
+ const account = await server.getAccount(address);
+
+ const transaction = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase,
+ })
+ .addOperation(
+ Operation.changeTrust({
+ asset: new Asset(assetMetadata.code, assetMetadata.issuer),
+ }),
+ )
+ .setTimeout(180)
+ .build();
+
+ onStatus(statusInfo("Waiting for user signature..."));
+ const { signedTxXdr } = await StellarWalletsKit.signTransaction(transaction.toXDR(), {
+ address,
+ networkPassphrase,
+ });
+
+ if (!signedTxXdr) {
+ throw new Error("Wallet did not return a signed transaction.");
+ }
+
+ onStatus(statusInfo("Submitting trustline..."));
+ const signed = TransactionBuilder.fromXDR(signedTxXdr, networkPassphrase);
+ const sent = await server.sendTransaction(signed);
+
+ if (sent.status === "ERROR") {
+ throw new Error(
+ `Trustline transaction was rejected by the network (${sent.errorResult?.result().switch().name ?? "unknown reason"}).`,
+ );
+ }
+
+ await waitForTransaction(server, sent.hash);
+
+ onStatus(statusSuccess(`${assetMetadata.code} trustline added.`));
+ onAdded();
+ } catch (error) {
+ console.error("Failed to add trustline", error);
+ onStatus(statusError(parseError(error, "Failed to add the trustline.")));
+ } finally {
+ setIsAddingTrustline(false);
+ }
+ }, [address, assetMetadata, network, onStatus, onAdded, runtimeRpcUrl]);
+
+ return {
+ isAddingTrustline,
+ // Both the wallet and the asset identity are required; without either there
+ // is nothing to sign or nothing to trust.
+ addTrustline: address && assetMetadata ? addTrustline : null,
+ };
+}
+
+/**
+ * Polls until the transaction leaves `NOT_FOUND`, then throws unless it succeeded.
+ */
+async function waitForTransaction(server: Server, hash: string): Promise {
+ const deadline = Date.now() + CONFIRMATION_TIMEOUT_MS;
+
+ while (Date.now() < deadline) {
+ const result = await server.getTransaction(hash);
+
+ if (result.status === "SUCCESS") {
+ return;
+ }
+ if (result.status === "FAILED") {
+ throw new Error("Trustline transaction failed on-ledger.");
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_POLL_INTERVAL_MS));
+ }
+
+ throw new Error("Timed out waiting for the trustline transaction to confirm.");
+}
From a322f1e20936a561640d09c9a84bde408cbf38db Mon Sep 17 00:00:00 2001
From: Pedro
Date: Tue, 11 Aug 2026 18:34:31 -0300
Subject: [PATCH 2/2] fix(paywall): make the trustline banner actionable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The banner sent people to `lab.stellar.org/account/fund` — the friendbot
account funding page, which has nothing to do with trustlines and does
not exist on mainnet. Its own text said "add the USDC trustline", but the
link led nowhere that could do it.
Offer the action in the banner instead, signed by the wallet already
connected. The banner clears itself and Pay becomes available without
leaving the page. The Lab link stays as the fallback for when the asset's
code and issuer could not be read, and now points at
`/transaction/build`, where a `change_trust` operation can be assembled.
---
.../paywall/src/browser/StellarPaywall.tsx | 47 +++++++++++++++----
packages/paywall/src/browser/styles.css | 20 ++++++++
2 files changed, 57 insertions(+), 10 deletions(-)
diff --git a/packages/paywall/src/browser/StellarPaywall.tsx b/packages/paywall/src/browser/StellarPaywall.tsx
index 1362988..32173c4 100644
--- a/packages/paywall/src/browser/StellarPaywall.tsx
+++ b/packages/paywall/src/browser/StellarPaywall.tsx
@@ -3,6 +3,7 @@ import type { PaymentRequired, PaymentRequirements } from "@x402/core/types";
import { getNetworkDisplayName } from "./utils";
import { Spinner } from "./Spinner";
import { statusError, statusInfo, type Status } from "./status";
+import { useAddTrustline } from "./useAddTrustline";
import { useStellarBalance } from "./useStellarBalance";
import { useStellarPayment } from "./useStellarPayment";
import { useSWKConnection } from "./useSWKConnection";
@@ -21,6 +22,13 @@ type StellarPaywallMainProps = {
const STELLAR_PAYMENT_SCALE = 10_000_000;
+/**
+ * Lab's transaction builder, where a `change_trust` operation can be assembled.
+ * `/account/fund` — where this used to point — is the friendbot XLM funding
+ * page and has nothing to do with trustlines.
+ */
+const STELLAR_LAB_BUILD_TX_URL = "https://lab.stellar.org/transaction/build";
+
/**
* Paywall experience for Stellar networks. Validates that a Stellar payment
* requirement exists and either renders the error shell or delegates to the
@@ -103,6 +111,14 @@ function StellarPaywallMain({
onStatus: setStatus,
});
+ const { isAddingTrustline, addTrustline } = useAddTrustline({
+ address,
+ network,
+ assetMetadata,
+ onStatus: setStatus,
+ onAdded: refreshBalance,
+ });
+
const walletSigner = useSWKSigner({
kitReady,
network,
@@ -270,17 +286,28 @@ function StellarPaywallMain({
Your account needs a {assetMetadata ? assetMetadata.code : "asset"}{" "}
- trustline before you can hold or pay with this asset. Add one via{" "}
-
- Stellar Laboratory
-
- : connect your wallet, add the {assetMetadata ? assetMetadata.code : "asset"}{" "}
- trustline, and sign the transaction.
+ trustline before you can hold or pay with this asset.{" "}
+ {addTrustline ? (
+ <>Add it with the wallet you already connected.>
+ ) : (
+ <>
+ Build a change_trust operation in{" "}
+
+ Stellar Laboratory
+
+ , then sign and submit it.
+ >
+ )}