Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/nextjs-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^1.9.5",
"@rozoai/intent-common": "0.1.35",
"@rozoai/intent-pay": "0.1.45",
"@rozoai/intent-common": "0.1.36",
"@rozoai/intent-pay": "0.1.46-beta.2",
"@stellar/stellar-sdk": "^14.6.1",
"@tanstack/react-query": "^5.0.0",
"@web3icons/react": "^4.1.19",
Expand Down
6 changes: 5 additions & 1 deletion packages/connectkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
ssr: true,
// Optional: Base builder code attribution (https://docs.base.org/apps/builder-codes)
// dataSuffix: Attribution.toDataSuffix({ codes: [process.env.NEXT_PUBLIC_BASE_BUILDER_CODE] }),
// Optional: your own WalletConnect Cloud project ID (https://cloud.reown.com).
// Desktop WalletConnect QR is available by default via Rozo's shared
// project ID — pass yours here to use a separate WalletConnect project.
// walletConnectProjectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID,
})));
const [qc] = useState(() => new QueryClient());
return (
Expand Down Expand Up @@ -95,7 +99,7 @@ Then drop a `<RozoPayButton appId="rozoSandbox" ... />` (snippet above) anywhere

### Supported Wallets

**EVM Wallets:** MetaMask, Coinbase Wallet, Trust Wallet, Rainbow Wallet, Family Wallet, Zerion, OKX, Bitget
**EVM Wallets:** MetaMask, Coinbase Wallet, Trust Wallet, Rainbow Wallet, Family Wallet, Zerion, OKX, Bitget, WalletConnect (desktop QR only)

**Solana Wallets:** Phantom, Backpack, Solflare

Expand Down
4 changes: 2 additions & 2 deletions packages/connectkit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rozoai/intent-pay",
"version": "0.1.46",
"version": "0.1.46-beta.2",
"private": false,
"description": "Seamless crypto payments. Onboard users from any chain, any coin into your app with one click.",
"keywords": [
Expand Down Expand Up @@ -49,7 +49,7 @@
"@reown/appkit": "^1.7.0",
"@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-typescript": "^12.1.2",
"@rozoai/intent-common": "0.1.35",
"@rozoai/intent-common": "0.1.36",
"@solana/spl-memo": "^0.2.5",
"@solana/spl-token": "^0.4.14",
"@solana/wallet-adapter-base": "^0.9.27",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useRozoPay } from "../../../hooks/useRozoPay";
import useIsMobile from "../../../hooks/useIsMobile";
import { useLastConnector } from "../../../hooks/useLastConnector";
import { usePayContext } from "../../../hooks/usePayContext";
import { detectBrowser, isCoinbaseWalletConnector } from "../../../utils";
import { detectBrowser, isCoinbaseWalletConnector, isWalletConnectConnector } from "../../../utils";
import {
WALLET_ID_MOBILE_WALLETS,
WALLET_ID_OTHER_WALLET,
Expand Down Expand Up @@ -141,6 +141,24 @@ const ConnectorItem = ({

// Desktop multi-chain wallet flow: prompt for chain selection.
if (!isMobile) {
if (isWalletConnectConnector(wallet.connector?.id)) {
context.setPendingConnectorId(wallet.id);
context.setRoute(ROUTES.CONNECT_WALLETCONNECT, meta);
return;
}

// No-extension fallback tile: any WC wallet can scan the QR, so route
// to the shared WalletConnect page. pendingConnectorId keeps the stub
// wallet id so the QR page can brand itself ("Scan with MetaMask").
// Only the no-connector fallback stub routes to QR. The real injected
// wallet inherits walletConnectFallback from its config but has a live
// connector, so it must connect directly (not show the WC QR).
if (wallet.walletConnectFallback && !wallet.connector) {
context.setPendingConnectorId(wallet.id);
context.setRoute(ROUTES.CONNECT_WALLETCONNECT, meta);
return;
}

if (wallet.solanaConnectorName) {
const supportsEvm = wallet.connector?.name != null;

Expand Down
10 changes: 9 additions & 1 deletion packages/connectkit/src/components/Common/OrderHeader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
Solana,
Stellar,
} from "../../../assets/chains";
import { WalletConnect } from "../../../assets/logos";
import { isWalletConnectConnector } from "../../../utils";
import defaultTheme from "../../../constants/defaultTheme";
import { ROUTES } from "../../../constants/routes";
import { useRozoPay } from "../../../hooks/useRozoPay";
Expand Down Expand Up @@ -113,7 +115,13 @@ export const OrderHeader = ({
);
};

const walletIcon = renderIcon(connector?.icon, undefined, 32);
// WalletConnect connectors have no icon payload — show WC branding instead.
const evmWalletIcon = isWalletConnectConnector(connector?.id) ? (
<WalletConnect />
) : (
connector?.icon
);
const walletIcon = renderIcon(evmWalletIcon, undefined, 32);
const solanaIcon = renderIcon(
solanaWallet?.adapter.icon || <Solana />,
solanaWallet?.adapter.name,
Expand Down
79 changes: 55 additions & 24 deletions packages/connectkit/src/components/Pages/Confirmation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Link, ModalBody, ModalContent, ModalH1, PageContent } from "../../Commo
import {
assert,
getAddressContraction,
isHydrated,
getChainExplorerTxUrl,
getOrderDestChainId,
getPayment,
Expand All @@ -23,6 +24,7 @@ import { ROZO_INVOICE_URL } from "../../../constants/rozoConfig";
import { usePayoutPolling } from "../../../hooks/usePayoutPolling";
import { usePusherPayout } from "../../../hooks/usePusherPayout";
import { useRozoPay } from "../../../hooks/useRozoPay";
import { resolveConfirmedPayoutTxHash } from "../../../payment/resolveConfirmedPayoutTxHash";
import {
beginRequestScope,
cancelRequestScope,
Expand Down Expand Up @@ -91,17 +93,19 @@ const Confirmation: React.FC = () => {
// is known as soon as the pay-in txHash is confirmed.
const isStellarDirectSameTx = useMemo(() => {
if (!order) return false;

const meta = (order as any).metadata as Record<string, unknown> | undefined;
const meta = order.metadata as Record<string, unknown> | undefined;
if (meta?.settlementMode !== "stellar_direct") return false;

// txHash fields only exist on hydrated orders — narrow first.
const hydrated = isHydrated(order) ? order : null;
const sourceTx =
(order as any).sourceStartTxHash ?? (meta?.payinTransactionHash as string | undefined);

hydrated?.sourceStartTxHash ??
(meta?.payinTransactionHash as string | undefined);
const destTx =
(order as any).payoutTransactionHash ??
(order as any).destFastFinishTxHash ??
(order as any).destClaimTxHash;
(meta?.payoutTransactionHash as string | undefined) ??
hydrated?.payoutTransactionHash ??
hydrated?.destFastFinishTxHash ??
hydrated?.destClaimTxHash;

return !!sourceTx && !!destTx && sourceTx === destTx;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require stellar_direct before skipping payout tracking

The settlementMode value is read but no longer checked, so any non-Stellar order whose source and destination hash fields happen to match is classified as isStellarDirectSameTx. When showProcessingPayout is enabled, this suppresses the payout-processing state and its Pusher/polling path even though the order was not routed through stellar_direct; restore the settlementMode === "stellar_direct" guard before comparing hashes.

Useful? React with 👍 / 👎.

}, [order]);
Comment on lines 94 to 111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — test gap on payout-status logic. isStellarDirectSameTx decides whether to skip Pusher/polling entirely for the payout confirmation (per the comment above). This is payout-status-affecting logic and its inputs changed in this PR (new settlementMode / payinTransactionHash / payoutTransactionHash metadata fields, hydrated-order narrowing), yet there's no test covering it. A wrong true here would short-circuit payout polling; a wrong false would keep polling forever. Please add unit coverage for the same-tx and different-tx cases (hydrated vs metadata-only sources).

Expand All @@ -123,7 +127,13 @@ const Confirmation: React.FC = () => {
}
}

if (payParams && (tokenMode === "stellar" || tokenMode === "solana" || tokenMode === "evm")) {
if (
payParams &&
(tokenMode === "stellar" ||
tokenMode === "solana" ||
tokenMode === "evm" ||
tokenMode === "all")
) {
return payParams.showProcessingPayout;
}

Expand All @@ -140,6 +150,15 @@ const Confirmation: React.FC = () => {
return undefined;
}, [pusherPayoutTxHash, order]);

const confirmedPayoutTxHash = payinConfirmed?.payoutTxHash;
const confirmedPayoutTxHashUrl = useMemo(() => {
if (!confirmedPayoutTxHash || !order) return undefined;
return getChainExplorerTxUrl(
getOrderDestChainId(order),
confirmedPayoutTxHash,
);
}, [confirmedPayoutTxHash, order]);

const rozoPaymentId = useMemo(() => {
const id = order?.externalId || paymentStateContext.rozoPaymentId;
return id;
Expand Down Expand Up @@ -500,6 +519,7 @@ const Confirmation: React.FC = () => {

// Payout is resolved when either Pusher or polling has found the destination txhash
const payoutResolved = !!(
(confirmedPayoutTxHash && confirmedPayoutTxHashUrl) ||
(pusherPayoutTxHash && computedPusherPayoutTxHashUrl) ||
(payoutTxHash && payoutTxHashUrl)
);
Expand Down Expand Up @@ -683,28 +703,28 @@ const Confirmation: React.FC = () => {
}, [done, paymentStateContext, rawPayInHash, rozoPaymentId]);

/**
* Payout that is settled by the payin itself, so there is nothing to wait
* for: stellar_direct with source txHash === destination txHash (known from
* the order, or from the confirmed API response), and deposit-address flows
* (which previously marked payout completed on payin detection). Separate
* from the completion effect so its dedupe cannot swallow this event.
* Complete payout immediately when the payin confirmation response already
* includes its destination hash. This covers payouts completed before the
* Confirmation page subscribed to Pusher, plus stellar_direct same-tx flows.
*/
useEffect(() => {
if (!done || !rawPayInHash || !rozoPaymentId) return;
const sameTx = isStellarDirectSameTx || !!payinConfirmed?.sameTxPayout;
// Deposit-address flow: only when the API already reports the destination
// tx. Otherwise leave payoutCompletedRef untouched so the normal Pusher /
// polling payout wait runs — never report the source tx as the payout.
const depositPayout =
!!paymentStateContext.selectedDepositAddressOption && !!payinConfirmed?.payoutTxHash;
if (!sameTx && !depositPayout) return;
const payoutHash = sameTx ? rawPayInHash : payinConfirmed!.payoutTxHash!;
const payoutHash = resolveConfirmedPayoutTxHash(
rawPayInHash,
payinConfirmed?.payoutTxHash,
sameTx,
);
if (!payoutHash) return;
const payoutKey = `${payoutHash}-${rozoPaymentId}`;
if (payoutCompletedSent.current === payoutKey) return;
payoutCompletedSent.current = payoutKey;
payoutCompletedRef.current = true;
setPaymentPayoutCompleted(payoutHash, rozoPaymentId);
context.log("[CONFIRMATION] payout completed directly:", { sameTx, depositPayout, payoutHash });
context.log("[CONFIRMATION] payout completed from payin response:", {
sameTx,
payoutHash,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done, rawPayInHash, rozoPaymentId, isStellarDirectSameTx, payinConfirmed]);

Expand Down Expand Up @@ -854,15 +874,26 @@ const Confirmation: React.FC = () => {
<ModalBody>
{payoutLoading ? (
<LoadingText>Processing payout...</LoadingText>
) : (computedPusherPayoutTxHashUrl && pusherPayoutTxHash) ||
) : (confirmedPayoutTxHashUrl && confirmedPayoutTxHash) ||
(computedPusherPayoutTxHashUrl && pusherPayoutTxHash) ||
(payoutTxHashUrl && payoutTxHash) ? (
<Link
href={computedPusherPayoutTxHashUrl || payoutTxHashUrl || "#"}
href={
confirmedPayoutTxHashUrl ||
computedPusherPayoutTxHashUrl ||
payoutTxHashUrl ||
"#"
}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 14, fontWeight: 400 }}
>
{getAddressContraction(pusherPayoutTxHash || payoutTxHash || "")}
{getAddressContraction(
confirmedPayoutTxHash ||
pusherPayoutTxHash ||
payoutTxHash ||
"",
)}
<ExternalIcon />
</Link>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { useAccount } from "wagmi";

import { ROUTES } from "../../../constants/routes";
import { useConnect } from "../../../hooks/useConnect";
import { useConnectors } from "../../../hooks/useConnectors";
import { usePayContext } from "../../../hooks/usePayContext";
import { useWallet } from "../../../wallets/useWallets";
import CopyToClipboard from "../../Common/CopyToClipboard";
import CustomQRCode from "../../Common/CustomQRCode";
import Alert from "../../Common/Alert";
import Button from "../../Common/Button";
import {
ModalBody,
ModalContent,
ModalH1,
PageContent,
} from "../../Common/Modal/styles";

/**
* Desktop-only WalletConnect connect flow: renders our own QR + copyable URI
* instead of WalletConnect's bundled modal. The desktop-only connector is
* configured with showQrModal: false in defaultConnectors.ts.
*
* pendingConnectorId may be either the real "walletConnect" connector id, or a
* no-extension fallback stub id ("wc-fallback-…"). The connector is always the
* shared walletConnect one; the stub only drives branding ("Scan with
* MetaMask" + its logo), since any WC wallet can scan the QR.
*
* The connector emits a "display_uri" message on its own `emitter` (wagmi
* gives every connector a dedicated Emitter<ConnectorEventMap>, wired up in
* createConfig's setup()) once connect() kicks off the pairing handshake;
* we grab that URI and render it here.
*/
const ConnectWalletConnect: React.FC = () => {
const context = usePayContext();
const { pendingConnectorId, setRoute } = context;
const brandingWallet = useWallet(pendingConnectorId ?? "");
const { connect } = useConnect();
const { isConnected } = useAccount();

// Resolve the real WC connector regardless of which tile the user clicked.
const connectors = useConnectors();
const wcConnector = connectors.find((c) => c.id === "walletConnect");

const [uri, setUri] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);

useEffect(() => {
if (!wcConnector) return;

setError(null);
setUri(null);

const listener = (event: { type: string; data?: unknown }) => {
if (event.type === "display_uri" && typeof event.data === "string") {
setUri(event.data);
}
};
wcConnector.emitter.on("message", listener);

connect({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — connect side effect has no abort/cancel on unmount. Cleanup only removes the message listener; the in-flight WalletConnect pairing is not cancelled. If the user hits back (the modal routes CONNECT_WALLETCONNECT → CONNECTORS) while pairing is pending, the request lingers and a late display_uri/connect can fire against a stale route. Consider aborting the pending connect on cleanup.

Also: if isConnected is already true on mount (an EVM wallet connected before this tile was clicked), both this connect effect and the redirect effect run, making this connect() redundant — an early-return when already connected would be cleaner.

connector: wcConnector,
mutation: {
onError: (err) => setError(err?.message ?? "Failed to connect"),
},
});

return () => {
wcConnector.emitter.off("message", listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wcConnector, attempt]);

// Error Alert changes content height — poke the modal so it re-measures.
useEffect(() => {
if (error) setTimeout(context.triggerResize, 10);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
Comment on lines +76 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — redirects even when already connected via a different wallet. This effect navigates to SELECT_TOKEN whenever isConnected is true. If the user already has an EVM wallet connected and then clicks the WalletConnect tile, isConnected is already true on mount, so this fires immediately and bounces to token selection using the existing connection — the WC pairing initiated in the effect above never completes/matters. Likely harmless, but worth gating on the WC connector actually being the active one (or on a fresh connection) to avoid confusing behavior.


useEffect(() => {
if (!isConnected) return;
setRoute(ROUTES.SELECT_TOKEN, { event: "walletconnect-connected" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected]);

if (!wcConnector) return <Alert>Connector not found</Alert>;

// Branding: fallback stubs carry the wallet's own name/logo; the generic
// walletConnect tile falls back to WalletConnect branding.
const title = brandingWallet?.name
? `Scan with ${brandingWallet.name}`
: "Scan with WalletConnect";

return (
<PageContent>
<ModalContent style={{ paddingBottom: 8, gap: 14 }}>
<ModalH1>{title}</ModalH1>
<ModalBody>
Scan this QR code with a WalletConnect-compatible wallet, or copy
the link below.
</ModalBody>
<div style={{ width: "60%", margin: "0 auto" }}>
<CustomQRCode value={uri ?? undefined} image={brandingWallet?.iconConnector} />
</div>
{error && (
<>
<Alert error>{error}</Alert>
<Button
onClick={() => setAttempt((n) => n + 1)}
style={{ margin: "0 auto" }}
>
Try again
</Button>
</>
)}
<div style={{ display: "flex", justifyContent: "center" }}>
<CopyToClipboard variant="button" string={uri ?? ""}>
Copy to clipboard
</CopyToClipboard>
</div>
</ModalContent>
</PageContent>
);
};

export default ConnectWalletConnect;
Loading
Loading