Skip to content
Draft
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
114 changes: 85 additions & 29 deletions src/components/data-display/tables/UndernamesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -74,6 +76,7 @@ const UndernamesTable = ({
const [{ dataGateway }] = useGlobalState();

const [{ wallet, walletAddress }] = useWalletState();
const turbo = useTurboArNSClient();
const isOwner = walletAddress
? state?.Owner === walletAddress.toString()
: false;
Expand All @@ -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();
Expand Down Expand Up @@ -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: (
Expand Down Expand Up @@ -180,7 +221,7 @@ const UndernamesTable = ({
targetId: record.transactionId,
ttlSeconds: record.ttlSeconds,
priority: record.index,
action: isAuthorized ? (
action: canManage ? (
<span className="flex justify-end pr-3 gap-3">
{isOwner && (
<Tooltip
Expand Down Expand Up @@ -440,18 +481,30 @@ const UndernamesTable = ({
return '';
}}
addOnAfterTable={
// controllers and owners can add undernames
isAuthorized ? (
<div className="w-full flex flex-row text-primary font-semibold border-t-[1px] border-dark-grey text-sm">
<button
data-testid="add-undername-button"
className="flex flex-row w-full items-center p-3 bg-background hover:bg-primary-gradient text-primary hover:text-primary fill-primary hover:fill-black transition-all"
style={{ gap: '10px' }}
onClick={() => setAction(UNDERNAME_TABLE_ACTIONS.CREATE)}
>
<Plus className="size-4 text-primary fill-black" />
Add Undername
</button>
// controllers and owners can add undernames; custodial (Model A)
// names add them with credits
canManage ? (
<div className="w-full flex flex-col border-t-[1px] border-dark-grey">
{isCustodial && (
<span
className="w-full px-3 pt-2 text-xs text-primary"
data-testid="undernames-credit-paid-note"
>
Held in Turbo custody — undername changes are paid with your
Turbo Credits.
</span>
)}
<div className="w-full flex flex-row text-primary font-semibold text-sm">
<button
data-testid="add-undername-button"
className="flex flex-row w-full items-center p-3 bg-background hover:bg-primary-gradient text-primary hover:text-primary fill-primary hover:fill-black transition-all"
style={{ gap: '10px' }}
onClick={() => setAction(UNDERNAME_TABLE_ACTIONS.CREATE)}
>
<Plus className="size-4 text-primary fill-black" />
Add Undername
</button>
</div>
</div>
) : (
<></>
Expand Down Expand Up @@ -494,10 +547,13 @@ const UndernamesTable = ({
{arnsRecord.processId &&
transactionData &&
interactionType &&
isAuthorized ? (
canManage ? (
<ConfirmTransactionModal
interactionType={interactionType}
gasParams={(() => {
// 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;
Expand Down
124 changes: 96 additions & 28 deletions src/components/forms/DomainSettings/DomainSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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 && (
<div
className="w-full mt-3 p-3 rounded bg-foreground border border-dark-grey text-sm text-grey"
data-testid="self-manage-sol-note"
>
You own this name&apos;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.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 w-full mt-3">
{Object.entries({
// TODO: this should go on a name section, not the ant section
Expand Down Expand Up @@ -345,21 +384,36 @@ function DomainSettings({
<TargetIDRow
targetId={data?.apexRecord?.transactionId}
key={DomainSettingsRowTypes.TARGET_ID}
editable={isAuthorized}
editable={canManageRecords}
creditPaid={isCustodial}
confirm={(targetId: string) =>
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,
})
}
/>
),
Expand Down Expand Up @@ -436,22 +490,36 @@ function DomainSettings({
[DomainSettingsRowTypes.TTL]: (
<TTLRow
ttlSeconds={data?.apexRecord?.ttlSeconds}
editable={isAuthorized}
editable={canManageRecords}
creditPaid={isCustodial}
key={DomainSettingsRowTypes.TTL}
confirm={(ttlSeconds: number) =>
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,
})
}
/>
),
Expand Down
Loading
Loading