diff --git a/package.json b/package.json index 4daed66a7..13ee44f83 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "dependencies": { "@ant-design/icons": "5.4.0", "@ar.io/sdk": "^4.1.0-alpha.1", - "@ardrive/turbo-sdk": "1.39.2", + "@ardrive/turbo-sdk": "1.42.0-alpha.3", + "@dha-team/arbundles": "1.0.1", "@permaweb/aoconnect": "0.0.69", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-switch": "^1.1.2", + "@rainbow-me/rainbowkit": "^2.2.9", "@sentry/react": "^7.45.0", "@solana/kit": "6.8.0", "@solana/wallet-adapter-base": "^0.9.27", @@ -50,6 +52,7 @@ "@tanstack/react-query-persist-client": "^5.45.1", "@tanstack/react-table": "^8.20.5", "antd": "^5.20.6", + "arconnect": "^1.0.3", "arweave": "^1.15.1", "arweave-graphql": "^0.0.5", "axios": "^1.1.3", diff --git a/src/components/data-display/tables/UndernamesTable.tsx b/src/components/data-display/tables/UndernamesTable.tsx index 32a08450f..a861fea0d 100644 --- a/src/components/data-display/tables/UndernamesTable.tsx +++ b/src/components/data-display/tables/UndernamesTable.tsx @@ -8,6 +8,7 @@ import ArweaveID, { import { AddUndernameModal, EditUndernameModal } from '@src/components/modals'; import ConfirmTransactionModal from '@src/components/modals/ConfirmTransactionModal/ConfirmTransactionModal'; import { usePrimaryName } from '@src/hooks/usePrimaryName'; +import { useTurboArNSClient } from '@src/hooks/useTurboArNSClient'; import { SolanaAddress } from '@src/services/solana/SolanaAddress'; import { SolanaSignature } from '@src/services/solana/SolanaSignature'; import { @@ -18,6 +19,7 @@ import { useWalletState, } from '@src/state'; import dispatchANTInteraction from '@src/state/actions/dispatchANTInteraction'; +import dispatchCustodialANTRecordInteraction from '@src/state/actions/dispatchCustodialANTRecordInteraction'; import { ANT_INTERACTION_TYPES, SetRecordPayload, @@ -74,6 +76,7 @@ const UndernamesTable = ({ const [{ dataGateway }] = useGlobalState(); const [{ wallet, walletAddress }] = useWalletState(); + const turbo = useTurboArNSClient(); const isOwner = walletAddress ? state?.Owner === walletAddress.toString() : false; @@ -82,6 +85,19 @@ const UndernamesTable = ({ : false; const isAuthorized = (isOwner || isController) ?? false; + // Model A (custodial): ANTs are Solana assets, so a connected NON-Solana + // identity can never own/control one — the ANT is Turbo-held and the user + // manages undernames with CREDITS. The bundler authorizes each op against the + // custody mapping (non-owner → non-leaky 404), so enabling the UI here is + // server-gated-safe. (The custodian address is not exposed, so detect + // structurally rather than by owner-address comparison.) + const isCustodial = + wallet?.tokenType !== 'solana' && + !!arnsRecord.processId && + !isOwner && + !isController; + const canManage = isAuthorized || isCustodial; + const [, dispatchTransactionState] = useTransactionState(); const [, dispatchModalState] = useModalState(); const { data: primaryNameData } = usePrimaryName(); @@ -111,26 +127,51 @@ const UndernamesTable = ({ throw new Error('Unable to interact with ANT contract - missing ID.'); } - // Solana wallets don't carry an AO contractSigner — accept either. - const hasSigner = - !!wallet?.contractSigner || - (wallet?.tokenType === 'solana' && !!wallet.solanaSigner); - if (!hasSigner || !walletAddress) { + if (!walletAddress) { throw new Error( 'Unable to interact with ANT contract - missing signer.', ); } - const { id } = await dispatchANTInteraction({ - processId, - payload, - workflowName, - signer: wallet?.contractSigner as never, - wallet, - owner: walletAddress?.toString(), - dispatchTransactionState, - dispatchArNSState, - }); + let id: string; + if (isCustodial) { + // Custodial (Model A): pay with credits — the user doesn't own the ANT. + if (!turbo || !wallet?.turboSigner) { + throw new Error( + 'A connected wallet is required to manage this name with credits.', + ); + } + ({ id } = await dispatchCustodialANTRecordInteraction({ + turbo, + wallet, + antId: processId, + payload, + workflowName, + owner: walletAddress.toString(), + dispatchTransactionState, + })); + } else { + // Model B (self-owned): wallet-signed ANT interaction. + // Solana wallets don't carry an AO contractSigner — accept either. + const hasSigner = + !!wallet?.contractSigner || + (wallet?.tokenType === 'solana' && !!wallet.solanaSigner); + if (!hasSigner) { + throw new Error( + 'Unable to interact with ANT contract - missing signer.', + ); + } + ({ id } = await dispatchANTInteraction({ + processId, + payload, + workflowName, + signer: wallet?.contractSigner as never, + wallet, + owner: walletAddress?.toString(), + dispatchTransactionState, + dispatchArNSState, + })); + } eventEmitter.emit('success', { name: 'Manage Undernames', message: ( @@ -180,7 +221,7 @@ const UndernamesTable = ({ targetId: record.transactionId, ttlSeconds: record.ttlSeconds, priority: record.index, - action: isAuthorized ? ( + action: canManage ? ( {isOwner && ( - + // controllers and owners can add undernames; custodial (Model A) + // names add them with credits + canManage ? ( +
+ {isCustodial && ( + + Held in Turbo custody — undername changes are paid with your + Turbo Credits. + + )} +
+ +
) : ( <> @@ -494,10 +547,13 @@ const UndernamesTable = ({ {arnsRecord.processId && transactionData && interactionType && - isAuthorized ? ( + canManage ? ( { + // Custodial (Model A) ops are credit-paid, not SOL-gas-paid, and the + // gas estimate reads the ANT as if the user owned it — skip it. + if (isCustodial) return undefined; const undername = (transactionData as { subDomain?: string }) ?.subDomain; if (!undername) return undefined; diff --git a/src/components/forms/DomainSettings/DomainSettings.tsx b/src/components/forms/DomainSettings/DomainSettings.tsx index 800e5df6c..12359e07d 100644 --- a/src/components/forms/DomainSettings/DomainSettings.tsx +++ b/src/components/forms/DomainSettings/DomainSettings.tsx @@ -8,9 +8,11 @@ import { ReassignNameModal } from '@src/components/modals/ant-management/Reassig import { ReturnNameModal } from '@src/components/modals/ant-management/ReturnNameModal/ReturnNameModal'; import useDomainInfo from '@src/hooks/useDomainInfo'; import { usePrimaryName } from '@src/hooks/usePrimaryName'; +import { useTurboArNSClient } from '@src/hooks/useTurboArNSClient'; import { SolanaAddress } from '@src/services/solana/SolanaAddress'; import { useArNSState, useGlobalState } from '@src/state'; import dispatchANTInteraction from '@src/state/actions/dispatchANTInteraction'; +import dispatchCustodialANTRecordInteraction from '@src/state/actions/dispatchCustodialANTRecordInteraction'; import { useTransactionState } from '@src/state/contexts/TransactionState'; import { useWalletState } from '@src/state/contexts/WalletState'; import { ANT_INTERACTION_TYPES } from '@src/types'; @@ -21,6 +23,7 @@ import { } from '@src/utils'; import { DEFAULT_MAX_UNDERNAMES, + MIN_TTL_SECONDS, SECONDS_IN_GRACE_PERIOD, } from '@src/utils/constants'; import { useQueryClient } from '@tanstack/react-query'; @@ -79,6 +82,7 @@ function DomainSettings({ const [{ wallet, walletAddress }] = useWalletState(); const { data: primaryNameData } = usePrimaryName(); const { data, isLoading, refetch } = useDomainInfo({ domain, antId }); + const turbo = useTurboArNSClient(); const [showReturnNameModal, setShowReturnNameModal] = useState(false); const [showReassignNameModal, setShowReassignNameModal] = useState(false); @@ -92,6 +96,24 @@ function DomainSettings({ : false; const isAuthorized = (isOwner || isController) ?? false; + // Model A (custodial): ANTs are Solana assets, so a connected NON-Solana + // identity (Arweave / Ethereum) can never be the on-chain owner/controller — + // the ANT is held by the Turbo custody signer. Such a user manages the name's + // records with CREDITS instead of a wallet-signed interaction (which they + // physically can't produce). The bundler is the authority: it authorizes each + // credit-manage op against the custody mapping and returns a non-leaky 404 if + // the caller doesn't custody the ANT — so enabling the UI here is server-gated. + // (The custodian's Solana address is not exposed by the bundler, so we detect + // structurally rather than by owner-address comparison.) + const isCustodial = + wallet?.tokenType !== 'solana' && + !!data?.processId && + !isOwner && + !isController; + // Only the record-based ops (target `@`, undernames) can be credit-managed; + // owner-only ops (transfer/controllers/ticker/…) require claiming the ANT out. + const canManageRecords = isAuthorized || isCustodial; + useEffect(() => { if (!domain && !antId) { navigate('/manage/names'); @@ -141,8 +163,25 @@ function DomainSettings({ return 'Active'; } + // Self-managed (Model B): the user owns the ANT on-chain, so every record edit + // is a wallet-signed Solana transaction that costs a little SOL. Surface that + // once up front so an owner with an empty SOL balance isn't surprised on their + // first edit. (Custodial names are gasless/credit-paid — no note there.) + const showSelfManageNote = + !isCustodial && isAuthorized && wallet?.tokenType === 'solana'; + return ( <> + {showSelfManageNote && ( +
+ You own this name's ANT, so editing its records (target, + undernames, controllers, etc.) is signed by your wallet and costs a + small amount of SOL for network fees. +
+ )}
{Object.entries({ // TODO: this should go on a name section, not the ant section @@ -345,21 +384,36 @@ function DomainSettings({ - dispatchANTInteraction({ - payload: { - transactionId: targetId, - ttlSeconds: data?.apexRecord?.ttlSeconds, - }, - workflowName: ANT_INTERACTION_TYPES.SET_TARGET_ID, - signer: wallet!.contractSigner!, - wallet, - owner: walletAddress!.toString(), - processId: data!.processId, - dispatchTransactionState, - dispatchArNSState, - }) + isCustodial + ? dispatchCustodialANTRecordInteraction({ + turbo: turbo!, + wallet, + antId: data!.processId.toString(), + workflowName: ANT_INTERACTION_TYPES.SET_TARGET_ID, + payload: { + transactionId: targetId, + ttlSeconds: + data?.apexRecord?.ttlSeconds ?? MIN_TTL_SECONDS, + }, + owner: walletAddress!.toString(), + dispatchTransactionState, + }) + : dispatchANTInteraction({ + payload: { + transactionId: targetId, + ttlSeconds: data?.apexRecord?.ttlSeconds, + }, + workflowName: ANT_INTERACTION_TYPES.SET_TARGET_ID, + signer: wallet!.contractSigner!, + wallet, + owner: walletAddress!.toString(), + processId: data!.processId, + dispatchTransactionState, + dispatchArNSState, + }) } /> ), @@ -436,22 +490,36 @@ function DomainSettings({ [DomainSettingsRowTypes.TTL]: ( - dispatchANTInteraction({ - payload: { - ttlSeconds, - transactionId: data?.apexRecord?.transactionId, - }, - workflowName: ANT_INTERACTION_TYPES.SET_TTL_SECONDS, - signer: wallet!.contractSigner!, - wallet, - owner: walletAddress!.toString(), - processId: data!.processId, - dispatchTransactionState, - dispatchArNSState, - }) + isCustodial + ? dispatchCustodialANTRecordInteraction({ + turbo: turbo!, + wallet, + antId: data!.processId.toString(), + workflowName: ANT_INTERACTION_TYPES.SET_TTL_SECONDS, + payload: { + ttlSeconds, + transactionId: data?.apexRecord?.transactionId, + }, + owner: walletAddress!.toString(), + dispatchTransactionState, + }) + : dispatchANTInteraction({ + payload: { + ttlSeconds, + transactionId: data?.apexRecord?.transactionId, + }, + workflowName: ANT_INTERACTION_TYPES.SET_TTL_SECONDS, + signer: wallet!.contractSigner!, + wallet, + owner: walletAddress!.toString(), + processId: data!.processId, + dispatchTransactionState, + dispatchArNSState, + }) } /> ), diff --git a/src/components/forms/DomainSettings/TTLRow.tsx b/src/components/forms/DomainSettings/TTLRow.tsx index bd7baf6c0..700381d3c 100644 --- a/src/components/forms/DomainSettings/TTLRow.tsx +++ b/src/components/forms/DomainSettings/TTLRow.tsx @@ -21,10 +21,13 @@ export default function TTLRow({ ttlSeconds, confirm, editable = false, + creditPaid = false, }: { ttlSeconds?: number; confirm: (ttlSeconds: number) => Promise; editable?: boolean; + /** Custodial (Model A) name: this edit is paid with Turbo Credits. */ + creditPaid?: boolean; }) { const [editing, setEditing] = useState(false); const [newTTL, setNewTTL] = useState( @@ -127,6 +130,12 @@ export default function TTLRow({ seconds of this token to
{`"${newTTL}"`}. + {creditPaid && ( + + This name is held in Turbo custody — the change will be paid + with your Turbo Credits. + + )} Are you sure you want to continue? } diff --git a/src/components/forms/DomainSettings/TargetIDRow.tsx b/src/components/forms/DomainSettings/TargetIDRow.tsx index 09a104035..8a140a583 100644 --- a/src/components/forms/DomainSettings/TargetIDRow.tsx +++ b/src/components/forms/DomainSettings/TargetIDRow.tsx @@ -22,10 +22,13 @@ export default function TargetIDRow({ targetId, confirm, editable = false, + creditPaid = false, }: { targetId?: string; confirm: (targetId: string) => Promise; editable?: boolean; + /** Custodial (Model A) name: this edit is paid with Turbo Credits. */ + creditPaid?: boolean; }) { const [editing, setEditing] = useState(false); const [newTargetId, setNewTargetId] = useState(targetId ?? ''); @@ -131,6 +134,15 @@ export default function TargetIDRow({ {`"${newTargetId}"`}. + {creditPaid && ( + + This name is held in Turbo custody — the change will be paid + with your Turbo Credits. + + )} Are you sure you want to continue? } diff --git a/src/components/forms/PaymentOptionsForm/PaymentOptionsForm.tsx b/src/components/forms/PaymentOptionsForm/PaymentOptionsForm.tsx index 8109dd591..909dafcc2 100644 --- a/src/components/forms/PaymentOptionsForm/PaymentOptionsForm.tsx +++ b/src/components/forms/PaymentOptionsForm/PaymentOptionsForm.tsx @@ -36,12 +36,9 @@ import { import { Tabs } from 'radix-ui'; import { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import { isEmail } from 'validator'; -// NOTE (de-AO refactor): wagmi hooks crash without a `WagmiProvider`, which -// the Solana-only refactor removed. Stub them out — the EVM-funded payment -// branches are unreachable from the Solana-only UI but the wagmi hook -// calls themselves still ran on every render and crashed the form. -const useAccount = () => ({ connector: undefined, address: undefined }) as any; -const useBalance = (_args?: unknown) => ({ data: undefined }) as any; +// Multi-wallet restored (Model A): the `WagmiProvider` is mounted again in +// `main.tsx`, so the real wagmi hooks are safe to use here. +import { useAccount, useBalance } from 'wagmi'; export type PaymentMethod = 'card' | 'crypto' | 'credits'; export type ARIOCryptoOptions = 'ARIO' | 'dARIO' | 'tARIO'; @@ -53,7 +50,9 @@ export type CryptoOptions = ARIOCryptoOptions | BaseTokenType; * when those paths are ready. */ const DISABLE_CREDIT_CARD_CHECKOUT_UI = true; -const DISABLE_TURBO_CREDITS_CHECKOUT_UI = true; +// Turbo Credits checkout is now wired to the bundler payment-service +// (`POST /v1/arns/purchase/...`) via `dispatchArNSPurchaseWithCredits`. +const DISABLE_TURBO_CREDITS_CHECKOUT_UI = false; const DISABLE_BASE_CRYPTO_CHECKOUT_UI = true; const FormEntry: FC<{ @@ -566,7 +565,6 @@ function PaymentOptionsForm({
@@ -718,6 +716,20 @@ function PaymentOptionsForm({ {turboCreditBalance} Credits
{' '} + {/* + Model B truth-in-advertising: even when the purchase is paid in + credits, the name's ANT is created on Solana client-side, which + costs a small amount of native SOL for network fees + rent. Tell + the user up front so a credits-rich / SOL-empty wallet isn't + surprised by a blocked "Pay now". + */} +
+ + + Paying with credits still requires a small amount of SOL (~0.02) + for network fees to create your name's ANT. + +
{isInsufficientBalance && (
{' '} diff --git a/src/components/modals/ConnectWalletModal/ConnectWalletModal.tsx b/src/components/modals/ConnectWalletModal/ConnectWalletModal.tsx index f1b61781a..32ccd7806 100644 --- a/src/components/modals/ConnectWalletModal/ConnectWalletModal.tsx +++ b/src/components/modals/ConnectWalletModal/ConnectWalletModal.tsx @@ -1,30 +1,43 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit'; import { useWalletModal } from '@solana/wallet-adapter-react-ui'; +import { + EthWalletConnector, + WanderWalletConnector, +} from '@src/services/wallets'; import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useAccount, useDisconnect } from 'wagmi'; import { useWalletState } from '../../../state/contexts/WalletState'; -import { AoAddress } from '../../../types'; -import { CloseIcon } from '../../icons'; +import { AoAddress, ArNSWalletConnector } from '../../../types'; +import eventEmitter from '../../../utils/events'; +import { CloseIcon, MetamaskIcon, WanderIcon } from '../../icons'; import PageLoader from '../../layout/progress/PageLoader/PageLoader'; import './styles.css'; function ConnectWalletModal(): JSX.Element { const modalRef = useRef(null); - const [{ wallet, walletAddress, walletStateInitialized }] = useWalletState(); + const [ + { wallet, walletAddress, walletStateInitialized }, + dispatchWalletState, + ] = useWalletState(); const navigate = useNavigate(); const { state } = useLocation(); const [connecting, setConnecting] = useState(false); const [loading, setLoading] = useState(!walletStateInitialized); const { setVisible: setSolanaModalVisible } = useWalletModal(); + const ethAccount = useAccount(); + const { openConnectModal } = useConnectModal(); + const { disconnectAsync } = useDisconnect(); // The bridging of `@solana/wallet-adapter-react` → `SolanaWalletConnector` - // now lives in `WalletStateProvider` so reconnection happens on every - // mount regardless of route. This component only opens the picker; once - // the user approves, the global effect notices `useWallet().connected` - // flip and pushes the connector + address into wallet state, which - // triggers our `useEffect([wallet, walletAddress])` below to navigate - // away from `/connect`. + // and of the wagmi ETH session → `EthWalletConnector` now lives in + // `WalletStateProvider`, so reconnection happens on every mount regardless of + // route. This component only opens the pickers; once the user approves, the + // global effects push the connector + address into wallet state, which + // triggers the `useEffect([wallet, walletAddress])` below to navigate away + // from `/connect`. useEffect(() => { if (walletStateInitialized) { @@ -69,6 +82,48 @@ function ConnectWalletModal(): JSX.Element { } } + /** + * Connect an injected/native connector (Arweave/Wander). Solana + ETH are + * handled by their own picker modals; their connectors are wired up by the + * effects in `WalletStateProvider` once the picker completes. + */ + async function connect(walletConnector: ArNSWalletConnector) { + try { + setConnecting(true); + + // Disconnect any existing wallet before connecting a new identity. + if (wallet) { + try { + await wallet.disconnect(); + } catch { + // Ignore — wallet may already be disconnected. + } + } + if (ethAccount.isConnected) { + try { + await disconnectAsync(); + } catch { + // Ignore disconnect errors. + } + } + + await walletConnector.connect(); + const address = await walletConnector.getWalletAddress(); + dispatchWalletState({ + type: 'setWalletAndAddress', + payload: { + wallet: walletConnector, + walletAddress: address, + }, + }); + closeModal({ next: true, address }); + } catch (error: any) { + eventEmitter.emit('error', error); + } finally { + setConnecting(false); + } + } + if (loading) { return ; } @@ -81,9 +136,7 @@ function ConnectWalletModal(): JSX.Element { onClick={handleClickOutside} >
-

- Connect with a Solana wallet -

+

Connect a wallet

+ + + + void }) { const [{ wallet, walletAddress }] = useWalletState(); - const walletType = window.localStorage.getItem('walletType'); const turbo = useTurboArNSClient(); const [paymentMethod, setPaymentMethod] = useState('fiat'); @@ -74,14 +69,20 @@ function BaseTurboTopUpModal({ onClose }: { onClose: () => void }) { ); const [cryptoQuote, setCryptoQuote] = useState(); - // Set default token based on wallet type + // Set the default crypto token from the CONNECTED identity, not a hardcoded + // Solana/Arweave assumption. Fiat top-up is identity-agnostic (it credits + // `walletAddress` under `wallet.tokenType` — see the payment-intent effect + // below); this only seeds the crypto tab's initial token. useEffect(() => { - if (walletType === WALLET_TYPES.ETHEREUM) { + if (wallet?.tokenType === 'ethereum') { setSelectedToken('ethereum'); + } else if (wallet?.tokenType === 'arweave') { + setSelectedToken('arweave'); } else { + // Solana (Model B) offers no crypto top-up tokens here; harmless default. setSelectedToken('arweave'); } - }, [walletType]); + }, [wallet]); // Update payment intent for fiat useEffect(() => { diff --git a/src/components/modals/turbo/panels/crypto/CryptoConfirmation.tsx b/src/components/modals/turbo/panels/crypto/CryptoConfirmation.tsx index 90068a8f2..38c9ffcdc 100644 --- a/src/components/modals/turbo/panels/crypto/CryptoConfirmation.tsx +++ b/src/components/modals/turbo/panels/crypto/CryptoConfirmation.tsx @@ -13,6 +13,7 @@ import { mARIOToken } from '@ar.io/sdk/web'; import { ARIOToTokenAmount, + ARToTokenAmount, ETHToTokenAmount, POLToTokenAmount, TokenType, @@ -46,12 +47,9 @@ import { } from '@src/utils/constants'; import { AlertCircle, RefreshCw, Wallet } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -// NOTE (de-AO refactor): wagmi hooks crash without a `WagmiProvider`, which -// the Solana-only refactor removed. Stub them out — the EVM crypto top-up -// branches that read these values are unreachable from the Solana-only UI. -const useAccount = () => ({ address: undefined }) as any; -const useBalance = (_args?: unknown) => ({ data: undefined }) as any; -const useWalletClient = () => ({ data: undefined }) as any; +// Multi-wallet restored (Model A): the `WagmiProvider` is mounted again in +// `main.tsx`, so the real wagmi hooks are safe to use here. +import { useAccount, useBalance, useWalletClient } from 'wagmi'; // Fallback value for winc per GiB when upload cost data is unavailable const WINC_PER_GIB_FALLBACK = 1e12; @@ -358,12 +356,47 @@ function CryptoConfirmation({ // Direct payment via Turbo SDK if ( (walletType === WALLET_TYPES.WANDER || + walletType === WALLET_TYPES.ARWEAVE || walletType === WALLET_TYPES.ARWEAVE_APP || walletType === WALLET_TYPES.BEACON) && - window.arweaveWallet && (tokenType === 'arweave' || tokenType === 'ario') ) { - throw new Error('ArConnect wallet is not supported in Solana mode'); + // Arweave identity (Model A): fund credits with AR or ARIO using the + // wallet's arbundles turbo signer (ArconnectSigner). Credits land on + // the connected Arweave address (the signer's own address) — mirrors + // the ETH-ARIO branch below, just with the Arweave signer/token. + if (!wallet?.turboSigner) { + setPaymentError( + 'Wallet signer not available. Please reconnect your wallet and try again.', + ); + setIsProcessing(false); + return; + } + + const turboSigner = wallet.turboSigner as any; + // Ensure public key is set (required for data-item / fund signing). + if (!turboSigner.publicKey && turboSigner.setPublicKey) { + await turboSigner.setPublicKey(); + } + + const turboClient = TurboFactory.authenticated({ + signer: turboSigner, + token: tokenType, + paymentServiceConfig: { + url: turboNetwork.PAYMENT_URL, + }, + }); + + const tokenAmount = + tokenType === 'ario' + ? ARIOToTokenAmount(cryptoAmount) + : ARToTokenAmount(cryptoAmount); + await turboClient.topUpWithTokens({ + tokenAmount, + }); + + onComplete(); + return; } else if (walletType === WALLET_TYPES.ETHEREUM) { // ARIO payments for ETH wallets use the InjectedEthereumSigner (AO-based token) if (tokenType === 'ario') { @@ -642,11 +675,9 @@ function CryptoConfirmation({ token: tokenType as any, }); - console.log('Retrying submitFundTransaction with txId:', failedTxId); const response = await turboClient.submitFundTransaction({ txId: failedTxId, }); - console.log('Retry response:', response); if (response.status === 'failed') { setPaymentError( diff --git a/src/components/notices/CustodialNameNotice.test.tsx b/src/components/notices/CustodialNameNotice.test.tsx new file mode 100644 index 000000000..a79e24587 --- /dev/null +++ b/src/components/notices/CustodialNameNotice.test.tsx @@ -0,0 +1,144 @@ +/** + * Unit tests for the custodial claim/exit UX (`CustodialNameNotice`). + * + * Focus: the confirmation gate (a transfer can only fire with a valid Solana + * target AND a deliberate confirmation) and that the wallet's credit-identity + * is threaded to `transferCustodialArNSName`. + */ +import '@testing-library/jest-dom'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +// CustodialNameNotice imports typed error classes from TurboArNSClient, which +// pulls the heavy (ESM) turbo-sdk / AO deps. Stub them for module load — the +// component only needs the exported error classes, not a live SDK. Mirrors the +// stubs in TurboArNSClient.test.ts. +jest.mock('@ardrive/turbo-sdk', () => ({ + TurboFactory: { + unauthenticated: jest.fn(() => ({})), + authenticated: jest.fn(() => ({})), + }, + ARIOToTokenAmount: jest.fn(), + ARToTokenAmount: jest.fn(), + ETHToTokenAmount: jest.fn(), + POLToTokenAmount: jest.fn(), +})); +jest.mock('@permaweb/aoconnect', () => ({ connect: jest.fn(() => ({})) })); +jest.mock('@src/utils/constants', () => ({ + devPaymentServiceFqdn: 'payment.ardrive.dev', + ARNS_TX_ID_REGEX: new RegExp('^[a-zA-Z0-9\\-_s+]{43}$'), + NETWORK_DEFAULTS: { + AO: { ARIO: {} }, + TURBO: { + UPLOAD_URL: 'https://turbo.ardrive.io', + PAYMENT_URL: 'http://localhost:4001', + GATEWAY_URL: 'https://turbo-gateway.com', + WALLETS_URL: 'http://localhost:4001/info', + }, + }, +})); + +import CustodialNameNotice from './CustodialNameNotice'; + +const transferCustodialArNSName = jest.fn(); + +jest.mock('@src/hooks/useTurboArNSClient', () => ({ + useTurboArNSClient: jest.fn(() => ({ + transferCustodialArNSName, + })), +})); + +jest.mock('@src/state', () => ({ + useWalletState: jest.fn(() => [ + { + wallet: { + tokenType: 'arweave', + turboSigner: { sign: jest.fn() }, + }, + }, + ]), +})); + +const ANT_ID = 'ANT-custodial-1'; +const VALID_TARGET = '7T9x6CWBfdC8UUVsifNS3bWbroSvuFi7g8vebXHAxcxB'; + +describe('CustodialNameNotice (claim/exit)', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('opens the claim form and keeps the transfer button disabled until valid + confirmed', () => { + render(); + + fireEvent.click(screen.getByTestId('custodial-claim-open')); + const submit = screen.getByTestId( + 'custodial-claim-submit', + ) as HTMLButtonElement; + + // Nothing entered yet → disabled. + expect(submit).toBeDisabled(); + + // Valid target but not yet confirmed → still disabled. + fireEvent.change(screen.getByTestId('custodial-claim-target'), { + target: { value: VALID_TARGET }, + }); + expect(submit).toBeDisabled(); + + // Confirm → enabled. + fireEvent.click(screen.getByTestId('custodial-claim-confirm')); + expect(submit).toBeEnabled(); + }); + + it('flags a malformed Solana target and blocks the transfer', () => { + render(); + fireEvent.click(screen.getByTestId('custodial-claim-open')); + + fireEvent.change(screen.getByTestId('custodial-claim-target'), { + target: { value: 'not-a-real-address!!' }, + }); + fireEvent.click(screen.getByTestId('custodial-claim-confirm')); + + expect( + screen.getByTestId('custodial-claim-invalid-target'), + ).toBeInTheDocument(); + expect(screen.getByTestId('custodial-claim-submit')).toBeDisabled(); + expect(transferCustodialArNSName).not.toHaveBeenCalled(); + }); + + it('transfers with the wallet credit-identity on confirm, then shows success', async () => { + transferCustodialArNSName.mockResolvedValueOnce({ + antId: ANT_ID, + target: VALID_TARGET, + name: 'mycoolname', + messageId: 'tx-1', + confirmed: true, + }); + + render(); + fireEvent.click(screen.getByTestId('custodial-claim-open')); + fireEvent.change(screen.getByTestId('custodial-claim-target'), { + target: { value: VALID_TARGET }, + }); + fireEvent.click(screen.getByTestId('custodial-claim-confirm')); + fireEvent.click(screen.getByTestId('custodial-claim-submit')); + + expect(transferCustodialArNSName).toHaveBeenCalledWith( + expect.objectContaining({ + antId: ANT_ID, + target: VALID_TARGET, + tokenType: 'arweave', + }), + ); + + // Success state renders once the promise resolves. + expect( + await screen.findByTestId('custodial-name-notice-claimed'), + ).toBeInTheDocument(); + expect(screen.getByText(VALID_TARGET)).toBeInTheDocument(); + }); + + it('disables the open button when there is no known ANT id', () => { + render(); + expect(screen.getByTestId('custodial-claim-open')).toBeDisabled(); + }); +}); diff --git a/src/components/notices/CustodialNameNotice.tsx b/src/components/notices/CustodialNameNotice.tsx new file mode 100644 index 000000000..94a88a264 --- /dev/null +++ b/src/components/notices/CustodialNameNotice.tsx @@ -0,0 +1,350 @@ +import { useTurboArNSClient } from '@src/hooks/useTurboArNSClient'; +import { + CustodialANTNotFoundError, + CustodyTransferUnauthorizedError, + InvalidTransferTargetError, +} from '@src/services/turbo/TurboArNSClient'; +import { useWalletState } from '@src/state'; +import { isValidSolanaAddress } from '@src/utils'; +import { useState } from 'react'; + +/** + * Shown for a name bought with Turbo Credits under the **Model-A custodial** + * path (Arweave / Ethereum identities). The bundler provisioned and OWNS the + * ANT on the buyer's behalf, so the buyer controls the name through Turbo but + * does not yet hold the ANT in their own wallet. + * + * The "Claim / Transfer to a wallet" action is the credit-authed self-custody + * exit (`POST /v1/arns/transfer/:antId?target=`). ANTs are Solana + * assets, so the exit **target must be a Solana pubkey**. The flow is: + * 1. the user enters/pastes a Solana pubkey (validated: base58, 32 bytes), + * 2. an explicit IRREVERSIBLE-action confirmation gates the transfer, + * 3. the user's credit-identity signer produces an ACTION-BOUND, single-use + * signature (built by the SDK — bound to this exact antId+target, so it + * can't be replayed elsewhere), and the bundler transfers the ANT and + * clears the `user_ant` custody mapping. + * + * Threat model (see the PR security note): + * - **Wrong target** — an incorrect address permanently loses the name, so the + * target is validated and a deliberate confirmation is required. + * - **Replay** — the signed message is action-bound + single-use nonce (SDK), + * so a captured signature cannot move a different ANT or to a different owner. + * - **Not-owner** — the bundler authorizes against custody and returns a + * deliberately non-leaky 404 (we surface it without revealing other names). + */ +export function CustodialNameNotice({ + antId, + name, + className, + onClaimed, +}: { + /** Custodial ANT id, when the settlement receipt reported it. */ + antId?: string; + /** The ArNS name, shown in the irreversible-action confirmation copy. */ + name?: string; + className?: string; + /** Notified with the destination pubkey after a successful transfer. */ + onClaimed?: (target: string) => void; +}): JSX.Element { + const turbo = useTurboArNSClient(); + const [{ wallet }] = useWalletState(); + + const [mode, setMode] = useState<'idle' | 'form' | 'done'>('idle'); + const [target, setTarget] = useState(''); + const [confirmed, setConfirmed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [claimedTo, setClaimedTo] = useState(null); + + const trimmedTarget = target.trim(); + const targetIsValid = isValidSolanaAddress(trimmedTarget); + const displayName = name ? `'${name}'` : 'this name'; + + const resetForm = () => { + setMode('idle'); + setTarget(''); + setConfirmed(false); + setError(null); + }; + + const handleTransfer = async () => { + setError(null); + + if (!targetIsValid) { + setError( + 'Enter a valid Solana wallet address (base58). ANTs are Solana assets, so the destination must be a Solana pubkey.', + ); + return; + } + if (!confirmed) { + setError('Please confirm you understand this action is permanent.'); + return; + } + if (!antId) { + setError('This name has no known ANT id yet, so it cannot be claimed.'); + return; + } + if (!turbo) { + setError('Wallet is not ready. Please try again in a moment.'); + return; + } + if (!wallet) { + setError('Connect a wallet to claim this name.'); + return; + } + + setSubmitting(true); + try { + const result = await turbo.transferCustodialArNSName({ + antId, + target: trimmedTarget, + tokenType: wallet.tokenType, + // Model A (Arweave / ETH) authenticates with the wallet's turbo signer; + // a solana identity would use the wallet adapter instead. + signer: wallet.turboSigner, + walletAdapter: + wallet.tokenType === 'solana' + ? ((typeof window !== 'undefined' + ? (window as any).solana + : undefined) ?? (wallet as any).solanaWallet) + : undefined, + }); + setClaimedTo(result.target); + setMode('done'); + onClaimed?.(result.target); + } catch (e) { + // Map typed errors to safe, actionable copy. Never echo another owner's + // name or raw chain internals. + if (e instanceof InvalidTransferTargetError) { + setError(e.message); + } else if (e instanceof CustodialANTNotFoundError) { + setError(e.message); + } else if (e instanceof CustodyTransferUnauthorizedError) { + setError(e.message); + } else { + setError( + e instanceof Error + ? `The transfer did not complete: ${e.message}` + : 'The transfer did not complete. Please try again.', + ); + } + } finally { + setSubmitting(false); + } + }; + + // ---- Success state: the name is now self-custodied. ---- + if (mode === 'done' && claimedTo) { + return ( +
+ Name transferred out of custody + + {displayName}'s ANT now belongs to the Solana wallet you control: + + + {claimedTo} + +
+ ); + } + + return ( +
+ + Turbo is holding this name for you + + + You paid with Turbo Credits, so Turbo provisioned and custodies the name + record (ANT) on your behalf. You control it through your ar.io account. + You can claim it to a Solana wallet you control at any time. + {antId ? ` (ANT ${antId})` : ''} + + + {mode === 'idle' ? ( +
+ +
+ ) : ( +
+ + { + setTarget(e.target.value); + setError(null); + }} + placeholder="Solana pubkey (base58)" + data-testid="custodial-claim-target" + style={{ + width: '100%', + boxSizing: 'border-box', + padding: '10px 12px', + borderRadius: '4px', + background: 'var(--bg, #0f0f0f)', + color: 'var(--text-white, #fff)', + border: `1px solid ${ + trimmedTarget && !targetIsValid + ? 'var(--error, #e8636b)' + : 'var(--divider, #333)' + }`, + }} + /> + {trimmedTarget && !targetIsValid ? ( + + Not a valid Solana address. + + ) : null} + +
+ This transfers ownership of {displayName}'s ANT to the address + above, moving it out of Turbo custody.{' '} + + This is permanent — an incorrect address loses the name. + {' '} + After claiming, you manage this name yourself: record edits will + need your Solana wallet's signature and a little SOL for + network fees (they're no longer gasless credit-paid). +
+ + + + {error ? ( + + {error} + + ) : null} + +
+ + +
+
+ )} +
+ ); +} + +export default CustodialNameNotice; diff --git a/src/components/pages/Register/Checkout.tsx b/src/components/pages/Register/Checkout.tsx index 75d4b07cb..315ffb4bd 100644 --- a/src/components/pages/Register/Checkout.tsx +++ b/src/components/pages/Register/Checkout.tsx @@ -4,6 +4,7 @@ import PaymentOptionsForm, { PaymentMethod, } from '@src/components/forms/PaymentOptionsForm/PaymentOptionsForm'; import { StepProgressBar } from '@src/components/layout/progress'; +import TurboTopUpModal from '@src/components/modals/turbo/TurboTopUpModal'; import { useIsMobile } from '@src/hooks'; import { useArNSIntentPrice } from '@src/hooks/useArNSIntentPrice'; import { useBaseTokenPrice } from '@src/hooks/useBaseTokenPrice'; @@ -19,11 +20,13 @@ import { executeBaseTokenPurchase, } from '@src/services/turbo/BaseTokenPurchaseService'; import { + InsufficientCreditsError, PaymentInformation, TurboArNSIntent, } from '@src/services/turbo/TurboArNSClient'; import { dispatchArNSUpdate, useArNSState } from '@src/state'; import dispatchArIOInteraction from '@src/state/actions/dispatchArIOInteraction'; +import dispatchArNSPurchaseWithCredits from '@src/state/actions/dispatchArNSPurchaseWithCredits'; import { useGlobalState } from '@src/state/contexts/GlobalState'; import { useTransactionState } from '@src/state/contexts/TransactionState'; import { useWalletState } from '@src/state/contexts/WalletState'; @@ -35,6 +38,7 @@ import { } from '@src/types'; import { formatARIOWithCommas, formatSolFromLamports } from '@src/utils'; import { getBaseChainId } from '@src/utils/baseNetwork'; +import { checkInsufficientSolForGas } from '@src/utils/checkInsufficientSolForGas'; import { ARNS_PURCHASES_DISABLED, ARNS_PURCHASES_DISABLED_TOOLTIP, @@ -50,14 +54,10 @@ import { queryClient } from '@src/utils/network'; import { Tooltip as AntdTooltip } from 'antd'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -// NOTE (de-AO refactor): wagmi hooks crash without a `WagmiProvider`, which -// the Solana-only refactor removed. Stub them out — the resulting `undefined` -// values flow into the EVM-funded checkout branches that are unreachable -// from the Solana-only UI anyway. Re-import from 'wagmi' if/when EVM -// wallets come back. -const useAccount = () => ({ connector: undefined, address: undefined }) as any; -const useBalance = (_args?: unknown) => ({ data: undefined }) as any; -const useConfig = () => undefined as any; +// Multi-wallet restored (Model A): the `WagmiProvider` is mounted again in +// `main.tsx`, so the real wagmi hooks are safe to use for the EVM-funded +// checkout branches. +import { useAccount, useBalance, useConfig } from 'wagmi'; // page on route transaction/review // on completion routes to transaction/complete @@ -92,6 +92,9 @@ function Checkout() { const [isProcessingBaseToken, setIsProcessingBaseToken] = useState(false); const [baseTokenStage, setBaseTokenStage] = useState(null); + // Opened when a credits purchase fails at runtime with a 402 (the balance + // went stale / was spent elsewhere between the pre-flight check and paying). + const [showTopUpModal, setShowTopUpModal] = useState(false); // Wagmi hooks for Base token purchases const wagmiConfig = useConfig(); @@ -205,16 +208,23 @@ function Checkout() { baseArioBalance, ]); - // Paying with ARIO on Solana also costs SOL: transaction fees plus rent - // deposits for the accounts the intent creates (Buy-Name spawns an ANT). - // Gate the pay button on the wallet actually holding that much. - const isInsufficientSolForGas = useMemo(() => { - if (paymentMethod !== 'crypto' || isBaseToken(selectedCryptoToken)) { - return false; - } - if (!costDetail?.gasEstimate || solBalance === undefined) return false; - return solBalance < costDetail.gasEstimate.totalLamports; - }, [costDetail, paymentMethod, selectedCryptoToken, solBalance]); + // Paying on Solana also costs SOL — transaction fees plus rent deposits for + // the accounts the intent creates (Buy-Name spawns an ANT). This is true + // even on the CREDITS path: the ANT is spawned client-side (~0.02 SOL) before + // the credit-funded buy, so a wallet with credits but no SOL still can't + // complete. Gate the pay button on the wallet actually holding that much for + // both the crypto (ARIO) and credits flows. Base-token top-ups pay gas on + // the EVM side, so they're exempt. + const isInsufficientSolForGas = useMemo( + () => + checkInsufficientSolForGas({ + paymentMethod, + isBaseTokenSelected: isBaseToken(selectedCryptoToken), + gasEstimateTotalLamports: costDetail?.gasEstimate?.totalLamports, + solBalanceLamports: solBalance, + }), + [costDetail, paymentMethod, selectedCryptoToken, solBalance], + ); const fees = useMemo(() => { if (paymentMethod === 'card') { @@ -344,14 +354,35 @@ function Checkout() { }; } if (paymentMethod === 'credits') { + // Real-world anchor next to the abstract "Credits" figure. USD comes from + // the fiat estimate (cents); the ARIO equivalent from the mARIO price. + const usdEquiv = intentPrice?.fiatEstimate?.paymentAmount + ? intentPrice.fiatEstimate.paymentAmount / 100 + : undefined; + const arioEquiv = intentPrice?.mARIO + ? new mARIOToken(Number(intentPrice.mARIO)).toARIO().valueOf() + : undefined; + const anchorParts = [ + usdEquiv !== undefined + ? `$${formatARIOWithCommas(usdEquiv)} USD` + : null, + arioEquiv ? `${formatARIOWithCommas(arioEquiv)} ${arioTicker}` : null, + ].filter(Boolean); return { 'Total due:': intentPrice?.winc && Number(intentPrice?.winc) > 0 ? ( - - {formatARIOWithCommas( - turbo?.wincToCredits(Number(intentPrice?.winc ?? 0)) ?? 0, - )}{' '} - Credits + + + {formatARIOWithCommas( + turbo?.wincToCredits(Number(intentPrice?.winc ?? 0)) ?? 0, + )}{' '} + Credits + + {anchorParts.length > 0 && ( + + ≈ {anchorParts.join(' · ')} + + )} ) : ( @@ -572,8 +603,27 @@ function Checkout() { setIsProcessingBaseToken(false); setBaseTokenStage(null); } + } else if (paymentMethod === 'credits') { + // Turbo Credits: settle through the bundler payment-service REST API + // (credits debited server-side, on-chain write server-fronted), NOT + // the dead `@ar.io/sdk buyRecord({ fundFrom: 'turbo' })` alias. Covers + // buy / extend-lease / increase-undername / upgrade — same intent path. + await dispatchArNSPurchaseWithCredits({ + turbo, + workflowName: workflowName as ARNS_INTERACTION_TYPES, + intent: costDetailsParams.intent as TurboArNSIntent, + payload: { + ...transactionData, + }, + owner: walletAddress, + wallet, + paidBy: creditsBalance?.receivedApprovals.map( + (approval) => approval.payingAddress, + ), + dispatch: dispatchTransactionState, + }); } else { - // Standard payment flow (ARIO, fiat, credits) + // Standard payment flow (ARIO crypto, fiat) await dispatchArIOInteraction({ arioContract: arioContract as ARIOWrite, workflowName: workflowName as ARNS_INTERACTION_TYPES, @@ -587,12 +637,9 @@ function Checkout() { dispatch: dispatchTransactionState, signer: wallet?.contractSigner, wallet, - fundFrom: - paymentMethod === 'card' - ? 'fiat' - : paymentMethod === 'credits' - ? 'turbo' - : fundingSource, + // credits are handled above via `dispatchArNSPurchaseWithCredits`; + // here paymentMethod is 'card' (fiat) or 'crypto' (funding source). + fundFrom: paymentMethod === 'card' ? 'fiat' : fundingSource, paidBy: creditsBalance?.receivedApprovals.map( (approval) => approval.payingAddress, ), @@ -601,7 +648,14 @@ function Checkout() { }); } } catch (error) { - eventEmitter.emit('error', error); + // A runtime 402 on the credits path is not a dead-end: the wallet just + // needs more credits. Route to Top-Up (same modal as the pre-flight + // insufficient-balance button) instead of a generic error toast. + if (error instanceof InsufficientCreditsError) { + setShowTopUpModal(true); + } else { + eventEmitter.emit('error', error); + } } finally { if (walletAddress) { // Refresh the user's ArNS / ANT slice (resets `domainInfo`, @@ -623,6 +677,10 @@ function Checkout() { // connect otherwise. queryClient.resetQueries({ queryKey: ['ario-liquid-balance'] }); queryClient.resetQueries({ queryKey: ['ario-delegated-stake'] }); + // A credits purchase debits the wallet's Turbo Credit balance + // server-side; that query has a 2-min staleTime, so invalidate it to + // reflect the debit immediately (navbar pill + next checkout). + queryClient.invalidateQueries({ queryKey: ['turbo-credit-balance'] }); } } } @@ -757,6 +815,9 @@ function Checkout() {
+ {showTopUpModal && ( + setShowTopUpModal(false)} /> + )} ); } diff --git a/src/components/pages/Transaction/TransactionComplete.tsx b/src/components/pages/Transaction/TransactionComplete.tsx index a915b52ea..a2b27cb08 100644 --- a/src/components/pages/Transaction/TransactionComplete.tsx +++ b/src/components/pages/Transaction/TransactionComplete.tsx @@ -18,6 +18,7 @@ import { ANTCard } from '../../cards'; import ActionCard from '../../cards/ActionCard/ActionCard'; import { ArrowLeft, SettingsIcon } from '../../icons'; import PageLoader from '../../layout/progress/PageLoader/PageLoader'; +import CustodialNameNotice from '../../notices/CustodialNameNotice'; import { getTransactionCompleteAnnouncement } from './transaction-announcements'; function TransactionComplete() { @@ -95,6 +96,13 @@ function TransactionComplete() { })} + {localData.interactionResult?.payload?.custodial ? ( + + ) : null} +
{ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + , ); }); diff --git a/src/services/turbo/TurboArNSClient.test.ts b/src/services/turbo/TurboArNSClient.test.ts new file mode 100644 index 000000000..907d70b75 --- /dev/null +++ b/src/services/turbo/TurboArNSClient.test.ts @@ -0,0 +1,755 @@ +/** + * Unit tests for the credit-settlement path added in Phase 1 + * (`TurboArNSClient.executeArNSIntent`). The turbo-sdk authenticated client is + * injected (`purchaseClient`) so these tests never stand up the real SDK; the + * status endpoint is exercised through a mocked `fetch`. + */ + +// Keep the heavy SDK / AO deps out of the unit test — the settlement path uses +// an injected client, so only lightweight stubs are needed for module load. +jest.mock('@ardrive/turbo-sdk', () => ({ + TurboFactory: { + unauthenticated: jest.fn(() => ({})), + authenticated: jest.fn(() => ({})), + }, + ARIOToTokenAmount: jest.fn(), + ARToTokenAmount: jest.fn(), + ETHToTokenAmount: jest.fn(), + POLToTokenAmount: jest.fn(), +})); +jest.mock('@permaweb/aoconnect', () => ({ connect: jest.fn(() => ({})) })); +// `@src/utils/constants` uses `import.meta.env`, which ts-jest (CJS) cannot +// compile — a pre-existing repo-wide jest limitation. Stub the two values the +// client actually reads so this suite runs in isolation. +jest.mock('@src/utils/constants', () => ({ + devPaymentServiceFqdn: 'payment.ardrive.dev', + ARNS_TX_ID_REGEX: new RegExp('^[a-zA-Z0-9\\-_s+]{43}$'), + NETWORK_DEFAULTS: { + AO: { ARIO: {} }, + TURBO: { + UPLOAD_URL: 'https://turbo.ardrive.io', + PAYMENT_URL: 'http://localhost:4001', + GATEWAY_URL: 'https://turbo-gateway.com', + WALLETS_URL: 'http://localhost:4001/info', + }, + }, +})); + +import { + ArNSPurchaseFailedError, + AuthenticatedArNSCustodyClient, + AuthenticatedArNSPurchaseClient, + AuthenticatedArNSRecordClient, + CustodialANTNotFoundError, + CustodyTransferUnauthorizedError, + ExecuteArNSIntentParams, + InsufficientCreditsError, + InvalidTransferTargetError, + TurboArNSClient, +} from './TurboArNSClient'; + +// A valid-length Solana address so the constructor derives token 'solana'. +const SOLANA_ADDRESS = '3xJ8mF1qZ9wYtP2vN6bK7cR4dQ5eH8gS1uA2iL3oM4n'; +const NONCE = '11111111-2222-4333-8444-555555555555'; + +function makeClient(): TurboArNSClient { + return new TurboArNSClient({ + paymentUrl: 'http://localhost:4001', + walletAddress: SOLANA_ADDRESS, + stripe: {} as any, + }); +} + +function makePurchaseClient( + nonce = NONCE, +): jest.Mocked { + const ok = async () => ({ nonce, purchaseReceipt: { nonce } }); + return { + buyArNSName: jest.fn(ok), + extendArNSLease: jest.fn(ok), + increaseArNSUndernameLimit: jest.fn(ok), + upgradeArNSName: jest.fn(ok), + } as any; +} + +/** Mock `fetch` (used by `getIntentStatus`) to yield a sequence of records. */ +function mockStatusSequence(records: Record[]) { + let i = 0; + const fn = jest.fn(async (_url: unknown) => ({ + json: async () => records[Math.min(i++, records.length - 1)], + })); + (global as any).fetch = fn; + return fn; +} + +const baseParams = ( + overrides: Partial, +): ExecuteArNSIntentParams => ({ + intent: 'Buy-Name', + name: 'mycoolname', + pollIntervalMs: 1, + pollTimeoutMs: 2000, + ...overrides, +}); + +describe('TurboArNSClient.executeArNSIntent', () => { + afterEach(() => jest.clearAllMocks()); + + describe('intent → turbo-sdk method mapping', () => { + it('routes Buy-Name to buyArNSName with the processId (ANT)', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'tx-buy' }]); + + const res = await client.executeArNSIntent( + baseParams({ + intent: 'Buy-Name', + name: 'MyCoolName', + type: 'lease', + years: 2, + processId: 'ANT-123', + paidBy: ['payer1'], + purchaseClient, + }), + ); + + expect(purchaseClient.buyArNSName).toHaveBeenCalledTimes(1); + expect(purchaseClient.buyArNSName).toHaveBeenCalledWith({ + name: 'mycoolname', // lower-cased + type: 'lease', + years: 2, + processId: 'ANT-123', + paidBy: ['payer1'], + }); + expect(res.messageId).toBe('tx-buy'); + expect(res.nonce).toBe(NONCE); + }); + + it('routes Extend-Lease to extendArNSLease', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'tx-extend' }]); + + await client.executeArNSIntent( + baseParams({ intent: 'Extend-Lease', years: 3, purchaseClient }), + ); + + expect(purchaseClient.extendArNSLease).toHaveBeenCalledWith({ + name: 'mycoolname', + years: 3, + paidBy: undefined, + }); + expect(purchaseClient.buyArNSName).not.toHaveBeenCalled(); + }); + + it('routes Increase-Undername-Limit to increaseArNSUndernameLimit', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'tx-inc' }]); + + await client.executeArNSIntent( + baseParams({ + intent: 'Increase-Undername-Limit', + increaseQty: 100, + purchaseClient, + }), + ); + + expect(purchaseClient.increaseArNSUndernameLimit).toHaveBeenCalledWith({ + name: 'mycoolname', + increaseQty: 100, + paidBy: undefined, + }); + }); + + it('routes Upgrade-Name to upgradeArNSName', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'tx-up' }]); + + await client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ); + + expect(purchaseClient.upgradeArNSName).toHaveBeenCalledWith({ + name: 'mycoolname', + paidBy: undefined, + }); + }); + + it('rejects Buy-Name without a processId (would orphan the buy)', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'never' }]); + + await expect( + client.executeArNSIntent( + baseParams({ intent: 'Buy-Name', purchaseClient }), + ), + ).rejects.toThrow(/processId/i); + expect(purchaseClient.buyArNSName).not.toHaveBeenCalled(); + }); + }); + + describe('nonce capture + idempotent resume', () => { + it('captures the UUID nonce and polls the status endpoint with it', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + const fetchMock = mockStatusSequence([{ messageId: 'tx-1' }]); + + const res = await client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ); + + expect(res.nonce).toBe(NONCE); + const polledUrl = String(fetchMock.mock.calls[0][0]); + expect(polledUrl).toContain(`/v1/arns/purchase/${NONCE}`); + }); + + it('resumes polling an existing nonce WITHOUT re-submitting (no double debit)', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + const fetchMock = mockStatusSequence([{ messageId: 'tx-resumed' }]); + + const res = await client.executeArNSIntent( + baseParams({ + intent: 'Buy-Name', + processId: 'ANT-1', + resumeNonce: NONCE, + purchaseClient, + }), + ); + + // Critical: the mint method must NOT be called again on resume. + expect(purchaseClient.buyArNSName).not.toHaveBeenCalled(); + expect(res.nonce).toBe(NONCE); + expect(res.messageId).toBe('tx-resumed'); + expect(String(fetchMock.mock.calls[0][0])).toContain(NONCE); + }); + }); + + describe('error mapping', () => { + it('maps a 402 to a typed InsufficientCreditsError', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + purchaseClient.upgradeArNSName.mockRejectedValueOnce( + Object.assign(new Error('Failed request (Status 402): no credits'), { + status: 402, + }), + ); + mockStatusSequence([{ messageId: 'never' }]); + + await expect( + client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ), + ).rejects.toBeInstanceOf(InsufficientCreditsError); + }); + + it('maps a "(Status 402)" message with no status field to InsufficientCreditsError', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + purchaseClient.upgradeArNSName.mockRejectedValueOnce( + new Error('Failed request (Status 402): insufficient balance'), + ); + mockStatusSequence([{ messageId: 'never' }]); + + await expect( + client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ), + ).rejects.toBeInstanceOf(InsufficientCreditsError); + }); + }); + + describe('polling to terminal', () => { + it('tolerates pending/blip responses then resolves on messageId', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + const fetchMock = mockStatusSequence([ + {}, // pending — no messageId + {}, // pending again + { messageId: 'tx-final' }, // terminal success + ]); + + const res = await client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ); + + expect(res.messageId).toBe('tx-final'); + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it('throws ArNSPurchaseFailedError when the record carries failedDate', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ failedDate: '2026-07-15T00:00:00Z' }]); + + await expect( + client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ), + ).rejects.toBeInstanceOf(ArNSPurchaseFailedError); + }); + + it('keeps polling through a transient fetch rejection (network blip)', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + let call = 0; + (global as any).fetch = jest.fn(async () => { + call += 1; + if (call === 1) throw new Error('network down'); + return { json: async () => ({ messageId: 'tx-after-blip' }) }; + }); + + const res = await client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', purchaseClient }), + ); + + expect(res.messageId).toBe('tx-after-blip'); + expect(call).toBeGreaterThanOrEqual(2); + }); + }); + + // ---- Model A (custodial) — identity-agnostic authenticated client ---- + describe('identity-agnostic client + Model A custodial buy', () => { + it('Model A (arweave, injected client): buys WITHOUT a processId', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'tx-custodial' }]); + + const res = await client.executeArNSIntent( + baseParams({ + intent: 'Buy-Name', + name: 'CoolName', + type: 'lease', + years: 1, + tokenType: 'arweave', + // No processId — the bundler custodially provisions the ANT. + purchaseClient, + }), + ); + + expect(purchaseClient.buyArNSName).toHaveBeenCalledTimes(1); + const arg = purchaseClient.buyArNSName.mock.calls[0][0]; + expect(arg).not.toHaveProperty('processId'); // omitted for Model A + expect(arg.name).toBe('coolname'); + expect(res.messageId).toBe('tx-custodial'); + }); + + it('Model B (solana) still REQUIRES a processId for Buy-Name', async () => { + const client = makeClient(); + const purchaseClient = makePurchaseClient(); + mockStatusSequence([{ messageId: 'never' }]); + + await expect( + client.executeArNSIntent( + baseParams({ + intent: 'Buy-Name', + tokenType: 'solana', + purchaseClient, + }), + ), + ).rejects.toThrow(/processId/i); + expect(purchaseClient.buyArNSName).not.toHaveBeenCalled(); + }); + + it('builds an arweave-signed authenticated client from the wallet signer', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { TurboFactory } = require('@ardrive/turbo-sdk'); + const buyArNSName = jest.fn(async () => ({ + nonce: NONCE, + purchaseReceipt: { nonce: NONCE }, + })); + TurboFactory.authenticated.mockReturnValueOnce({ + buyArNSName, + extendArNSLease: jest.fn(), + increaseArNSUndernameLimit: jest.fn(), + upgradeArNSName: jest.fn(), + }); + mockStatusSequence([{ messageId: 'tx-arweave' }]); + + const client = makeClient(); + const fakeSigner = { sign: jest.fn() }; + const res = await client.executeArNSIntent( + baseParams({ + intent: 'Buy-Name', + name: 'ArweaveName', + tokenType: 'arweave', + signer: fakeSigner, + }), + ); + + expect(TurboFactory.authenticated).toHaveBeenCalledWith( + expect.objectContaining({ token: 'arweave', signer: fakeSigner }), + ); + expect(buyArNSName).toHaveBeenCalledTimes(1); + expect((buyArNSName as jest.Mock).mock.calls[0][0]).not.toHaveProperty( + 'processId', + ); + expect(res.messageId).toBe('tx-arweave'); + }); + + it('throws a clear error when a Model A (ethereum) signer is missing', async () => { + const client = makeClient(); + mockStatusSequence([{ messageId: 'never' }]); + + await expect( + client.executeArNSIntent( + baseParams({ intent: 'Upgrade-Name', tokenType: 'ethereum' }), + ), + ).rejects.toThrow(/signer is required/i); + }); + }); + + // ---- Claim / exit — custodial ANT self-custody transfer ---- + describe('transferCustodialArNSName (claim/exit)', () => { + const ANT_ID = 'ANT-custodial-1'; + // A valid Solana pubkey (base58, 32 bytes) — the exit target. + const TARGET = '7T9x6CWBfdC8UUVsifNS3bWbroSvuFi7g8vebXHAxcxB'; + + function makeTransferClient( + impl?: () => Promise, + ): jest.Mocked { + return { + transferArNSAnt: jest.fn( + impl ?? + (async () => ({ + antId: ANT_ID, + target: TARGET, + name: 'mycoolname', + messageId: 'tx-transfer', + confirmed: true, + })), + ), + } as any; + } + + it('calls transferArNSAnt with { antId, target } and normalizes the result', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(); + + const res = await client.transferCustodialArNSName({ + antId: ANT_ID, + target: TARGET, + tokenType: 'arweave', + transferClient, + }); + + expect(transferClient.transferArNSAnt).toHaveBeenCalledTimes(1); + expect(transferClient.transferArNSAnt).toHaveBeenCalledWith({ + antId: ANT_ID, + target: TARGET, + }); + expect(res).toEqual({ + antId: ANT_ID, + target: TARGET, + name: 'mycoolname', + messageId: 'tx-transfer', + confirmed: true, + }); + }); + + it('treats a null messageId (thrown-but-landed) as unconfirmed but successful', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(async () => ({ + antId: ANT_ID, + target: TARGET, + messageId: null, + confirmed: false, + })); + + const res = await client.transferCustodialArNSName({ + antId: ANT_ID, + target: TARGET, + transferClient, + }); + + expect(res.messageId).toBeNull(); + expect(res.confirmed).toBe(false); + }); + + it('rejects a malformed target BEFORE signing (no request is made)', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(); + + await expect( + client.transferCustodialArNSName({ + antId: ANT_ID, + target: 'not-a-real-address!!', + transferClient, + }), + ).rejects.toBeInstanceOf(InvalidTransferTargetError); + expect(transferClient.transferArNSAnt).not.toHaveBeenCalled(); + }); + + it('rejects an empty target BEFORE signing', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(); + + await expect( + client.transferCustodialArNSName({ + antId: ANT_ID, + target: ' ', + transferClient, + }), + ).rejects.toBeInstanceOf(InvalidTransferTargetError); + expect(transferClient.transferArNSAnt).not.toHaveBeenCalled(); + }); + + it('requires an antId', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(); + + await expect( + client.transferCustodialArNSName({ + antId: '', + target: TARGET, + transferClient, + }), + ).rejects.toThrow(/ANT id is required/i); + expect(transferClient.transferArNSAnt).not.toHaveBeenCalled(); + }); + + it('maps a 404 to a non-leaky CustodialANTNotFoundError', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(async () => { + throw Object.assign( + new Error('Failed request (Status 404): ANT not found'), + { status: 404 }, + ); + }); + + await expect( + client.transferCustodialArNSName({ + antId: ANT_ID, + target: TARGET, + transferClient, + }), + ).rejects.toBeInstanceOf(CustodialANTNotFoundError); + }); + + it('maps a 401 to a CustodyTransferUnauthorizedError', async () => { + const client = makeClient(); + const transferClient = makeTransferClient(async () => { + throw Object.assign( + new Error('Failed request (Status 401): bad signature'), + { status: 401 }, + ); + }); + + await expect( + client.transferCustodialArNSName({ + antId: ANT_ID, + target: TARGET, + transferClient, + }), + ).rejects.toBeInstanceOf(CustodyTransferUnauthorizedError); + }); + }); + + // ---- Model A (custodial) — credit-paid record management ---- + describe('setCustodialArNSRecord / removeCustodialArNSRecord', () => { + const ANT_ID = 'ANT-custodial-1'; + const TX_ID = 'abcdefghijklmnopqrstuvwxyz0123456789-_ABCDE'; + + function makeRecordClient( + overrides?: Record, + ): jest.Mocked { + return { + setArNSRecord: jest.fn(async (p: any) => ({ + antId: p.antId, + undername: p.undername ?? '@', + transactionId: p.transactionId, + ttlSeconds: p.ttlSeconds, + messageId: 'tx-set', + })), + removeArNSRecord: jest.fn(async (p: any) => ({ + antId: p.antId, + undername: p.undername, + messageId: 'tx-remove', + })), + ...overrides, + } as any; + } + + it('sets the apex @ record via the injected record client', async () => { + const client = makeClient(); + const recordClient = makeRecordClient(); + + const res = await client.setCustodialArNSRecord({ + antId: ANT_ID, + transactionId: TX_ID, + ttlSeconds: 900, + tokenType: 'arweave', + recordClient, + }); + + expect(recordClient.setArNSRecord).toHaveBeenCalledWith({ + antId: ANT_ID, + undername: '@', + transactionId: TX_ID, + ttlSeconds: 900, + }); + expect(res).toEqual({ + antId: ANT_ID, + undername: '@', + transactionId: TX_ID, + ttlSeconds: 900, + messageId: 'tx-set', + }); + }); + + it('sets an undername record with the provided undername', async () => { + const client = makeClient(); + const recordClient = makeRecordClient(); + + await client.setCustodialArNSRecord({ + antId: ANT_ID, + undername: 'blog', + transactionId: TX_ID, + ttlSeconds: 3600, + recordClient, + }); + + expect(recordClient.setArNSRecord).toHaveBeenCalledWith({ + antId: ANT_ID, + undername: 'blog', + transactionId: TX_ID, + ttlSeconds: 3600, + }); + }); + + it('removes an undername record', async () => { + const client = makeClient(); + const recordClient = makeRecordClient(); + + const res = await client.removeCustodialArNSRecord({ + antId: ANT_ID, + undername: 'blog', + recordClient, + }); + + expect(recordClient.removeArNSRecord).toHaveBeenCalledWith({ + antId: ANT_ID, + undername: 'blog', + }); + expect(res).toEqual({ + antId: ANT_ID, + undername: 'blog', + messageId: 'tx-remove', + }); + }); + + it('refuses to remove the apex @ record', async () => { + const client = makeClient(); + const recordClient = makeRecordClient(); + + await expect( + client.removeCustodialArNSRecord({ + antId: ANT_ID, + undername: '@', + recordClient, + }), + ).rejects.toThrow(/non-apex undername/i); + expect(recordClient.removeArNSRecord).not.toHaveBeenCalled(); + }); + + it('requires an antId to set a record', async () => { + const client = makeClient(); + const recordClient = makeRecordClient(); + + await expect( + client.setCustodialArNSRecord({ + antId: '', + transactionId: TX_ID, + ttlSeconds: 900, + recordClient, + }), + ).rejects.toThrow(/ANT id is required/i); + expect(recordClient.setArNSRecord).not.toHaveBeenCalled(); + }); + + it('maps a 404 to a non-leaky CustodialANTNotFoundError', async () => { + const client = makeClient(); + const recordClient = makeRecordClient({ + setArNSRecord: jest.fn(async () => { + throw Object.assign( + new Error('Failed request (Status 404): not found'), + { status: 404 }, + ); + }), + }); + + await expect( + client.setCustodialArNSRecord({ + antId: ANT_ID, + transactionId: TX_ID, + ttlSeconds: 900, + recordClient, + }), + ).rejects.toBeInstanceOf(CustodialANTNotFoundError); + }); + + it('maps a 401 to a CustodyTransferUnauthorizedError', async () => { + const client = makeClient(); + const recordClient = makeRecordClient({ + removeArNSRecord: jest.fn(async () => { + throw Object.assign( + new Error('Failed request (Status 401): bad signature'), + { status: 401 }, + ); + }), + }); + + await expect( + client.removeCustodialArNSRecord({ + antId: ANT_ID, + undername: 'blog', + recordClient, + }), + ).rejects.toBeInstanceOf(CustodyTransferUnauthorizedError); + }); + }); + + // ---- Identity-agnostic fiat top-up ---- + describe('getTopupPaymentIntent (identity-agnostic fiat top-up)', () => { + afterEach(() => { + (global as any).fetch = undefined; + }); + + it.each([ + ['arweave', '7gI4LqBxQSyTRu5e2Zfgyw2UEMgsUsxsoW2KajneFC8'], + ['ethereum', '0x1F98431c8aD98523631AE4a59f267346ea31F984'], + ['solana', SOLANA_ADDRESS], + ] as const)( + 'credits the connected %s identity address with its native token', + async (token, address) => { + const client = makeClient(); + const fetchMock = jest.fn(async () => ({ + status: 200, + json: async () => ({ + topUpQuote: { quotedPaymentAmount: 1000 }, + paymentSession: { id: 'pi_1' }, + }), + })); + (global as any).fetch = fetchMock; + + const res = await client.getTopupPaymentIntent({ + address, + amount: 1000, + token, + }); + + expect(res.paymentSession).toEqual({ id: 'pi_1' }); + const calledUrl = (fetchMock.mock.calls[0] as any[])[0] as string; + // The connected identity's native address is the credit destination, + // and its token drives the destination-address type — no Solana default. + expect(calledUrl).toContain( + `/top-up/payment-intent/${address}/usd/1000`, + ); + expect(calledUrl).toContain(`token=${token}`); + }, + ); + }); +}); diff --git a/src/services/turbo/TurboArNSClient.ts b/src/services/turbo/TurboArNSClient.ts index c80f24a42..8eb9ecf8f 100644 --- a/src/services/turbo/TurboArNSClient.ts +++ b/src/services/turbo/TurboArNSClient.ts @@ -1,4 +1,4 @@ -import { Intent, MessageResult } from '@ar.io/sdk/web'; +import { Intent } from '@ar.io/sdk/web'; import { ARIOToTokenAmount, ARToTokenAmount, @@ -33,10 +33,11 @@ export interface TurboArNSClientConfig { paymentUrl?: string; gatewayUrl?: string; walletsUrl?: string; - // `signer` and `ao` are vestigial after the de-AO refactor — the Stripe- - // funded ArNS purchase flow (`executeArNSIntent`) is currently AO-coupled - // and gated behind a tooltip in the UI. Phase 9 keeps the field for - // back-compat; Solana support requires the Turbo payment service update. + // `signer` and `ao` are vestigial after the de-AO refactor. Credit-paid ArNS + // purchases (`executeArNSIntent`) now settle through the bundler + // payment-service REST API — the authenticated turbo-sdk client is built + // on demand from the connected Solana wallet adapter, not from these fields. + // The Stripe (`card`) path remains gated in the UI. signer?: any; walletAddress?: string; stripe: Stripe; @@ -119,6 +120,260 @@ export type TurboArNSIntentPriceParams = { promoCode?: string; }; +/** + * The subset of the turbo-sdk authenticated client used to settle an ArNS + * purchase with Turbo Credits. Declared structurally so it can be injected in + * tests without standing up the whole SDK. + */ +export interface AuthenticatedArNSPurchaseClient { + buyArNSName(params: { + name: string; + type?: 'lease' | 'permabuy'; + years?: number; + /** + * ANT the name resolves to. Required for Model B (user-owned ANT); OMITTED + * for Model A (custodial) — the bundler provisions + custodies the ANT + * server-side. The published SDK/CLI make this optional. + */ + processId?: string; + paidBy?: string[]; + }): Promise; + extendArNSLease(params: { + name: string; + years: number; + paidBy?: string[]; + }): Promise; + increaseArNSUndernameLimit(params: { + name: string; + increaseQty: number; + paidBy?: string[]; + }): Promise; + upgradeArNSName(params: { + name: string; + paidBy?: string[]; + }): Promise; +} + +/** Shape returned by the turbo-sdk `*ArNSName`/`*ArNSLease` purchase methods. */ +export type ArNSPurchaseResult = { + /** UUID that is both the idempotency key and the status-lookup key. */ + nonce: string; + purchaseReceipt?: { nonce: string; messageId?: string } & Record< + string, + unknown + >; + arioWriteResult?: { id: string }; +}; + +/** + * Progress phases emitted while settling an ArNS purchase with credits, so the + * UI can surface a signing/polling message without echoing raw chain errors. + */ +export type ArNSSettlementPhase = + | 'submitting' + | 'submitted' + | 'resumed' + | 'polling' + | 'success'; + +export type ArNSSettlementStatus = { + phase: ArNSSettlementPhase; + nonce?: string; + messageId?: string; +}; + +export type ArNSSettlementResult = { + /** UUID nonce used for the purchase (idempotency + status key). */ + nonce: string; + /** Solana transaction id of the on-chain ArNS write. Drives success nav. */ + messageId: string; + /** The terminal purchase record from the status endpoint. */ + receipt: Record; +}; + +export type ExecuteArNSIntentParams = { + intent: TurboArNSIntent; + name: string; + type?: 'lease' | 'permabuy'; + years?: number; + increaseQty?: number; + /** ANT (Metaplex Core asset) the name resolves to — required for Buy-Name. */ + processId?: string; + paidBy?: string[]; + /** + * Solana wallet adapter (e.g. `window.solana`) used to build the + * authenticated turbo-sdk client that signs the request nonce. Not required + * when `resumeNonce` is supplied (a resume only polls, it never re-submits). + * Only used for the Solana (Model B) path. + */ + walletAdapter?: unknown; + /** + * Connected wallet's token type. Drives which authenticated turbo-sdk client + * is built: `solana` uses the wallet adapter; `arweave`/`ethereum` (Model A — + * custodial) use {@link signer}. Defaults to `solana` for back-compat. + */ + tokenType?: TokenType; + /** + * arbundles-compatible turbo signer (e.g. `ArconnectSigner` / + * `InjectedEthereumSigner`) used to build the authenticated client for the + * Arweave / Ethereum (Model A) path. Ignored for Solana. + */ + signer?: unknown; + /** + * Inject a pre-built authenticated client (used by tests). When omitted, one + * is constructed from `walletAdapter`. + */ + purchaseClient?: AuthenticatedArNSPurchaseClient; + /** + * Resume polling an already-submitted purchase (e.g. after a page reload) + * instead of submitting a fresh one. The nonce is the server-side + * idempotency key, so resuming never risks a double debit. + */ + resumeNonce?: string; + onStatus?: (status: ArNSSettlementStatus) => void; + pollIntervalMs?: number; + pollTimeoutMs?: number; +}; + +/** + * Thrown when the bundler responds `402` — the wallet lacks the Turbo Credits + * to cover the purchase. Deterministic (not retried); the UI should route to + * the Top-Up flow rather than showing a generic error. + */ +export class InsufficientCreditsError extends Error { + public readonly code = 'INSUFFICIENT_CREDITS' as const; + constructor(message = 'Insufficient Turbo Credits for this purchase.') { + super(message); + this.name = 'InsufficientCreditsError'; + } +} + +/** Thrown when the purchase terminally fails on-chain (`failedDate` set). */ +export class ArNSPurchaseFailedError extends Error { + public readonly code = 'ARNS_PURCHASE_FAILED' as const; + constructor( + message = 'The ArNS purchase failed to settle on-chain.', + public readonly nonce?: string, + ) { + super(message); + this.name = 'ArNSPurchaseFailedError'; + } +} + +/** + * Thrown when a claim/exit target is not a valid Solana pubkey. ANTs are Solana + * assets, so the self-custody exit target MUST be a base58 Solana address that + * decodes to 32 bytes — anything else is rejected client-side BEFORE a signature + * is produced (a signed request for a junk target only wastes a single-use nonce). + */ +export class InvalidTransferTargetError extends Error { + public readonly code = 'INVALID_TRANSFER_TARGET' as const; + constructor( + message = 'The transfer target must be a valid Solana wallet address.', + ) { + super(message); + this.name = 'InvalidTransferTargetError'; + } +} + +/** + * Thrown when the bundler responds `404` to a custodial transfer — the caller + * does not custody that ANT (or it does not exist). The bundler deliberately + * conflates "not found" and "not yours" into one `404` so it never reveals that + * an ANT exists under another owner; we surface a single non-leaky message. + */ +export class CustodialANTNotFoundError extends Error { + public readonly code = 'CUSTODIAL_ANT_NOT_FOUND' as const; + constructor( + message = 'This name is not held in your Turbo custody, so it cannot be transferred from this account.', + ) { + super(message); + this.name = 'CustodialANTNotFoundError'; + } +} + +/** Thrown when the bundler rejects the action-bound signature (`401`). */ +export class CustodyTransferUnauthorizedError extends Error { + public readonly code = 'CUSTODY_TRANSFER_UNAUTHORIZED' as const; + constructor( + message = 'The transfer request could not be authenticated. Please reconnect your wallet and try again.', + ) { + super(message); + this.name = 'CustodyTransferUnauthorizedError'; + } +} + +/** + * The subset of the turbo-sdk authenticated client used for the custodial + * self-custody exit. Declared structurally so it can be injected in tests + * without standing up the whole SDK. + */ +export interface AuthenticatedArNSCustodyClient { + transferArNSAnt(params: { antId: string; target: string }): Promise<{ + antId: string; + target: string; + name?: string; + /** Solana tx id of the on-chain transfer; `null` when thrown-but-landed. */ + messageId: string | null; + /** `false` when the transfer landed on-chain but the confirm RPC failed. */ + confirmed?: boolean; + }>; +} + +/** Result of a successful custodial ANT transfer (self-custody exit). */ +export type ArNSTransferResult = { + antId: string; + /** Solana pubkey that now owns the ANT. */ + target: string; + name?: string; + /** Solana tx id of the on-chain transfer; `null` when thrown-but-landed. */ + messageId: string | null; + /** `false` when the transfer landed on-chain but the confirm RPC failed. */ + confirmed: boolean; +}; + +/** + * The subset of the turbo-sdk authenticated client used to MANAGE a + * custodially-held ArNS name's records with credits (Model A). Declared + * structurally so it can be injected in tests without standing up the whole SDK. + * + * The concrete SDK client builds an ACTION-BOUND, single-use signed message for + * each op (`set-record`/`remove-record` + antId + fields + a fresh nonce), so a + * captured signature can't be replayed against a different ANT/undername. We + * never hand-roll that message. + */ +export interface AuthenticatedArNSRecordClient { + setArNSRecord(params: { + antId: string; + undername?: string; + transactionId: string; + ttlSeconds: number; + }): Promise<{ + antId: string; + undername: string; + transactionId: string; + ttlSeconds: number; + messageId: string; + }>; + removeArNSRecord(params: { antId: string; undername: string }): Promise<{ + antId: string; + undername: string; + messageId: string; + }>; +} + +/** Result of a credit-paid custodial ArNS record set/remove. */ +export type ArNSRecordResult = { + antId: string; + undername: string; + /** Present for a set (omitted for a remove). */ + transactionId?: string; + /** Present for a set (omitted for a remove). */ + ttlSeconds?: number; + /** Solana tx id of the on-chain record write. */ + messageId: string; +}; + export class TurboArNSClient { public readonly turboUploader; public readonly uploadUrl; @@ -302,59 +557,297 @@ export class TurboArNSClient { } /** - * Stripe-funded direct ArNS purchase ("buy a name with a credit card"). + * Settle an ArNS purchase (buy / extend / increase-undernames / upgrade) by + * debiting the connected wallet's Turbo Credit balance via the bundler + * payment-service REST API (`POST /v1/arns/purchase/:intent/:name`), then + * poll the status endpoint to a terminal state. * - * **Currently disabled on the Solana-only build.** + * This is the **credits** settlement path (Model B — the user owns the ANT, + * whose `processId` is passed for `Buy-Name`). It replaces the dead + * `@ar.io/sdk buyRecord({ fundFrom: 'turbo' })` alias, which never debited + * credits (it paid ARIO straight from the wallet's token account). * - * The Turbo payment service still settles ArNS purchases by emitting an - * AO message (`Buy-Name`/`Extend-Lease`/`Increase-Undername-Limit`) on - * the AO ARIO process. Until the service learns to relay those intents - * to the Solana ARIO programs, this flow can't complete on Solana — the - * payment would clear but no on-chain mutation would happen, leaving - * the user with neither funds nor a name. - * - * The UI gates this behind a tooltip on the credit-card payment option - * (see `TransactionDetails`/`PaymentDetails`), and the dispatcher - * (`dispatchArIOInteraction`) throws if `fundFrom === 'fiat'` is reached - * on Solana. The class method is preserved as documentation + a hook - * for re-enabling once the service ships Solana support. + * Resilience (see arns-spike RED_TEAM_REVIEW / UI_INTEGRATION_PLAN §3): + * - The turbo-sdk purchase method mints a UUID nonce which is BOTH the + * idempotency key and the status key; we capture it immediately and never + * blind-re-call the mint method (that would risk a double debit). The SDK's + * own HTTP retry reuses the same signed nonce, so it is debit-safe. + * - `resumeNonce` lets a page reload resume POLLING an already-submitted + * purchase instead of orphaning (or re-charging) it. + * - A `402` maps to a typed {@link InsufficientCreditsError} so the caller can + * route to Top-Up rather than showing a generic failure. + * - Polling tolerates transient network blips (non-terminal); only a + * `messageId` (success) or `failedDate` (failure) is terminal. */ public async executeArNSIntent({ - paymentMethodId, - email, - ...intentParams - }: TurboArNSIntentPriceParams & { - processId?: string; - paymentMethodId: string; - email?: string; - address: string; - }): Promise> { - // Suppress unused-destructure warnings. - void paymentMethodId; - void email; - void intentParams; + intent, + name, + type, + years, + increaseQty, + processId, + paidBy, + walletAdapter, + tokenType, + signer, + purchaseClient, + resumeNonce, + onStatus, + pollIntervalMs = 2500, + pollTimeoutMs = 120_000, + }: ExecuteArNSIntentParams): Promise { + let nonce = resumeNonce; + + if (!nonce) { + onStatus?.({ phase: 'submitting' }); + const client = + purchaseClient ?? + this.buildAuthenticatedArNSClient({ walletAdapter, tokenType, signer }); + try { + const result = await this.submitArNSPurchase(client, { + intent, + name, + type, + years, + increaseQty, + processId, + paidBy, + tokenType, + }); + // Prefer the top-level nonce; fall back to the receipt's copy. + nonce = result.nonce ?? result.purchaseReceipt?.nonce; + } catch (error) { + throw this.mapArNSPurchaseError(error); + } + if (!nonce) { + throw new Error( + 'ArNS purchase did not return a nonce; cannot track settlement.', + ); + } + onStatus?.({ phase: 'submitted', nonce }); + } else { + onStatus?.({ phase: 'resumed', nonce }); + } + + return this.pollArNSPurchaseToTerminal({ + nonce, + name, + onStatus, + pollIntervalMs, + pollTimeoutMs, + }); + } + + /** + * Build the authenticated turbo-sdk client that signs the request nonce for + * the connected identity. Identity-agnostic: + * + * - **Solana (Model B)** — build from the wallet adapter (`window.solana`), + * mirroring the proven Solana authed pattern used for logo uploads + * (`useUploadArNSLogo`). The user's wallet owns the ANT. + * - **Arweave / Ethereum (Model A — custodial)** — build from an + * arbundles-compatible `signer` (`ArconnectSigner` / `InjectedEthereumSigner` + * exposed by the wallet connector's `turboSigner`). The bundler custodies + * the ANT; only the buy params + custody UX differ, not this client. + */ + private buildAuthenticatedArNSClient({ + walletAdapter, + tokenType = 'solana', + signer, + }: { + walletAdapter?: unknown; + tokenType?: TokenType; + signer?: unknown; + }): AuthenticatedArNSPurchaseClient { + return this.buildAuthenticatedTurboClient({ + walletAdapter, + tokenType, + signer, + }) as unknown as AuthenticatedArNSPurchaseClient; + } + + /** + * Build the raw authenticated turbo-sdk client for the connected identity. + * The concrete client exposes BOTH the credit-purchase methods and the + * custodial ANT methods (`transferArNSAnt`, `setArNSRecord`, …); callers cast + * it to the narrow interface they use. Identity handling is identical to the + * purchase path — the same signer authenticates every credit-authed request. + */ + private buildAuthenticatedTurboClient({ + walletAdapter, + tokenType = 'solana', + signer, + }: { + walletAdapter?: unknown; + tokenType?: TokenType; + signer?: unknown; + }): unknown { + if (tokenType === 'arweave' || tokenType === 'ethereum') { + if (!signer) { + throw new Error( + `A connected ${tokenType} wallet signer is required to authenticate this request.`, + ); + } + return TurboFactory.authenticated({ + token: tokenType, + signer: signer as any, + paymentServiceConfig: { + url: this.paymentUrl, + }, + } as any); + } + + // Solana (default): use the injected wallet adapter. + const adapter = + walletAdapter ?? + (typeof window !== 'undefined' ? (window as any).solana : undefined); + if (!adapter) { + throw new Error( + 'A connected Solana wallet is required to authenticate this request.', + ); + } + return TurboFactory.authenticated({ + walletAdapter: adapter, + token: 'solana', + paymentServiceConfig: { + url: this.paymentUrl, + }, + } as any); + } + + /** Map an ArNS intent to the matching turbo-sdk per-intent purchase method. */ + private submitArNSPurchase( + client: AuthenticatedArNSPurchaseClient, + { + intent, + name, + type, + years, + increaseQty, + processId, + paidBy, + tokenType = 'solana', + }: { + intent: TurboArNSIntent; + name: string; + type?: 'lease' | 'permabuy'; + years?: number; + increaseQty?: number; + processId?: string; + paidBy?: string[]; + tokenType?: TokenType; + }, + ): Promise { + const domain = lowerCaseDomain(name); + switch (intent) { + case 'Buy-Name': { + // Model B (Solana) MUST supply the client-spawned ANT's processId. + // Model A (Arweave / Ethereum — custodial) OMITS it so the bundler + // provisions + custodies the ANT server-side. + if (tokenType === 'solana' && !processId) { + throw new Error( + 'A processId (ANT) is required to buy an ArNS name with credits.', + ); + } + return client.buyArNSName({ + name: domain, + type, + years, + // Only forward processId when present (Model B). Omitting it for + // Model A triggers the bundler's custodial provisioning path. + ...(processId ? { processId } : {}), + paidBy, + }); + } + case 'Extend-Lease': { + if (years === undefined) { + throw new Error('years is required to extend an ArNS lease.'); + } + return client.extendArNSLease({ name: domain, years, paidBy }); + } + case 'Increase-Undername-Limit': { + if (increaseQty === undefined) { + throw new Error( + 'increaseQty is required to increase the undername limit.', + ); + } + return client.increaseArNSUndernameLimit({ + name: domain, + increaseQty, + paidBy, + }); + } + case 'Upgrade-Name': + return client.upgradeArNSName({ name: domain, paidBy }); + default: + throw new Error( + `Unsupported ArNS intent for credit settlement: ${String(intent)}`, + ); + } + } + + /** + * Poll `GET /v1/arns/purchase/:nonce` to a terminal state. `messageId` ⇒ + * success; `failedDate` ⇒ failure. Transient network errors are non-terminal. + */ + private async pollArNSPurchaseToTerminal({ + nonce, + name, + onStatus, + pollIntervalMs, + pollTimeoutMs, + }: { + nonce: string; + name: string; + onStatus?: (status: ArNSSettlementStatus) => void; + pollIntervalMs: number; + pollTimeoutMs: number; + }): Promise { + const deadline = Date.now() + pollTimeoutMs; + + while (Date.now() < deadline) { + onStatus?.({ phase: 'polling', nonce }); + let record: Record | undefined; + try { + record = (await this.getIntentStatus(nonce)) as Record; + } catch { + // Transient network/parse error — non-terminal, keep polling. + record = undefined; + } + + const messageId = record?.messageId as string | undefined; + if (messageId) { + onStatus?.({ phase: 'success', nonce, messageId }); + return { nonce, messageId, receipt: record ?? {} }; + } + if (record?.failedDate) { + throw new ArNSPurchaseFailedError( + `The purchase of '${name}' failed to settle on-chain.`, + nonce, + ); + } + + await sleep(pollIntervalMs); + } + throw new Error( - 'Credit-card payments for ArNS purchases are temporarily unavailable on Solana. ' + - 'The Turbo payment service still settles via AO and needs Solana support before this flow can be re-enabled.', + `Timed out waiting for the '${name}' purchase to settle (nonce ${nonce}). ` + + 'Your credits are safe; the purchase may still complete — check back shortly.', ); - /* Original AO-coupled implementation preserved for reference once the - * Turbo payment service ships Solana support. Re-enable by deleting the - * throw above and uncommenting: - * - * const intent = await this.getArNSPaymentIntent(intentParams); - * if (!intent.paymentSession.client_secret) { - * throw new Error('No client secret found on payment intent'); - * } - * const result = await this.stripe.confirmCardPayment( - * intent.paymentSession.client_secret, - * { payment_method: paymentMethodId, receipt_email: email }, - * ); - * if (result.error) throw new Error(result.error.message); - * - * // poll getIntentStatus until success/failed, then: - * // const messageResult = await this.ao.result({ process: this.arioProcessId, message: messageId }); - * // return { id: messageId, result: messageResult }; - */ + } + + /** + * Normalize a purchase error. A bundler `402` (surfaced by the turbo-sdk as a + * `FailedRequestError` with `status === 402`, or a "(Status 402)" message) + * becomes a typed {@link InsufficientCreditsError}. + */ + private mapArNSPurchaseError(error: unknown): Error { + const status = (error as { status?: number })?.status; + const message = error instanceof Error ? error.message : String(error); + if (status === 402 || /\(Status 402\)/.test(message)) { + return new InsufficientCreditsError(); + } + return error instanceof Error ? error : new Error(message); } public async getWincForToken( @@ -431,4 +924,238 @@ export class TurboArNSClient { public wincToCredits(winc: number) { return winc / 1_000_000_000_000; } + + /** + * Claim / transfer a **custodially-held** ArNS name (Model A) out to a + * wallet-controlled owner via the credit-authed bundler endpoint + * `POST /v1/arns/transfer/:antId?target=`. + * + * This is the self-custody escape hatch: the buyer's credit-identity signer + * (solana / arweave / ethereum — whichever bought the name) authenticates an + * **action-bound, single-use** request; the bundler confirms the caller + * custodies the ANT, then Turbo (the on-chain owner) transfers it to `target`. + * On success the bundler clears the `user_ant` custody mapping, so the name is + * fully self-custodied. + * + * Security: + * - `target` MUST be a valid Solana pubkey (ANTs are Solana assets). Validated + * BEFORE any signature is produced — a junk target never burns a nonce. + * - The signed message is built by the SDK (`arns\ntransfer\n{antId}\n{target}` + * + a fresh nonce), so it is bound to this exact antId+target and cannot be + * replayed against a different ANT/recipient. We never hand-roll that string. + * - The request is NOT retried on 5xx (single-use nonce). The bundler + * reconciles a "thrown-but-landed" transfer server-side. + * - A `404` ("not found" == "not yours", deliberately conflated) maps to a + * typed {@link CustodialANTNotFoundError} so the UI never leaks another + * owner's name; a `401` maps to {@link CustodyTransferUnauthorizedError}. + */ + public async transferCustodialArNSName({ + antId, + target, + tokenType, + signer, + walletAdapter, + transferClient, + }: { + /** Custodial ANT id (Solana Metaplex Core asset) to move out of custody. */ + antId: string; + /** Destination Solana pubkey that will own the ANT. */ + target: string; + /** Connected wallet's token type (drives which authed client is built). */ + tokenType?: TokenType; + /** arbundles-compatible turbo signer for arweave/ethereum identities. */ + signer?: unknown; + /** Solana wallet adapter for the solana identity. */ + walletAdapter?: unknown; + /** Inject a pre-built authenticated client (tests). */ + transferClient?: AuthenticatedArNSCustodyClient; + }): Promise { + if (!antId) { + throw new Error('An ANT id is required to transfer a custodial name.'); + } + // Target MUST be a real Solana pubkey — reject malformed/empty up front, + // before we ask the wallet to sign (a signed junk request wastes a nonce). + if (!target || !isValidSolanaAddress(target)) { + throw new InvalidTransferTargetError(); + } + + const client = + transferClient ?? + (this.buildAuthenticatedTurboClient({ + walletAdapter, + tokenType, + signer, + }) as AuthenticatedArNSCustodyClient); + + try { + const result = await client.transferArNSAnt({ antId, target }); + return { + antId: result.antId ?? antId, + target: result.target ?? target, + name: result.name, + messageId: result.messageId ?? null, + // The SDK types omit `confirmed`; the bundler returns it. Treat a + // returned messageId as confirmed when the flag is absent. + confirmed: result.confirmed ?? result.messageId != null, + }; + } catch (error) { + throw this.mapCustodyTransferError(error); + } + } + + /** + * Normalize a custodial-transfer error. A bundler `404` (not-your-ANT) → + * {@link CustodialANTNotFoundError}; a `401` (bad signature) → + * {@link CustodyTransferUnauthorizedError}. Surfaced by the turbo-sdk as a + * `FailedRequestError` carrying `.status` (and a "(Status NNN)" message). + */ + private mapCustodyTransferError(error: unknown): Error { + const status = (error as { status?: number })?.status; + const message = error instanceof Error ? error.message : String(error); + if (status === 404 || /\(Status 404\)/.test(message)) { + return new CustodialANTNotFoundError(); + } + if (status === 401 || /\(Status 401\)/.test(message)) { + return new CustodyTransferUnauthorizedError(); + } + return error instanceof Error ? error : new Error(message); + } + + /** + * Set an ArNS record (target `@` or an undername) on a **custodially-held** + * (Model A) ANT by debiting the connected identity's Turbo Credits via the + * bundler `POST /v1/arns/manage/:antId/set-record`. + * + * Distinct from the wallet-signed `dispatchANTInteraction` path (Model B): the + * user does NOT own the ANT — Turbo does — so a wallet interaction physically + * can't work. Instead the user's credit-identity signer authenticates an + * ACTION-BOUND, single-use request (built by the SDK, bound to this exact + * antId+op+fields) and the bundler, as the on-chain owner, writes the record. + * + * Security: + * - The bundler authorizes against the `user_ant` custody mapping; a caller + * who doesn't custody the ANT gets a deliberately non-leaky `404` + * ({@link CustodialANTNotFoundError}) — we never reveal another owner's name. + * - A `401` (bad signature) maps to {@link CustodyTransferUnauthorizedError}. + */ + public async setCustodialArNSRecord({ + antId, + undername = '@', + transactionId, + ttlSeconds, + tokenType, + signer, + walletAdapter, + recordClient, + }: { + antId: string; + undername?: string; + transactionId: string; + ttlSeconds: number; + tokenType?: TokenType; + signer?: unknown; + walletAdapter?: unknown; + recordClient?: AuthenticatedArNSRecordClient; + }): Promise { + if (!antId) { + throw new Error('An ANT id is required to manage a custodial name.'); + } + if (!transactionId) { + throw new Error('A target transaction id is required to set a record.'); + } + + const client = + recordClient ?? + (this.buildAuthenticatedTurboClient({ + walletAdapter, + tokenType, + signer, + }) as AuthenticatedArNSRecordClient); + + try { + const result = await client.setArNSRecord({ + antId, + undername, + transactionId, + ttlSeconds, + }); + return { + antId: result.antId ?? antId, + undername: result.undername ?? undername, + transactionId: result.transactionId ?? transactionId, + ttlSeconds: result.ttlSeconds ?? ttlSeconds, + messageId: result.messageId, + }; + } catch (error) { + throw this.mapCustodyManageError(error); + } + } + + /** + * Remove an undername record from a **custodially-held** (Model A) ANT by + * debiting the connected identity's Turbo Credits via the bundler + * `POST /v1/arns/manage/:antId/remove-record`. See {@link setCustodialArNSRecord} + * for the identity/security model. + */ + public async removeCustodialArNSRecord({ + antId, + undername, + tokenType, + signer, + walletAdapter, + recordClient, + }: { + antId: string; + undername: string; + tokenType?: TokenType; + signer?: unknown; + walletAdapter?: unknown; + recordClient?: AuthenticatedArNSRecordClient; + }): Promise { + if (!antId) { + throw new Error('An ANT id is required to manage a custodial name.'); + } + if (!undername || undername === '@') { + throw new Error('A non-apex undername is required to remove a record.'); + } + + const client = + recordClient ?? + (this.buildAuthenticatedTurboClient({ + walletAdapter, + tokenType, + signer, + }) as AuthenticatedArNSRecordClient); + + try { + const result = await client.removeArNSRecord({ antId, undername }); + return { + antId: result.antId ?? antId, + undername: result.undername ?? undername, + messageId: result.messageId, + }; + } catch (error) { + throw this.mapCustodyManageError(error); + } + } + + /** + * Normalize a custodial-manage error. A bundler `404` (not-your-ANT, or the + * ANT doesn't exist — deliberately conflated) → {@link CustodialANTNotFoundError} + * with manage-appropriate copy; a `401` (bad signature) → + * {@link CustodyTransferUnauthorizedError}. + */ + private mapCustodyManageError(error: unknown): Error { + const status = (error as { status?: number })?.status; + const message = error instanceof Error ? error.message : String(error); + if (status === 404 || /\(Status 404\)/.test(message)) { + return new CustodialANTNotFoundError( + 'This name is not held in your Turbo custody, so its records cannot be managed from this account.', + ); + } + if (status === 401 || /\(Status 401\)/.test(message)) { + return new CustodyTransferUnauthorizedError(); + } + return error instanceof Error ? error : new Error(message); + } } diff --git a/src/services/turbo/arnsPurchaseResume.test.ts b/src/services/turbo/arnsPurchaseResume.test.ts new file mode 100644 index 000000000..abe5a8b86 --- /dev/null +++ b/src/services/turbo/arnsPurchaseResume.test.ts @@ -0,0 +1,102 @@ +/** + * The resume store is the durable substrate for the credit-purchase + * money-safety guarantees: it must survive a spawned ANT's processId (SOL-safe + * retry) and a submitted nonce (debit-safe resume), and must reject records + * that carry neither. + */ +import { + clearPendingArNSPurchase, + getPendingArNSPurchase, + savePendingArNSPurchase, +} from './arnsPurchaseResume'; + +describe('arnsPurchaseResume store', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('round-trips a spawn-only record (processId, no nonce yet)', () => { + savePendingArNSPurchase({ + processId: 'ANT-abc', + intent: 'Buy-Name', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now(), + }); + + const pending = getPendingArNSPurchase(); + expect(pending?.processId).toBe('ANT-abc'); + expect(pending?.nonce).toBeUndefined(); + expect(pending?.name).toBe('MyName'); + }); + + it('round-trips a submitted record (nonce + processId)', () => { + savePendingArNSPurchase({ + nonce: 'nonce-1', + processId: 'ANT-abc', + intent: 'Buy-Name', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now(), + }); + + const pending = getPendingArNSPurchase(); + expect(pending?.nonce).toBe('nonce-1'); + expect(pending?.processId).toBe('ANT-abc'); + }); + + it('still accepts a nonce-only record (non-Buy intents have no ANT)', () => { + savePendingArNSPurchase({ + nonce: 'nonce-1', + intent: 'Extend-Lease', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now(), + }); + + expect(getPendingArNSPurchase()?.nonce).toBe('nonce-1'); + }); + + it('rejects a record with neither nonce nor processId', () => { + // Write a malformed record directly (the typed API requires one of them). + window.localStorage.setItem( + 'turbo:pending-arns-purchase', + JSON.stringify({ + intent: 'Buy-Name', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now(), + }), + ); + + expect(getPendingArNSPurchase()).toBeUndefined(); + // And it self-heals by clearing the junk. + expect( + window.localStorage.getItem('turbo:pending-arns-purchase'), + ).toBeNull(); + }); + + it('expires a stale record past MAX_AGE', () => { + savePendingArNSPurchase({ + processId: 'ANT-old', + intent: 'Buy-Name', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now() - 31 * 60 * 1000, + }); + + expect(getPendingArNSPurchase()).toBeUndefined(); + }); + + it('clears on demand', () => { + savePendingArNSPurchase({ + processId: 'ANT-abc', + intent: 'Buy-Name', + name: 'MyName', + owner: 'owner1', + savedAt: Date.now(), + }); + clearPendingArNSPurchase(); + expect(getPendingArNSPurchase()).toBeUndefined(); + }); +}); diff --git a/src/services/turbo/arnsPurchaseResume.ts b/src/services/turbo/arnsPurchaseResume.ts new file mode 100644 index 000000000..c40d5c573 --- /dev/null +++ b/src/services/turbo/arnsPurchaseResume.ts @@ -0,0 +1,86 @@ +import type { TurboArNSIntent } from './TurboArNSClient'; + +/** + * A credit-paid ArNS purchase that has progressed past a costly, non-repeatable + * step and must survive a reload / tab close / failed attempt. Persisted so a + * retry resumes rather than repeats work that costs money: + * + * - `processId` is captured the instant a Model-B ANT is spawned client-side + * (real SOL, ~0.02). A retry MUST reuse this ANT instead of spawning another, + * or every failed attempt bleeds SOL and orphans an ANT. + * - `nonce` is the server-side idempotency + status key captured once the + * purchase is submitted (credits debited, on-chain write in flight). Resuming + * is a pure read (`GET /v1/arns/purchase/:nonce`); it never re-submits, so it + * can never double-debit. + * + * At least one of `nonce` / `processId` is always present. See + * UI_INTEGRATION_PLAN §3.4. + */ +export type PendingArNSPurchase = { + /** Idempotency + status key. Absent before the purchase is submitted. */ + nonce?: string; + /** Client-spawned ANT (Model B). Absent for non-Buy intents. */ + processId?: string; + intent: TurboArNSIntent; + name: string; + owner: string; + savedAt: number; +}; + +const STORAGE_KEY = 'turbo:pending-arns-purchase'; + +// Guard against a stale nonce lingering forever if terminal polling never lands +// (the server is durable; this is just UI hygiene). Slightly over the poll +// ceiling used by `executeArNSIntent`. +const MAX_AGE_MS = 30 * 60 * 1000; + +function safeStorage(): Storage | undefined { + try { + return typeof window !== 'undefined' ? window.localStorage : undefined; + } catch { + return undefined; + } +} + +export function savePendingArNSPurchase(entry: PendingArNSPurchase): void { + const storage = safeStorage(); + if (!storage) return; + try { + storage.setItem(STORAGE_KEY, JSON.stringify(entry)); + } catch { + // Ignore quota / serialization failures — persistence is best-effort. + } +} + +export function getPendingArNSPurchase(): PendingArNSPurchase | undefined { + const storage = safeStorage(); + if (!storage) return undefined; + try { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as PendingArNSPurchase; + if ( + // At least one durable key must be present to be worth resuming. + (!parsed?.nonce && !parsed?.processId) || + !parsed?.name || + typeof parsed.savedAt !== 'number' || + Date.now() - parsed.savedAt > MAX_AGE_MS + ) { + clearPendingArNSPurchase(); + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +export function clearPendingArNSPurchase(): void { + const storage = safeStorage(); + if (!storage) return; + try { + storage.removeItem(STORAGE_KEY); + } catch { + // no-op + } +} diff --git a/src/services/turbo/custodyStrategy.ts b/src/services/turbo/custodyStrategy.ts new file mode 100644 index 000000000..e6921f057 --- /dev/null +++ b/src/services/turbo/custodyStrategy.ts @@ -0,0 +1,70 @@ +import { TokenType } from '@ardrive/turbo-sdk'; + +/** + * ANT custody model for a credit-paid ArNS purchase. + * + * On-chain, an ArNS *name* is a pointer to an *ANT* (a Metaplex Core NFT + + * record/controller PDAs on Solana). Turbo always pays ARIO + SOL and debits + * the user's credits; the only thing that varies per identity is **who owns + * the ANT and who may mutate it**. See `arns-spike/UI_INTEGRATION_PLAN.md` §2. + */ +export type CustodyModel = + /** Model B — the user's Solana wallet spawns + owns the ANT (Phase 1). */ + | 'B-user-owned' + /** Model A — Turbo (the bundler) provisions + custodies the ANT (Phase 2/3). */ + | 'A-custodial'; + +export interface CustodyStrategy { + model: CustodyModel; + /** Whether the connected wallet owns the ANT outright. */ + ownsAnt: boolean; + /** + * Whether the client must spawn the ANT before the buy and hand its + * `processId` to the bundler. Model B does; Model A leaves the bundler to + * provision one server-side (no client `processId`). + */ + requiresClientAntSpawn: boolean; + /** + * `true` when this strategy is not yet implemented for credit settlement. + * Model A is a designed seam only in Phase 1 — the Checkout credits path + * throws a clear, actionable error rather than half-settling. + */ + isStub: boolean; +} + +/** + * Resolve the custody strategy purely from the connected wallet's token type. + * Keeping this a pure function of `wallet.tokenType` lets Model A slot in behind + * the same Checkout/Manage components later without a rewrite. + */ +export function resolveCustodyStrategy( + tokenType: TokenType | undefined, +): CustodyStrategy { + switch (tokenType) { + case 'solana': + // Model B — build now. The user's wallet spawns the ANT client-side + // (as it already does in `dispatchArIOInteraction`) and we pass that + // `processId` to `POST /v1/arns/purchase/buy-name/:name?processId=...`. + return { + model: 'B-user-owned', + ownsAnt: true, + requiresClientAntSpawn: true, + isStub: false, + }; + + // ---- Model A (Arweave / Ethereum / keyless) — custodial ---- + // These identities can't own a Solana ANT directly, so the bundler + // provisions + custodies it (ARNS_PROVISIONING_ENABLED) and later exposes a + // claim/exit transfer. The settlement client is identity-agnostic; only the + // buy params (no client `processId`) + custody UX differ. Now WIRED: + // multi-wallet is restored, so an Arweave (or ETH) identity pays with + // credits while Turbo holds the ANT. `isStub: false`. + default: + return { + model: 'A-custodial', + ownsAnt: false, + requiresClientAntSpawn: false, + isStub: false, + }; + } +} diff --git a/src/services/wallets/EthWalletConnector.ts b/src/services/wallets/EthWalletConnector.ts new file mode 100644 index 000000000..e9b84aa47 --- /dev/null +++ b/src/services/wallets/EthWalletConnector.ts @@ -0,0 +1,159 @@ +import { TokenType } from '@ardrive/turbo-sdk'; +import { InjectedEthereumSigner } from '@dha-team/arbundles'; +import { EthereumWalletError } from '@src/utils/errors'; +import { hashMessage, parseEther, recoverPublicKey, toBytes } from 'viem'; +import { mainnet } from 'viem/chains'; +import { Config, Connector } from 'wagmi'; +import { + connect, + disconnect, + getAccount, + sendTransaction, + signMessage, +} from 'wagmi/actions'; + +import { + AoAddress, + ArNSWalletConnector, + TransferTransactionResult, + WALLET_TYPES, +} from '../../types'; + +/** + * Ethereum identity connector (wagmi/viem + RainbowKit) restored from the + * pre-Solana-only build (commit `3f43b85`) for the Model-A custodial credit-buy + * path. + * + * `turboSigner` is an arbundles `InjectedEthereumSigner` driven by the wagmi + * `signMessage` action, so it plugs straight into + * `TurboFactory.authenticated({ token: 'ethereum', signer })` — the signer the + * bundler verifies for the ArNS purchase nonce. + * + * NOTE: the ETH path is wired + compiles but is NOT live-validated in this PR — + * the bundler's ETH request-auth fix is landing separately. Arweave is the + * validated Model-A identity. The old AO `contractSigner` (client-side ANT + * writes) is intentionally dropped: Model A never spawns an ANT client-side. + */ +export class EthWalletConnector implements ArNSWalletConnector { + tokenType: TokenType = 'ethereum'; + turboSigner: InjectedEthereumSigner; + connector: Connector; + config: Config; + + constructor(config: Config, connector: Connector) { + this.connector = connector; + + const provider = { + getSigner: () => ({ + signMessage: async (message: any) => { + const arg = message instanceof String ? message : { raw: message }; + + const ethAccount = getAccount(config); + + return await signMessage(config, { + message: arg as any, + account: ethAccount.address, + connector: this.connector, + }); + }, + }), + }; + const signer = new InjectedEthereumSigner(provider as any); + + this.turboSigner = signer; + signer.setPublicKey = async () => { + const message = 'Sign this message to connect to ArNS.app'; + const ethAccount = getAccount(config); + + const signature = await signMessage(config, { + message: message, + account: ethAccount.address, + connector: this.connector, + }); + const hash = await hashMessage(message); + const recoveredKey = await recoverPublicKey({ + hash, + signature, + }); + signer.publicKey = Buffer.from(toBytes(recoveredKey)); + }; + + this.config = config; + } + + async connect(): Promise { + try { + localStorage.setItem('walletType', WALLET_TYPES.ETHEREUM); + const isConnected = await this.connector.isAuthorized(); + if (isConnected) { + return; + } + + await connect(this.config, { connector: this.connector }); + } catch (error: unknown) { + localStorage.removeItem('walletType'); + + // Check for user rejection errors (common patterns across wallets) + const errorMessage = + error instanceof Error ? error.message.toLowerCase() : ''; + const isUserRejection = + errorMessage.includes('user rejected') || + errorMessage.includes('user denied') || + errorMessage.includes('user cancelled') || + errorMessage.includes('rejected the request'); + + if (isUserRejection) { + throw new EthereumWalletError('User cancelled authentication.'); + } + + // For other errors, preserve the original message + const message = + error instanceof Error ? error.message : 'Connection failed'; + throw new EthereumWalletError(message); + } + } + + async disconnect(): Promise { + localStorage.removeItem('walletType'); + await disconnect(this.config, { connector: this.connector }); + } + + async getWalletAddress(): Promise { + const address = getAccount(this.config).address; + if (!address) { + throw new EthereumWalletError('No address found'); + } + return address as unknown as AoAddress; + } + + async submitNativeTransaction( + amount: number, + toAddress: string, + ): Promise { + if (!toAddress.startsWith('0x')) { + throw new Error('Invalid address'); + } + + // switch user to ETH mainnet if not already on it + if (this.connector.chainId !== mainnet.id) { + await this.connector?.switchChain?.({ chainId: mainnet.id }); + } + + try { + const res = await sendTransaction(this.config, { + account: (await this.getWalletAddress()) as `0x${string}`, + to: toAddress as `0x${string}`, + value: parseEther(amount.toString()), + chainId: mainnet.id, // require that transaction is on ETH mainnet + }); + + return { + hash: res, + status: 'success', + }; + } catch (error) { + console.error('Transaction failed', error); + throw error; + } + } +} diff --git a/src/services/wallets/WanderWalletConnector.ts b/src/services/wallets/WanderWalletConnector.ts new file mode 100644 index 000000000..b0e90c288 --- /dev/null +++ b/src/services/wallets/WanderWalletConnector.ts @@ -0,0 +1,140 @@ +import { TokenType } from '@ardrive/turbo-sdk'; +import { ArconnectSigner } from '@dha-team/arbundles'; +import { WalletNotInstalledError, WanderError } from '@src/utils/errors'; +import eventEmitter from '@src/utils/events'; + +import { WANDER_UNRESPONSIVE_ERROR } from '../../components/layout/Notifications/Notifications'; +import { ArNSWalletConnector, WALLET_TYPES } from '../../types'; +import { executeWithTimeout } from '../../utils'; +import { ArweaveTransactionID } from '../arweave/ArweaveTransactionID'; + +// Typed loosely (`string[]`) then cast at the `window.arweaveWallet` call sites: +// two copies of the `arconnect` types resolve in the tree (the root dep and the +// one nested under `arweave`), and `window.arweaveWallet` uses the nested one, +// so a shared `PermissionType[]` import from either produces a nominal clash. +export const WANDER_WALLET_PERMISSIONS: string[] = [ + 'ACCESS_ADDRESS', + 'ACCESS_ALL_ADDRESSES', + 'ACCESS_PUBLIC_KEY', + 'SIGN_TRANSACTION', + 'ACCESS_ARWEAVE_CONFIG', + 'SIGNATURE', +]; + +/** + * Arweave identity connector for the injected `window.arweaveWallet` + * (Wander / ArConnect). Restored from the pre-Solana-only multi-wallet build + * (commit `3f43b85`) to unlock the Model-A custodial credit-buy path. + * + * `turboSigner` is an `ArconnectSigner` (an arbundles `Signer`) so it can be + * handed straight to `TurboFactory.authenticated({ token: 'arweave', signer })` + * — that's what signs the ArNS purchase request nonce the bundler verifies. + */ +export class WanderWalletConnector implements ArNSWalletConnector { + tokenType: TokenType = 'arweave'; + private _wallet: Window['arweaveWallet']; + contractSigner: Window['arweaveWallet']; + turboSigner: ArconnectSigner; + constructor() { + this._wallet = window?.arweaveWallet; + this.contractSigner = window?.arweaveWallet; + // Build the turbo-sdk-compatible signer from the injected wallet. This is + // an arbundles `Signer` (not the raw `window.arweaveWallet`), which is what + // `TurboFactory.authenticated({ signer })` expects for `token: 'arweave'`. + this.turboSigner = new ArconnectSigner(window?.arweaveWallet); + } + + // The API has been shown to be unreliable, so we call each function with a timeout + async safeWanderApiExecutor(fn: () => T): Promise { + if (!this._wallet) + throw new WalletNotInstalledError('Wander is not installed.'); + /** + * This is here because occasionally wander injects but does not initialize internally properly, + * allowing the api to be called but then hanging. + * This is a workaround to check that and emit appropriate errors, + * and to trigger the workaround workflow of reloading the page and re-initializing wander. + */ + const res = await executeWithTimeout(() => fn(), 3000); + + if (res === 'timeout') { + throw new Error(WANDER_UNRESPONSIVE_ERROR); + } + return res as T; + } + + async connect(): Promise { + if (!window.arweaveWallet) { + window.open('https://wander.app/download'); + + return; + } + // confirm they have the extension installed + localStorage.setItem('walletType', WALLET_TYPES.WANDER); + const permissions = await this.safeWanderApiExecutor( + this._wallet?.getPermissions, + ); + if ( + permissions && + !WANDER_WALLET_PERMISSIONS.every((permission) => + (permissions as string[]).includes(permission), + ) + ) { + // disconnect due to missing permissions, then re-connect + await this.safeWanderApiExecutor(this._wallet?.disconnect); + } else if (permissions) { + return; + } + + await this._wallet + .connect( + WANDER_WALLET_PERMISSIONS as never, + { + name: 'ARNS - ar.io', + }, + // TODO: add arweave configs here + ) + .catch((err) => { + localStorage.removeItem('walletType'); + console.error(err); + throw new WanderError('User cancelled authentication.'); + }); + } + + async disconnect(): Promise { + localStorage.removeItem('walletType'); + return this.safeWanderApiExecutor(this._wallet?.disconnect); + } + + async getWalletAddress(): Promise { + return this.safeWanderApiExecutor(() => + this._wallet + ?.getActiveAddress() + .then((res) => new ArweaveTransactionID(res)), + ); + } + + async updatePermissions(): Promise { + // check we have the necessary permissions + const permissions = await this._wallet.getPermissions(); + if ( + permissions && + !WANDER_WALLET_PERMISSIONS.every((permission) => + (permissions as string[]).includes(permission), + ) + ) { + const missingPermissions = WANDER_WALLET_PERMISSIONS.filter( + (permission) => !(permissions as string[]).includes(permission), + ); + eventEmitter.emit( + 'error', + new Error( + `Missing permissions (${missingPermissions.join( + ', ', + )}), please re-authorize permissions.`, + ), + ); + await this.disconnect(); + await this.connect(); + } + } +} diff --git a/src/services/wallets/index.ts b/src/services/wallets/index.ts index 50c6c3d5b..a72e8ac26 100644 --- a/src/services/wallets/index.ts +++ b/src/services/wallets/index.ts @@ -1,3 +1,5 @@ +import { EthWalletConnector } from './EthWalletConnector'; import { SolanaWalletConnector } from './SolanaWalletConnector'; +import { WanderWalletConnector } from './WanderWalletConnector'; -export { SolanaWalletConnector }; +export { SolanaWalletConnector, WanderWalletConnector, EthWalletConnector }; diff --git a/src/state/actions/dispatchArNSPurchaseWithCredits.test.ts b/src/state/actions/dispatchArNSPurchaseWithCredits.test.ts new file mode 100644 index 000000000..5e46ed4c3 --- /dev/null +++ b/src/state/actions/dispatchArNSPurchaseWithCredits.test.ts @@ -0,0 +1,189 @@ +/** + * Money-safety tests for the credits ArNS purchase orchestrator. The heavy + * Solana / SDK deps are mocked; the real resume store (localStorage via jsdom) + * is exercised so we prove: + * - a client-spawned ANT is persisted BEFORE the buy, + * - a retry REUSES that ANT instead of spawning (and paying for) another, + * - a non-402 failure after spawn surfaces an honest "ANT created" message, + * - a 402 is re-thrown as InsufficientCreditsError so Checkout can route to + * Top-Up. + */ + +// Keep import.meta.env / SDK / Solana kit out of the unit test. +jest.mock('@src/utils/constants', () => ({ + devPaymentServiceFqdn: 'payment.ardrive.dev', + NETWORK_DEFAULTS: { + AO: { ARIO: {} }, + TURBO: { + UPLOAD_URL: 'https://turbo.ardrive.io', + PAYMENT_URL: 'http://localhost:4001', + GATEWAY_URL: 'https://turbo-gateway.com', + WALLETS_URL: 'http://localhost:4001/info', + }, + }, +})); +jest.mock('@ardrive/turbo-sdk', () => ({ + TurboFactory: { + unauthenticated: jest.fn(() => ({})), + authenticated: jest.fn(() => ({})), + }, + ARIOToTokenAmount: jest.fn(), + ARToTokenAmount: jest.fn(), + ETHToTokenAmount: jest.fn(), + POLToTokenAmount: jest.fn(), +})); +jest.mock('@permaweb/aoconnect', () => ({ connect: jest.fn(() => ({})) })); + +const spawnMock = jest.fn(async (_args: unknown) => ({ + processId: 'ANT-SPAWNED', +})); +jest.mock('@ar.io/sdk/web', () => ({ + ANT: { spawn: (args: unknown) => spawnMock(args) }, +})); +jest.mock('@src/utils/solana', () => ({ + getActiveSolanaConfig: () => ({ programIds: { antProgramId: 'prog' } }), + getSolanaRpc: () => ({}), + getSolanaRpcSubscriptions: () => ({}), +})); +jest.mock('@src/utils/transactionUtils/transactionUtils', () => ({ + createAntStateForOwner: () => ({}), +})); + +import { InsufficientCreditsError } from '@src/services/turbo/TurboArNSClient'; + +import { getPendingArNSPurchase } from '@src/services/turbo/arnsPurchaseResume'; +import dispatchArNSPurchaseWithCredits from './dispatchArNSPurchaseWithCredits'; + +const OWNER = 'owner-address-1'; + +function makeWallet() { + return { + tokenType: 'solana', + solanaSigner: { address: OWNER }, + solanaWallet: {}, + } as any; +} + +function makeArweaveWallet() { + return { + tokenType: 'arweave', + // Model A authenticates with a turbo signer; no solanaSigner. + turboSigner: { sign: jest.fn() }, + } as any; +} + +function makeArgs(executeArNSIntent: jest.Mock, wallet = makeWallet()) { + return { + turbo: { executeArNSIntent } as any, + workflowName: 'buyRecord' as any, + intent: 'Buy-Name' as any, + payload: { name: 'MyCoolName', type: 'lease', years: 1 }, + owner: { toString: () => OWNER } as any, + wallet, + dispatch: jest.fn(), + }; +} + +describe('dispatchArNSPurchaseWithCredits (money safety)', () => { + beforeEach(() => { + window.localStorage.clear(); + spawnMock.mockClear(); + }); + + it('spawns an ANT once and persists its processId before the buy', async () => { + const execute = jest.fn(async ({ processId, onStatus }: any) => { + onStatus?.({ phase: 'submitted', nonce: 'nonce-1' }); + return { nonce: 'nonce-1', messageId: 'tx-1', receipt: {}, processId }; + }); + + await dispatchArNSPurchaseWithCredits(makeArgs(execute)); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0].processId).toBe('ANT-SPAWNED'); + // Cleared on success. + expect(getPendingArNSPurchase()).toBeUndefined(); + }); + + it('REUSES the spawned ANT on retry instead of spawning again', async () => { + // Attempt 1: buy fails after the ANT is spawned. + const failing = jest.fn(async () => { + throw new Error('on-chain submit failed'); + }); + await expect( + dispatchArNSPurchaseWithCredits(makeArgs(failing)), + ).rejects.toThrow(/ANT for 'MyCoolName' was created/i); + + expect(spawnMock).toHaveBeenCalledTimes(1); + // The ANT is persisted (no nonce) so a retry can reuse it. + const pending = getPendingArNSPurchase(); + expect(pending?.processId).toBe('ANT-SPAWNED'); + expect(pending?.nonce).toBeUndefined(); + + // Attempt 2 (retry): succeeds, and must NOT spawn a second ANT. + const succeeding = jest.fn(async ({ processId }: any) => ({ + nonce: 'nonce-2', + messageId: 'tx-2', + receipt: {}, + processId, + })); + await dispatchArNSPurchaseWithCredits(makeArgs(succeeding)); + + expect(spawnMock).toHaveBeenCalledTimes(1); // still 1 — reused + expect(succeeding.mock.calls[0][0].processId).toBe('ANT-SPAWNED'); + expect(getPendingArNSPurchase()).toBeUndefined(); + }); + + it('re-throws a 402 as InsufficientCreditsError (routes to Top-Up), keeping the ANT', async () => { + const four02 = jest.fn(async () => { + throw new InsufficientCreditsError(); + }); + + await expect( + dispatchArNSPurchaseWithCredits(makeArgs(four02)), + ).rejects.toBeInstanceOf(InsufficientCreditsError); + + // ANT retained so topping up + retrying reuses it (no second spawn). + expect(getPendingArNSPurchase()?.processId).toBe('ANT-SPAWNED'); + }); + + // ---- Model A (custodial, Arweave identity) ---- + it('Model A (arweave): does NOT spawn an ANT and buys with processId omitted', async () => { + const execute = jest.fn(async ({ processId, onStatus }: any) => { + onStatus?.({ phase: 'submitted', nonce: 'nonce-a' }); + return { + nonce: 'nonce-a', + messageId: 'tx-custodial', + // Bundler reports the custodial ANT it provisioned. + receipt: { processId: 'ANT-CUSTODIAL' }, + processId, + }; + }); + + const interaction = await dispatchArNSPurchaseWithCredits( + makeArgs(execute, makeArweaveWallet()), + ); + + // No client-side ANT spawn for Model A. + expect(spawnMock).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(1); + const call = execute.mock.calls[0][0]; + expect(call.processId).toBeUndefined(); // omitted → bundler custodies + expect(call.tokenType).toBe('arweave'); + expect(call.signer).toBeDefined(); + // Interaction is flagged custodial and carries the bundler's ANT id. + expect(interaction.payload.custodial).toBe(true); + expect(interaction.payload.custodialAntId).toBe('ANT-CUSTODIAL'); + expect(interaction.processId).toBe('ANT-CUSTODIAL'); + expect(getPendingArNSPurchase()).toBeUndefined(); + }); + + it('Model A (arweave): throws a clear error when no turbo signer is present', async () => { + const execute = jest.fn(); + const wallet = { tokenType: 'arweave' } as any; // no turboSigner + await expect( + dispatchArNSPurchaseWithCredits(makeArgs(execute, wallet)), + ).rejects.toThrow(/arweave wallet is required/i); + expect(execute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/state/actions/dispatchArNSPurchaseWithCredits.ts b/src/state/actions/dispatchArNSPurchaseWithCredits.ts new file mode 100644 index 000000000..e24df7126 --- /dev/null +++ b/src/state/actions/dispatchArNSPurchaseWithCredits.ts @@ -0,0 +1,291 @@ +import { ANT } from '@ar.io/sdk/web'; +import { + ArNSSettlementStatus, + InsufficientCreditsError, + TurboArNSClient, + TurboArNSIntent, +} from '@src/services/turbo/TurboArNSClient'; +import { + clearPendingArNSPurchase, + getPendingArNSPurchase, + savePendingArNSPurchase, +} from '@src/services/turbo/arnsPurchaseResume'; +import { resolveCustodyStrategy } from '@src/services/turbo/custodyStrategy'; +import { TransactionAction } from '@src/state/reducers/TransactionReducer'; +import { + ARNS_INTERACTION_TYPES, + AoAddress, + ArNSWalletConnector, + ContractInteraction, +} from '@src/types'; +import { lowerCaseDomain } from '@src/utils'; +import { + getActiveSolanaConfig, + getSolanaRpc, + getSolanaRpcSubscriptions, +} from '@src/utils/solana'; +import { createAntStateForOwner } from '@src/utils/transactionUtils/transactionUtils'; +import { Dispatch } from 'react'; + +/** + * Settle an ArNS purchase (buy / extend / increase-undernames / upgrade) by + * debiting the connected wallet's **Turbo Credits** through the bundler + * payment-service, then poll to a terminal state and drive the transaction + * state's `interactionResult`. + * + * This is the credits counterpart to `dispatchArIOInteraction` (which pays ARIO + * from the wallet). It intentionally does NOT call + * `@ar.io/sdk buyRecord({ fundFrom: 'turbo' })` — that alias never debits + * credits. Credit debit + on-chain write happen server-side; here we spawn the + * user-owned ANT (Model B) and pass its `processId` to the bundler. + */ +export default async function dispatchArNSPurchaseWithCredits({ + turbo, + workflowName, + intent, + payload, + owner, + wallet, + paidBy, + dispatch, +}: { + turbo: TurboArNSClient; + workflowName: ARNS_INTERACTION_TYPES; + intent: TurboArNSIntent; + payload: Record; + owner: AoAddress; + wallet?: ArNSWalletConnector; + paidBy?: string[]; + dispatch: Dispatch; +}): Promise { + const name: string = payload.name; + const lowered = lowerCaseDomain(name); + + if (!wallet) { + throw new Error( + 'A connected wallet is required to pay with Turbo Credits.', + ); + } + + const strategy = resolveCustodyStrategy(wallet.tokenType); + + // Per-model identity readiness check. + if (strategy.model === 'B-user-owned') { + // Model B (Solana) requires a connected wallet + signer to spawn the ANT + // and sign the request nonce the bundler verifies. + if (wallet.tokenType !== 'solana' || !wallet.solanaSigner) { + throw new Error( + 'A connected Solana wallet with a signer is required to pay with Turbo Credits.', + ); + } + } else { + // Model A (Arweave / Ethereum — custodial). The bundler custodies the ANT; + // we only need a turbo signer to authenticate the credit-debited request. + if (!wallet.turboSigner) { + throw new Error( + `A connected ${wallet.tokenType} wallet is required to pay with Turbo Credits.`, + ); + } + } + + // Solana wallet adapter — only used for the Model B authed client. Undefined + // (and unused) for Model A. + const walletAdapter = + strategy.model === 'B-user-owned' + ? ((typeof window !== 'undefined' ? (window as any).solana : undefined) ?? + (wallet as any).solanaWallet) + : undefined; + + const onStatus = (status: ArNSSettlementStatus) => { + switch (status.phase) { + case 'submitting': + dispatch({ + type: 'setSigningMessage', + payload: `Paying with Turbo Credits for '${name}'`, + }); + break; + case 'resumed': + case 'submitted': + dispatch({ + type: 'setSigningMessage', + payload: `Confirming purchase of '${name}' on-chain`, + }); + break; + case 'polling': + dispatch({ + type: 'setSigningMessage', + payload: `Waiting for '${name}' to settle on-chain`, + }); + break; + case 'success': + dispatch({ + type: 'setSigningMessage', + payload: `Successfully purchased '${name}'`, + }); + break; + } + }; + + // A prior attempt for this same name/owner/intent that already paid the + // costly, non-repeatable steps (spawned an ANT and/or submitted a nonce). + // Reusing it is what makes a retry debit-safe AND SOL-safe. + const pending = getPendingArNSPurchase(); + const matchedPending = + pending && + pending.owner === owner.toString() && + lowerCaseDomain(pending.name) === lowered && + pending.intent === intent + ? pending + : undefined; + + // Model B: the ANT the name will resolve to. Prefer an ANT already spawned + // for this purchase (supplied on the payload, or persisted by a previous + // attempt) — spawning is real SOL, so we must NEVER spawn a second one on a + // retry. Hoisted above `try` so the failure handler can reuse it. Only spawn + // (below) when none exists yet. + let processId: string | undefined = + payload.processId ?? matchedPending?.processId; + + try { + dispatch({ type: 'setSigning', payload: true }); + + // Resume a purchase already submitted for this name (e.g. after a reload): + // poll the existing nonce instead of re-submitting (no double debit). + const resumeNonce = matchedPending?.nonce; + + if ( + !resumeNonce && + strategy.requiresClientAntSpawn && + intent === 'Buy-Name' && + !processId + ) { + dispatch({ + type: 'setSigningMessage', + payload: `Spawning new ANT for new ArNS name '${name}'`, + }); + const { programIds } = getActiveSolanaConfig(); + const spawnResult = await ANT.spawn({ + rpc: getSolanaRpc(), + rpcSubscriptions: getSolanaRpcSubscriptions(), + // Guaranteed defined here: this block only runs for Model B (Solana), + // whose readiness check above asserts a `solanaSigner`. + signer: wallet.solanaSigner!, + antProgramId: programIds.antProgramId, + state: { + ...createAntStateForOwner(owner.toString(), payload.targetId), + name, + }, + }); + processId = spawnResult.processId; + payload.processId = processId; + // Persist the spawned ANT BEFORE submitting the buy. If the buy then + // fails (or the tab closes), a retry reuses this ANT instead of spawning + // — and burning — another. No nonce yet: this is a spawn-only record. + savePendingArNSPurchase({ + processId, + intent, + name, + owner: owner.toString(), + savedAt: Date.now(), + }); + } + + const result = await turbo.executeArNSIntent({ + intent, + name: lowered, + type: payload.type, + years: payload.years, + increaseQty: payload.qty, + // Model B passes the client-spawned ANT; Model A leaves it undefined so + // the bundler custodially provisions one. + processId, + paidBy, + walletAdapter, + tokenType: wallet.tokenType, + // Model A (Arweave / ETH) authenticates with the wallet's turbo signer. + signer: wallet.turboSigner, + resumeNonce, + onStatus: (status) => { + // Persist the nonce the instant it exists so a reload can resume. + // Keep the spawned ANT's processId alongside it so a resumed record + // still knows which ANT the (Buy) purchase belongs to. + if ( + (status.phase === 'submitted' || status.phase === 'resumed') && + status.nonce + ) { + savePendingArNSPurchase({ + nonce: status.nonce, + processId, + intent, + name, + owner: owner.toString(), + savedAt: Date.now(), + }); + } + onStatus(status); + }, + }); + + clearPendingArNSPurchase(); + + // Model A: the ANT is provisioned server-side, so its processId isn't known + // client-side until the bundler reports it on the settlement receipt. Prefer + // the client-spawned id (Model B), then the receipt's custodial ANT id. + const custodialAntId = + (result.receipt?.processId as string | undefined) ?? + (result.receipt?.antProcessId as string | undefined) ?? + (result.receipt?.antId as string | undefined); + const resolvedProcessId = processId ?? custodialAntId; + + const interaction: ContractInteraction = { + deployer: owner.toString(), + processId: (resolvedProcessId ?? '').toString(), + id: result.messageId, + type: 'interaction', + payload: { + ...payload, + custodial: strategy.model === 'A-custodial', + ...(custodialAntId ? { custodialAntId } : {}), + }, + }; + + dispatch({ type: 'setWorkflowName', payload: workflowName }); + dispatch({ type: 'setInteractionResult', payload: interaction }); + return interaction; + } catch (error) { + // A buy can fail AFTER the ANT was already spawned (paid SOL). Keep the + // ANT persisted (drop any nonce) so the next attempt REUSES it — no second + // spawn, no SOL bleed, and (since credits are debited server-side against + // the idempotency nonce) no charge for a purchase that never completed. + if (processId && intent === 'Buy-Name') { + savePendingArNSPurchase({ + processId, + intent, + name, + owner: owner.toString(), + savedAt: Date.now(), + }); + } + + // Route a 402 to the caller unchanged so it can open Top-Up. + if (error instanceof InsufficientCreditsError) { + throw error; + } + + // Otherwise, if we already spawned/hold an ANT, be honest: the name's ANT + // exists but the purchase didn't complete; retrying reuses it and won't + // re-charge / re-spawn. + if (processId && intent === 'Buy-Name') { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Your ANT for '${name}' was created, but the purchase didn't complete. ` + + 'Your credits were not charged. Retrying will reuse the same ANT ' + + `(no extra SOL). (${detail})`, + ); + } + + throw error; + } finally { + dispatch({ type: 'setSigning', payload: false }); + } +} diff --git a/src/state/actions/dispatchCustodialANTRecordInteraction.test.ts b/src/state/actions/dispatchCustodialANTRecordInteraction.test.ts new file mode 100644 index 000000000..ec43e551e --- /dev/null +++ b/src/state/actions/dispatchCustodialANTRecordInteraction.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the custodial (Model A) credit-paid record manager. Proves each + * manage workflow routes to the correct `TurboArNSClient` method with the + * connected credit-identity signer, and that owner-only ops are rejected — so a + * Model A user's target/undername edits go through credits (never a wallet + * interaction they can't sign, since Turbo owns the ANT). + */ + +// Keep import.meta.env / SDK / Solana kit out of the unit test. +jest.mock('@src/utils/constants', () => ({ + devPaymentServiceFqdn: 'payment.ardrive.dev', + NETWORK_DEFAULTS: { + AO: { ARIO: {} }, + TURBO: { + UPLOAD_URL: 'https://turbo.ardrive.io', + PAYMENT_URL: 'http://localhost:4001', + GATEWAY_URL: 'https://turbo-gateway.com', + WALLETS_URL: 'http://localhost:4001/info', + }, + }, +})); +jest.mock('@ardrive/turbo-sdk', () => ({ + TurboFactory: { + unauthenticated: jest.fn(() => ({})), + authenticated: jest.fn(() => ({})), + }, + ARIOToTokenAmount: jest.fn(), + ARToTokenAmount: jest.fn(), + ETHToTokenAmount: jest.fn(), + POLToTokenAmount: jest.fn(), +})); +jest.mock('@permaweb/aoconnect', () => ({ connect: jest.fn(() => ({})) })); + +import { ANT_INTERACTION_TYPES } from '@src/types'; + +import dispatchCustodialANTRecordInteraction from './dispatchCustodialANTRecordInteraction'; + +const OWNER = '7gI4LqBxQSyTRu5e2Zfgyw2UEMgsUsxsoW2KajneFC8'; +const ANT_ID = 'ANT-custodial-1'; +const TX_ID = 'abcdefghijklmnopqrstuvwxyz0123456789-_ABCDE'; + +function makeArweaveWallet() { + return { tokenType: 'arweave', turboSigner: { sign: jest.fn() } } as any; +} + +function makeTurbo() { + return { + setCustodialArNSRecord: jest.fn(async () => ({ + antId: ANT_ID, + undername: '@', + transactionId: TX_ID, + ttlSeconds: 900, + messageId: 'tx-set', + })), + removeCustodialArNSRecord: jest.fn(async () => ({ + antId: ANT_ID, + undername: 'blog', + messageId: 'tx-remove', + })), + } as any; +} + +function baseArgs(overrides: Record = {}) { + return { + turbo: makeTurbo(), + wallet: makeArweaveWallet(), + antId: ANT_ID, + payload: {}, + owner: OWNER, + dispatchTransactionState: jest.fn(), + ...overrides, + }; +} + +describe('dispatchCustodialANTRecordInteraction', () => { + it('routes SET_TARGET_ID to setCustodialArNSRecord on the apex @ record', async () => { + const args = baseArgs({ + workflowName: ANT_INTERACTION_TYPES.SET_TARGET_ID, + payload: { transactionId: TX_ID, ttlSeconds: 900 }, + }); + + const res = await dispatchCustodialANTRecordInteraction(args as any); + + expect(args.turbo.setCustodialArNSRecord).toHaveBeenCalledWith( + expect.objectContaining({ + antId: ANT_ID, + undername: '@', + transactionId: TX_ID, + ttlSeconds: 900, + tokenType: 'arweave', + signer: args.wallet.turboSigner, + }), + ); + expect(res.id).toBe('tx-set'); + expect(res.payload.custodial).toBe(true); + // Records the final interaction so the manage UI refreshes. + expect(args.dispatchTransactionState).toHaveBeenCalledWith( + expect.objectContaining({ type: 'setInteractionResult' }), + ); + }); + + it('routes SET_RECORD to setCustodialArNSRecord with the (lowercased) undername', async () => { + const args = baseArgs({ + workflowName: ANT_INTERACTION_TYPES.SET_RECORD, + payload: { subDomain: 'BLOG', transactionId: TX_ID, ttlSeconds: 3600 }, + }); + + await dispatchCustodialANTRecordInteraction(args as any); + + expect(args.turbo.setCustodialArNSRecord).toHaveBeenCalledWith( + expect.objectContaining({ + antId: ANT_ID, + undername: 'blog', + transactionId: TX_ID, + ttlSeconds: 3600, + }), + ); + }); + + it('routes REMOVE_RECORD to removeCustodialArNSRecord', async () => { + const args = baseArgs({ + workflowName: ANT_INTERACTION_TYPES.REMOVE_RECORD, + payload: { subDomain: 'blog' }, + }); + + const res = await dispatchCustodialANTRecordInteraction(args as any); + + expect(args.turbo.removeCustodialArNSRecord).toHaveBeenCalledWith( + expect.objectContaining({ antId: ANT_ID, undername: 'blog' }), + ); + expect(res.id).toBe('tx-remove'); + }); + + it('rejects a Model A wallet with no turbo signer', async () => { + const args = baseArgs({ + wallet: { tokenType: 'arweave' } as any, + workflowName: ANT_INTERACTION_TYPES.SET_TARGET_ID, + payload: { transactionId: TX_ID, ttlSeconds: 900 }, + }); + + await expect( + dispatchCustodialANTRecordInteraction(args as any), + ).rejects.toThrow(/required to manage this name with credits/i); + }); + + it('rejects an unsupported (owner-only) workflow', async () => { + const args = baseArgs({ + workflowName: ANT_INTERACTION_TYPES.TRANSFER, + payload: { target: 'x' }, + }); + + await expect( + dispatchCustodialANTRecordInteraction(args as any), + ).rejects.toThrow(/Unsupported custodial record interaction/i); + }); +}); diff --git a/src/state/actions/dispatchCustodialANTRecordInteraction.ts b/src/state/actions/dispatchCustodialANTRecordInteraction.ts new file mode 100644 index 000000000..8bbcbe2aa --- /dev/null +++ b/src/state/actions/dispatchCustodialANTRecordInteraction.ts @@ -0,0 +1,153 @@ +import { + CustodialANTNotFoundError, + CustodyTransferUnauthorizedError, + TurboArNSClient, +} from '@src/services/turbo/TurboArNSClient'; +import { TransactionAction } from '@src/state/reducers/TransactionReducer'; +import { + ANT_INTERACTION_TYPES, + ArNSWalletConnector, + ContractInteraction, +} from '@src/types'; +import { lowerCaseDomain } from '@src/utils'; +import { Dispatch } from 'react'; + +/** + * Manage a **custodially-held** (Model A) ArNS name's records by debiting the + * connected identity's Turbo Credits, instead of the wallet-signed + * `dispatchANTInteraction` path (which can't work: for a custodial name Turbo + * owns the ANT, not the user). + * + * Routes the credit-paid custody endpoints via `TurboArNSClient`: + * - `SET_TARGET_ID` / `SET_TTL_SECONDS` → set the apex `@` record + * - `SET_RECORD` / `EDIT_RECORD` → set an undername record + * - `REMOVE_RECORD` → remove an undername record + * + * Owner-only operations (transfer, controllers, ticker, name, logo, …) are NOT + * available for a custodial name — the user first claims the ANT out of custody + * (the claim/exit flow), then manages it wallet-signed as a Model B name. + */ +export default async function dispatchCustodialANTRecordInteraction({ + turbo, + wallet, + antId, + workflowName, + payload, + owner, + dispatchTransactionState, + stepCallback, +}: { + turbo: TurboArNSClient; + /** Connected credit-identity wallet (Arweave / Ethereum / — Model A). */ + wallet?: ArNSWalletConnector; + /** Custodial ANT id (Solana Metaplex Core asset) whose records to manage. */ + antId: string; + workflowName: ANT_INTERACTION_TYPES; + payload: Record; + owner: string; + dispatchTransactionState: Dispatch; + stepCallback?: (step?: string) => void; +}): Promise { + if (!wallet) { + throw new Error('A connected wallet is required to manage this name.'); + } + if (!antId) { + throw new Error('This name has no known ANT id, so it cannot be managed.'); + } + // Model A identities authenticate with the wallet's turbo signer; a Solana + // identity (defensive — custodial names are non-Solana) would use its adapter. + const isSolana = wallet.tokenType === 'solana'; + if (!isSolana && !wallet.turboSigner) { + throw new Error( + `A connected ${wallet.tokenType} wallet is required to manage this name with credits.`, + ); + } + const walletAdapter = isSolana + ? ((typeof window !== 'undefined' ? (window as any).solana : undefined) ?? + (wallet as any).solanaWallet) + : undefined; + + const signerParams = { + tokenType: wallet.tokenType, + signer: wallet.turboSigner, + walletAdapter, + }; + + const setSigning = (message?: string) => { + dispatchTransactionState({ type: 'setSigningMessage', payload: message }); + stepCallback?.(message); + }; + + let result: { messageId: string }; + try { + dispatchTransactionState({ type: 'setSigning', payload: true }); + + switch (workflowName) { + case ANT_INTERACTION_TYPES.SET_TARGET_ID: + case ANT_INTERACTION_TYPES.SET_TTL_SECONDS: { + setSigning('Paying with Turbo Credits to update the target record…'); + result = await turbo.setCustodialArNSRecord({ + antId, + undername: '@', + transactionId: payload.transactionId, + ttlSeconds: payload.ttlSeconds, + ...signerParams, + }); + break; + } + case ANT_INTERACTION_TYPES.SET_RECORD: + case ANT_INTERACTION_TYPES.EDIT_RECORD: { + setSigning('Paying with Turbo Credits to set the undername…'); + result = await turbo.setCustodialArNSRecord({ + antId, + undername: lowerCaseDomain(payload.subDomain), + transactionId: payload.transactionId, + ttlSeconds: payload.ttlSeconds, + ...signerParams, + }); + break; + } + case ANT_INTERACTION_TYPES.REMOVE_RECORD: { + setSigning('Paying with Turbo Credits to remove the undername…'); + result = await turbo.removeCustodialArNSRecord({ + antId, + undername: lowerCaseDomain(payload.subDomain), + ...signerParams, + }); + break; + } + default: + throw new Error( + `Unsupported custodial record interaction: ${workflowName}`, + ); + } + } catch (error) { + // Surface the typed custody errors with safe, non-leaky copy; never echo + // another owner's name or raw chain internals. + if ( + error instanceof CustodialANTNotFoundError || + error instanceof CustodyTransferUnauthorizedError + ) { + throw error; + } + throw error instanceof Error ? error : new Error(String(error)); + } finally { + setSigning(undefined); + dispatchTransactionState({ type: 'setSigning', payload: false }); + } + + const interaction: ContractInteraction = { + deployer: owner, + processId: antId, + id: result.messageId, + type: 'interaction', + payload: { ...payload, custodial: true, custodialAntId: antId }, + }; + + dispatchTransactionState({ type: 'setWorkflowName', payload: workflowName }); + dispatchTransactionState({ + type: 'setInteractionResult', + payload: interaction, + }); + return interaction; +} diff --git a/src/state/contexts/WalletState.tsx b/src/state/contexts/WalletState.tsx index 9af6e212d..91a72609d 100644 --- a/src/state/contexts/WalletState.tsx +++ b/src/state/contexts/WalletState.tsx @@ -1,6 +1,10 @@ import { ARIO } from '@ar.io/sdk/web'; import { useWallet } from '@solana/wallet-adapter-react'; -import { SolanaWalletConnector } from '@src/services/wallets'; +import { + EthWalletConnector, + SolanaWalletConnector, + WanderWalletConnector, +} from '@src/services/wallets'; import { getSolanaRpc, getSolanaRpcSubscriptions } from '@src/utils/solana'; import React, { Dispatch, @@ -10,6 +14,7 @@ import React, { useReducer, useRef, } from 'react'; +import { useAccount, useConfig } from 'wagmi'; import { useEffectOnce } from '../../hooks/useEffectOnce/useEffectOnce'; import { AoAddress, ArNSWalletConnector, WALLET_TYPES } from '../../types'; @@ -60,6 +65,12 @@ export function WalletStateProvider({ const { walletAddress, wallet } = state; + // Ethereum (wagmi) session — used to rehydrate an `EthWalletConnector` when + // the user previously connected an EVM wallet (Model A). Safe to call now + // that `main.tsx` mounts the `WagmiProvider` again. + const wagmiConfig = useConfig(); + const ethAccount = useAccount(); + useEffect(() => { if (!walletAddress) { wallet?.disconnect(); @@ -76,12 +87,13 @@ export function WalletStateProvider({ if (solanaConfig.programIds.antProgramId) programIds.antProgramId = solanaConfig.programIds.antProgramId; - const signer = wallet?.solanaSigner; - console.debug('[WalletState] init Solana ARIO', { - hasSigner: !!signer, - walletAddress, - network: solanaConfig.network, - }); + // Only the Solana identity (Model B — user-owned ANT) contributes a signer + // to the on-chain ARIO client. For Arweave / Ethereum (Model A) the bundler + // custodies the ANT and settles the buy server-side, so we intentionally do + // NOT wire their signer into `ARIO.init`. A read-only Solana ARIO client is + // still built so name resolution / lookups keep working for every identity. + const signer = + wallet?.tokenType === 'solana' ? wallet?.solanaSigner : undefined; const contract = signer ? ARIO.init({ rpc: getSolanaRpc(), @@ -121,6 +133,8 @@ export function WalletStateProvider({ // override the active address without disconnecting the wallet, and an // address-based gate would refire this effect and clobber the override // back to the adapter's publicKey on the next render. + // - We also bail when a non-Solana (Arweave / ETH) wallet is already + // connected so the Solana adapter's autoConnect can't clobber it. const solanaWallet = useWallet(); const wiredPublicKeyRef = useRef(undefined); useEffect(() => { @@ -128,6 +142,11 @@ export function WalletStateProvider({ wiredPublicKeyRef.current = undefined; return; } + // Don't override an active non-Solana identity with the Solana adapter's + // rehydrated session. + if (wallet && wallet.tokenType !== 'solana') { + return; + } const addr = solanaWallet.publicKey.toBase58(); if (wallet?.tokenType === 'solana' && wiredPublicKeyRef.current === addr) { return; @@ -142,10 +161,6 @@ export function WalletStateProvider({ signTransaction: solanaWallet.signTransaction as never, }); localStorage.setItem('walletType', WALLET_TYPES.SOLANA); - console.info( - '[WalletState] auto-reconnect SolanaWalletConnector for', - addr, - ); dispatchWalletState({ type: 'setWalletAndAddress', payload: { @@ -165,8 +180,99 @@ export function WalletStateProvider({ wallet, ]); + // Rehydrate an Arweave (Wander / injected `window.arweaveWallet`) identity. + // The extension fires `arweaveWalletLoaded` once injected; we also try on + // mount for the case where it injected before React hydrated. + useEffect(() => { + window.addEventListener('arweaveWalletLoaded', reconnectArweaveIfSelected); + return () => { + window.removeEventListener( + 'arweaveWalletLoaded', + reconnectArweaveIfSelected, + ); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function reconnectArweaveIfSelected() { + const walletType = window.localStorage.getItem('walletType'); + if (walletType !== WALLET_TYPES.WANDER) return; + try { + const connector = new WanderWalletConnector(); + const address = await connector.getWalletAddress(); + await connector.updatePermissions(); + dispatchWalletState({ + type: 'setWalletAndAddress', + payload: { + wallet: connector, + walletAddress: address, + }, + }); + } catch (error) { + eventEmitter.emit('error', error); + } + } + + // Rehydrate an Ethereum identity once wagmi restores the session (mirrors the + // `arweaveWalletLoaded` behaviour for Arweave). Only when the user last chose + // ETH and no other wallet is already wired. + useEffect(() => { + const walletType = window.localStorage.getItem('walletType'); + if ( + walletType === WALLET_TYPES.ETHEREUM && + ethAccount.isConnected && + ethAccount.address && + ethAccount.connector && + (!wallet || wallet.tokenType === 'ethereum') && + ethAccount.address !== walletAddress + ) { + try { + const connector = new EthWalletConnector( + wagmiConfig, + ethAccount.connector, + ); + dispatchWalletState({ + type: 'setWalletAndAddress', + payload: { + wallet: connector, + walletAddress: ethAccount.address as never, + }, + }); + } catch (error) { + eventEmitter.emit('error', error); + } + } + }, [ + ethAccount.isConnected, + ethAccount.address, + ethAccount.connector, + wallet, + walletAddress, + wagmiConfig, + ]); + + // Handle external Ethereum wallet disconnection (user disconnects from the + // extension) so app state doesn't retain a stale ETH identity. + useEffect(() => { + if ( + !ethAccount.isConnected && + wallet instanceof EthWalletConnector && + walletAddress + ) { + localStorage.removeItem('walletType'); + dispatchWalletState({ + type: 'setWalletAndAddress', + payload: { + wallet: undefined, + walletAddress: undefined, + }, + }); + } + }, [ethAccount.isConnected, wallet, walletAddress]); + useEffect(() => { updateIfConnected(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffectOnce(() => { @@ -185,9 +291,12 @@ export function WalletStateProvider({ async function updateIfConnected() { // Solana wallet rehydration is driven by `` - // and the `ConnectWalletModal` picker effect — there's nothing to do - // here. We simply flip the `walletStateInitialized` flag so the rest + // and the `ConnectWalletModal` picker effect. Arweave/ETH rehydration is + // driven by the effects above. Here we additionally attempt an eager + // Arweave reconnect (covers the case where the extension injected before + // this component mounted), then flip `walletStateInitialized` so the rest // of the app stops waiting on us. + await reconnectArweaveIfSelected(); dispatchWalletState({ type: 'setWalletStateInitialized', }); diff --git a/src/types.ts b/src/types.ts index 653c04d21..278a8db9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,13 @@ export interface ArNSWalletConnector { export enum WALLET_TYPES { SOLANA = 'Solana', + // Restored for the Model-A (custodial credit-buy) multi-wallet path. Arweave + // identities (Wander / injected `window.arweaveWallet`) and Ethereum wallets + // pay with Turbo Credits while the bundler custodies the ANT. `SOLANA` remains + // the Model-B (user-owned ANT) path. + WANDER = 'Wander', + ARWEAVE = 'Arweave', + ETHEREUM = 'Ethereum', } export interface KVCache { diff --git a/src/utils/checkInsufficientSolForGas.test.ts b/src/utils/checkInsufficientSolForGas.test.ts new file mode 100644 index 000000000..9e9a0317d --- /dev/null +++ b/src/utils/checkInsufficientSolForGas.test.ts @@ -0,0 +1,81 @@ +import { checkInsufficientSolForGas } from './checkInsufficientSolForGas'; + +describe('checkInsufficientSolForGas', () => { + const gas = 20_000_000; // ~0.02 SOL in lamports + + it('never blocks the card (fiat) path', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'card', + isBaseTokenSelected: false, + gasEstimateTotalLamports: gas, + solBalanceLamports: 0, + }), + ).toBe(false); + }); + + it('never blocks a Base-token crypto top-up (gas paid on the EVM side)', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'crypto', + isBaseTokenSelected: true, + gasEstimateTotalLamports: gas, + solBalanceLamports: 0, + }), + ).toBe(false); + }); + + it('blocks the crypto (ARIO) path when SOL < gas', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'crypto', + isBaseTokenSelected: false, + gasEstimateTotalLamports: gas, + solBalanceLamports: gas - 1, + }), + ).toBe(true); + }); + + // The core money-safety fix: credits still need SOL for the client-side ANT + // spawn, so the same gate must apply to the credits path. + it('blocks the CREDITS path when the wallet has credits but < gas SOL', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'credits', + isBaseTokenSelected: false, + gasEstimateTotalLamports: gas, + solBalanceLamports: 0, + }), + ).toBe(true); + }); + + it('allows the credits path when SOL covers the gas', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'credits', + isBaseTokenSelected: false, + gasEstimateTotalLamports: gas, + solBalanceLamports: gas, + }), + ).toBe(false); + }); + + it('does not block while inputs are still loading (missing gas or balance)', () => { + expect( + checkInsufficientSolForGas({ + paymentMethod: 'credits', + isBaseTokenSelected: false, + gasEstimateTotalLamports: undefined, + solBalanceLamports: 0, + }), + ).toBe(false); + expect( + checkInsufficientSolForGas({ + paymentMethod: 'credits', + isBaseTokenSelected: false, + gasEstimateTotalLamports: gas, + solBalanceLamports: undefined, + }), + ).toBe(false); + }); +}); diff --git a/src/utils/checkInsufficientSolForGas.ts b/src/utils/checkInsufficientSolForGas.ts new file mode 100644 index 000000000..c24148a0c --- /dev/null +++ b/src/utils/checkInsufficientSolForGas.ts @@ -0,0 +1,40 @@ +import type { PaymentMethod } from '@src/components/forms/PaymentOptionsForm/PaymentOptionsForm'; + +/** + * Pure predicate for the "not enough native SOL to cover the on-chain cost" + * pay-button gate, extracted so it can be unit-tested without rendering the + * Checkout tree. + * + * On Solana every ArNS intent costs SOL (transaction fee + rent for accounts + * the intent creates — Buy-Name spawns an ANT). Crucially this is true on the + * **credits** path too: the ANT is spawned client-side (~0.02 SOL) *before* the + * credit-funded buy, so a wallet flush with credits but empty of SOL still + * can't complete. The gate therefore applies to both `crypto` (ARIO) and + * `credits`. It does NOT apply to: + * - `card` (fiat) — no Solana leg here, and + * - Base-token crypto top-ups — gas is paid on the EVM side. + * + * Returns `false` (do not block) while inputs are still loading, so the UI + * never blocks on missing data. + */ +export function checkInsufficientSolForGas({ + paymentMethod, + isBaseTokenSelected, + gasEstimateTotalLamports, + solBalanceLamports, +}: { + paymentMethod: PaymentMethod; + isBaseTokenSelected: boolean; + gasEstimateTotalLamports?: number; + solBalanceLamports?: number; +}): boolean { + if (paymentMethod === 'card') return false; + if (paymentMethod === 'crypto' && isBaseTokenSelected) return false; + if ( + gasEstimateTotalLamports === undefined || + solBalanceLamports === undefined + ) { + return false; + } + return solBalanceLamports < gasEstimateTotalLamports; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 2be80a717..ce8b9604d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -124,6 +124,29 @@ export const PAYMENT_SERVICE_FQDN = ? prodPaymentServiceFqdn : devPaymentServiceFqdn; +// Full payment-service origin used by the Turbo clients. In dev/default we +// point at the locally-running ar-io-bundler payment-service (:4001), which is +// the source of truth for ArNS purchases paid with Turbo Credits +// (`POST /v1/arns/purchase/...`). Prod keeps the hosted ArDrive payment +// service. Overridable at runtime via Settings → NetworkSettings. +export const devPaymentServiceUrl = + import.meta.env.VITE_PAYMENT_SERVICE_URL || 'http://localhost:4001'; +export const prodPaymentServiceUrl = `https://${prodPaymentServiceFqdn}`; + +export const PAYMENT_SERVICE_URL = + import.meta.env.VITE_NODE_ENV === 'production' + ? prodPaymentServiceUrl + : devPaymentServiceUrl; + +// The Turbo upload service + gateway are likewise pointable at any bundler +// (e.g. a self-hosted ar-io-bundler such as upload.services.perma.online) via +// env, defaulting to the hosted ArDrive services. Overridable at runtime via +// Settings → NetworkSettings. +export const UPLOAD_SERVICE_URL = + import.meta.env.VITE_UPLOAD_SERVICE_URL || 'https://turbo.ardrive.io'; +export const TURBO_GATEWAY_URL = + import.meta.env.VITE_GATEWAY_URL || 'https://turbo-gateway.com'; + // PUBLISHABLE KEYS export const devStripePublishableKey = 'pk_test_51JUAtwC8apPOWkDLh2FPZkQkiKZEkTo6wqgLCtQoClL6S4l2jlbbc5MgOdwOUdU9Tn93NNvqAGbu115lkJChMikG00XUfTmo2z'; @@ -167,10 +190,10 @@ export const NETWORK_DEFAULTS = { HOST: 'turbo-gateway.com', }, TURBO: { - UPLOAD_URL: 'https://turbo.ardrive.io', - PAYMENT_URL: `https://${PAYMENT_SERVICE_FQDN}`, - GATEWAY_URL: 'https://turbo-gateway.com', - WALLETS_URL: `https://${PAYMENT_SERVICE_FQDN}/info`, + UPLOAD_URL: UPLOAD_SERVICE_URL, + PAYMENT_URL: PAYMENT_SERVICE_URL, + GATEWAY_URL: TURBO_GATEWAY_URL, + WALLETS_URL: `${PAYMENT_SERVICE_URL}/info`, STRIPE_PUBLISHABLE_KEY, }, }; diff --git a/vite.config.ts b/vite.config.ts index 899e4ad8b..d1ea8631f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import path from 'path'; import { sentryVitePlugin } from '@sentry/vite-plugin'; import react from '@vitejs/plugin-react'; @@ -5,6 +6,81 @@ import { defineConfig } from 'vite'; import nodePolyfills from 'vite-plugin-node-stdlib-browser'; import svgr from 'vite-plugin-svgr'; +/** + * Dependency-blocker fix (Turbo-ArNS integration). + * + * `@ardrive/turbo-sdk` → `@solana/spl-token` pulls in + * `@solana/spl-token-metadata@0.1.6`, which imports `getDataEnumCodec` from + * `@solana/codecs@2.0.0-rc.1`. This repo's root `resolutions` force the whole + * `@solana/*` codecs family to `6.8.0`, where that helper was renamed + * `getDataEnumCodec` → `getDiscriminatedUnionCodec` (a pure rename, same + * signature). With the broad `"@solana/codecs": "6.8.0"` pin present, Yarn 1 + * classic collapses any scoped/nested override back onto `6.8.0`, so the two + * majors cannot coexist as nested deps here — leaving Rollup to fail the build + * with: `"getDataEnumCodec" is not exported by @solana/codecs`. + * + * This plugin rewrites that single renamed identifier ONLY inside + * `@solana/spl-token-metadata`, so the module binds against the installed + * 6.8.0 codecs. Scoped by module id, it never touches the app's or any other + * package's `@solana/codecs` usage. + * + * Follow-up (not done here): the turbo-sdk should either widen its Solana + * codecs peer range / align `@solana/spl-token` to a codecs-6.x-compatible + * release, or vendor an spl-token build that doesn't drag in the rc.1 codecs — + * which would remove the need for this shim in every consumer. + */ +function patchSplTokenMetadataCodecs() { + return { + name: 'patch-spl-token-metadata-codecs', + enforce: 'pre' as const, + transform(code: string, id: string) { + if ( + id.includes('@solana/spl-token-metadata') && + code.includes('getDataEnumCodec') + ) { + return { + code: code.replace(/getDataEnumCodec/g, 'getDiscriminatedUnionCodec'), + map: null, + }; + } + return null; + }, + }; +} + +/** + * Dev-server counterpart of `patchSplTokenMetadataCodecs`. + * + * The Rollup `transform` plugin above only runs during `vite build`. In dev, + * Vite pre-bundles dependencies with **esbuild** (optimizeDeps), which never + * sees that plugin — so `yarn dev` would still crash with + * `No matching export ... for import "getDataEnumCodec"`. This esbuild `onLoad` + * hook applies the same identifier rename while esbuild optimizes + * `@solana/spl-token-metadata`, so dev and build both work. + */ +const patchSplTokenMetadataCodecsEsbuild = { + name: 'patch-spl-token-metadata-codecs-esbuild', + setup(build: { + onLoad: ( + opts: { filter: RegExp }, + cb: (args: { path: string }) => { contents: string; loader: 'js' }, + ) => void; + }) { + build.onLoad( + { filter: /@solana[\\/]spl-token-metadata[\\/].*\.js$/ }, + (args) => { + const src = fs.readFileSync(args.path, 'utf8'); + return { + contents: src.includes('getDataEnumCodec') + ? src.replace(/getDataEnumCodec/g, 'getDiscriminatedUnionCodec') + : src, + loader: 'js' as const, + }; + }, + ); + }, +}; + // https://vitejs.dev/config/ export default defineConfig({ base: '', @@ -29,11 +105,13 @@ export default defineConfig({ optimizeDeps: { esbuildOptions: { target: 'esnext', + plugins: [patchSplTokenMetadataCodecsEsbuild], }, include: ['@ar.io/sdk', '@ar.io/sdk/web'], exclude: ['@base-org/account'], }, plugins: [ + patchSplTokenMetadataCodecs(), svgr(), react(), nodePolyfills(), diff --git a/yarn.lock b/yarn.lock index 0986f8e83..ddb1d7844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -164,19 +164,19 @@ "@noble/hashes" "^1.5.0" bs58 "^6.0.0" -"@ardrive/turbo-sdk@1.39.2": - version "1.39.2" - resolved "https://registry.yarnpkg.com/@ardrive/turbo-sdk/-/turbo-sdk-1.39.2.tgz#6be800b76a77742f2e20547e3506dbe45e1310d8" - integrity sha512-1F3c7U8nDvM/gZXuBldLGRxIcV6CVP0u/58VCXeCYUh2kkggD9Y3fCUeHoiUc0Kd+4sFOKnM5XhmgDHpb5wRXQ== +"@ardrive/turbo-sdk@1.42.0-alpha.3": + version "1.42.0-alpha.3" + resolved "https://registry.yarnpkg.com/@ardrive/turbo-sdk/-/turbo-sdk-1.42.0-alpha.3.tgz#abcd41ee7d96fec44d9024999676886ce84738e5" + integrity sha512-MtYkLbmWRI+LmlL2Ax9jE9SaUheSASr29HehdYiY6i38alElCH7o89vdtosrHPOV4708i41gICxveBGM3m1cxA== dependencies: "@cosmjs/proto-signing" "^0.33.1" "@cosmjs/stargate" "^0.33.1" "@dha-team/arbundles" "^1.0.1" "@ethersproject/signing-key" "^5.7.0" "@permaweb/aoconnect" "0.0.57" - "@solana/web3.js" "^1.91.7" + "@solana/spl-token" "^0.4.14" + "@solana/web3.js" "^1.95.5" arweave "^1.15.1" - axios "^1.13.2" bignumber.js "^9.1.2" bs58 "^5.0.0" cli-progress "^3.12.0" @@ -1793,7 +1793,7 @@ resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31" integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA== -"@dha-team/arbundles@^1.0.1": +"@dha-team/arbundles@1.0.1", "@dha-team/arbundles@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@dha-team/arbundles/-/arbundles-1.0.1.tgz#5e81039b74da241cf0e2b074ae77e489eec7887d" integrity sha512-cgVxhZJLK1HG2+vcRBZ0CYGpxz7mA2QvLaspcw2gOzb2V/ZUxlifUu1aufBK3iz63Ww2OhgO0j9DstRJqrG1uA== @@ -1851,6 +1851,11 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== +"@emotion/hash@^0.9.0": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b" + integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== + "@emotion/unitless@^0.7.5": version "0.7.5" resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" @@ -4181,6 +4186,19 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== +"@rainbow-me/rainbowkit@^2.2.9": + version "2.2.11" + resolved "https://registry.yarnpkg.com/@rainbow-me/rainbowkit/-/rainbowkit-2.2.11.tgz#717265fcae250d3a6dfdf59177a1c9b828e038d7" + integrity sha512-FHPsRHMBpuHHhuyKktAR13O9agmsUUunDnVEP4hG1dSZ2JojXLUSWyLG28VbGIJakHYylkNguiLFnqM/BM8ERA== + dependencies: + "@vanilla-extract/css" "1.20.1" + "@vanilla-extract/dynamic" "2.1.5" + "@vanilla-extract/sprinkles" "1.6.5" + clsx "2.1.1" + cuer "0.0.3" + react-remove-scroll "2.7.2" + ua-parser-js "^2.0.9" + "@randlabs/communication-bridge@1.0.1", "@randlabs/communication-bridge@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@randlabs/communication-bridge/-/communication-bridge-1.0.1.tgz#d1ecfc29157afcbb0ca2d73122d67905eecb5bf3" @@ -4870,7 +4888,17 @@ dependencies: "@solana/errors" "6.8.0" -"@solana/buffer-layout@^4.0.1": +"@solana/buffer-layout-utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.3.0.tgz#88a5d69fd5606fee198f719d37065b43796a396e" + integrity sha512-MuQOCC1j0np1xH9yAv0ZWWfwvr7Bt7Sz4LId11Wi4wDdAmJ+lobE+vHg/mZmGcihF0BIkqVBNxGmlv8QE5DrtA== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/web3.js" "^1.32.0" + bigint-buffer "^1.1.5" + bignumber.js "^9.0.1" + +"@solana/buffer-layout@^4.0.0", "@solana/buffer-layout@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz#b996235eaec15b1e0b5092a8ed6028df77fa6c15" integrity sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== @@ -4910,7 +4938,7 @@ "@solana/codecs-numbers" "6.8.0" "@solana/errors" "6.8.0" -"@solana/codecs@6.8.0": +"@solana/codecs@2.0.0-rc.1", "@solana/codecs@6.8.0": version "6.8.0" resolved "https://registry.npmjs.org/@solana/codecs/-/codecs-6.8.0.tgz#7ba706eb14401cb9e206a74111d901ba216a61b5" integrity sha512-qCSAaw1qszeQflavkIM7c21qJ3BHReP/qgDelZbhsEXpZc852CCZM00FOIWuxePr6X+JjSNqJquxwdDSoZe7Bw== @@ -5227,6 +5255,31 @@ "@solana/transaction-messages" "6.8.0" "@solana/transactions" "6.8.0" +"@solana/spl-token-group@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz#83c00f0cd0bda33115468cd28b89d94f8ec1fee4" + integrity sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token-metadata@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz#d240947aed6e7318d637238022a7b0981b32ae80" + integrity sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token@^0.4.14": + version "0.4.15" + resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.4.15.tgz#d9e9ca30a15d4b73579f9423d4422e0baa77721f" + integrity sha512-3Lof3mNov8NVQ3PalIWb1Jgr/TZ6lYM+/sexv2TLqdhNFVth2OfWmH3d7QucgMjSbokkjNiNlRr6I8Fd269uaw== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/buffer-layout-utils" "^0.3.0" + "@solana/spl-token-group" "^0.0.7" + "@solana/spl-token-metadata" "^0.1.6" + buffer "^6.0.3" + "@solana/subscribable@6.8.0": version "6.8.0" resolved "https://registry.npmjs.org/@solana/subscribable/-/subscribable-6.8.0.tgz#d8086e659acb69628343ad4484a4c51bb8fecb6f" @@ -5377,17 +5430,17 @@ "@wallet-standard/app" "^1.1.0" "@wallet-standard/base" "^1.1.0" -"@solana/web3.js@^1.91.7": - version "1.95.2" - resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.2.tgz#6f8a0362fa75886a21550dbec49aad54481463a6" - integrity sha512-SjlHp0G4qhuhkQQc+YXdGkI8EerCqwxvgytMgBpzMUQTafrkNant3e7pgilBGgjy/iM40ICvWBLgASTPMrQU7w== +"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.95.5", "@solana/web3.js@^1.98.1", "@solana/web3.js@^1.98.4": + version "1.98.4" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.4.tgz#df51d78be9d865181ec5138b4e699d48e6895bbe" + integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== dependencies: - "@babel/runtime" "^7.24.8" + "@babel/runtime" "^7.25.0" "@noble/curves" "^1.4.2" "@noble/hashes" "^1.4.0" "@solana/buffer-layout" "^4.0.1" + "@solana/codecs-numbers" "^2.1.0" agentkeepalive "^4.5.0" - bigint-buffer "^1.1.5" bn.js "^5.2.1" borsh "^0.7.0" bs58 "^4.0.1" @@ -5398,17 +5451,17 @@ rpc-websockets "^9.0.2" superstruct "^2.0.2" -"@solana/web3.js@^1.98.1", "@solana/web3.js@^1.98.4": - version "1.98.4" - resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.4.tgz#df51d78be9d865181ec5138b4e699d48e6895bbe" - integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== +"@solana/web3.js@^1.91.7": + version "1.95.2" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.2.tgz#6f8a0362fa75886a21550dbec49aad54481463a6" + integrity sha512-SjlHp0G4qhuhkQQc+YXdGkI8EerCqwxvgytMgBpzMUQTafrkNant3e7pgilBGgjy/iM40ICvWBLgASTPMrQU7w== dependencies: - "@babel/runtime" "^7.25.0" + "@babel/runtime" "^7.24.8" "@noble/curves" "^1.4.2" "@noble/hashes" "^1.4.0" "@solana/buffer-layout" "^4.0.1" - "@solana/codecs-numbers" "^2.1.0" agentkeepalive "^4.5.0" + bigint-buffer "^1.1.5" bn.js "^5.2.1" borsh "^0.7.0" bs58 "^4.0.1" @@ -6301,6 +6354,40 @@ dependencies: "@types/yargs-parser" "*" +"@vanilla-extract/css@1.20.1": + version "1.20.1" + resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.20.1.tgz#403ee77306105d1986db6c57222ead8b19fb8c79" + integrity sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA== + dependencies: + "@emotion/hash" "^0.9.0" + "@vanilla-extract/private" "^1.0.9" + css-what "^6.1.0" + csstype "^3.2.3" + dedent "^1.5.3" + deep-object-diff "^1.1.9" + deepmerge "^4.2.2" + lru-cache "^10.4.3" + media-query-parser "^2.0.2" + modern-ahocorasick "^1.0.0" + picocolors "^1.0.0" + +"@vanilla-extract/dynamic@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.5.tgz#2e2721d5e17071c161e3fdf29b8204772e3bbabc" + integrity sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw== + dependencies: + "@vanilla-extract/private" "^1.0.9" + +"@vanilla-extract/private@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0" + integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA== + +"@vanilla-extract/sprinkles@1.6.5": + version "1.6.5" + resolved "https://registry.yarnpkg.com/@vanilla-extract/sprinkles/-/sprinkles-1.6.5.tgz#71f8cf21ca47cf75b2fc74e63c1854a8ef4e4a82" + integrity sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA== + "@vitejs/plugin-react@^4.0.0": version "4.3.1" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz#d0be6594051ded8957df555ff07a991fb618b48e" @@ -7065,6 +7152,11 @@ arconnect@^0.4.2: dependencies: arweave "^1.10.13" +arconnect@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arconnect/-/arconnect-1.0.4.tgz#6aa287b6f07c4427ab67d31d4b49a0039e324ab5" + integrity sha512-oEJRl2Cci2dSH+IEpdS1XEBqiZ+3OgPRSYWRx6MxDJU3I+NORrfrrjeZLcuruj6YNId5dpr0ROQuVnUrNkpfRg== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -7281,7 +7373,7 @@ axios@^1.1.3: form-data "^4.0.0" proxy-from-env "^1.1.0" -axios@^1.12.2, axios@^1.13.2: +axios@^1.12.2: version "1.13.2" resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== @@ -7464,6 +7556,11 @@ bignumber.js@^9.0.0, bignumber.js@^9.0.2, bignumber.js@^9.1.2: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== +bignumber.js@^9.0.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -8006,7 +8103,7 @@ clsx@1.2.1, clsx@^1.2.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: +clsx@2.1.1, clsx@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -8369,6 +8466,11 @@ css-mediaquery@^0.1.2: resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q== +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -8406,6 +8508,18 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +cuer@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/cuer/-/cuer-0.0.3.tgz#bb892dd748ca279d7452413e8d28abd2d9ef5914" + integrity sha512-f/UNxRMRCYtfLEGECAViByA3JNflZImOk11G9hwSd+44jvzrc99J35u5l+fbdQ2+ZG441GvOpaeGYBmWquZsbQ== + dependencies: + qr "~0" + "d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" @@ -8578,6 +8692,11 @@ dedent@^1.0.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== +dedent@^1.5.3: + version "1.7.2" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.2.tgz#34e2264ab538301e27cf7b07bf2369c19baa8dd9" + integrity sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA== + deep-eql@^5.0.1: version "5.0.2" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" @@ -8607,6 +8726,11 @@ deep-equal@^2.0.5: which-collection "^1.0.1" which-typed-array "^1.1.13" +deep-object-diff@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595" + integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA== + deepmerge@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" @@ -8685,6 +8809,11 @@ detect-browser@5.3.0, detect-browser@^5.2.0: resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.3.0.tgz#9705ef2bddf46072d0f7265a1fe300e36fe7ceca" integrity sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w== +detect-europe-js@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/detect-europe-js/-/detect-europe-js-0.1.2.tgz#aa76642e05dae786efc2e01a23d4792cd24c7b88" + integrity sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow== + detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -10330,6 +10459,11 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.7" +is-standalone-pwa@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz#7a1b0459471a95378aa0764d5dc0a9cec95f2871" + integrity sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -11435,7 +11569,7 @@ loupe@^3.1.0, loupe@^3.1.1, loupe@^3.1.2: resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.2.tgz#c86e0696804a02218f2206124c45d8b15291a240" integrity sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg== -lru-cache@^10.2.0: +lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -11615,6 +11749,13 @@ mdurl@^1.0.0: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== +media-query-parser@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29" + integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w== + dependencies: + "@babel/runtime" "^7.12.5" + memoizerific@^1.11.3: version "1.11.3" resolved "https://registry.yarnpkg.com/memoizerific/-/memoizerific-1.11.3.tgz#7c87a4646444c32d75438570905f2dbd1b1a805a" @@ -11855,6 +11996,11 @@ mnemonist@^0.39.8: dependencies: obliterator "^2.0.1" +modern-ahocorasick@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420" + integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ== + moment@^2.10.2: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" @@ -12857,6 +13003,11 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +qr@~0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/qr/-/qr-0.6.0.tgz#00c3d080dc76adf5d3754d9ad7ff0f9263dee2e0" + integrity sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA== + qrcode@1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170" @@ -13462,6 +13613,17 @@ react-remove-scroll-bar@^2.3.7: react-style-singleton "^2.2.2" tslib "^2.0.0" +react-remove-scroll@2.7.2: + version "2.7.2" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz#6442da56791117661978ae99cd29be9026fecca0" + integrity sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-remove-scroll@^2.6.1: version "2.6.2" resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz#2518d2c5112e71ea8928f1082a58459b5c7a2a97" @@ -14876,6 +15038,20 @@ typescript@^5.4.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== +ua-is-frozen@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz#bfbc5f06336e379590e36beca444188c7dc3a7f3" + integrity sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw== + +ua-parser-js@^2.0.9: + version "2.0.10" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.10.tgz#a28f1087ef5a3fff5aeb6bd86258841bfb6327b8" + integrity sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw== + dependencies: + detect-europe-js "^0.1.2" + is-standalone-pwa "^0.1.1" + ua-is-frozen "^0.1.2" + ufo@^1.4.0, ufo@^1.5.3: version "1.5.4" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"